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.
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).
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
{
"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": "<user_uuid>",
"app_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) | Scute’s User ID (cross-app, identifies the human). |
app_user_id | string (uuid) | Scute’s AppUser ID (scoped to 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. |
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:
"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)
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 for the challenge creation API. Use webhook endpoints unless you specifically need per-challenge routing.