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

# Webhooks

> The signed events Frayme pushes to you — decision outcomes, callback dispatches, and node notifications — plus how to verify and deduplicate them.

Rather than polling, let Frayme push updates to you. Frayme delivers signed
HTTPS `POST`s to a URL configured for your tenant whenever a case decision is
reached (or later overridden), when a workflow node asks an external system to
act, or when a node emits a notification mid-run.

<Info>
  Your webhook URL, signing secret, secret ID, and retry policy are provisioned
  for you by the Frayme team. These will move to self-service in the portal.
</Info>

## Events

| `event_type`                 | When                                                                                                                                                                | Payload                                          |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `case.decided`               | Workflow finished with `approved` or `declined`.                                                                                                                    | [Decision](#decision-events)                     |
| `case.pending_review`        | Workflow finished with `in_review`; the case is now in a queue.                                                                                                     | [Decision](#decision-events)                     |
| `case.decision_overridden`   | An analyst overrode an existing decision.                                                                                                                           | [Decision](#decision-events)                     |
| `external_callback.dispatch` | A workflow node paused for an external system. Frayme is asking that system to do work and post back to a one-time callback URL.                                    | [Callback dispatch](#external-callback-dispatch) |
| `node.notification`          | A node configured to notify emitted output mid-run — for example, a Sumsub node publishing a hosted verification link and a pending-action handle before it pauses. | [Node notification](#node-notification)          |
| `case.rfi_requested`         | A request for information was raised on a case (by an analyst, a workflow node, or an API call). Your systems send the resulting email — Frayme never does.         | [RFI requested](#rfi-requested)                  |

<Note>
  `event_type` is always snake\_case. Decision events and `case.rfi_requested`
  also use snake\_case for their top-level identity keys (`case_id`,
  `tenant_id`); the `external_callback.dispatch` and `node.notification` events
  use camelCase (`caseId`, `tenantId`). The examples below reflect the exact
  keys on the wire.
</Note>

## Payload shapes

### Decision events

Sent for `case.decided`, `case.pending_review`, and `case.decision_overridden`.
The body carries the full decision `result` so you can act without re-fetching
the case.

```json theme={null}
{
  "webhookId": "8f1d2e6c-3a4b-4c5d-9e8f-0a1b2c3d4e5f",
  "event_type": "case.decided",
  "case_id": "case_01HABCXYZ",
  "external_customer_id": "cust-00481",
  "tenant_id": "tenant_01HABCXYZ",
  "result": {
    "decision": {
      "value": "approved",
      "source": "workflow",
      "actor": "wf_transactions_v2",
      "decidedAt": "2026-05-19T14:32:04Z"
    },
    "decisionHistory": [
      {
        "value": "approved",
        "source": "workflow",
        "actor": "wf_transactions_v2",
        "decidedAt": "2026-05-19T14:32:04Z"
      }
    ],
    "workflow_result": { "...": "raw vars emitted by the output node" }
  },
  "timestamp": "2026-05-19T14:32:04Z"
}
```

* `external_customer_id` is present only when the case carried one — it is the
  stable key to correlate the decision back to your own user record.
* `result` is the same envelope returned by
  [`GET /cases/{caseId}`](/api-reference/introduction#endpoints): `result.decision`
  is the current decision, `result.decisionHistory[0]` is the workflow's initial
  decision, and later entries are analyst overrides.
* `result.decision.source` is one of `workflow`, `risk_evaluation`, or `analyst`.
* `result.workflow_result` is write-once — analyst overrides never mutate it.

For a `case.decision_overridden` event, the new `result.decision` reflects the
analyst's override and a new entry is appended to `result.decisionHistory`.

### External callback dispatch

Sent for `external_callback.dispatch`. The receiving system performs its work,
then posts the result to the one-time `callbackUrl` (see
[Delivering an external callback](/integration/guide#delivering-an-external-callback))
before `expiresAt`.

```json theme={null}
{
  "webhookId": "1c2b3a4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
  "event_type": "external_callback.dispatch",
  "tenantId": "tenant_01HABCXYZ",
  "caseId": "case_01HABCXYZ",
  "nodeId": "node_partner_review",
  "webhookKey": "partner-review",
  "callbackUrl": "https://core.us.api.frayme.io/cases/case_01HABCXYZ/callbacks/cbk_oneTimeToken",
  "expiresAt": "2026-05-19T15:32:04Z",
  "payload": { "...": "node-specific input" }
}
```

### Node notification

Sent for `node.notification` when a node configured to notify emits output.
`payloadType` identifies the output schema (for example,
`sumsub-application-start`); `output` carries the node's emitted fields,
including a `pending_handle` when the node also exposes
[pending actions](/integration/guide#pending-actions-on-a-paused-node).

```json theme={null}
{
  "webhookId": "3f2a1b0c-9d8e-7f6a-5b4c-3d2e1f0a9b8c",
  "event_type": "node.notification",
  "payloadType": "sumsub-application-start",
  "tag": "kyc-onboarding",
  "tenantId": "tenant_01HABCXYZ",
  "caseId": "case_01HABCXYZ",
  "nodeId": "node_sumsub",
  "webhookKey": "kyc-links",
  "output": {
    "applicant_id": "6a3d4ca539de5a064a06f245",
    "external_user_id": "tenant_01HABCXYZ:cust-00481:node_sumsub",
    "verification_url": "https://in.sumsub.com/websdk/p/abc123",
    "sdk_token": "_act-sbx-jwt-...",
    "sdk_token_expires_at": "2026-05-19T15:00:00Z",
    "pending_handle": "84147546-1b38-46d9-95ed-537d0dd4945e"
  }
}
```

`payloadType` and `tag` appear only when configured on the node. The `output`
keys depend on the node's provider and configuration. A Sumsub node that allows
retries emits a `node.notification` with `payloadType: sumsub-retry-requested`
each time the applicant must resubmit — see
[Data sources → Sumsub → Resubmission retries](/data-sources/kyc/sumsub#resubmission-retries).

### RFI requested

Sent for `case.rfi_requested` when a request for information is raised on a case
— by an analyst in the review console, a workflow node, or your own API call.
**Frayme never sends the email itself**; your systems send it to the customer
using this payload. Delivered to your tenant's default webhook configuration (or
the one named by `webhook_key`).

```json theme={null}
{
  "webhookId": "b7c1a2d3-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
  "event_type": "case.rfi_requested",
  "tenant_id": "tenant_01HABCXYZ",
  "case_id": "case_01HABCXYZ",
  "rfi_id": "rfi_01HABCXYZ",
  "case_type": "KYC",
  "to": "maria.silva@example.com",
  "subject": "One more document to verify your account",
  "body": "Hi Maria, please reply with a photo of your proof of address.",
  "requested_by": "analyst:user_01HABCXYZ",
  "source": "analyst",
  "timestamp": "2026-05-19T14:40:00Z"
}
```

* `source` is one of `analyst`, `workflow`, or `api` — who raised the request.
  `requested_by` is a prefixed identifier: `analyst:<userId>`, `node:<nodeId>`,
  or `apikey:<keyId>`.
* `template_id` (when a template was used), `cc`, and `webhook_key` are present
  only when set.

<Warning>
  This event carries **PII** — `to`, `cc`, `subject`, and `body` are the
  customer-facing email content. Handle and store it accordingly, and keep it
  out of your logs.
</Warning>

<Note>
  **Deduplicate this event on `rfi_id`, not `webhookId`.** Re-raising a
  still-pending RFI legitimately re-publishes the event with a *fresh*
  `webhookId`, so the `webhookId` rule below would let a duplicate through.
</Note>

## Headers

Every outbound webhook carries these headers.

| Header               | Value                                                                               |
| -------------------- | ----------------------------------------------------------------------------------- |
| `X-Frayme-Signature` | HMAC-SHA256 of the **raw request body**, keyed by your signing secret, hex-encoded. |
| `X-Frayme-Secret-ID` | (When set) identifier of the active signing secret — used during secret rotation.   |
| `Content-Type`       | `application/json`                                                                  |

## Verifying the signature

Compute the HMAC over the **exact bytes** of the request body — do not
re-serialize the JSON — and compare it to `X-Frayme-Signature` in constant time.

```python theme={null}
import hmac, hashlib

expected = hmac.new(SIGNING_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Frayme-Signature"]):
    return 401
```

1. Compute HMAC-SHA256 over the raw body bytes; compare hex digests with a
   constant-time comparison (for example, `hmac.compare_digest`).
2. If `X-Frayme-Secret-ID` is present, select the matching secret from your
   rotation set before computing the HMAC.
3. Respond `2xx` within the per-delivery timeout (about 10 seconds).

## Deduplication

Every outbound webhook carries a unique `webhookId` (UUID). Delivery is
**at-least-once** — a transient failure or network blip can produce duplicate
deliveries with the same `webhookId`. Persist the `webhookId` of every event you
successfully process, and short-circuit any event whose `webhookId` you have seen
before.

The one exception is [`case.rfi_requested`](#rfi-requested): deduplicate it on
`rfi_id`, because re-raising a still-pending RFI re-publishes with a fresh
`webhookId`.

## Retries

A non-`2xx` response or a timeout is retried with **exponential backoff** — each
wait is twice the last (1s, 2s, 4s, 8s, …) — up to a configured maximum number
of attempts (5 by default). After the final attempt the delivery is recorded as
failed and not retried further, so treat webhooks as best-effort and reconcile
with [`GET /cases/{caseId}`](/api-reference/introduction#endpoints) if you must
be certain of a case's current decision.
