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
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)
python
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 wieder

Delivery

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.

http
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.

typescript
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);
  });
}
python
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 False
php
function 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;
}
go
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.

curl
# 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.

curl
# 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 }
json
{
  "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.