9.0 KiB
Public Lead Intake — eingehender Webhook
Öffentlicher Endpunkt, über den externe Systeme (Website-Kontaktformular,
Landingpage, Zapier/Make, Partner-CRM …) einen Lead direkt in einen
HiveGrid-Tenant einliefern. Aus dem eingelieferten JSON wird ein InboxLead
im Ziel-Tenant erzeugt, gegen bestehende Kontakte gematcht und optional als
webhook.received-Trigger für Automationen ausgewertet.
- Route:
POST /v1/webhooks/incoming/:tenant/:slug - Implementierung: Route
apps/api/src/routes/public-api-routes.ts(postPublicIncomingWebhookRequest) → ServiceingestPublicLeadWebhookinpackages/db/src/public-api.server.ts - Content-Type:
application/json(Body muss ein JSON-Objekt sein, kein Array/Skalar)
Adressierung: :tenant und :slug
:tenant— Tenant-ID oder Tenant-Slug (beides wird aufgelöst).:slug— Slug des Webhook-Endpunkts (PublicIncomingWebhookEndpoint), pro Tenant eindeutig. Der Endpunkt mussstatus = "active"sein.
Unbekannter Tenant, unbekannter Slug oder inaktiver Endpunkt liefern
einheitlich 404 not_found — bewusst ohne Unterscheidung, damit von außen
nicht erkennbar ist, ob Tenant oder Slug falsch war (keine Enumeration).
Authentifizierung — drei Modi
Der Auth-Modus ist pro Endpunkt konfiguriert (authMode). Es gilt genau
der Modus des adressierten Endpunkts; ein anderer Modus wird nicht akzeptiert.
1. api_key
- Header
x-api-key: <api-key>(alternativ FeldapiKeyim JSON-Body). - Der Schlüssel muss zum selben Tenant gehören wie der Endpunkt; ist am
Endpunkt ein
apiKeyIdhinterlegt, muss es genau dieser Schlüssel sein. - Der Schlüssel muss
activeund nicht abgelaufen sein und den Scopepublic_api:webhooks:incoming(oder*) besitzen. Fehlt der Scope →403 insufficient_scope.
2. url_token
- Header
X-HiveGrid-Webhook-Token: <token>— alternativ Query-Parameter?token=<token>(praktisch für Formular-Tools, die keine Custom-Header senden können). - Das Token wird gegen den gespeicherten
tokenHashgeprüft (der Klartext wird nie gespeichert und ist nur einmal bei der Erstellung sichtbar).
3. hmac
- Header
X-HiveGrid-Webhook-Token: <secret>(das einmalig ausgegebene Secret),X-HiveGrid-Timestamp: <unix-seconds>undX-HiveGrid-Signature: sha256=<hex>. - Signatur =
HMAC-SHA256(secret, "<timestamp>.<raw-body>"), hex-kodiert, mit Präfixsha256=. Signiert wird der rohe Request-Body (Byte-für-Byte), nicht das neu serialisierte JSON.
Ungültige Credentials in jedem Modus → 401 unauthorized (ohne Detail,
welcher Teil falsch war).
Rate-Limit
Fixed-Window-Limiter (Fenster: 1 Stunde), zwei Ebenen gleichzeitig:
| Ebene | Limit / Stunde | Zähl-Schlüssel |
|---|---|---|
| pro Schlüssel/Endpunkt | 1.000 | API-Key-ID (api_key) bzw. Endpunkt-ID (url_token/hmac) |
| pro Tenant | 10.000 | Tenant-ID |
Bei jeder Antwort (Erfolg wie Ablehnung) werden Rate-Limit-Header gesetzt:
| Header | Bedeutung |
|---|---|
X-RateLimit-Limit |
geltendes Limit (bei kombinierter Sicht 1000) |
X-RateLimit-Remaining |
verbleibende Anfragen im Fenster (Minimum beider Ebenen) |
X-RateLimit-Reset |
Unix-Zeit (Sekunden), wann das Fenster zurücksetzt |
X-RateLimit-Scope |
key, tenant oder combined (welche Ebene griff) |
Bei Überschreitung → 429 rate_limit_exceeded, zusätzlich Retry-After
(Sekunden bis Reset).
Feld-Mapping (payloadMapping)
Aus dem JSON-Body werden fünf strukturierte Lead-Felder extrahiert. Für jedes
Feld gibt es Standard-Pfade; über payloadMapping (JSON am Endpunkt) lässt
sich pro Feld ein abweichender Dot-Pfad in den Payload setzen.
| Lead-Feld | Standard-Pfade (in Reihenfolge) |
|---|---|
vorname |
vorname, firstName, first_name, contact.firstName |
nachname |
nachname, lastName, last_name, contact.lastName |
email |
email, contact.email, from.email (wird auf lowercase normalisiert) |
telefon |
telefon, phone, telephone, contact.phone |
message |
message, nachricht, text, body |
payloadMapping-Beispiel:{ "email": "customer.mail", "message": "form.comment" }liestemailauscustomer.mailundmessageausform.comment; für nicht gemappte Felder greifen weiter die Standard-Pfade.- Werte werden getrimmt und auf 2.000 Zeichen gekürzt.
- Der komplette eingelieferte Payload wird zusätzlich unverändert in
rohdatengespeichert — Felder wie Firma, Quelle/Kampagne oder eigene Metadaten gehen also nicht verloren, werden aber (Stand heute) nicht in eigene Lead-Spalten gemappt (siehe „Bekannte Grenzen").
Kontakt-Matching
Nach der Extraktion wird versucht, den Lead einem bestehenden Kontakt
zuzuordnen: zuerst per E-Mail (KontaktEmail), sonst per Telefon
(KontaktTelefon), jeweils tenant-scoped. Treffer landet als
matchedKontaktId am Lead und in der Antwort.
Beispiel — Demo-Endpunkt
Demo-Tenant hivegrid-demo, Endpunkt-Slug website-kontaktformular
(authMode: "api_key", verknüpft mit dem API-Key-Alias „Website Lead Intake").
Den echten Schlüssel im Tenant-Dashboard erzeugen/ablesen; unten steht ein
Platzhalter, kein echtes Secret.
curl -sS -X POST \
"https://api.hivegrid.de/v1/webhooks/incoming/hivegrid-demo/website-kontaktformular" \
-H "content-type: application/json" \
-H "x-api-key: $HIVEGRID_API_KEY" \
-d '{
"firstName": "Ada",
"lastName": "Lovelace",
"company": "Analytical Engines GmbH",
"email": "ada@example.test",
"phone": "+49 30 1234567",
"message": "Bitte um ein Angebot für eine 12-kWp-Anlage.",
"source": "website",
"campaign": "sommer-2026",
"meta": { "utm_medium": "cpc", "formId": "kontakt-hero" }
}'
- Bei
authMode: "url_token"stattdessen-H "x-hivegrid-webhook-token: $TOKEN"(oder?token=$TOKENan die URL). - Der API-Key muss den Scope
public_api:webhooks:incomingbesitzen (der reine Lese-/Schreib-Scope reicht nicht).
Erfolgs-Antwort (201)
{
"data": {
"leadId": "clx…",
"matchedKontaktId": null,
"automationExecutionIds": []
},
"meta": {
"requestId": "req_…",
"endpoint": "website-kontaktformular"
}
}
Aus dem Beispiel-Payload werden vorname=Ada, nachname=Lovelace,
email=ada@example.test, telefon=+49 30 1234567, message=… gesetzt;
company, source, campaign, meta bleiben in rohdaten erhalten.
Fehlerverhalten
Alle Fehler nutzen die Standard-Hülle
{ "error": { "code", "message", "requestId" } } (Details bewusst knapp,
keine Stacktraces, keine Secrets, keine internen IDs im Klartext).
| Status | code |
Auslöser |
|---|---|---|
404 |
not_found |
Tenant/Slug unbekannt oder Endpunkt inaktiv |
400 |
invalid_json |
Body ist kein JSON-Objekt / nicht parsebar |
401 |
unauthorized |
fehlendes/ungültiges Token, HMAC oder API-Key |
403 |
insufficient_scope |
API-Key ohne public_api:webhooks:incoming |
402 |
payment_required |
Tenant read-only (Billing) oder API-Zugang aus/aufgebraucht |
429 |
rate_limit_exceeded |
Rate-Limit überschritten (mit Retry-After) |
500 |
webhook_ingestion_failed |
interner Fehler beim Anlegen |
Was serverseitig passiert
- Tenant + Endpunkt auflösen, Endpunkt-Status prüfen.
- Body als JSON-Objekt parsen.
- Billing-/Usage-Gate (
402bei read-only oder deaktiviertem API-Zugang). - Auth gemäß
authMode+ Rate-Limit. - In einer Transaktion: Kontakt-Matching,
InboxLeadanlegen, Audit-Event für die Lead-Erstellung über die transaktionale Outbox (audit.event.create, Actionlead.created, Targetlead) — commit-atomar mit dem Lead; die manipulationssichere Hash-Kette wird asynchron vomaudit-event-persist-Worker verkettet. Anschließend passendewebhook.received-Automationen einreihen und einPublicWebhookIngestionLogschreiben.
Bekannte Grenzen (Stand dieser Doku)
Der Intake schreibt bewusst nur Lead + Audit + Ingestion-Log +
Automation-Einreihung. Im Vergleich zum internen Service
leadService.createLead fehlen (bewusst, als Folgearbeit):
- Anti-Spam-Scoring (
spamScore, Quarantäne, Ratenanomalie) — der Public-Intake setzt keinen Spam-Score. lead.created-Outbox-Fan-out (Workflow-Engine/Notifications über den Lead-Workflow-Event) — nur die Audit-Outbox wird geschrieben.- Such-Projektion (Meilisearch-Upsert des Leads).
- Activity-Stream-Eintrag (
logActivity) — siehe Hinweis unten. - Firma/Quelle/Kampagne werden nicht in eigene Lead-Felder gemappt (nur in
rohdatenerhalten).
Der Activity-Eintrag wird hier nicht geschrieben, weil
@hivegrid/activityvon@hivegrid/dbabhängt; ein Import inpackages/dberzeugte einen Paket-Zyklus. Der saubere Weg ist eine geteilte Lead-Ingestion-Schicht bzw. einlogActivity-Aufruf auf Route-Ebene (apps/api, darf@hivegrid/activityimportieren) — nachzuziehen, wenn der Intake ohnehin angefasst wird.