> ## Documentation Index
> Fetch the complete documentation index at: https://docs.didit.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks: Echtzeit-KYC- & KYB-Ereignisse, HMAC-Signatur

> Echtzeit-Events für Verifizierungen und Transaktionen von Didit empfangen: HMAC-SHA256-Signatur, Fan-Out-Ziele und Wiederholungsversuche.

> [!NOTE]
>
> Authentifizierungsaktivität und ihre versionsabhängige Webhook-Zustellung sind in [Authentifizierungsereignisse und Webhooks](https://docs.didit.me/de/authentication/events-and-webhooks.md) dokumentiert. Registrieren Sie keine nicht aufgeführten Auth-Ereignistypen über die aktuelle Ziel-API.

Prompt zur Webhook-Integration
        Prompt in die Zwischenablage kopieren



      
      

\<pre>

\`Add a Didit webhook endpoint to my application to receive real-time identity verification, entity, and transaction events.

\## What to Build
A POST endpoint (e.g., /api/webhooks/didit) that:
1\. Reads the raw request body (do NOT parse JSON before signature verification)
2\. Verifies the HMAC-SHA256 signature (X-Signature-V2 recommended)
3\. Validates the timestamp is fresh (within 5 minutes)
4\. Parses the JSON body and processes the event by webhook_type
5\. Returns 2xx ASAP and updates state idempotently

\## Signature Headers
\- X-Signature-V2: HMAC-SHA256 over the sorted, Unicode-preserved canonical JSON (recommended).
\- X-Signature: HMAC-SHA256 over the exact raw bytes (works only if your stack does not re-encode the body).
\- X-Signature-Simple: HMAC-SHA256 over "{timestamp}:{session_id}:{status}:{webhook_type}" (DEPRECATED last-resort fallback; authenticates the envelope only, NOT the decision body — if you verify with it, re-fetch the decision from GET /v3/session/{session_id}/decision/ instead of trusting the payload).
\- X-Timestamp: Unix epoch seconds when Didit dispatched the webhook.
\- X-Signature-V2-Previous, X-Signature-Previous, X-Signature-Simple-Previous: sent only for 24 hours after a destination's signing secret is rotated in the Business Console. Same signatures, made with the replaced secret. Never sent for the legacy application webhook_url.

Verification:
1\. abs(now - X-Timestamp) <= 300 seconds
2\. Recompute the signature using your destination secret
3\. Compare with constant-time comparison against the header and, when present, its -Previous header (accept a match on either), so verification keeps working while you deploy a rotated secret

\## Webhook Event Types (use exact values in destination.subscribed_events)
\- "status.updated": session or business session status changed
\- "data.updated": session or business-session verification data was edited
\- "user.status.updated": User entity status changed ("ACTIVE" / "FLAGGED" / "BLOCKED")
\- "user.data.updated": User entity profile or aggregate data changed
\- "business.status.updated": Business entity status changed ("ACTIVE" / "FLAGGED" / "BLOCKED")
\- "business.data.updated": Business entity profile or aggregate data changed
\- "activity.created": reserved for the activity timeline (currently only sent by Try Webhook tests)
\- "transaction.created": transaction created and initial verdict ready
\- "transaction.status.updated": transaction status changed
\- "travel_rule.status.updated": a Travel Rule exchange's status changed (see /transaction-monitoring/travel-rule)
\- "workflow.compliance_changed": the compliance status of a published workflow changed after a re-evaluation
\- "transaction_rule_suggestions.created": a transaction rule suggestion run proposed new rule changes
\- "investigator.report.ready": the Investigator wrote a report on an item
\- "investigator.decision.automated": the Investigator decided an item on its own
\- "investigator.decision.overridden": a person took another action than the Investigator's report, or overrode a decision it took on its own
\- "investigator.decision.contested": the end user contested a decision and asked for a human review
\- "investigator.question.raised": the Investigator asked the team a question about its policy
\- "investigator.automation.paused": automation stopped deciding, paused by a person or at the monthly spending limit
\- "investigator.rfi.answered": the end user answered an information request
\- "investigator.rfi.overdue": an information request passed its deadline unanswered
\- "investigator.drift.detected": a queue drifted and automation stepped down a tier
\- "investigator.sla.breached": a report waited past its queue's SLA without a decision
\- "investigator.qa.failed": a decision a person took failed the Investigator's quality review
\- "investigator.external.report.ready": the report on an item you sent from outside Didit is ready

There is no wildcard; include every event family you want to receive. You can create multiple destinations to fan out to different URLs.

\## Webhook Envelope (session events)
\{
"event_id": "uuid",
"session_id": "uuid",
"status": "Approved" | "Declined" | "In Review" | "In Progress" | "Not Started" | "Abandoned" | "Expired" | "Kyc Expired" | "Resubmitted" | "Awaiting User",
"webhook_type": "status.updated",
"created_at": 1627680000,
"timestamp": 1627680000,
"application_id": "uuid",
"environment": "live" | "sandbox",
"sandbox_scenario": "decline_aml_hit" | null,  // armed scenario slug on sandbox sessions; null on live sessions or an unarmed sandbox session
"workflow_id": "uuid",
"workflow_version": 1,
"vendor_data": "your-internal-user-id",
"metadata": { "user_type": "premium" },
"decision": { ... }  // only when Approved / Declined / In Review / Abandoned
}

Status values are exact, case-sensitive strings — note "Kyc Expired" uses a single capital K.

KYB / business-session payloads additionally include "business_session_id" and "session_kind": "business".

\## Decision Object (V3)
The decision mirrors the GET /v3/session/decision/ response. Per-feature data is in plural arrays so a workflow can include multiple instances of the same feature:
\- id_verifications[], nfc_verifications[], liveness_checks[], face_matches[]
\- aml_screenings[], poa_verifications[], phone_verifications[], email_verifications[]
\- ip_analyses[], database_validations[], questionnaire_responses
\- bank_verifications[]: one item per Bank Verification step. Its method is "open_banking" (bank sign-in), "account_lookup" (account check), "statement" or "microdeposits", or "none" when the person continued without a bank before any attempt was evaluated. continued_without_bank: true says the person continued without a bank, and continuation.reason says why.
\- reviews[]
\- signatures[]: only when the workflow has an E-signature step. One item per signer; status "signed" means the documents are signed. Download signed_file and evidence_file with your API key.

Each item carries a node_id so you can correlate it with the workflow graph.
User Verification decisions also carry one biometric_consent object (not an array): required, trigger, status (not_required | pending | given | declined), reason. status "pending" with reason "BIOMETRIC_CONSENT_UNSUPPORTED_SDK" means a native SDK below 4.10.0 captured a face without the consent step, so consent was not collected.

\## Database Update Logic
switch (status):
"Approved"     -> user.verified = true; store decision data
"Declined"     -> user.verification_status = "declined"; log decision.*.warnings
"In Review"    -> user.verification_status = "pending_review"
"In Progress"  -> user.verification_status = "in_progress"
"Resubmitted"  -> reopen the listed feature nodes per resubmit_info
"Abandoned"    -> optionally trigger reminder
"Awaiting User" -> KYB parent session waiting on party verifications; no action needed
"Expired" / "Kyc Expired" -> mark session expired; optionally create a new one

\## Retry Policy
On 5xx, 404, timeout, or connection failure Didit retries up to 2 times:
\- 1st retry: ~1 minute after the initial failure
\- 2nd retry: ~4 minutes after the 1st retry
After that the delivery is dropped. Each delivery attempt is logged separately in the console's Deliveries tab.
Each attempt in a destination's delivery log has an attempt number: 1 for the first delivery of an event, then one more than the attempt it repeats for each retry or resend.
Automatic retries go to the URL the first attempt used; a manual resend goes to the destination's current URL.
Retries and resends are signed again with the destination's current secret and a fresh timestamp.

\## Error Handling
\- Return 2xx as fast as possible; do heavy work asynchronously.
\- Log the raw body for debugging signature failures.
\- Treat duplicate webhooks idempotently (key on event_id, or session_id + status + webhook_type).
\- Delivery order is not guaranteed: never let an older event overwrite newer state. Compare created_at, or re-fetch the decision from GET /v3/session/{session_id}/decision/.
\- HTTPS endpoints only. Webhook receivers behind Cloudflare must whitelist 18.203.201.92.

\## Environment Variable
DIDIT_WEBHOOK_SECRET -- from the Business Console (per-destination secret_shared_key).

\## Testing
Send test webhooks from the Business Console (API & Webhooks > Try Webhook) before going live. The console covers approved, declined, in-review, KYB, entity, activity, and transaction scenarios.

\## Docs
\- Webhooks guide: https://docs.didit.me/integration/webhooks
\- Webhook destinations API: https://docs.didit.me/management-api/webhook/list-destinations
\- Verification statuses: https://docs.didit.me/integration/verification-statuses
\- Decision schema: https://docs.didit.me/sessions-api/retrieve-session

\## Important
\- ALWAYS verify the signature before processing any webhook
\- Use constant-time comparison to prevent timing attacks
\- Store and HMAC the raw JSON string -- never re-stringify after parsing
\- Return 2xx quickly; do heavy processing asynchronously if needed`

\</pre>



      
      

Einfügen in

## ÜbersichtÜber Webhooks sendet Didit **Echtzeit-Updates** an Ihr Backend, sobald eine Verifizierungssitzung, Unternehmenssitzung, ein Vendor-Benutzer, ein Vendor-Unternehmen oder eine Transaktion den Status ändert. Wenn Sie ein oder mehrere Ziele für die Ereignisse abonnieren, die für Sie relevant sind, können Sie sofort reagieren, sobald ein Ergebnis vorliegt – meist innerhalb von Sekunden, nachdem der zu verifizierende Benutzer seinen Ablauf abgeschlossen hat.Webhooks sind das **empfohlene** Integrationsmuster. Das Abfragen (Polling) von [`GET /v3/session/{id}/decision/`](https://docs.didit.me/de/sessions-api/retrieve-session) wird als Fallback unterstützt, ist jedoch langsamer, verbraucht mehr Anfragen und erfasst keine Ereignisse wie `data.updated`, `transaction.status.updated` sowie Änderungen auf Entitätsebene, die ausschließlich über Webhooks gesendet werden. Behandeln Sie die Webhook-Zustellung als primäre Datenquelle (Quelle der Wahrheit) und nutzen Sie Polling nur beim Kaltstart oder zum Datenabgleich.Dieses Kapitel der Didit Academy-Integrationslektion schließt an die Webhook-Übersicht an.Die Integrationslektion der Academy behandelt Webhooks ab 3:12: Hinzufügen eines Ziels, Auswählen von Ereignissen und das Signaturgeheimnis, das Sie weiter unten verifizieren.[Webhooks overview — Didit Academy](https://www.youtube.com/embed/fX03-WEu_EI?start=192\&rel=0\&playsinline=1)> [!NOTE]
>
> **Cloudflare-/WAF-Benutzer.** Didit stellt Webhooks von der statischen Adresse `18.203.201.92` mit dem Header `User-Agent: DiditWebhook/2.0 +https://didit.me` zu. Wenn Ihre Edge-Infrastruktur unbekannte Clients blockiert, lassen Sie diese Adresse unter **Security > WAF > Tools > IP Access Rules** zu und wählen Sie **Zulassen** für den empfangenden Hostnamen aus.## EreignistypenVerwenden Sie den genauen Wert im `subscribed_events`-Array eines Webhook-Ziels. Es gibt keine Wildcards — führen Sie jede Familie auf, die Sie empfangen möchten — und Sie können Ereignisse auf mehrere Webhook-Ziele verteilen.| Ereignis | Ressource | Wann es ausgelöst wird |
| - | - | - |
| `status.updated` | KYC-Sitzung oder KYB-Unternehmenssitzung | Der Status einer Sitzung ändert sich (Not Started → In Progress → Approved / Declined / In Review / Abandoned / Resubmitted). KYB-Payloads enthalten `session_kind: "business"` und `business_session_id`. |
| `data.updated` | KYC-Sitzung oder KYB-Unternehmenssitzung | Verifizierungsdaten werden nach der Erstellung bearbeitet (Korrekturen durch manuelle Prüfer an KYC, Adressnachweis, NFC, KYB-Unternehmensregister, KYB-Dokumenten usw.). |
| `user.status.updated` | Benutzer-Entität | Der Status eines konsolidierten Benutzers ändert sich zwischen `ACTIVE`, `FLAGGED` und `BLOCKED` (Console-Aktion, Regel-Aktion, Sperrliste). Enthält `previous_status`. |
| `user.data.updated` | Benutzer-Entität | Profil, Zähler, Metadaten oder genehmigte Identifikatoren eines konsolidierten Benutzers ändern sich. Enthält `changed_fields` und eine `changes`-Map mit `previous`- / `current`-Werten. |
| `business.status.updated` | Unternehmens-Entität | Der Status eines konsolidierten Unternehmens ändert sich zwischen `ACTIVE`, `FLAGGED` und `BLOCKED` (Console-Aktion, Regel-Aktion, Sperrliste). Enthält `previous_status`. |
| `business.data.updated` | Unternehmens-Entität | Profil, Registrierungsdaten, Zähler, Metadaten oder aggregierte Verifizierungsfelder eines konsolidierten Unternehmens ändern sich. Enthält `changed_fields` und `changes`. |
| `activity.created` | Aktivitäts-Timeline | Reserviert für den Feed der Aktivitäts-Timeline. Der Wert wird in `subscribed_events` akzeptiert und vom **Try Webhook**-Tester abgedeckt, Didit stellt `activity.created` für Live-Traffic jedoch derzeit nicht zu. |
| `transaction.created` | Transaktionsüberwachung | Eine Transaktion wird erstellt und ihr anfängliches Regelurteil liegt vor. Enthält `transaction_id`, `txn_id`, `status`, `score`, `severity`, `amount`, `currency`, `direction`, `action_required`. |
| `transaction.status.updated` | Transaktionsüberwachung | Der Status einer Transaktion ändert sich nach einer Regelauswertung, Analystenaktion, Behebung, Treffern auf Sperrlisten, Provider-Aktualisierungen oder API-/Console-Aktionen. Wird auch einmalig gesendet (unabhängig davon, ob sich der Status geändert hat), wenn ein im Hintergrund wiederholtes Krypto-Screening abgeschlossen ist ([Transaktions-Hashes noch nicht indexiert](https://docs.didit.me/de/transaction-monitoring/aml-screening.md#transaktions-hashes-noch-nicht-indexiert)). |
| `travel_rule.status.updated` | Travel Rule | Der Status eines [Travel Rule](https://docs.didit.me/de/transaction-monitoring/travel-rule.md)-Austauschs ändert sich – bei Erstellung (einschließlich des Wechsels zu `NOT_APPLICABLE` unterhalb des Schwellenwerts), Eigentumsbestätigung, Antwort auf E-Mail-Abholung, Abschluss/Stornierung oder Ablauf des Timeouts. Wird für ausgehende und eingehende Überweisungen ausgelöst: das eingehende Gegenstück auf der `INTERNAL`-Schiene, Sunrise-Registrierungen und eingehende `TRP`-Anfragen. Für eine von Didit erstellte Transaktion werden diese Ereignisse nach dem zugehörigen `transaction.created`-Ereignis gesendet. Enthält `transaction_id`, `txn_id`, `travel_rule_status`, `rail`, `direction`. |
| `workflow.compliance_changed` | Workflow-Compliance | Der Compliance-Status eines veröffentlichten Workflows hat sich nach einer Neubewertung geändert. Ein Ereignis pro Workflow. Enthält `workflow_id`, `workflow_version`, `workflow_label`, `transition` (`from` und `to`), `severity` und `kb_version`, und kein `status`. |
| `transaction_rule_suggestions.created` | Transaktionsüberwachung | Ein Regelvorschlagslauf hat neue Regeländerungen vorgeschlagen und in den Einstellungen für Regelvorschläge sind Webhook-Benachrichtigungen aktiviert. Enthält `run_id`, `open_count` und `suggestions[]`, und kein `status`. |
| `investigator.report.ready` | Investigator | Der Investigator hat einen Bericht zu einem Element erstellt. |
| `investigator.decision.automated` | Investigator | Der Investigator hat eigenständig eine Entscheidung zu einem Element getroffen. |
| `investigator.decision.overridden` | Investigator | Eine Person hat eine andere Aktion als im Bericht des Investigators ergriffen oder eine von ihm eigenständig getroffene Entscheidung überschrieben. |
| `investigator.decision.contested` | Investigator | Der Endbenutzer hat eine Entscheidung angefochten und eine manuelle Prüfung angefordert. |
| `investigator.question.raised` | Investigator | Der Investigator hat dem Team eine Frage zu seiner Richtlinie gestellt. |
| `investigator.automation.paused` | Investigator | Die Automatisierung hat das Treffen von Entscheidungen eingestellt; sie wurde von einer Person oder beim Erreichen des monatlichen Ausgabenlimits pausiert. |
| `investigator.rfi.answered` | Investigator | Der Endbenutzer hat eine Informationsanfrage beantwortet. |
| `investigator.rfi.overdue` | Investigator | Eine Informationsanfrage hat ihre Frist unbeantwortet überschritten. |
| `investigator.drift.detected` | Investigator | Eine Warteschlange ist abgedriftet und die Automatisierung wurde um eine Stufe herabgestuft. |
| `investigator.sla.breached` | Investigator | Ein Bericht hat länger als das SLA seiner Warteschlange ohne Entscheidung gewartet. |
| `investigator.qa.failed` | Investigator | Eine von einer Person getroffene Entscheidung hat die Qualitätsprüfung des Investigators nicht bestanden. |
| `investigator.external.report.ready` | Investigator | Der Bericht zu einem Element, das Sie von außerhalb von Didit übermittelt haben, ist fertig. |
| `auth.signup` | Authentication | Eine Person registriert sich über [Authentication](https://docs.didit.me/de/authentication/events-and-webhooks.md). Enthält `auth_event` und die `authentication`-Zusammenfassung der Person. |
| `auth.login` | Authentication | Eine Person meldet sich an. Enthält `auth_event` und die `authentication`-Zusammenfassung der Person. |
| `auth.login_failed` | Authentication | Ein Anmeldeversuch schlägt fehl. `vendor_data` ist `null`, wenn der Versuch keiner bekannten Person zugeordnet werden kann. Enthält `auth_event` und die `authentication`-Zusammenfassung der Person. |
| `auth.password_reset_requested` | Authentication | Eine Person fordert das Zurücksetzen des Passworts an. Enthält `auth_event` und die `authentication`-Zusammenfassung der Person. |
| `auth.password_reset_completed` | Authentication | Eine Person schließt das Zurücksetzen des Passworts ab. Enthält `auth_event` und die `authentication`-Zusammenfassung der Person. |
| `auth.mfa_changed` | Authentication | Eine Person fügt einen Authentifikator hinzu oder ändert diesen. Enthält `auth_event` und die `authentication`-Zusammenfassung der Person. |
| `auth.new_device` | Authentication | Eine Person meldet sich von einem Gerät an, das sie zuvor noch nicht verwendet hat. Enthält `auth_event` und die `authentication`-Zusammenfassung der Person. |
| `auth.account_locked` | Authentication | Eine Sicherheitsrichtlinie sperrt das Konto oder Ihr Team blockiert die Person. Enthält `auth_event` und die `authentication`-Zusammenfassung der Person. |Die Werte in dieser Tabelle stellen die vollständige Menge der abonnierbaren Ereignisse dar – es gibt kein `session.status.updated`, `kyc.completed` oder Ähnliches. Die einzige andere Zustellung, die Didit vornimmt, ist der unsignierte [`kyb.registry_search.resolved`-Callback](#callback-für-die-kyb-unternehmensregistersuche), der an die anfragespezifische `webhook_url` gesendet wird, die Sie an [`POST /v3/kyb/search/`](https://docs.didit.me/de/standalone-apis/kyb-registry) übergeben, niemals an ein Webhook-Ziel.> [!TIP]
>
> Das Menü Business Console > API & Webhooks > **Try Webhook** sendet vollständige Payloads für jedes dieser Ereignisse (genehmigt, abgelehnt, in Prüfung, Unicode-Namen, KYC/KYB, Entität, Aktivität und Transaktion). Nutzen Sie es, um Ihren Endpoint zu validieren, bevor Sie live gehen.## Payload-Struktur### EnvelopeJeder Webhook nutzt denselben Envelope. Felder werden immer mit `sort_keys=True` und kompakten Trennzeichen (`,` / `:`) serialisiert, sodass dieselbe Payload auf beiden Seiten dieselbe Signatur erzeugt.| Feld | Typ | Hinweise |
| - | - | - |
| `event_id` | `uuid` | Eindeutiger, stabiler Bezeichner für das Ereignis. Dieselbe `event_id` wird an jedes Ziel gesendet, an das das Ereignis weitergeleitet wird, und bei Wiederholungsversuchen wiederverwendet – verwenden Sie sie als Idempotenzschlüssel. |
| `webhook_type` | string | Einer der [Ereignistypen](#ereignistypen) oben. |
| `timestamp` | int (Unix-Sekunden) | Zeitpunkt, zu dem Didit den Webhook versendet hat. Wird für die Signaturprüfung verwendet. Wird bei jedem Wiederholungsversuch aktualisiert. |
| `created_at` | int (Unix-Sekunden) | Zeitpunkt, zu dem der zugrunde liegende Datensatz zuletzt aktualisiert wurde. |
| `application_id` | `uuid` | Die Anwendung, zu der das Ziel gehört. |
| `environment` | `"live"` \| `"sandbox"` | Gibt an, ob das Ereignis aus einer Live- oder Sandbox-Sitzung bzw. -Anwendung stammt. |
| `status` | string | Vorhanden bei jedem Sitzungs-, Entitäts- und Transaktionsereignis (einschließlich `*.data.updated`); `investigator.*`-Ereignisse enthalten keinen `status` (siehe Investigator-Payload unten). Sitzungsereignisse verwenden die exakten [Verifizierungsstatus-Labels](https://docs.didit.me/de/integration/verification-statuses.md) (z. B. `"Approved"`, `"Kyc Expired"`); Entitätsereignisse verwenden `ACTIVE` / `FLAGGED` / `BLOCKED`; Transaktionsereignisse verwenden `APPROVED` / `IN_REVIEW` / `DECLINED` / `AWAITING_USER`. |Bei **Sitzungs**-Ereignissen (`status.updated`, `data.updated` bei regulären und Unternehmenssitzungen) enthält der Envelope zusätzlich:| Feld | Typ | Hinweise |
| - | - | - |
| `session_id` | `uuid` | Die UUID der Benutzerverifizierungs- oder Unternehmenssitzung. |
| `business_session_id` | `uuid` | Nur bei KYB. Entspricht `session_id`. |
| `session_kind` | `"business"` | Nur bei KYB-Ereignissen; ermöglicht Verzweigungen in einem generischen Handler. |
| `workflow_id` | `uuid` | Workflow, der zum Erstellen der Sitzung verwendet wurde, sofern festgelegt. |
| `workflow_version` | int | Workflow-Version. |
| `vendor_data` | string | Ihre interne Kennung, sofern bei der Sitzungserstellung angegeben. |
| `vendor_business_id` | string | Nur bei KYB-Sitzungen, sofern angegeben. |
| `sandbox_scenario` | string \| null | Bei jedem Sitzungsereignis (KYC und KYB). Der Slug des aktivierten [Sandbox](https://docs.didit.me/de/integration/sandbox-testing.md)-Szenarios (z. B. `"decline_aml_hit"`) bei einer Sandbox-Sitzung mit aktiviertem Szenario; `null` bei einer Live-Sitzung oder einer Sandbox-Sitzung ohne aktiviertes Szenario. Nicht vorhanden bei Entitäts- oder Transaktions-Webhooks. |
| `metadata` | object | Alle Metadaten, die Sie bei der Sitzungserstellung angehängt haben, wobei interne Deduplizierungsschlüssel entfernt wurden. |
| `trigger` | string | Optional. Grund für das Auslösen des Webhooks, wenn er nicht durch den Verifizierungsablauf selbst erzeugt wurde: `"manual_review"` (Prüfer-Bearbeitungen über die Konsole), `"manual_step_update"` (ein Prüfer hat den Status einer Funktion geändert), `"ongoing_monitoring"` (Aktualisierung der laufenden AML-Überwachung), `"webhook_reconciliation"` (ein erneut gesendeter `status.updated`, weil der erste finale Webhook nie erstellt wurde, sodass Sie ein zweites Ereignis für eine bereits erhaltene Entscheidung empfangen können: deduplizieren Sie anhand von `session_id` und `status`), `"manual_webhook_recovery"` (ein manuell wiederhergestellter Webhook), `"kyb_registry_change"` (das Unternehmensregister hat eine Änderung gemeldet), `"console_registry_edit"`, `"console_key_people_skip"` und `"console_key_people_regenerate"` (in der Konsole vorgenommene Bearbeitungen an einer Unternehmenssitzung) und `"biometric_retention"` (ein `data.updated`, das gesendet wird, wenn die Live-Biometriedaten der Sitzung planmäßig gelöscht werden). Diese Liste kann erweitert werden; behandeln Sie unbekannte Werte daher als informativ. |
| `decision` | object | Vorhanden, wenn `status` den Wert `Approved`, `Declined`, `In Review` oder `Abandoned` hat. Entspricht der [V3-Sitzungsentscheidung](https://docs.didit.me/de/sessions-api/retrieve-session.md). |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | Vorhanden, wenn `status` den Wert `Resubmitted` hat (keine `decision`). |
| `decided_by` | string | Nur bei `status.updated`, wenn der Status `Approved`, `Declined` oder `Resubmitted` lautet und die Anwendung den Investigator verwendet. `"investigator"`, wenn der Investigator die Entscheidung eigenständig getroffen hat, `"human"`, wenn ein Konsolenbenutzer sie getroffen hat (einschließlich der Annahme des Investigator-Berichts). Wird weggelassen, wenn kein Prüfer die Entscheidung erfasst hat, wenn ein API-Schlüssel sie getroffen hat oder wenn der Investigator nicht verwendet wird. |### Beispiel für Session-Envelope```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-444444444444",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "status": "Approved",
  "workflow_id": "66666666-7777-8888-9999-000000000000",
  "workflow_version": 4,
  "vendor_data": "user_42",
  "metadata": { "tier": "premium" },
  "decision": { "...": "see /sessions-api/retrieve-session" }
}
```> [!TIP]
>
> Das `decision`-Objekt hat dasselbe Schema, das von [`GET /v3/session/{id}/decision/`](https://docs.didit.me/de/sessions-api/retrieve-session) zurückgegeben wird. Die kanonische Feldreferenz pro Funktion (jede Eigenschaft von `id_verifications[]`, `aml_screenings[]` usw.) finden Sie unter [Datenmodelle](https://docs.didit.me/de/reference/data-models.md). Wir duplizieren die Feldtabellen pro Funktion hier bewusst nicht, damit das Schema nur an einer einzigen Stelle gepflegt wird.### Plural-Arrays in V3 (wichtig)Innerhalb von `decision` ist jedes funktionsbezogene Ergebnis ein **Plural-Array**, sodass ein einzelner Workflow mehrere Instanzen derselben Funktion enthalten kann (beispielsweise mehrere Ausweisprüfungen über verschiedene Dokumente hinweg):```text theme={null}
id_verifications[]        nfc_verifications[]
liveness_checks[]         face_matches[]
phone_verifications[]     email_verifications[]
poa_verifications[]       aml_screenings[]
ip_analyses[]             database_validations[]
questionnaire_responses   reviews[]
bank_verifications[]
```Jedes Element enthält eine `node_id`, die dem Workflow-Graphen entspricht, sowie einen funktionsbezogenen `status`. Lesen Sie die veralteten V2-Singular-Felder (`id_verification`, `nfc`, `liveness`, `face_match`, `phone`, `email`, `poa`, `aml`, `ip_analysis`, `database_validation`) **nicht** aus; sie erscheinen nur, wenn ein Ziel explizit auf `webhook_version: "v2"` festgelegt ist.Ergebnisse der **E-Signatur** befinden sich in `signatures[]` und sind nur vorhanden, wenn der Workflow einen E-Signatur-Schritt enthält: ein Element pro Unterzeichner, mit dem Unterzeichner, der Code-Bestätigung, dem Zertifikat, dem Zeitstempel, den Hashes und Download-Pfaden jedes Dokuments sowie dem Nachweis-PDF. Es gibt kein separates Signaturereignis: `status.updated` teilt Ihnen mit, wann Sie sie auslesen können. Siehe den [E-Signatur-Bericht](https://docs.didit.me/de/core-technology/electronic-signature/report-electronic-signature.md).Ergebnisse der **Bankverifizierung** befinden sich in `bank_verifications[]`, mit einem Element pro Bankverifizierungsschritt. Das Feld `method` jedes Elements gibt an, wie das Konto verifiziert wurde: `open_banking` (Bank-Login), `account_lookup` (Kontoabfrage) oder ein Fallback über `statement` oder `microdeposits`, sowie `null`, bis ein Konto ausgewertet wurde. Wenn die Person **Continue without your bank** ausgewählt hat, ist `continued_without_bank` `true` und `continuation` gibt den Grund an. `method` ist dann `none`, es sei denn, ein Versuch wurde zuvor ausgewertet: siehe [Wenn die Person ihre Bank nicht verifizieren kann](https://docs.didit.me/de/core-technology/bank-verification/fallbacks.md#im-ergebnis-nachlesen). Bei einem über die Kontoabfrage verifizierten Konto werden keine Finanzdaten ausgelesen, weshalb `financial_data.not_collected` die Kontostände, Transaktionen oder Einkünfte auflistet, die der Schritt angefordert hat. Siehe [Methoden der Bankverifizierung](https://docs.didit.me/de/core-technology/bank-verification/verification-methods.md#methode-im-ergebnis-auslesen).### Investigator-BlockEine `decision` der Version V3 für eine Benutzer- oder Unternehmenssitzung enthält zudem `investigator`: die neueste [Investigator](https://docs.didit.me/de/console/investigator.md)-Prüfung der Sitzung oder `null`, falls es nie eine gab.| Feld | Typ | Bedeutung |
| - | - | - |
| `run_id` | uuid | Die Prüfung. Lesen Sie den vollständigen Bericht unter `GET /v3/investigator/runs/{run_id}/`. |
| `status` | string | `running`, `completed` oder `failed`. |
| `stage` | string | `shadow` oder `live`, als die Prüfung lief. |
| `queue` | string | `kyc`, `kyb`, `aml`, `transactions` oder `cases`. |
| `is_open` | boolean | Die Prüfung wartet noch auf eine Entscheidung. |
| `recommendation` | string \| null | `approve`, `decline`, `escalate` oder `request`. |
| `recommendation_detail` | string \| null | Eine Zeile zur empfohlenen Aktion. |
| `confidence` | string \| null | `high`, `moderate` oder `low`. |
| `summary` | string \| null | Die Zusammenfassung des Berichts. |
| `ready_to_accept` | boolean | Der Bericht kann so übernommen werden, wie er ist. |
| `prepared_at` | datetime \| null | Wann der Bericht fertiggestellt war. |
| `automated` | boolean | Der Investigator hat eigenständig entschieden, selbst wenn jemand dies später überschrieben hat. |
| `decided_by` | string \| null | `investigator`, solange die eigene Entscheidung Bestand hat, andernfalls `member`, `null`, solange noch unentschieden. |
| `decision` | object \| null | Wie das Element die Warteschlange verlassen hat: `outcome`, `action`, `final_status`, `agreed`, `decided_at`, `billed` und wer entschieden hat. |Funktionen zur Dokumentenerfassung – **KYB-Dokumente** und **Document AI** – verfügen über kein eigenes Plural-Array; sie erscheinen in der `features[]`-Liste der Entscheidung als `{ "feature": "DOCUMENT_AI", "node_id": "…" }` (ein Eintrag pro Knoten). Siehe [Document AI](https://docs.didit.me/de/core-technology/document-ai/overview.md).Entscheidungen der Benutzerverifizierung enthalten zudem ein einzelnes `biometric_consent`-Objekt: ob die Sitzung eine ausdrückliche biometrische Einwilligung erforderte, den `trigger`, den `status` der Antwort und deren Nachweis. `status: "pending"` mit `reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK"` bedeutet, dass ein natives SDK unter 4.10.0 ein Gesicht ohne den Einwilligungsschritt erfasst hat. Siehe [Biometrische Einwilligung](https://docs.didit.me/de/getting-started/biometric-consent.md).### Schlüssel für AusweisverifizierungsmethodenJedes Element in `id_verifications[]` gibt an, welche [Ausweisverifizierungsmethode](https://docs.didit.me/de/core-technology/id-verification/verification-methods.md) es erzeugt hat, sodass ein Handler anhand der tatsächlich erhaltenen Nachweise verzweigen kann:| Schlüssel | Wert |
| - | - |
| `verification_method` | `document`, `id_lookup` oder `wallet` |
| `assurance` | `documentary`, `data_match` oder `cryptographic` |
| `wallet_provider` | Katalog-Wallet-ID, wenn eine Wallet dies erzeugt hat, andernfalls `null` |
| `id_lookup` | Registernachweis, wenn die Methode `id_lookup` war, andernfalls `null` |
| `wallet_verification` | Credential-Nachweis, wenn die Methode `wallet` war, andernfalls `null` |
| `fallback_from` | Warum eine Methode ohne Ausweisdokument den ID-Schritt nicht abgeschlossen hat: die Methode, der Grund und ob der Benutzer auf die Dokumentenerfassung zurückgegriffen hat oder abgelehnt wurde |Bestehende Handler funktionieren unverändert weiter: Eine Dokumentsitzung setzt `verification_method` auf `document` und belässt den Rest bei `null`. Typen für jedes einzelne Feld finden Sie unter [Datenmodelle](https://docs.didit.me/de/reference/data-models.md#ausweisverifizierung).#### Genehmigte Wallet-Sitzung (`status.updated`)```json theme={null}
{
  "event_id": "3a0c1b2c-4444-4555-8666-777777777777",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "session_id": "cccccccc-dddd-eeee-ffff-000000000000",
  "status": "Approved",
  "vendor_data": "user_77",
  "decision": {
    "session_id": "cccccccc-dddd-eeee-ffff-000000000000",
    "status": "Approved",
    "features": ["ID_VERIFICATION"],
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Approved",
        "issuing_state": "DNK",
        "first_name": "Freja",
        "last_name": "Nielsen",
        "full_name": "Freja Nielsen",
        "date_of_birth": "1988-03-02",
        "verification_method": "wallet",
        "assurance": "cryptographic",
        "wallet_provider": "mitid",
        "fallback_from": null,
        "id_lookup": null,
        "wallet_verification": {
          "provider": "mitid",
          "provider_name": "MitID",
          "issuing_authority": "Danish Agency for Digital Government",
          "issuing_country": "DNK",
          "credential_type": "person_identification",
          "level_of_assurance": "substantial",
          "verified_at": "2026-09-04T09:12:44Z",
          "signature_valid": true,
          "attributes": {
            "full_name": "Freja Nielsen",
            "date_of_birth": "1988-03-02",
            "cpr_alias": "b0f1c2d3-e4f5-4678-9abc-def012345678"
          },
          "portrait": null,
          "face_match_score": null
        },
        "warnings": []
      }
    ],
    "reviews": [],
    "created_at": "2026-09-04T09:12:20.443172Z"
  }
}
```### Payloads von Entitätsereignissen```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "user.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "uuid",
  "environment": "live",
  "vendor_user_id": "uuid",
  "vendor_data": "user_42",
  "status": "BLOCKED",
  "previous_status": "ACTIVE"
}
```Entitätsstatus sind `ACTIVE`, `FLAGGED` oder `BLOCKED`. `user.data.updated` und `business.data.updated` behalten das `status`-Feld bei und fügen ein `changed_fields`-Array sowie eine `changes`-Map mit `{ field: { previous, current } }` hinzu. `business.*`-Ereignisse verwenden `vendor_business_id` anstelle von `vendor_user_id`.### Transaktionsereignis-PayloadsTransaktions-Webhooks nutzen dieselbe Fan-out-Infrastruktur und dieselbe Envelope-Struktur, identifizieren jedoch die Transaktion anstelle einer Sitzung:```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "transaction.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "uuid",
  "environment": "live",
  "transaction_id": "uuid",
  "txn_id": "finance0001",
  "status": "APPROVED",
  "score": 0,
  "severity": "UNKNOWN",
  "amount": "1200.00",
  "currency": "EUR",
  "direction": "OUTBOUND",
  "action_required": null
}
````action_required` ist `null` oder die ausstehende Benutzeraktion (`type` `wallet_ownership` oder `verification_session` mit der zugehörigen `url`), wie unter [Erforderliche Benutzeraktionen](https://docs.didit.me/de/transaction-monitoring/sdk-transaction-submission.md#erforderliche-benutzeraktionen) beschrieben.Neue Transaktionen haben standardmäßig den Status `APPROVED`. Wenn eine Regel, eine Sperrliste oder ein Workflow den Status während der Erstellung ändert, spiegelt die anfängliche Payload von `transaction.created` diesen finalen Status wider. `transaction.status.updated` weist dieselbe Struktur mit dem aktualisierten `status` auf. Transaktionsstatus sind `APPROVED`, `IN_REVIEW`, `DECLINED` oder `AWAITING_USER`; `severity` ist `UNKNOWN` / `LOW` / `MEDIUM` / `HIGH` / `CRITICAL` und `direction` ist `INBOUND` / `OUTBOUND`. `transaction.created` und `transaction.status.updated` werden nur für **Live**-Anwendungen zugestellt. Austauschvorgänge nach der Travel Rule in der Sandbox geben `travel_rule.status.updated` mit `environment: sandbox` aus.### Callback für die KYB-UnternehmensregistersucheDer asynchrone Ablauf [`POST /v3/kyb/search/`](https://docs.didit.me/de/standalone-apis/kyb-registry) stellt eine zusätzliche Payload zu, die **kein** Zielereignis ist: Wenn Sie eine `webhook_url` an die Suchanfrage übergeben, sendet Didit per POST einen `kyb.registry_search.resolved`-Callback an diese URL, sobald die Registersuche aufgelöst wurde. Er unterscheidet sich in drei Punkten von Ziel-Webhooks:- Er ist **nicht signiert** – keine `X-Signature*`-Header. Didit kennzeichnet ihn stattdessen mit `X-Didit-Unsigned-Callback: true`; validieren Sie ihn, indem Sie die `request_id` mit der von der Suchanfrage zurückgegebenen ID abgleichen.
- Der Ereignisname steht in `event_type` (nicht in `webhook_type`), und es gibt kein `application_id` oder `environment`.
- `timestamp` und `created_at` sind Epoch-Ganzzahlen, wie bei anderen Webhooks, jedoch im Gegensatz zum ISO-Format von `created_at` des Such-Endpoints.```json theme={null}
{
  "event_id": "uuid",
  "event_type": "kyb.registry_search.resolved",
  "request_id": "uuid",
  "vendor_data": "business-123",
  "metadata": { "source": "onboarding" },
  "search_status": "resolved",
  "search_resolved": true,
  "kyb_registry": { "companies": ["..."], "pagination": { "...": "..." } },
  "timestamp": 1774970000,
  "created_at": 1774970000
}
```Der Callback folgt derselben [Wiederholungsrichtlinie](#wiederholungsrichtlinie) wie Ziel-Webhooks (bei Wiederholungsversuchen wird `timestamp` mit einem neuen Zeitstempel versehen, und die Nachrichten bleiben unsigniert). Das vollständige Schema der Kandidatenliste finden Sie in der [KYB-Unternehmensregister-API](https://docs.didit.me/de/standalone-apis/kyb-registry.md).## SignaturprüfungJeder Webhook wird mittels HMAC-SHA256 unter Verwendung des `secret_shared_key` des Ziels signiert. Es werden drei Signatur-Header gesendet, damit Sie denjenigen auswählen können, der Ihren Stack unverändert durchläuft:| Header | Was signiert wird | Middleware-sicher | Wann zu verwenden |
| - | - | - | - |
| `X-Signature-V2` | Sortiertes, kompaktes JSON mit beibehaltenem Unicode (`ensure_ascii=False`) | Ja | **Empfohlen** – funktioniert selbst dann, wenn Ihr Framework den Body neu codiert. |
| `X-Signature` | Exakte Roh-Bytes der Anfrage, wie Didit sie übertragen hat (`sort_keys`, `ensure_ascii=True`) | Nein | Nur wenn Sie den rohen Body lesen können, bevor ein Parser ihn verarbeitet. |
| `X-Signature-Simple` **(veraltet)** | `"{timestamp}:{session_id}:{status}:{webhook_type}"` | Ja | Letzter Fallback, der **nur den Umschlag** authentifiziert – er authentifiziert `decision` **nicht**. Beachten Sie vor der Verwendung den Hinweis zur Veraltung unten. |Header, die Didit immer sendet:| Header | Beispiel | Bedeutung |
| - | - | - |
| `Content-Type` | `application/json` | Der Body ist JSON. |
| `User-Agent` | `DiditWebhook/2.0 +https://didit.me` | Identifiziert den Webhook-Worker von Didit. |
| `X-Timestamp` | `1774970000` | Unix-Epochenzeit in Sekunden, zu der der Webhook versendet wurde. Verwerfen Sie die Anfrage, wenn `abs(now - X-Timestamp) > 300`. |
| `X-Signature` / `X-Signature-V2` / `X-Signature-Simple` | Hexadezimalwerte | HMAC-SHA256 des Bodys oder des oben genannten kanonischen Strings. |Für 24 Stunden nach der Rotation des Signaturgeheimnisses eines Ziels enthalten Zustellungen zusätzlich `X-Signature-Previous`, `X-Signature-V2-Previous` und `X-Signature-Simple-Previous` (siehe [Rotation des Signaturgeheimnisses](#rotation-des-signaturgeheimnisses)).### Rotation des SignaturgeheimnissesSie rotieren das Signaturgeheimnis eines Webhook-Ziels in der Business Console.
Für 24 Stunden nach einer Rotation enthält jede Zustellung an dieses Ziel drei zusätzliche Header, die mit dem ersetzten Geheimnis erstellt wurden:| Header | Gleiche Signatur wie | Signiert mit |
| - | - | - |
| `X-Signature-V2-Previous` | `X-Signature-V2` | Dem ersetzten Geheimnis |
| `X-Signature-Previous` | `X-Signature` | Dem ersetzten Geheimnis |
| `X-Signature-Simple-Previous` | `X-Signature-Simple` (veraltet) | Dem ersetzten Geheimnis |Ein Handler, der eine Übereinstimmung mit einem Signatur-Header oder dessen `-Previous`-Header akzeptiert, führt die Verifizierung unterbrechungsfrei fort, während Sie das neue Geheimnis bereitstellen: Das alte Geheimnis stimmt mit dem `-Previous`-Header überein und das neue mit dem regulären Header.
Die folgenden Codebeispiele setzen dies um.
Stellen Sie das neue Geheimnis innerhalb der 24 Stunden bereit, da danach nur noch das neue Geheimnis verifiziert werden kann.
`previous_secret_expires_at` unter [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/de/management-api/webhook/get-destination) gibt an, wann die 24 Stunden enden.Wiederholungsversuche und erneute Sendungen werden beim Versand erneut mit dem aktuellen Geheimnis des Ziels signiert und enthalten die `-Previous`-Header nur so lange, wie die 24 Stunden andauern.
Eine erneute Rotation innerhalb der 24 Stunden setzt das älteste Geheimnis sofort außer Kraft: Nur das Geheimnis, das bis zur neuen Rotation aktuell war, verifiziert weiterhin über die `-Previous`-Header – für 24 Stunden ab der neuen Rotation.Die `-Previous`-Header existieren nur für Webhook-Ziele.
Der auf Anwendungsebene definierte `secret_shared_key`, der die veraltete `webhook_url` signiert, bietet keine solche Übergangsphase: Eine Rotation über `rotate_secret_key` unter [`PATCH /v3/webhook/`](https://docs.didit.me/de/sessions-api/management-api#webhook-konfiguration) macht das alte Geheimnis sofort ungültig.### `X-Signature-Simple` ist veraltet> [!WARNING]
>
> **`X-Signature-Simple` authentifiziert den Umschlag, nicht die Payload.** Sein kanonischer String deckt nur `timestamp`, `session_id`, `status` und `webhook_type` ab. Er deckt `decision` **nicht** ab – ebenso wenig wie `resubmit_info`, `metadata`, `vendor_data` oder `event_id`. Eine Anfrage, deren `decision` manipuliert wurde, während diese vier Felder unberührt blieben, besteht die Prüfung mit `X-Signature-Simple` weiterhin.
>
> Bei Ereignissen, die überhaupt keine `session_id` enthalten, ist er noch schwächer. Transaktionsereignisse (`transaction_id`, `txn_id`, `amount`, `score`) und Entitätsereignisse (`vendor_user_id`, `vendor_business_id`) signieren `"{timestamp}::{status}:{webhook_type}"`, was nicht identifiziert, um **welche** Transaktion oder Entität es sich handelt – zwei verschiedene Transaktionen, die in derselben Sekunde mit demselben Status gemeldet werden, besitzen dasselbe `X-Signature-Simple`.
> Ereignisse vom Typ `travel_rule.status.updated`, `workflow.compliance_changed` und `transaction_rule_suggestions.created` enthalten auch keinen `status`, daher signieren sie `"{timestamp}:::{webhook_type}"`, beispielsweise `"{timestamp}:::travel_rule.status.updated"`.
>
> Wenn `X-Signature-Simple` der einzige Header ist, den Sie prüfen, behandeln Sie alles außerhalb dieser vier Felder als **nicht vertrauenswürdig** und rufen Sie den Datensatz erneut über die API ab – [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/de/sessions-api/retrieve-session) für eine Sitzung, [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/de/management-api/transactions/get) für eine Transaktion –, welche die maßgebliche Datenquelle ist.Dies ist eine Kompatibilitätsbeschränkung und kann nicht ohne Weiteres direkt geändert werden. Der aus vier Feldern bestehende kanonische String ist ein Wire-Contract: Jeder Empfänger, der `X-Signature-Simple` verifiziert, berechnet genau diese vier Felder neu. Eine Änderung dessen, was der Header signiert, würde daher bei allen Empfängern die Signaturprüfung unbemerkt fehlschlagen lassen. Die Abdeckung des Bodys erfordert zudem eine spezifizierte kanonische Form des JSON, die beide Seiten Byte für Byte reproduzieren, und Didit stellt eine solche bereits mit `X-Signature-V2` bereit. Anstatt eine zweite Body-abdeckende Variante mit eigenen Kanonisierungsregeln einzuführen, bleibt der Header genau so, wie er ist, und wird zugunsten der bestehenden Varianten mit vollständiger Payload-Abdeckung als veraltet eingestuft.Was das für Sie bedeutet:- **Bei der Übertragung ändert sich nichts.** `X-Signature-Simple` wird weiterhin bei jeder Zustellung mit derselben Berechnungsmethode gesendet. Bestehende Prüfungen funktionieren weiterhin wie gewohnt, und wir werden eine eventuelle Entfernung rechtzeitig ankündigen.
- **Neue Integrationen müssen `X-Signature-V2` verwenden.** Es ist middleware-sicher *und* deckt die gesamte Payload ab.
- **Wenn Sie `X-Signature-Simple` nicht ablösen können**, verifizieren Sie den Header und rufen Sie den Datensatz anschließend über die API ab, anstatt ihn aus dem Webhook-Body auszulesen.### Warum drei Varianten?Einige Web-Frameworks (Express-Body-Parser, Django-Middleware, API-Gateways) codieren JSON stillschweigend neu, bevor Ihr Handler es liest. Wenn der ursprüngliche Body `"José"` enthielt und Ihre Middleware ihn in `"José"` umschreibt, ändern sich die Bytes, obwohl die Daten identisch sind. `X-Signature` schlägt dann fehl. `X-Signature-V2` wird anhand einer kanonischen JSON-Form berechnet (sortierte Schlüssel, kompakte Trennzeichen, **nicht-escapetes Unicode**), die nahezu jede Middleware deterministisch reproduziert – und das veraltete `X-Signature-Simple` dient als Fallback, indem es nur einen kurzen, parser-unabhängigen String signiert, falls selbst das fehlschlägt. Da es nur diesen kurzen String und niemals den Body signiert, kann es die Integrität des Bodys nicht gewährleisten.### Empfohlene Prüfreihenfolge1) Versuchen Sie zuerst **`X-Signature-V2`**. Re-enkodieren Sie `JSON.parse(body)` mit sortierten Schlüsseln und beibehaltenem Unicode, führen Sie dann `HMAC-SHA256(secret, canonical)` aus und vergleichen Sie das Ergebnis mittels `timingSafeEqual` mit dem Header.
2) Wenn V2 fehlschlägt und Sie rohe Bytes lesen können, versuchen Sie **`X-Signature`** gegen `HMAC-SHA256(secret, rawBody)`.
3) Nur wenn beides fehlschlägt, greifen Sie auf das veraltete **`X-Signature-Simple`** gegen `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")` zurück. Dies authentifiziert ausschließlich den Umschlag: Behandeln Sie die `decision` im Body als nicht vertrauenswürdig und rufen Sie sie erneut über [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/de/sessions-api/retrieve-session) ab, bevor Sie darauf reagieren.Akzeptieren Sie bei jedem Schritt, wenn der entsprechende `-Previous`-Header vorhanden ist, eine Übereinstimmung mit einem der beiden Header (siehe [Rotation des Signatur-Secrets](#rotation-des-signaturgeheimnisses)).**Lehnen Sie in jedem Fall alle Anfragen ab**, die älter als **5 Minuten** sind (`abs(now - X-Timestamp) > 300`), um sich vor Replay-Angriffen zu schützen.## WiederholungsrichtlinieWenn Ihr Endpoint mit einem `5xx` oder `404` antwortet, unternimmt Didit bis zu **2 Wiederholungsversuche** mit exponentiellem Backoff:- **1. Wiederholungsversuch** — etwa **1 Minute** nach dem ersten Fehlschlag.
- **2. Wiederholungsversuch** — etwa **4 Minuten** nach dem 1. Wiederholungsversuch.Nach dem zweiten Wiederholungsversuch wird die Zustellung verworfen. Jeder Zustellungsversuch (initialer Versuch + Wiederholungsversuche) wird als separater Eintrag in der Business Console unter dem Tab **Zustellungen** des Ziels protokolliert, sodass Sie jeden Versuch erneut abspielen oder überprüfen können.Jeder Versuch im Zustellungs-Log eines Ziels enthält eine `attempt`-Nummer: `1` für die erste Zustellung eines Ereignisses und jeweils eins mehr als der Versuch, den er wiederholt, für jeden Wiederholungsversuch oder erneuten Versand.
Automatische Wiederholungsversuche gehen an die URL, an die der erste Versuch gesendet wurde, selbst wenn Sie die `url` des Ziels zwischenzeitlich geändert haben.
Ein manueller erneuter Versand geht an die aktuelle URL des Ziels, sodass die Korrektur einer URL auch den erneuten Versand korrigiert.Weitere wichtige Zustellungsregeln:- Bei der ausgehenden HTTP-Anfrage tritt nach **5 Sekunden** eine Zeitüberschreitung auf.
- Didit blockiert Zustellungen an private/Localhost-URLs zum Schutz vor SSRF und folgt niemals Weiterleitungen. Verwenden Sie einen öffentlichen HTTPS-Endpoint, der direkt antwortet.
- Eine Ziel-`url`, die einen nicht maskierten Backslash, ein Steuerzeichen oder mehr als ein `@` in der Authority enthält, wird als fehlerhaft abgelehnt – eine solche URL kann nicht erstellt werden, und eine bestehende kann weder erneut zugestellt noch erneut abgespielt werden. Verwenden Sie eine reguläre `https://host/path`-URL mit höchstens einem Userinfo-Abschnitt (für Webhook-URLs im Basic-Auth-Stil).
- `2xx` wird als Erfolg gewertet. Timeouts und Verbindungsfehler gelten als wiederholbar (sie erscheinen als `504`/`503` im Zustellungs-Log); auf `3xx`- und `4xx`-Antworten außer `404` erfolgen **keine** Wiederholungsversuche.
- Bei einem Wiederholungsversuch berechnet Didit Signaturen mit einem neuen `X-Timestamp` neu, sodass das Paar aus Zeitstempel und Signatur immer zusammenpasst.
- Wiederholungsversuche und erneute Sendungen werden mit dem zum Sendezeitpunkt aktuellen Signatur-Secret des Ziels signiert.### ZustellungsreihenfolgeDidit garantiert nicht die Reihenfolge, in der Webhooks bei Ihnen eintreffen.
Jede Zustellung wird separat eingereiht, und ein Wiederholungsversuch trifft Minuten nach dem ersten Versuch ein, sodass ein `status.updated` für `Approved` vor einem früheren für `In Progress` bei Ihnen eintreffen kann.Richten Sie Ihren Empfänger darauf ein:- Behandeln Sie einen Webhook als Signal, dass sich etwas geändert hat. Wenn die Reihenfolge eine Rolle spielt, rufen Sie [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/de/sessions-api/retrieve-session) auf und agieren Sie basierend auf dem aktuellen `status`.
- Lassen Sie niemals zu, dass ein älteres Ereignis einen neueren Zustand überschreibt: Vergleichen Sie das `created_at` des Ereignisses mit dem zuletzt für diesen Datensatz gespeicherten und ignorieren Sie ein älteres.
- Verwenden Sie `event_id` zur Deduplizierung von Wiederholungen, da diese über Wiederholungsversuche hinweg unverändert bleibt.Didit reiht die Ereignisse einer Travel Rule-Übertragung in einer festen Reihenfolge ein, die unter [Statusänderung beim Travel Rule-Austausch](#travel-rule-austauschstatus-geändert-travel_rule-status-updated) beschrieben ist, aber ein Wiederholungsversuch kann die Reihenfolge ihres Eintreffens dennoch verändern.## Webhook-Ziel einrichtenSie können Ziele über die **Business Console > API & Webhooks** oder über die Management API verwalten.**1. Ziel erstellen**Rufen Sie [`POST /v3/webhook/destinations/`](https://docs.didit.me/de/management-api/webhook/create-destination) auf (oder klicken Sie in der Konsole auf **Ziel hinzufügen**) mit einem `label`, einer öffentlichen `url`, der `webhook_version` (`"v3"`) und einem `subscribed_events`-Array, das jede Ereignisfamilie enthält, die dieser Endpoint empfangen soll. Mindestens ein Ereignis ist erforderlich; Wildcards existieren nicht.**2. secret\_shared\_key speichern**Die Erstellungsantwort gibt den `secret_shared_key` des Ziels zurück. Speichern Sie ihn zusammen mit Ihren anderen Server-Secrets: Er gehört ausschließlich zu diesem Ziel und ist die Eingabe für alle drei HMAC-SHA256-Signaturvarianten. [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/de/management-api/webhook/get-destination) gibt ihn für Aufrufer mit API-Schlüssel sowie für Konsolenbenutzer, die Webhooks lesen dürfen, erneut zurück.**3. Endpoint implementieren**Stellen Sie eine öffentliche HTTPS-`POST`-Route bereit, lesen Sie den Roh-Body ein, prüfen Sie die Signatur (bevorzugt `X-Signature-V2`), validieren Sie das Zeitstempelfenster und leiten Sie die Verarbeitung anhand von `webhook_type` weiter. Geben Sie `2xx` zurück, sobald Sie die Aufgabe eingereiht haben – führen Sie rechenintensive Verarbeitungen asynchron durch.**4. Zustellungen prüfen**Beobachten Sie den Tab **Zustellungen** des Ziels. Fehlerfreie Webhooks antworten innerhalb von ein bis zwei Sekunden mit `200`. `5xx`/`404` löst die Wiederholungsrichtlinie von Didit aus; dauerhafte Fehler werden nach dem zweiten Wiederholungsversuch verworfen.**5. Mit Try Webhook testen**Nutzen Sie die Konsolenszenarien von [**Try Webhook**](#testen), um Ihren Endpoint mit Payloads für genehmigte, abgelehnte, in Prüfung befindliche, KYB-, Entitäts-, Aktivitäts- und Transaktionsfälle zu testen, bevor Sie den Produktionsverkehr aktivieren.![Didit-Webhook-Testfunktion in der Business Console](https://mintcdn.com/didit-0f962782/z6T2GHM4Zh-iSj-K/images/test-webhook.png?fit=max\&auto=format\&n=z6T2GHM4Zh-iSj-K\&q=85\&s=2787ef3ed7a8016798f951e5ae898e4e)Senden Sie Test-Webhooks direkt aus der Business Console, um Ihren Endpoint vor dem Produktivgang zu validieren.Sie können so viele Ziele erstellen, wie Sie benötigen. Ein gängiges Muster ist **ein Ziel pro Konsument**: z. B. KYC-Ereignisse → Ihr Authentifizierungsdienst, KYB-Ereignisse → Ihr Compliance-Operations-Dienst, Transaktionen → Ihre Fraud-Warteschlange. Jedes Ziel hat sein eigenes Secret, seine eigene Version und seine eigene Abonnementliste, sodass ein rotiertes Secret oder ein fehlerhaftes Deployment bei einem Konsumenten die anderen nicht beeinträchtigt.## CodebeispieleUm die Sicherheit Ihres Webhook-Endpoints zu gewährleisten, überprüfen Sie die Authentizität eingehender Anfragen mithilfe des `secret_shared_key` des Ziels.> [!WARNING]
>
> **Verwenden Sie `X-Signature-V2`.** Dies ist die Variante, die gegenüber einer erneuten Codierung durch Middleware am robustesten ist, und sie authentifiziert den Body vollständig (einschließlich `decision`).### Node.js / Express (empfohlen: X-Signature-V2)```javascript theme={null}
const express = require("express");
const crypto = require("crypto");

const app = express();
const PORT = process.env.PORT || 1337;

// Per-destination secret from POST /v3/webhook/destinations/
const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || "YOUR_WEBHOOK_SECRET_KEY";

app.use(express.json());

/**
 * Match Didit's float normalisation: whole-valued floats are serialised as ints.
 */
function shortenFloats(data) {
  if (Array.isArray(data)) return data.map(shortenFloats);
  if (data !== null && typeof data === "object") {
    return Object.fromEntries(
      Object.entries(data).map(([key, value]) => [key, shortenFloats(value)])
    );
  }
  if (typeof data === "number" && !Number.isInteger(data) && data % 1 === 0) {
    return Math.trunc(data);
  }
  return data;
}

/** Sort object keys recursively before re-stringifying. */
function sortKeys(obj) {
  if (Array.isArray(obj)) return obj.map(sortKeys);
  if (obj !== null && typeof obj === "object") {
    return Object.keys(obj).sort().reduce((acc, key) => {
      acc[key] = sortKeys(obj[key]);
      return acc;
    }, {});
  }
  return obj;
}

function verifySignatureV2(jsonBody, signatureHeader, timestampHeader, secret) {
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestampHeader, 10)) > 300) return false;

  // Reproduce Didit's canonical JSON: sorted keys, compact, Unicode preserved.
  const canonical = JSON.stringify(sortKeys(shortenFloats(jsonBody)));
  const expected = crypto.createHmac("sha256", secret).update(canonical, "utf8").digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signatureHeader, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifySignatureSimple(jsonBody, signatureHeader, timestampHeader, secret) {
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestampHeader, 10)) > 300) return false;

  const canonical = [
    jsonBody.timestamp ?? "",
    jsonBody.session_id ?? "",
    jsonBody.status ?? "",
    jsonBody.webhook_type ?? "",
  ].join(":");
  const expected = crypto.createHmac("sha256", secret).update(canonical).digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signatureHeader, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

/**
 * The signature header and, for 24 hours after the destination's secret is
 * rotated, its "-Previous" header (the same signature made with the replaced secret).
 */
function signaturesFor(req, header) {
  return [req.get(header), req.get(`${header}-Previous`)].filter(Boolean);
}

app.post("/webhook", (req, res) => {
  const timestamp = req.get("X-Timestamp");
  const body = req.body;

  if (!timestamp || !WEBHOOK_SECRET_KEY) {
    return res.status(401).json({ message: "Missing required headers" });
  }

  // A match on either header is enough.
  const matches = (header, verify) =>
    signaturesFor(req, header).some((sig) => verify(body, sig, timestamp, WEBHOOK_SECRET_KEY));

  let verified = false;
  if (matches("X-Signature-V2", verifySignatureV2)) {
    verified = true;
  } else if (matches("X-Signature-Simple", verifySignatureSimple)) {
    // Deprecated fallback: Simple authenticates the envelope only. Do not trust
    // body.decision here — re-fetch it from GET /v3/session/{session_id}/decision/.
    verified = true;
  }
  if (!verified) return res.status(401).json({ message: "Invalid signature" });

  // Dispatch by event type. Return 2xx fast; do heavy work asynchronously.
  switch (body.webhook_type) {
    case "status.updated":
    case "data.updated":
      // session or business session
      break;
    case "user.status.updated":
    case "user.data.updated":
    case "business.status.updated":
    case "business.data.updated":
      // consolidated entity event
      break;
    case "activity.created":
      // audit timeline event
      break;
    case "transaction.created":
    case "transaction.status.updated":
      // transaction monitoring event
      break;
    case "travel_rule.status.updated":
      // travel rule exchange status event
      break;
  }
  return res.status(200).json({ ok: true });
});

app.listen(PORT, () => console.log(`Server on http://localhost:${PORT}`));
```Wenn Sie die Roh-Bytes auslesen können, bevor ein Parser sie verarbeitet, funktioniert die ursprüngliche Variante `X-Signature` auf dieselbe Weise, signiert jedoch den Roh-Body. Der Vollständigkeit halber führen wir sie unten im Legacy-Abschnitt auf.### Python / FastAPI (empfohlen: X-Signature-V2)```python theme={null}
from fastapi import FastAPI, Request, HTTPException
from time import time
import json
import hmac
import hashlib
import os

app = FastAPI()


def shorten_floats(data):
    """Match Didit: whole-valued floats serialise as ints."""
    if isinstance(data, dict):
        return {k: shorten_floats(v) for k, v in data.items()}
    if isinstance(data, list):
        return [shorten_floats(x) for x in data]
    if isinstance(data, float) and data.is_integer():
        return int(data)
    return data


def verify_signature_v2(body_json, signature_header, timestamp_header, secret):
    if abs(int(time()) - int(timestamp_header)) > 300:
        return False
    canonical = json.dumps(
        shorten_floats(body_json),
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,   # unescaped Unicode -- matches Didit's V2 canonical form
    )
    expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header, expected)


def verify_signature_simple(body_json, signature_header, timestamp_header, secret):
    if abs(int(time()) - int(timestamp_header)) > 300:
        return False
    canonical = ":".join([
        str(body_json.get("timestamp", "")),
        str(body_json.get("session_id", "")),
        str(body_json.get("status", "")),
        str(body_json.get("webhook_type", "")),
    ])
    expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header, expected)


def signatures(request, header):
    """The signature header and, for 24 hours after the destination's secret is
    rotated, its -Previous header (the same signature made with the replaced secret)."""
    return [sig for sig in (request.headers.get(header), request.headers.get(f"{header}-previous")) if sig]


@app.post("/webhook")
async def handle_webhook(request: Request):
    body = json.loads((await request.body()).decode("utf-8"))
    secret = os.environ["WEBHOOK_SECRET_KEY"]

    ts = request.headers.get("x-timestamp")
    if not ts:
        raise HTTPException(status_code=401, detail="Missing X-Timestamp")

    # A match on either header is enough.
    if any(verify_signature_v2(body, sig, ts, secret) for sig in signatures(request, "x-signature-v2")):
        pass
    elif any(verify_signature_simple(body, sig, ts, secret) for sig in signatures(request, "x-signature-simple")):
        # Deprecated fallback: Simple authenticates the envelope only. Do not trust
        # body["decision"] here - re-fetch it from GET /v3/session/{session_id}/decision/.
        pass
    else:
        raise HTTPException(status_code=401, detail="Invalid signature")

    # Dispatch on body["webhook_type"]; return 2xx fast.
    return {"ok": True}
```### PHP / Laravel (empfohlen: X-Signature-V2)Die JSON-Standardeinstellungen von PHP reproduzieren die kanonische Form **nicht**. Vier davon müssen überschrieben werden; fehlt auch nur eine einzige, ändern sich die signierten Bytes, sodass der Digest niemals übereinstimmt:| PHP-Standard | Was ausgegeben wird | Was Sie benötigen |
| - | - | - |
| `json_decode($raw, true)` (assoziativ) | leere Objekte `{}` werden zu `[]` zusammengeklappt | Decodieren mit `$associative = false`, damit JSON-Objekte `stdClass` bleiben |
| `json_encode` maskiert Schrägstriche | `https:\/\/media.didit.me` | `JSON_UNESCAPED_SLASHES` |
| `json_encode` maskiert Nicht-ASCII-Zeichen | `Jos\u00e9` statt `José` | `JSON_UNESCAPED_UNICODE` |
| `ksort` vergleicht numerisch wirkende Schlüssel als Zahlen | `"9"` wird vor `"10"` sortiert | `ksort($props, SORT_STRING)` |Die Falle mit leeren Objekten ist die tückischste. Didit-Payloads enthalten regelmäßig leere Maps wie `risk_view.countries.risk_scores`, `risk_view.crimes.risk_scores` und `risk_view.custom_list`, sodass ein assoziativer Roundtrip die Signatur bereits bei gewöhnlichen genehmigten Sitzungen bricht und nicht erst bei seltenen Grenzfällen.```php theme={null}
<?php

use Illuminate\Http\Request;

class WebhookController extends Controller
{
    /**
     * Rebuild Didit's canonical JSON form: recursively sorted keys, JSON
     * objects preserved as objects, arrays left in order.
     */
    private function canonicalize($value)
    {
        if ($value instanceof \stdClass) {
            $props = get_object_vars($value);
            ksort($props, SORT_STRING); // code-point order, like Python's sort_keys
            $sorted = new \stdClass();
            foreach ($props as $key => $item) {
                $sorted->{$key} = $this->canonicalize($item);
            }
            return $sorted;
        }
        if (is_array($value)) { // JSON arrays keep their order
            return array_map(function ($item) { return $this->canonicalize($item); }, $value);
        }
        return $value;
    }

    private function verifySignatureV2(string $rawBody, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;

        // Decode to objects, not associative arrays: associative decoding turns
        // empty JSON objects ({}) into empty arrays ([]), and json_encode then
        // writes them back as [] -- different bytes than Didit signed.
        $decoded = json_decode($rawBody, false);
        if (json_last_error() !== JSON_ERROR_NONE) return false;

        $canonical = json_encode(
            $this->canonicalize($decoded),
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        );
        return hash_equals(hash_hmac('sha256', $canonical, $secret), $sig);
    }

    /** X-Signature: HMAC over the exact bytes received -- never a re-serialised copy. */
    private function verifySignatureRaw(string $rawBody, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;
        return hash_equals(hash_hmac('sha256', $rawBody, $secret), $sig);
    }

    private function verifySignatureSimple(?array $body, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;
        $canonical = implode(':', [
            $body['timestamp'] ?? '',
            $body['session_id'] ?? '',
            $body['status'] ?? '',
            $body['webhook_type'] ?? '',
        ]);
        return hash_equals(hash_hmac('sha256', $canonical, $secret), $sig);
    }

    public function handle(Request $request)
    {
        $secret = env('WEBHOOK_SECRET_KEY');
        $ts = $request->header('x-timestamp');
        if (!$ts || !$secret) {
            return response()->json(['message' => 'Missing required headers'], 401);
        }

        // getContent() hands you the exact bytes Didit transmitted, before any parser runs.
        $rawBody = $request->getContent();
        $body = json_decode($rawBody, true); // associative copy, for dispatch only

        // Check the signature header and, for 24 hours after the destination's secret is
        // rotated, its -Previous header (the same signature made with the replaced secret).
        // A match on either header is enough.
        $matches = function (string $header, callable $verify) use ($request): bool {
            foreach ([$header, $header . '-previous'] as $name) {
                $sig = $request->header($name);
                if ($sig && $verify($sig)) {
                    return true;
                }
            }
            return false;
        };

        $verified = $matches('x-signature-v2', fn ($sig) => $this->verifySignatureV2($rawBody, $sig, $ts, $secret))
            || $matches('x-signature', fn ($sig) => $this->verifySignatureRaw($rawBody, $sig, $ts, $secret))
            // Deprecated fallback: Simple authenticates the envelope only. Do not trust
            // the decision body here - re-fetch it from GET /v3/session/{session_id}/decision/.
            || $matches('x-signature-simple', fn ($sig) => $this->verifySignatureSimple($body, $sig, $ts, $secret));
        if (!$verified) {
            return response()->json(['message' => 'Invalid signature'], 401);
        }

        // Dispatch on $body['webhook_type']; return 2xx fast.
        return response()->json(['ok' => true]);
    }
}
```Zwei weitere wichtige Punkte:- **Ganzzahlige Floats erfordern in PHP keine besondere Behandlung.** Didit serialisiert sie als Integer (`100.0` → `100`), und PHPs `json_encode` schreibt einen ganzzahligen Float auf dieselbe Weise, sodass im Gegensatz zum obigen Node.js-Beispiel kein Schritt wie `shortenFloats` erforderlich ist.
- **Belassen Sie `serialize_precision` bei `-1`** (der Standardwert seit PHP 7.1). Wenn Ihr Host dies auf `17` überschreibt, schreibt `json_encode` `87.42` als `87.420000000000002` und jede Prüfung von `X-Signature-V2` schlägt fehl.Wenn Sie die kanonische Form nicht zur Übereinstimmung bringen können, ist das obige `verifySignatureRaw` der einfachere Fallback: Laravels `$request->getContent()` gibt die unveränderten Bytes zurück, sodass `X-Signature` funktioniert, solange nichts vor PHP den Body neu codiert. Genau für diesen Fall wurde `X-Signature-V2` entwickelt, weshalb V2 zuerst versucht wird.## TestenSie müssen **keine** vollständige Verifizierung durchführen, um Ihren Endpoint zu testen.- Wählen Sie unter **Business Console > API & Webhooks > Try Webhook** ein Szenario aus (z. B. `approved_full_features`, `declined_face_mismatch`, `in_review_aml_hit`, `business_session_approved`, `transaction_created`, `activity_created`, `user_status_updated`) und senden Sie einen vollständig formatierten Webhook an Ihre Ziel-URL.
- Jedes Szenario wird mit denselben drei Signatur-Headern und derselben kanonischen Codierung signiert wie der Produktionsdatenverkehr, einschließlich Unicode-Testdaten (`approved_kyc_with_unicode`), damit Sie bestätigen können, dass `X-Signature-V2` auch über Ihre Middleware hinweg funktioniert. Testzustellungen enthalten Beispieldaten und sind mit einem zusätzlichen Header `X-Didit-Test-Webhook: true` (sowie dem Suffix `(Test)` im `User-Agent`) gekennzeichnet, damit Sie diese aus Ihrem Produktionssystem heraushalten können.
- Führen Sie beliebige historische Zustellungen über den Tab **Zustellungen** eines Ziels erneut aus, wenn Sie eine Regression debuggen müssen.Für End-to-End-Smoketests führen Sie eine echte Verifizierung über einen Test-Workflow in Ihrer eigenen Anwendung durch – die resultierenden Webhooks verwenden dieselbe Payload-Struktur, dieselben Header und dieselbe Wiederholungsrichtlinie wie die Szenarien unter **Try Webhook**.## Beispiele### Genehmigte KYC-Sitzung (`status.updated`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-444444444444",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "status": "Approved",
  "workflow_id": "66666666-7777-8888-9999-000000000000",
  "workflow_version": 4,
  "vendor_data": "user_42",
  "metadata": { "tier": "premium" },
  "decision": {
    "session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
    "session_number": 43762,
    "status": "Approved",
    "workflow_id": "66666666-7777-8888-9999-000000000000",
    "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH", "AML"],
    "vendor_data": "user_42",
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Approved",
        "document_type": "Identity Card",
        "document_number": "SAMPLE-DOC-12345",
        "first_name": "Jane",
        "last_name": "Doe",
        "date_of_birth": "1990-01-01",
        "issuing_state": "ESP",
        "verification_method": "document",
        "assurance": "documentary",
        "wallet_provider": null,
        "fallback_from": null,
        "id_lookup": null,
        "wallet_verification": null,
        "warnings": []
      }
    ],
    "liveness_checks": [
      { "node_id": "liveness_1", "status": "Approved", "method": "ACTIVE_3D", "score": 95.4, "warnings": [] }
    ],
    "face_matches": [
      { "node_id": "face_match_1", "status": "Approved", "score": 96.1, "warnings": [] }
    ],
    "aml_screenings": [
      { "node_id": "aml_1", "status": "Approved", "total_hits": 0, "entity_type": "person", "hits": [], "warnings": [] }
    ],
    "reviews": [],
    "created_at": "2026-05-17T08:54:25.443172Z"
  }
}
```### Abgelehnte Sitzung mit Warnungen (`status.updated`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-555555555555",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
  "status": "Declined",
  "vendor_data": "user_99",
  "decision": {
    "session_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
    "status": "Declined",
    "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH"],
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Declined",
        "document_type": "Passport",
        "expiration_date": "2024-01-01",
        "warnings": [
          {
            "feature": "ID_VERIFICATION",
            "risk": "DOCUMENT_EXPIRED",
            "additional_data": null,
            "log_type": "error",
            "short_description": "Document expired",
            "long_description": "The document's expiration date has passed, rendering it no longer valid for use.",
            "node_id": "id_verification_1"
          }
        ]
      }
    ],
    "liveness_checks": [{ "node_id": "liveness_1", "status": "Approved", "score": 92.0, "warnings": [] }],
    "face_matches": [
      {
        "node_id": "face_match_1",
        "status": "Declined",
        "score": 32.0,
        "warnings": [
          {
            "feature": "FACEMATCH",
            "risk": "LOW_FACE_MATCH_SIMILARITY",
            "additional_data": null,
            "log_type": "error",
            "short_description": "Low face match similarity",
            "long_description": "The facial features of the provided image don't closely match the reference image, suggesting a potential identity mismatch.",
            "node_id": "face_match_1"
          }
        ]
      }
    ],
    "reviews": []
  }
}
```### Genehmigte Unternehmenssitzung (`status.updated`, KYB)```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "uuid",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "kyb-uuid",
  "business_session_id": "kyb-uuid",
  "session_kind": "business",
  "status": "Approved",
  "vendor_data": "biz_7",
  "vendor_business_id": "acme-inc",
  "decision": {
    "session_id": "kyb-uuid",
    "session_kind": "business",
    "status": "Approved",
    "features": ["REGISTRY_CHECK", "AML", "DOCUMENT_VERIFICATION"],
    "registry_checks": [{ "node_id": "registry_1", "status": "Approved", "company_name": "Acme Corp", "...": "..." }],
    "aml_screenings": [{ "node_id": "aml_1", "status": "Approved", "entity_type": "company", "total_hits": 0, "hits": [] }],
    "document_verifications": [{ "node_id": "doc_1", "status": "Approved", "document_type": "certificate_of_incorporation" }],
    "key_people_checks": [],
    "reviews": []
  }
}
```### Transaktion erstellt (`transaction.created`)```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "transaction.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "uuid",
  "environment": "live",
  "transaction_id": "uuid",
  "txn_id": "finance0001",
  "status": "APPROVED",
  "score": 0,
  "severity": "UNKNOWN",
  "amount": "1200.00",
  "currency": "EUR",
  "direction": "OUTBOUND",
  "action_required": null
}
```### Travel Rule-Austauschstatus geändert (`travel_rule.status.updated`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-666666666666",
  "webhook_type": "travel_rule.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "transaction_id": "22222222-3333-4444-5555-666666666666",
  "txn_id": "trv-2026-07-05-001",
  "travel_rule_status": "COMPLETED",
  "rail": "INTERNAL",
  "direction": "OUTBOUND"
}
````travel_rule_status` enthält einen der 13 Austauschstatus, die unter [Travel Rule](https://docs.didit.me/de/transaction-monitoring/travel-rule.md#lebenszyklus) dokumentiert sind.
`rail` ist `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA` oder `EMAIL` (leere Zeichenkette, bis ein Rail ausgewählt wird, falls keines aufgelöst werden konnte, und stets bei Sunrise-Registrierungen) oder `TEST_VASP` für einen Sandbox-Transfer, den der [Didit Test VASP](https://docs.didit.me/de/transaction-monitoring/travel-rule-sandbox.md) beantwortet hat, und `direction` ist `INBOUND` oder `OUTBOUND`.
Ein Sandbox-Ereignis enthält `"environment": "sandbox"`, und ein `TEST_VASP`-Ereignis enthält außerdem `test_vasp: true` sowie `test_vasp_scenario` (das Szenario, das die Antwort geliefert hat).Wenn Didit eine Transaktion für einen Travel Rule-Transfer erstellt (ein [`POST /v3/transactions/`](https://docs.didit.me/de/management-api/transactions/create)-Aufruf, eine [Sunrise-Registrierung nach Einzahlung](https://docs.didit.me/de/transaction-monitoring/travel-rule.md#nachträgliche-sunrise-registrierung-nach-der-einzahlung), eine eingehende `TRP`-Anfrage oder der eingehende Abschnitt des `INTERNAL`-Rails), sendet es zuerst das `transaction.created`-Ereignis für diese Transaktion und danach die `travel_rule.status.updated`-Ereignisse des Transfers.
Eine Regel, die den Status der Transaktion während dieses Schritts ändert, kann auch `transaction.status.updated` vor `transaction.created` senden, und eine Sunrise-Registrierung, die sich an eine bereits erstellte Einzahlungstransaktion anfügt, sendet kein `transaction.created`.
Jeder Webhook wird separat zugestellt und wiederholt, sodass ein wiederholtes Ereignis dennoch nach einem späteren eintreffen kann: verwenden Sie `transaction_id` als Schlüssel in Ihrem Handler und rufen Sie die Transaktion erneut ab, wenn Sie deren aktuellen Zustand benötigen.
Ein über TRUST gesendeter Transfer erhält niemals ein `COMPLETED`-Ereignis, da TRUST keine von Didit lesbaren Daten zurücksendet: er verbleibt bis zur Bestätigungsfrist auf `AWAITING_COUNTERPARTY` und endet dann mit `EXPIRED`. Über GTR, VerifyVASP und Sygna Bridge liest Didit die Antwort der Gegenpartei aus dem Netzwerk, und das Ereignis folgt darauf (siehe [Travel Rule-Netzwerke](https://docs.didit.me/de/transaction-monitoring/travel-rule-networks.md)).### Workflow-Compliance geändert (`workflow.compliance_changed`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-777777777777",
  "webhook_type": "workflow.compliance_changed",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "workflow_id": "22222222-3333-4444-5555-666666666666",
  "workflow_version": 4,
  "workflow_label": "KYC standard",
  "transition": { "from": "compliant", "to": "gaps_detected" },
  "severity": "actionable",
  "kb_version": "2026-10"
}
````transition.from` und `transition.to` entsprechen einem der Werte `compliant`, `gaps_detected`, `pending_change`, `unknown_coverage` oder `not_evaluated`.
`severity` ist `actionable`, wenn Lücken erkannt wurden, `indicative` bei einer ausstehenden Änderung und `informative`, wenn Lücken behoben wurden.
Didit sendet ein Ereignis pro geändertem Workflow.### Transaktionsregel-Vorschläge erstellt (`transaction_rule_suggestions.created`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-888888888888",
  "webhook_type": "transaction_rule_suggestions.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "run_id": "33333333-4444-5555-6666-777777777777",
  "open_count": 3,
  "suggestions": [
    {
      "suggestion_id": "44444444-5555-6666-7777-888888888888",
      "kind": "TWEAK",
      "title": "Raise the review threshold on high-value transfers",
      "rule_id": "55555555-6666-7777-8888-999999999999",
      "expected_impact": {}
    }
  ]
}
````kind` ist `ADD` für eine neue Regel, `TWEAK` für eine Änderung an einer bestehenden Regel und `DISABLE`, um eine Regel zu deaktivieren. `rule_id` ist für `ADD` gleich `null`.
`open_count` ist die Anzahl der nach dem Durchlauf noch offenen Vorschläge, einschließlich der neuen.
Über die Einstellungen für Regelvorschläge wird dieser Webhook aktiviert oder deaktiviert; siehe [Transaktions-Webhooks](https://docs.didit.me/de/transaction-monitoring/webhooks.md).### Investigator-Ereignisse (`investigator.*`)Die zwölf `investigator.*`-Ereignisse verwenden denselben Envelope (`event_id`, `webhook_type`, `timestamp`, `created_at`, `application_id`, `environment`) und dieselben Signatur-Header wie jedes andere Ereignis. Sie enthalten ausschließlich IDs und Zahlen: niemals einen Namen, eine von einem Prüfer eingegebene Begründung, eine Schilderung oder Eingaben des Endbenutzers. Sie enthalten keine `session_id`; das betroffene Element wird in `item` benannt.```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-777777777777",
  "webhook_type": "investigator.decision.automated",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "run_id": "33333333-4444-5555-6666-777777777777",
  "queue": "kyc",
  "item": { "kind": "session", "id": "cccccccc-dddd-eeee-ffff-000000000000" },
  "recommendation": "approve",
  "confidence": 0.93,
  "provenance": {
    "agent_version": "1",
    "engine": "a1b2c3d4",
    "policy_version": 4,
    "model_id": "a1b2c3d4"
  },
  "decided_by": "investigator",
  "outcome": "approved",
  "action": "approve",
  "final_status": "Approved",
  "agreed": true
}
```| Ereignis | Felder neben dem Envelope |
| - | - |
| `investigator.report.ready` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `ready_to_accept`, `questions` (Anzahl) |
| `investigator.decision.automated` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `decided_by` (`"investigator"`), `outcome`, `action`, `final_status`, `agreed` |
| `investigator.decision.overridden` | Entspricht `investigator.decision.automated` mit `decided_by` `"human"`, plus `override_category`, `overrode` (`"report"` oder `"automated"`) und, wenn `overrode` gleich `"automated"` ist, `previous_status` |
| `investigator.decision.contested` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `case_id`, `contested_at` |
| `investigator.question.raised` | `question_id`, `number`, `queue`, `situation`, `situation_label`, `origin`, `rule_action`, `run_id`, `item` |
| `investigator.automation.paused` | `reason` (`"manual"` oder `"spend_limit"`); bei `"spend_limit"` zusätzlich `month` (`YYYY-MM`) und `currency` |
| `investigator.rfi.answered` | `request_id`, `run_id`, `queue`, `item`, `status`, `deadline_at`, `answered_by`, `answered_at` |
| `investigator.rfi.overdue` | `request_id`, `run_id`, `queue`, `item`, `status`, `deadline_at` |
| `investigator.drift.detected` | `queue`, `reasons`, `recent_agreement`, `baseline_agreement`, `recent_decisions`, `baseline_decisions`, `drop_points`, `psi`, `from_tier`, `to_tier` |
| `investigator.sla.breached` | `run_id`, `queue`, `item`, `priority`, `sla_due_at` |
| `investigator.qa.failed` | `qa_id`, `run_id`, `queue`, `item`, `kind`, `overall`, `scores`, `sop_complies`, `missed_risks` (Risikocodes), `action`, `decided_at` |
| `investigator.external.report.ready` | `item_id`, `external_id`, `queue` (`"external"`), `kind`, `status`, `recommendation`, `confidence`, `policy_action`, `provenance` (`engine`, `agent_version`, `policy_version`, `model_id`) |`provenance` gibt an, welche Version des Investigators den Bericht erstellt hat:- `engine` ist die Version der Investigator-Engine: eine opake achtstellige ID. Sie ändert sich bei jeder Änderung der Engine, sodass zwei Berichte mit derselben `engine` von derselben Engine erstellt wurden.
- `agent_version` ist die Version des Agenten.
- `policy_version` ist die Version Ihrer eigenen Richtlinie, die der Bericht befolgt hat.
- `model_id` ist ein veralteter Alias, der denselben Wert wie `engine` trägt. Lesen Sie stattdessen `engine` aus.
- `prompt_hash` wird nicht mehr gesendet.`item.kind` benennt die Art des Elements (eine Sitzung, eine Unternehmenssitzung, eine Transaktion oder ein Fall) und `item.id` dessen ID. Zeitstempel, die auf `_at` enden, sind ISO 8601-Zeichenketten. Abonnieren Sie diese Ereignisse, indem Sie die genauen Zeichenketten wie bei jedem anderen Ereignis in `subscribed_events` auflisten.### Status der Benutzerentität geändert (`user.status.updated`)```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "user.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969980,
  "application_id": "uuid",
  "environment": "live",
  "vendor_user_id": "uuid",
  "vendor_data": "user_42",
  "status": "BLOCKED",
  "previous_status": "ACTIVE"
}
```### Erneut eingereichte Sitzung```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969980,
  "application_id": "uuid",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "uuid",
  "status": "Resubmitted",
  "vendor_data": "user_42",
  "resubmit_info": {
    "nodes_to_resubmit": [
      { "node_id": "feature_poa", "feature": "PROOF_OF_ADDRESS" }
    ],
    "reasons": { "feature_poa": "Poor document quality" }
  }
}
```## Legacy: X-Signature mit Raw-Body`X-Signature` wird weiterhin bei jeder Zustellung gesendet; es handelt sich um HMAC-SHA256 über die **exakten Bytes**, die Didit überträgt (`json.dumps(..., sort_keys=True, separators=(",", ":"))`). Verwenden Sie diesen Header nur, wenn Sie den Raw-Body auslesen können, bevor eine Middleware ihn verändert.> [tab] Node.js
>
> ```javascript theme={null}
> const express = require("express");
> const bodyParser = require("body-parser");
> const crypto = require("crypto");
>
> const app = express();
> const PORT = process.env.PORT || 1337;
> const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || "YOUR_WEBHOOK_SECRET_KEY";
>
> app.use(bodyParser.json({
>   verify: (req, _res, buf, encoding) => {
>     if (buf && buf.length) req.rawBody = buf.toString(encoding || "utf8");
>   },
> }));
>
> app.post("/webhook", (req, res) => {
>   const signature = req.get("X-Signature");
>   const timestamp = req.get("X-Timestamp");
>   if (!signature || !timestamp || !req.rawBody) return res.status(401).end();
>
>   if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)) > 300) {
>     return res.status(401).json({ message: "Stale timestamp" });
>   }
>
>   const expected = crypto.createHmac("sha256", WEBHOOK_SECRET_KEY).update(req.rawBody).digest("hex");
>   const a = Buffer.from(expected, "utf8");
>   const b = Buffer.from(signature, "utf8");
>   if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
>     return res.status(401).json({ message: "Invalid signature" });
>   }
>
>   const event = JSON.parse(req.rawBody);
>   // Handle event.webhook_type ...
>   return res.json({ ok: true });
> });
>
> app.listen(PORT);
> ```> [tab] Python
>
> ```python theme={null}
> from fastapi import FastAPI, Request, HTTPException
> from time import time
> import hmac
> import hashlib
> import os
>
> app = FastAPI()
>
>
> def verify_raw(body: str, signature: str, timestamp: str, secret: str) -> bool:
>     if abs(int(time()) - int(timestamp)) > 300:
>         return False
>     expected = hmac.new(secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256).hexdigest()
>     return hmac.compare_digest(signature, expected)
>
>
> @app.post("/webhook")
> async def handle(request: Request):
>     body = (await request.body()).decode("utf-8")
>     sig = request.headers.get("x-signature")
>     ts = request.headers.get("x-timestamp")
>     secret = os.environ["WEBHOOK_SECRET_KEY"]
>
>     if not sig or not ts or not verify_raw(body, sig, ts, secret):
>         raise HTTPException(status_code=401, detail="Unauthorized")
>     return {"ok": True}
> ```## Legacy: V2-Webhook-Format> [!WARNING]
>
> Die nachfolgenden Felder gelten nur, wenn ein Webhook-Ziel auf `webhook_version: "v2"` festgelegt ist. Für Ziele, die vor V2 erstellt wurden, existiert zudem eine noch ältere `"v1"`-Festlegung (eine dritte, singuläre Entscheidungsstruktur) – kontaktieren Sie den Support, bevor Sie sich darauf verlassen. Neue Integrationen sollten **V3** (Standard) verwenden, das oben dokumentiert ist und plurale Arrays nutzt.| Aspekt | V2 | V3 |
| - | - | - |
| Signatur-Header | `X-Signature`, `X-Signature-V2`, `X-Signature-Simple` (alle drei werden unabhängig von der Version gesendet; Simple ist [veraltet](#x-signature-simple-ist-veraltet)) | Identisch |
| Feldnamen der Entscheidung | Singuläre Objekte (`id_verification`, `liveness`, `face_match`, ...) | Plurale Arrays (`id_verifications`, `liveness_checks`, `face_matches`, ...) |
| Mehrere Funktionsinstanzen | Nicht unterstützt | Unterstützt über funktionsspezifische Arrays mit `node_id` |
| AML-Scores | `aml.score`, `aml.hits[].match_score`, `aml.hits[].risk_score` | Dieselben Werte unter `aml_screenings[]` |Die funktionsspezifischen Objekte selbst sind in beiden Versionen identisch: Ein V2-Ziel erhält exakt dieselben Treffer-Werte für `match_score`, `risk_score`, `score_breakdown`, `risk_view` und `review_status` sowie den aggregierten `score` wie V3 (siehe [AML-Bericht](https://docs.didit.me/de/core-technology/aml-screening/report-aml-screening.md#drei-scores-kein-einzelnes-urteil)). Die Festlegung auf V2 ändert lediglich, an welcher Stelle im `decision`-Objekt der AML-Bericht liegt, nicht dessen Inhalt.Falls ein nachgelagertes System weiterhin V2-Strukturen benötigt, erstellen Sie ein separates Webhook-Ziel mit `webhook_version: "v2"` und belassen Sie Ihre neuen Systeme auf V3.## Verwandte Themen- [API für Webhook-Ziele](https://docs.didit.me/de/management-api/webhook/list-destinations.md) – Webhook-Ziele auflisten, erstellen, aktualisieren, löschen und Zustellungs-Logs prüfen.
- [Webhook-Ziel erstellen](https://docs.didit.me/de/management-api/webhook/create-destination.md) – Anfrage- und Antwortschema einschließlich des `secret_shared_key` des Ziels.
- [Datenmodelle](https://docs.didit.me/de/reference/data-models.md) – Kanonisches Schema für jedes in `decision` eingebettete funktionsspezifische Objekt.
- [Entscheidungsschema](https://docs.didit.me/de/sessions-api/retrieve-session.md) – Vollständige Feldliste für das in Sitzungs-Webhooks eingebettete `decision`-Objekt.
- [Verifizierungsstatus](https://docs.didit.me/de/integration/verification-statuses.md) – Genaue `status`-Werte und oben erwähnte Übergänge.
- [Rate-Limiting](https://docs.didit.me/de/integration/rate-limiting.md) – Limits, die gelten, wenn `GET /v3/session/{id}/decision/` als Fallback per Polling abgefragt wird.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.