claritos

Webhooks

claritos ruft dich, wenn etwas passiert — statt dass du fragst. Ein Endpunkt, ein Secret, signierte Zustellungen, Wiederholung und Replay.

Wie es funktioniert

Du legst einen Endpunkt an (URL + Ereignistypen) und bekommst genau einmal ein Secret. Tritt ein abonniertes Ereignis ein, sendet claritos einen signierten HTTPS-POST an deine URL. Antworte mit 2xx innerhalb von 10 Sekunden — alles andere gilt als Fehlschlag und wird wiederholt. Ereignisse heissen domäne.entität.verb (z. B. contacts.contact.created). Den Katalog liefert GET /webhooks/events; ein vollständiges Beispiel je Typ GET /webhooks/events/{type}/sample; die drei letzten echten Ereignisse GET /webhooks/events/{type}/recent. Für „neuer Lead“ gibt es genau ein Ereignis, leads.lead.created, egal ob er aus Funnel, Formular, Behandler-Finder oder von Hand kommt. Endpunkte lassen sich per API (Scope webhooks:manage) oder in claritos unter Organisation → Einstellungen → API anlegen. Beides zeigt dieselbe Liste.

Abonnieren

Nur https-URLs, öffentlich erreichbar — private Netze, localhost und Weiterleitungen lehnt claritos beim Anlegen ab. Leere eventTypes bedeuten „alle Ereignisse“. Maximal 25 Endpunkte je Organisation.

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

Zustellung

Jede Zustellung trägt die Header webhook-id (Kennung der Zustellung, bei Wiederholung gleich), webhook-timestamp (Unix-Sekunden) und webhook-signature (siehe unten). Der Body ist immer gleich aufgebaut: id (Ereignis), type, timestamp, organizationId und data mit dem Ereignisinhalt. Das Lead-Ereignis ist flach: Name, E-Mail, Telefon, Firma oben, alle Formularwerte unter fields, Einwilligungen unter 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"
  }
}

Signatur prüfen

Wir signieren nach Standard Webhooks: HMAC-SHA256 über id.timestamp.body mit dem base64-dekodierten Secret (ohne whsec_-Präfix), base64-kodiert im Header als v1,<signatur>. Nach einer Secret-Rotation stehen 24 Stunden lang zwei Signaturen im Header — eine muss passen. Prüfe den Zeitstempel (±5 Minuten) gegen Wiederholungsangriffe und vergleiche zeitkonstant. Wichtig: den rohen Body prüfen, nicht ein neu serialisiertes 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
}

Wiederholung & Deaktivierung

Antwortet dein Endpunkt nicht mit 2xx (Timeout 10 s, Weiterleitungen zählen als Fehler), wiederholt claritos nach 1 min, 5 min, 30 min, 2 h und 12 h — fünf Versuche, dann ist die Zustellung DEAD. Fünf gescheiterte Zustellungen in Folge deaktivieren den Endpunkt (disabledAt); die Organisation wird benachrichtigt. Reaktivieren: PATCH /webhooks/{id} mit active: true — das setzt den Zähler zurück. Testzustellungen (POST …/test) zählen nicht. Zustellungen an einen Endpunkt laufen nacheinander (lose Reihenfolge); mache deinen Empfänger trotzdem idempotent — bei Wiederholung kommt dieselbe webhook-id.

Replay

Gescheiterte oder verpasste Ereignisse holst du dir zurück: entweder bestimmte Zustellungen per deliveryIds oder alle Ereignisse eines Zeitraums (Standard: letzte 24 Stunden, max. 500, Testereignisse ausgenommen). Jeder Replay ist eine neue Zustellung mit neuer 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"]}'

Kontakte spiegeln

Wer Kontakte in ein anderes System spiegelt, abonniert vier Ereignisse: contacts.contact.created (jede Neuanlage, gleich auf welchem Weg; ausgenommen sind Massen-Importe und Massen-Übernahmen: der Import aus einer Datei, die Übernahme von Stripe-Kunden, DATEV-Stammdaten oder der Partnerliste in der Buchhaltung und der Stammdaten-Import der Credits, sowie Testfälle, die claritos zum Üben in eine Organisation übergibt; die liest du nach dem Lauf über GET /contacts), contacts.contact.updated (mit changedFields), contacts.contact.deleted (der Kontakt ist weg) und contacts.contact.merged. Beim Zusammenführen bleibt ein Kontakt (contact), die Kontakte in mergedContactIds sind danach gelöscht. Für sie kommt kein deleted und für den bleibenden kein updated: Hänge deine Datensätze dieser Kennungen auf contact.id um, statt sie zu löschen, und übernimm contact als neuen Stand. Selbst zusammenführen kannst du mit POST /contacts/{id}/merge (contacts:write): {id} bleibt, die bis zu 20 Kontakte in mergeContactIds werden gelöscht, und alles an ihnen hängt danach am bleibenden Kontakt. Das ist endgültig. Ohne fieldSources gelten die Werte des bleibenden Kontakts; fieldSources nennt je Feld den Beteiligten, dessen Wert gilt. Tags werden vereinigt. Zusammenführen geht nur bei gleichem Typ. 409 nennt in reason, was vorher aufzulösen ist: type-mismatch, bookings-on-multiple (Buchungen an mehr als einem der Kontakte) oder eine Sperre wie 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 mit deiner Hook-URL, performUnsubscribe → DELETE /webhooks/{id}, performList → GET /webhooks/events/{type}/recent (Fallback: …/sample). Auth: „API Key“ mit Header Authorization: Bearer. n8n (Trigger-Node): Webhook-Methoden create/delete wie oben; Signatur im Node mit dem Secret aus der Credential prüfen. Make: Instant-Trigger mit derselben Abfolge; das Modul „Custom Webhook“ funktioniert sofort, wenn du die URL von Hand als Endpunkt anlegst. Polling als Rückfall: jede Liste bietet updatedSince und stabile Sortierung.