# 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](https://docs.descope.com/how-to-deploy-to-production/public-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:

```json
[
  {
    "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

1. Deploy an endpoint that accepts a `POST` with a JSON array body.
2. Generate a long random HMAC secret and store it server-side, for example as `DESCOPE_WEBHOOK_SECRET`.
3. 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.
4. Make a change in the project, such as editing a user, and wait a few minutes for the first batch.
5. If your firewall restricts inbound traffic, allowlist Descope's [static IPs](https://docs.descope.com/how-to-deploy-to-production/public-static-ips) for your region.

For local development, the [Hookdeck CLI](/docs/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.

```javascript
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:

```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](/event-gateway) verifies `x-descope-webhook-s256` with 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`, `occurred` and `data.correlation_id`, and skip events you have stored.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/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](/docs/retries) 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](/docs/transformations) 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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](/webhooks/guides/implement-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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Descope webhooks reliably in minutes.