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
"<timestamp>.<raw_body>" using your endpoint’s secret. Recompute it on your side and reject the request if it doesn’t match.
Body
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:
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 orPOST /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 theidfield 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-Signatureheader.
Verifying the signature (example, Node.js)
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 for the challenge creation API. Use webhook endpoints unless you specifically need per-challenge routing.