REST API reference
The-Y CRM's public REST API is meant for server-to-server integrations: create/read/update contacts and receive events via webhook. Requests are authenticated with an API key.
Authentication
Every request needs your API key in the header — either as a Bearer token or as X-API-Key:
Authorization: Bearer <YOUR_API_KEY>
# or
X-API-Key: <YOUR_API_KEY>
Create a key: in the app under Settings → Integrations (under “Advanced” → Connections) → "API keys" → new key. The key is shown only once — store it safely (The-Y only stores a hash). The key is bound to your tenant.
Permissions, expiry and renewal (since 1.1.60): each key has its own permissions: contacts:read (read contacts, GET) and/or contacts:write (create and update contacts, POST/PATCH). Optionally it gets an expiry date (at most 5 years). “Renew” creates a new key; the old one keeps working for the time you choose (immediately, 24 hours, 7 or 30 days). Keys from before 1.1.60 keep full permissions without expiry until you choose “Set permissions”.
Conventions
- Format: requests & responses in
application/json(UTF-8). - Pagination: lists accept
?limit=(default/max capped server-side) and?offset=; the response includeshasMore. - Rate limit: requests are capped per key/minute; on exceeding you get
429 Too Many Requests— retry with backoff. - Errors: as JSON
{ "error": { "code": "..." } }with the matching HTTP status.
| Status | Meaning |
|---|---|
200 / 201 | OK / created |
400 | validation error (e.g. required field missing) |
401 | key missing, invalid or expired |
403 | feature not enabled (Paid), the key lacks the permission (insufficient_scope) or the account is suspended (tenant_suspended) |
404 | resource not found |
429 | rate limit exceeded |
Contacts
GET/contacts
List contacts (paginated). Optional search via ?q=.
# Request curl "https://crm.the-y.at/api/ext/v1/contacts?limit=20&offset=0" \ -H "Authorization: Bearer $API_KEY" # Response { "data": [ { "id": "c_123", "name": "Maria Muster", "email": "maria@example.com", "phone": "+4366012345678", "company": "ACME" } ], "hasMore": false }
POST/contacts
Create a contact. At least a name or an email is required.
curl -X POST "https://crm.the-y.at/api/ext/v1/contacts" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Maria Muster", "email": "maria@example.com", "phone": "+4366012345678", "company": "ACME" }'
# 201 Created
{ "data": { "id": "c_123", "name": "Maria Muster", "email": "maria@example.com" } }
GET/contacts/{id}
Read a single contact.
curl "https://crm.the-y.at/api/ext/v1/contacts/c_123" \ -H "Authorization: Bearer $API_KEY"
PATCH/contacts/{id}
Partially update a contact — only the sent fields change (existing ones are kept).
curl -X PATCH "https://crm.the-y.at/api/ext/v1/contacts/c_123" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+4366099999999" }'
Outbound webhooks (events)
Instead of polling, have events pushed to your URL. Under Settings → Integrations → "Send data (webhook)" you set a target URL and the events (e.g. contact.created, contact.updated). The CRM creates the signing secret.
- The-Y sends a
POSTwith JSON to your URL, e.g.{ "event": "contact.created", "tenant": "<account ID>", "at": "2026-11-08T12:00:00.000Z", "data": { "id": "…", "name": "…", "emails": [], "phones": [] } }. - Target: public
http(s)addresses only. The CRM rejects internal targets (home or company network,localhost, Docker services, cloud metadata) when saving and never calls them when sending – not even after a redirect. This also applies when self-hosted. - Retries: on
429,5xxor a network error, up to 3 attempts within a few seconds – with the sameX-TheY-DeliveryID, the same timestamp and the same signature. Use the ID to detect duplicate deliveries. Your endpoint should return2xx.
Verify the webhook signature (since 1.1.60)
Every delivery is signed, so your system can check that the data really comes from The-Y CRM and wasn't changed on the way.
Secret: it is shown once when you create the webhook (whsec_…). Afterwards it is only stored encrypted and not visible anywhere. Lost it? “Renew secret” – the old one stops working immediately.
| Header | Content |
|---|---|
X-TheY-Timestamp | time of the delivery in seconds since 1970 (Unix time) |
X-TheY-Signature | v1=<hex> – HMAC-SHA256 with the secret over <X-TheY-Timestamp>.<body> (timestamp, a dot, the unchanged body) |
X-TheY-Event | e.g. contact.created |
X-TheY-Delivery | unique ID of the delivery (stays the same on retries) |
X-KSCRM-Signature, X-KSCRM-Event | old: HMAC-SHA256 over the body only, without a timestamp. Still sent so existing receivers don't break |
X-KSCRM-Signature header will be removed with version 1.2.0. Until then, verify X-TheY-Signature as shown below.How to verify: compute the signature yourself and compare in constant time, and reject deliveries whose timestamp is more than 5 minutes off your clock (protects against replaying intercepted deliveries). Important: sign the raw body as received – don't parse it as JSON and serialise it again first.
# Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.THEY_WEBHOOK_SECRET; // whsec_…
const app = express();
app.post("/the-y/webhook", express.raw({ type: "application/json" }), (req, res) => {
const ts = Number(req.get("X-TheY-Timestamp"));
const sig = (req.get("X-TheY-Signature") || "").split(",").map((s) => s.trim()).find((s) => s.startsWith("v1="))?.slice(3) || "";
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return res.status(400).send("too old");
const want = crypto.createHmac("sha256", SECRET).update(`${ts}.${req.body.toString("utf8")}`).digest("hex");
const a = Buffer.from(sig, "utf8"), b = Buffer.from(want, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// … process event.event, event.data
res.sendStatus(204);
});
# PHP
<?php
$secret = getenv('THEY_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input');
$ts = (int)($_SERVER['HTTP_X_THEY_TIMESTAMP'] ?? 0);
$sigHdr = $_SERVER['HTTP_X_THEY_SIGNATURE'] ?? '';
$sig = '';
foreach (explode(',', $sigHdr) as $part) { $part = trim($part); if (str_starts_with($part, 'v1=')) $sig = substr($part, 3); }
if (abs(time() - $ts) > 300) { http_response_code(400); exit('too old'); }
$want = hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($want, $sig)) { http_response_code(401); exit('bad signature'); }
$event = json_decode($body, true);
// … process $event['event'], $event['data']
http_response_code(204);
Inbound webhooks
External systems can create/update data in The-Y via a token URL: POST /api/hooks/<token> (token created in Integrations). Per webhook you choose the access level: upsert (create/update only, deletions ignored — anti-lock-in) or full (incl. delete).
