Webhooks
claritos calls you when something happens — instead of you asking. One endpoint, one secret, signed deliveries, retries and replay.
How it works
You create an endpoint (URL + event types) and receive a secret exactly once. When a subscribed event occurs, claritos sends a signed HTTPS POST to your URL. Respond with 2xx within 10 seconds — anything else counts as a failure and is retried. Events are named domain.entity.verb (e.g. contacts.contact.created). GET /webhooks/events returns the catalog; GET /webhooks/events/{type}/sample a complete sample per type; GET /webhooks/events/{type}/recent the three most recent real events. For “new lead” there is exactly one event, leads.lead.created, whether it comes from a funnel, a form, the practitioner finder or was entered by hand. Endpoints can be created via the API (scope webhooks:manage) or in claritos under Organization → Settings → API. Both show the same list.
Subscribe
https URLs only, publicly reachable — claritos rejects private networks, localhost and redirects when the endpoint is created. Empty eventTypes means “all events”. At most 25 endpoints per organization.
curl -X POST https://www.claritos.io/api/v1/webhooks \
-H "Authorization: Bearer capi_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/claritos",
"description": "Neue Leads → CRM",
"eventTypes": ["leads.lead.created", "contacts.contact.created"]
}'
# → 201 { "id": "…", "secret": "whsec_…", … } (secret nur dieses eine Mal)import requests
r = requests.post("https://www.claritos.io/api/v1/webhooks",
headers={"Authorization": "Bearer capi_…"},
json={"url": "https://hooks.example.com/claritos",
"eventTypes": ["leads.lead.created"]})
endpoint = r.json()
secret = endpoint["secret"] # sicher speichern — kommt nie wiederDelivery
Every delivery carries the headers webhook-id (delivery id, identical on retries), webhook-timestamp (Unix seconds) and webhook-signature (see below). The body always has the same shape: id (event), type, timestamp, organizationId and data with the event content. The lead event is flat: name, email, phone, company at the top, all form values under fields, consents under consent.
POST /claritos HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: claritos-webhooks/1
webhook-id: cmf2dlv000000000000000001
webhook-timestamp: 1757167391
webhook-signature: v1,K5oZ…base64…=
x-claritos-event: leads.lead.created
{
"id": "cmf2evt000000000000000001",
"type": "leads.lead.created",
"timestamp": "2026-09-06T14:03:11.000Z",
"organizationId": "cmf1org000000000000000000",
"data": {
"source": "funnel",
"sourceId": "cmf2funnel000000000000001",
"sourceName": "Longevity-Standortbestimmung",
"contactId": "cmf2contact00000000000001",
"prospectId": null,
"firstName": "Anna",
"lastName": "Muster",
"email": "anna@example.com",
"phone": "+49 30 1234567",
"company": null,
"jobTitle": null,
"fields": { "interesse": "Standortbestimmung" },
"consent": { "consent-email": true },
"receivedAt": "2026-09-06T14:03:11.000Z"
}
}Verify the signature
We sign per Standard Webhooks: HMAC-SHA256 over id.timestamp.body with the base64-decoded secret (without the whsec_ prefix), base64-encoded in the header as v1,<signature>. For 24 hours after a secret rotation the header carries two signatures — one must match. Check the timestamp (±5 minutes) against replay attacks and compare in constant time. Important: verify the raw body, not re-serialized JSON.
import { createHmac, timingSafeEqual } from "node:crypto";
// Express/Next: den ROHEN Body verwenden (kein re-serialisiertes JSON).
export function verifyClaritosWebhook(rawBody: string, headers: Record<string, string>, secret: string): boolean {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"] ?? "";
if (!id || !ts) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5 Minuten Toleranz
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
return sigHeader.split(" ").some((part) => {
const [version, sig] = part.split(",");
if (version !== "v1" || !sig) return false;
const given = Buffer.from(sig, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}import base64, hmac, hashlib, time
def verify_claritos_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
msg_id = headers.get("webhook-id"); ts = headers.get("webhook-timestamp")
if not msg_id or not ts or abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
for part in headers.get("webhook-signature", "").split(" "):
version, _, sig = part.partition(",")
if version == "v1" and hmac.compare_digest(base64.b64decode(sig), expected):
return True
return Falsefunction verifyClaritosWebhook(string $rawBody, array $headers, string $secret): bool {
$id = $headers['webhook-id'] ?? ''; $ts = $headers['webhook-timestamp'] ?? '';
if ($id === '' || $ts === '' || abs(time() - (int)$ts) > 300) return false;
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = hash_hmac('sha256', "$id.$ts.$rawBody", $key, true);
foreach (explode(' ', $headers['webhook-signature'] ?? '') as $part) {
[$version, $sig] = array_pad(explode(',', $part, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, base64_decode($sig))) return true;
}
return false;
}func verifyClaritosWebhook(rawBody []byte, h http.Header, secret string) bool {
id, ts := h.Get("webhook-id"), h.Get("webhook-timestamp")
t, err := strconv.ParseInt(ts, 10, 64)
if id == "" || err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 {
return false
}
key, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, part := range strings.Split(h.Get("webhook-signature"), " ") {
v, sig, ok := strings.Cut(part, ",")
if !ok || v != "v1" {
continue
}
given, _ := base64.StdEncoding.DecodeString(sig)
if hmac.Equal(given, expected) {
return true
}
}
return false
}Retries & disabling
If your endpoint does not answer with 2xx (timeout 10 s, redirects count as failures), claritos retries after 1 min, 5 min, 30 min, 2 h and 12 h — five attempts, then the delivery is DEAD. Five consecutive failed deliveries disable the endpoint (disabledAt) and the organization is notified. Re-enable with PATCH /webhooks/{id} and active: true — this resets the counter. Test deliveries (POST …/test) do not count. Deliveries to one endpoint run one after another (loose ordering); still make your receiver idempotent — a retry carries the same webhook-id.
Replay
Fetch failed or missed events back: either specific deliveries via deliveryIds or all events of a time range (default: last 24 hours, max. 500, test events excluded). Every replay is a new delivery with a new webhook-id.
# Zustellprotokoll (gescheiterte zuerst filtern)
curl "https://www.claritos.io/api/v1/webhooks/{id}/deliveries?status=DEAD" -H "Authorization: Bearer capi_…"
# Bestimmte Zustellungen erneut senden …
curl -X POST https://www.claritos.io/api/v1/webhooks/{id}/replay -H "Authorization: Bearer capi_…" \
-H "Content-Type: application/json" -d '{"deliveryIds": ["cmf2dlv…"]}'
# … oder alle Ereignisse eines Zeitraums (max. 500)
curl -X POST https://www.claritos.io/api/v1/webhooks/{id}/replay -H "Authorization: Bearer capi_…" \
-H "Content-Type: application/json" \
-d '{"since": "2026-09-05T00:00:00Z", "eventTypes": ["leads.lead.created"]}'Mirroring contacts
To mirror contacts into another system, subscribe to four events: contacts.contact.created (every new contact, whichever way it is created; except bulk imports and bulk takeovers: the import from a file, the takeover of Stripe customers, DATEV master data or the partner list in accounting, and the credits master data import, as well as test cases claritos hands over to an organization for practice; read those after the run with GET /contacts), contacts.contact.updated (with changedFields), contacts.contact.deleted (the contact is gone) and contacts.contact.merged. When contacts are merged, one contact remains (contact) and the contacts in mergedContactIds are deleted. No deleted fires for them and no updated for the remaining one: move your records of those ids onto contact.id instead of deleting them, and take contact as the new state. You can merge yourself with POST /contacts/{id}/merge (contacts:write): {id} remains, the up to 20 contacts in mergeContactIds are deleted, and everything attached to them moves to the remaining contact. This is final. Without fieldSources the values of the remaining contact apply; fieldSources names, per field, the participant whose value applies. Tags are combined. Only contacts of the same type can be merged. A 409 names in reason what has to be resolved first: type-mismatch, bookings-on-multiple (postings on more than one of the contacts) or a lock such as client-records-on-multiple.
# cmf2contact00000000000001 bleibt, cmf2contact00000000000009 wird gelöscht; die Telefonnummer kommt von ihm
curl -X POST https://www.claritos.io/api/v1/contacts/cmf2contact00000000000001/merge \
-H "Authorization: Bearer capi_…" \
-H "Content-Type: application/json" \
-d '{"mergeContactIds": ["cmf2contact00000000000009"], "fieldSources": {"phone": "cmf2contact00000000000009"}}'
# → 200 { "contact": { "id": "cmf2contact00000000000001", … }, "mergedContactIds": ["cmf2contact00000000000009"], "reassignedReferences": 7 }{
"type": "contacts.contact.merged",
"data": {
"contact": { "id": "cmf2contact00000000000001", "displayName": "Anna Muster", … },
"mergedContactIds": ["cmf2contact00000000000009"],
"reassignedReferences": 7
}
}Zapier, n8n, Make
Zapier (instant trigger): performSubscribe → POST /webhooks with your hook URL, performUnsubscribe → DELETE /webhooks/{id}, performList → GET /webhooks/events/{type}/recent (fallback: …/sample). Auth: “API Key” with header Authorization: Bearer. n8n (trigger node): webhook methods create/delete as above; verify the signature in the node with the secret from the credential. Make: instant trigger with the same sequence; the “Custom Webhook” module works right away if you create the URL as an endpoint by hand. Polling as fallback: every list offers updatedSince and stable ordering.