Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/docs/api/public-lead-intake.md
T

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) → Service ingestPublicLeadWebhook in packages/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 muss status = "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 Feld apiKey im JSON-Body).
  • Der Schlüssel muss zum selben Tenant gehören wie der Endpunkt; ist am Endpunkt ein apiKeyId hinterlegt, muss es genau dieser Schlüssel sein.
  • Der Schlüssel muss active und nicht abgelaufen sein und den Scope public_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 tokenHash geprü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> und X-HiveGrid-Signature: sha256=<hex>.
  • Signatur = HMAC-SHA256(secret, "<timestamp>.<raw-body>"), hex-kodiert, mit Präfix sha256=. 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" } liest email aus customer.mail und message aus form.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 rohdaten gespeichert — 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=$TOKEN an die URL).
  • Der API-Key muss den Scope public_api:webhooks:incoming besitzen (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

  1. Tenant + Endpunkt auflösen, Endpunkt-Status prüfen.
  2. Body als JSON-Objekt parsen.
  3. Billing-/Usage-Gate (402 bei read-only oder deaktiviertem API-Zugang).
  4. Auth gemäß authMode + Rate-Limit.
  5. In einer Transaktion: Kontakt-Matching, InboxLead anlegen, Audit-Event für die Lead-Erstellung über die transaktionale Outbox (audit.event.create, Action lead.created, Target lead) — commit-atomar mit dem Lead; die manipulationssichere Hash-Kette wird asynchron vom audit-event-persist-Worker verkettet. Anschließend passende webhook.received-Automationen einreihen und ein PublicWebhookIngestionLog schreiben.

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 rohdaten erhalten).

Der Activity-Eintrag wird hier nicht geschrieben, weil @hivegrid/activity von @hivegrid/db abhängt; ein Import in packages/db erzeugte einen Paket-Zyklus. Der saubere Weg ist eine geteilte Lead-Ingestion-Schicht bzw. ein logActivity-Aufruf auf Route-Ebene (apps/api, darf @hivegrid/activity importieren) — nachzuziehen, wenn der Intake ohnehin angefasst wird.