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

# Webhook Payload

> Shape of the HTTP POST body Scute sends to your webhook endpoints.

Every event you subscribe to is delivered as a `POST` to your endpoint with the same top-level shape. The two fields most integrations care about are `event_type` (what happened) and `metadata` (what you tagged the verification with at create time).

## Headers

```http theme={null}
POST <your_url>
Content-Type: application/json
User-Agent: AppName-Webhooks/1.0
X-Webhook-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>
```

The signature is HMAC-SHA256 of `"<timestamp>.<raw_body>"` using your endpoint's secret. Recompute it on your side and reject the request if it doesn't match.

## Body

```json theme={null}
{
  "id": "<delivery_uuid>",
  "verification_id": "<challenge_uuid>",
  "challenge_id": "<challenge_uuid>",
  "created_at": "2026-05-27T02:33:33Z",
  "event_type": "verification.success",
  "app_id": "app_l3RxEBGbQq5Q0b6T6K6N",
  "user": {
    "id": "<app_user_uuid>",
    "external_id": "your-own-id-or-null",
    "email": "jane@acme.com",
    "phone": "+15555550100",
    "msp_id": "msp_xxx"
  },
  "metadata": {
    "ticket_id": "T-123",
    "company_id": "C-9",
    "msp": {
      "id": "msp_xxx",
      "msp_client_app_id": "app_clientxxx",
      "msp_client_workspace_id": "<workspace_uuid>"
    }
  },
  "data": {
    "purpose": "verify_contact",
    "method": "magic_link",
    "outcome": "verified",
    "intent": "Refund approval",
    "attempts": 0
  },
  "api_version": "v1"
}
```

### Top-level fields

| Field             | Type              | Notes                                                                                                                                        |
| ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`              | string (uuid)     | The webhook delivery ID. Unique per delivery attempt — use it for idempotency keys.                                                          |
| `verification_id` | string (uuid)     | The challenge UUID that triggered the event. Same as `challenge_id`.                                                                         |
| `challenge_id`    | string (uuid)     | Alias for `verification_id`. Either works for lookups.                                                                                       |
| `created_at`      | ISO 8601          | When this delivery was created (not when the underlying event happened).                                                                     |
| `event_type`      | string            | The event slug. See below.                                                                                                                   |
| `app_id`          | string            | Public app ID (`app_xxx`) the event belongs to.                                                                                              |
| `user`            | object \| omitted | The verifying user. Omitted for system events with no user.                                                                                  |
| `metadata`        | object \| omitted | Whatever you passed in `metadata` at challenge create time, plus any auto-attached info (e.g. `msp` for MSP challenges). Omitted when empty. |
| `data`            | object            | Event-type-specific fields (purpose, method, outcome, etc.).                                                                                 |
| `api_version`     | string            | Webhook endpoint API version.                                                                                                                |

### `user` block

| Field         | Type           | Notes                                                                                                                   |
| ------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `id`          | string (uuid)  | The canonical user identifier for this app.                                                                             |
| `external_id` | string \| null | The ID you supplied at user creation, if any. Use this to map back to your own DB.                                      |
| `email`       | string \| null | Primary email.                                                                                                          |
| `phone`       | string \| null | Primary phone (E.164).                                                                                                  |
| `msp_id`      | string \| null | Public ID of the MSP this user belongs to (`msp_xxx` format). Omitted when the user's workspace isn't tagged to an MSP. |

### `metadata` block

Everything you passed in `metadata` when you created the challenge, echoed back verbatim. Use it as the primary correlation key — `ticket_id`, `order_id`, whatever your domain needs.

For MSP challenges (created via `POST /v1/workspaces/:workspace_id/challenges`), Scute auto-attaches an `msp` sub-object with routing identifiers:

```json theme={null}
"msp": {
  "id": "msp_xxx",
  "msp_client_app_id": "app_clientxxx",
  "msp_client_workspace_id": "<workspace_uuid>"
}
```

Cosmetic MSP fields (`name`, `logo_url`, `primary_color`) are used by the tenant verifier page but **not** included in webhook payloads — your handler gets only the routing IDs.

The `_plain_code` field (used internally for OTP delivery) is always stripped from the payload — it is never sent to your endpoint.

## Event types

Subscribe via the dashboard or `POST /v1/apps/:app_id/webhook_endpoints`. The slugs we currently emit:

### Verification

| Slug                           | When                                                                              |
| ------------------------------ | --------------------------------------------------------------------------------- |
| `verification.attempted`       | A challenge was created and delivery started.                                     |
| `verification.success`         | User completed verification (correct code, magic link clicked, passkey approved). |
| `verification.failed`          | Wrong code / max attempts exceeded.                                               |
| `verification.denied`          | User explicitly denied/rejected (consent mode).                                   |
| `verification.email.requested` | A new email verification was requested.                                           |

### User

| Slug                  | When                                             |
| --------------------- | ------------------------------------------------ |
| `user.created`        | New user account created via the management API. |
| `user.updated`        | User profile fields changed.                     |
| `user.deleted`        | User hard-deleted.                               |
| `user.meta.updated`   | `user_meta` fields changed.                      |
| `user.status.changed` | Active ⇄ inactive.                               |
| `user.invited`        | Magic-link invite sent.                          |

### Auth / sessions

| Slug                                                                         | When                        |
| ---------------------------------------------------------------------------- | --------------------------- |
| `magiclink.login.init`                                                       | Magic link login initiated. |
| `magiclink.auth.success`                                                     | Magic link authenticated.   |
| `otp.send.init`                                                              | OTP sent.                   |
| `otp.verify.success` / `otp.verify.failed`                                   | OTP verification outcome.   |
| `session.magic.created` / `session.otp.created` / `session.webauthn.created` | Session minted.             |

### App / system

| Slug                                          | When                                  |
| --------------------------------------------- | ------------------------------------- |
| `app.created` / `app.updated` / `app.deleted` | App lifecycle.                        |
| `webhook.test`                                | Manual test fired from the dashboard. |

### Wildcard

| Slug | When                     |
| ---- | ------------------------ |
| `*`  | Subscribe to everything. |

## Delivery semantics

* **At-least-once.** If your endpoint returns non-2xx, we retry with backoff up to the endpoint's `retry_limit` (default 3). Use the `id` field for idempotency.
* **Exactly one POST per event per matching subscriber.** No duplicates.
* **No ordering guarantee.** Don't assume events arrive in causal order.
* **Async delivery.** Webhook delivery happens shortly after the underlying event — typically within milliseconds, but can be delayed under load.
* **No automatic IP allowlisting** today. Validate via the `X-Webhook-Signature` header.

## Verifying the signature (example, Node.js)

```js theme={null}
import crypto from "crypto";

function verify(rawBody, headerValue, secret) {
  const [tsPart, sigPart] = headerValue.split(",");
  const timestamp = tsPart.replace("t=", "");
  const expected = sigPart.replace("v1=", "");
  const computed = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
}
```

Pass the **raw** request body (not the parsed JSON object) when computing the signature.

## Per-challenge `callback_url` (alternative to webhook endpoints)

If you set `callback_url` when creating a challenge, Scute also POSTs a single request to that URL when the challenge reaches a terminal state. The payload shape differs from the webhook endpoint payload — see [Manage Users](/api-reference/users/manage-users) for the challenge creation API. Use webhook endpoints unless you specifically need per-challenge routing.
