Guide to Descope Webhooks: Features and Best Practices
Descope's Audit Webhook connector streams your project's audit trail to your own API. Logins and failed logins, users created, modified and deleted, role and tenant changes, flow edits and settings changes all arrive in batches, so you can feed a SIEM, keep a compliance archive, alert on suspicious activity or sync user changes into your own systems.
This guide covers how the Audit Webhook connector is configured and signed, the batched payload and its fields, how delivery timing works, and the best practices for running a Descope receiver in production.
What are Descope webhooks?
Descope sends outbound requests through connectors. The one that behaves like a webhook is the Audit Webhook connector: point it at a URL, turn on Stream Audit Events, and Descope posts your project's audit events to it.
Each request is a POST whose body is a JSON array of up to 100 audit events, "even when it contains a single event". Delivery is not real time. Descope stores each audit event first, then a background loop collects pending events and sends them in batches, so a gap of several minutes between an action and the request is normal.
Signing is optional. If you give the connector an HMAC Secret, Descope signs each request and sends a base64 HMAC-SHA256 in the x-descope-webhook-s256 header. The connector can also send a static credential through its Authentication Type (Bearer token, API key or Basic), but that is a fixed value, not a signature.
Descope webhook features
| Feature | Details |
|---|---|
| Configuration | The Audit Webhook connector, with Stream Audit Events turned on |
| Subscription model | All of the project's audit events. No per-action filter |
| Payload | A JSON array of up to 100 audit events |
| Delivery timing | Batched, several minutes behind by design |
| Signature header | x-descope-webhook-s256, only when an HMAC secret is set |
| Scheme | HMAC-SHA256, base64 |
| Signed content | The JSON string Descope posts, which is the request body |
| Secret | The connector's HMAC secret, chosen by you |
| Replay window | None. No timestamp is sent or signed |
| Other authentication | Bearer token, API key, Basic or none, plus optional custom headers |
| Success | Any 2xx response. Delivered batches are not resent |
| Source IPs | Descope's published static IPs |
Descope publishes no retry schedule or response timeout for the connector. It says only that delays grow "under backlog or retry conditions".
Common events
The event type is each element's action. Descope's audit events page lists over 100 project-level actions, and the page says its Name column "is the exact string delivered in the action field". Some of the ones you are most likely to act on:
| Area | Actions |
|---|---|
| Authentication | LoginSucceed, LoginFailed, LoginExceedMaxAttempts, LoginStarted, UserTemporaryPasswordLock, TOTPCodeReused |
| Users | UserCreated, UserModified, UserDeleted, UsersCreated, UsersModified, UsersDeleted |
| Passwords and passkeys | Create Password, Change Password, Expire Password, Remove Passkey |
| Roles and permissions | RoleCreated, RoleModified, RolesDeleted, PermissionCreated, UsersRolesAssociationsModified |
| Tenants | TenantCreated, TenantDeleted, TenantSettings, TenantDomainModified |
| Configuration | ProjectSettings, PasswordSettings, SAMLSettings, FlowCreated, FlowUpdated, ConnectorModified, SignKeyGeneratedRevoked |
| Access keys | AccessKeyCreated, AccessKeyModified, AccessKeyDeleted |
Watch the spelling. Most actions are PascalCase, but the password and passkey actions contain spaces, such as Change Password, and must be matched exactly. Custom audit events that you create through the Management API or an SDK carry whatever action you gave them.
Here is Descope's Change Password example:
[
{
"action": "Change Password",
"actorId": "U3Dga0ziehqoVAyXog2H6AaknVFn",
"userId": "U3Dga0ziehqoVAyXog2H6AaknVFn",
"projectId": "P2ABc0defGHijKLmnOPqrSTuvWXyz",
"occurred": 1780949905843,
"occurred_formatted": "2026-06-08T20:18:25.843277Z",
"method": "",
"device": "Desktop",
"geo": "US",
"loginIds": ["user@company.com"],
"remoteAddress": "203.0.113.42",
"tenants": null,
"data": {
"browser": "Chrome",
"correlation_id": "3ErwHAgTHSy1pwg0SgN9eFcNIsu",
"flow_id": "reset-password",
"flow_execution_id": "3ErwG1GLbW4tWI3nAKRD5zO09E9",
"os": "macOS",
"request_details": { "uri": "/v1/flow/next", "method": "POST" }
}
}
]
actorId is who did it and userId is who it was done to. occurred is epoch milliseconds as a JSON number, and occurred_formatted gives the same time as an RFC 3339 string. tenants can be null, and method can be an empty string. data holds request context and event-specific detail, and on modification events such as UserModified it includes a Change object describing what changed. Descope says to "treat it as an open map and read only the keys you need".
If you also use Descope's Search Audit API, don't treat its records as the same shape. The API calls login identifiers externalIds, returns occurred as a string and includes an event ID and type. The webhook sends loginIds, a numeric occurred and no event ID.
Setting up Descope webhooks
- Deploy an endpoint that accepts a
POSTwith a JSON array body. - Generate a long random HMAC secret and store it server-side, for example as
DESCOPE_WEBHOOK_SECRET. - In the Descope console, add an Audit Webhook connector and fill in:
- Base URL: your endpoint. Use
https://. - Authentication Type: optional, if your API also expects a credential.
- HMAC Secret: the secret from step 2.
- Trust Any Certificate: leave this off in production.
- Stream Audit Events: turn this on.
- Base URL: your endpoint. Use
- Make a change in the project, such as editing a user, and wait a few minutes for the first batch.
- If your firewall restricts inbound traffic, allowlist Descope's static IPs for your region.
For local development, the Hookdeck CLI (hookdeck listen 3000 descope --path /webhooks/descope) gives you a public HTTPS URL that forwards to your local server, with no account required.
Securing Descope webhooks
Read x-descope-webhook-s256, compute an HMAC-SHA256 of the raw request body with your HMAC secret as the key, base64-encode it and compare in constant time. Base64 is case-sensitive, so compare it exactly.
const crypto = require('crypto');
const express = require('express');
function verifyDescopeSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
const received = Buffer.from(signatureHeader.trim(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/descope', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyDescopeSignature(req.body, req.get('x-descope-webhook-s256'), process.env.DESCOPE_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const events = JSON.parse(req.body.toString('utf8')); // always an array
// Store the batch, then process each event by its action.
res.sendStatus(204);
});
The same check in Python:
import base64
import hashlib
import hmac
import os
def verify_descope_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest)
return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)
# In FastAPI: raw_body = await request.body()
# verify_descope_signature(raw_body, request.headers.get("x-descope-webhook-s256"),
# os.environ["DESCOPE_WEBHOOK_SECRET"])
To check your verifier, use the secret descope-test-secret and this exact body:
[{"action":"UserModified","actorId":"U3Dga0ziehqoVAyXog2H6AaknVFn","userId":"U3Dga0ziehqoVAyXog2H6AaknVFn","projectId":"P2ABc0defGHijKLmnOPqrSTuvWXyz","occurred":1780949905871}]
The signature should be:
ydx9rrkr3XgMkX/39togKzGTwRT/0Ia72c07+0ECLo0=
Descope's HMAC page says it "creates the HMAC signature from a JSON string, not the raw post body". Its own test shows the posted body is that same JSON string, so hashing the raw bytes is correct. Its second example, which parses the body and hashes JSON.stringify(payload), only matches while your serialiser happens to reproduce Descope's bytes exactly.
Make Descope webhooks production-ready. Hookdeck Event Gateway verifies
x-descope-webhook-s256with your HMAC secret, stores every audit batch as soon as it arrives, and delivers it to your SIEM or archive with retries you control.
Descope webhook limitations and pain points
No HMAC secret, no signature
The Problem: Requests arrive without x-descope-webhook-s256, and anything that verifies signatures rejects them.
Why It Happens: The HMAC secret is optional on the connector. Without it, Descope sends unsigned requests, protected at most by the static credential from Authentication Type.
Workarounds:
- Set an HMAC secret on every Audit Webhook connector.
- If you must run without one, check a long random bearer token or API key on every request, and keep the URL private.
How Hookdeck Can Help: Hookdeck's Descope source type verifies x-descope-webhook-s256 with the HMAC secret you give it. Set the same secret on the Descope connector, because unsigned requests fail verification. If you didn't set an HMAC secret in Descope, leave verification off on the source.
A batch of mixed events with no IDs
The Problem: Your handler expects one event, or processes a retried batch twice.
Why It Happens: The body is always an array, a batch usually holds several different actions, and audit webhook events carry no event ID.
Workarounds:
- Always iterate the array and switch on each
action. - Build a key per event from
action,actorId,userId,occurredanddata.correlation_id, and skip events you have stored.
How Hookdeck Can Help: Hookdeck's deduplication can drop a repeated batch before it reaches your service, and each batch stays in Hookdeck's log for inspection and replay.
Minutes of delay, by design
The Problem: An alert on failed logins fires several minutes after the attack started.
Why It Happens: Descope stores events first and sends them from a background loop. It says several minutes of delay is normal, longer under backlog or retries, and that you should not "rely on the webhook for synchronous flow".
Workarounds:
- Use the audit webhook for monitoring, analytics and compliance, not for anything a user waits on.
- Drive real-time reactions from your own application logic, as Descope advises.
- Do not set timeouts based on the delays you observe, since Descope gives no service level.
How Hookdeck Can Help: Hookdeck records when each batch arrived and when your service processed it, so you can tell Descope's batching delay apart from your own processing time.
A 2xx too early loses events
The Problem: Your handler acknowledged a batch, then failed to write it, and the events are gone from your side.
Why It Happens: Descope "treats any 2xx response as a successful delivery and will not resend those events", and asks you to return a 2xx only after the batch is processed.
Workarounds:
- Write the whole batch to durable storage before responding.
- Reconcile periodically with the Search Audit API, as Descope recommends, to catch anything missed.
How Hookdeck Can Help: Hookdeck stores the batch durably before it answers Descope, then retries delivery to your service until it succeeds, so a crash after acknowledging no longer loses the events.
Two shapes for the same event
The Problem: Code written against the Search Audit API fails on webhook events, or the reverse.
Why It Happens: The webhook uses loginIds and a numeric occurred, and has no ID. The API uses externalIds, a string occurred and includes ID and type.
Workarounds:
- Normalise both into one internal shape at ingestion.
How Hookdeck Can Help: A Hookdeck transformation can rename loginIds and convert occurred before delivery, so webhook events arrive in the same shape your reconciliation job produces.
Best practices
Verify against the raw body
Mount express.raw({ type: '*/*' }) in Express, use await request.text() in a Next.js route handler, or read await request.body() in FastAPI, and parse only after verifying.
Persist before you acknowledge
Write the batch to a queue or table first, then return 2xx, and process each event from there. See why to process webhooks asynchronously.
Match actions exactly
Compare action strings exactly, spaces included, and log any you don't recognise. New actions appear as Descope adds features.
Parse occurred as a number
occurred is epoch milliseconds as a number. Use occurred_formatted if you need an ISO string, and don't pass occurred straight to an ISO date parser.
Reconcile with the Search Audit API
For data you depend on, run a periodic job against the Search Audit API to fill any gaps. Deduplicate both sources with the same key. See our guide to webhook idempotency.
Conclusion
Descope's Audit Webhook connector posts arrays of up to 100 audit events, minutes after they happen, signed with a base64 HMAC-SHA256 only when you set an HMAC secret. Set the secret, verify x-descope-webhook-s256 against the raw bytes, iterate every batch, match action strings exactly, store the batch before acknowledging, and reconcile with the Search Audit API.
Hookdeck Event Gateway checks each signature, stores every batch durably before answering Descope and retries delivery to your service, so a failed write on your side does not leave a gap in your audit trail.
Get started with Hookdeck for free and handle Descope webhooks reliably in minutes.