Webhook events — catalog + payload shapes
This is the customer-facing reference for webhook events emitted by the Driftstack control plane and the synthetic connectivity test event.
Quick index
| Event | When |
|---|---|
session.completed |
Session is destroyed cleanly |
session.failed |
Session terminates in errored state |
api_key.revoked |
API key is revoked by a customer or administrator |
test.ping |
Synthetic test event from POST /v1/webhooks/:id/test |
session.egress_capability_changed |
A session reports a changed SOCKS5 egress capability |
crypto.order.paid |
A NowPayments-backed order transitions to paid |
crypto.order.failed |
A crypto order moves to terminal failed |
session.challenge_detected |
The session harness detects a supported bot challenge |
session.profile_save_failed |
Profile save-back fails to replace the stored profile |
Common envelope
Every webhook delivery is a POST to the customer’s registered URL
with the following envelope:
{
"id": "<uuid>",
"type": "<event-type>",
"created_at": "2026-05-05T12:34:56.789Z",
"data": {
/* per-event-type shape, see below */
}
}
Headers:
Content-Type: application/jsonX-Driftstack-Signature: t=<unix-seconds>,v1=<hex>— HMAC-SHA256(<t>.<raw body>) keyed by the endpoint signing secret, where<t>is thet=<unix-seconds>value from this same header (NOT a body field). Verification reference:packages/sdk-typescript/src/webhook-signature.ts(TS),packages/sdk-go/webhook_signature.go(Go),packages/sdk-python/src/driftstack/webhook_signature.py(Py).X-Driftstack-Event-Id: <uuid>— duplicate of the top-levelid, surfaces in HTTP logs without parsing the body.X-Driftstack-Event-Type: <event-type>— the delivered event type (e.g.session.completed), so handlers can route without parsing the body.
Retry policy: 6 attempts (the initial delivery plus 5 retries) with exponential backoff at 1m, 5m, 15m, 30m, 60m. Final failures land in DLQ and can be re-sent from Replay.
Idempotency: every delivery includes the same <uuid> id. Customers
should dedup on this id — the same event may be re-delivered after a
manual replay (admin tooling) or DLQ requeue.
Ordering: deliveries are not ordered, and your handler must not
assume they are. A failed delivery is rescheduled onto the backoff
above, so it lands after events your account generated later — an event
that first fails and succeeds on its 15m retry arrives well behind
everything created in between. Delivery is also fair across endpoints
rather than strictly oldest-first, so one endpoint with a backlog cannot
hold up another’s events. If ordering matters to your integration, use
the created_at timestamp in the payload rather than arrival order, and
treat an older event arriving after a newer one as normal.
Event payloads
session.completed
Fires once per logical destroy of a session that was in a non-terminal state. The destroy path is idempotent. Three things destroy a session, and all three emit this event — do not assume the event means the session was deleted by you:
| Trigger | auto_destroyed |
reason |
|---|---|---|
You call DELETE /v1/sessions/:id |
absent | absent |
| The free-tier session duration cap expires the session | true |
auto-destroyed: free-tier session duration cap |
| Your account is suspended and its live sessions are reclaimed | true |
account suspended |
A customer-initiated destroy:
{
"session_id": "ses_<uuid>",
"duration_ms": 245000
}
An automatic destroy adds two fields. Branch on auto_destroyed if you
attribute session completions (billing, analytics, retry logic) —
without it, a cap-expired or suspension-reclaimed session is
indistinguishable from a clean completion you requested:
{
"session_id": "ses_<uuid>",
"duration_ms": 900000,
"auto_destroyed": true,
"reason": "auto-destroyed: free-tier session duration cap"
}
auto_destroyed is absent rather than false on a customer-initiated
destroy, so test for presence/truthiness, not equality with false.
Emitters: apps/server/src/services/sessions.ts — destroy() (customer
call), autoDestroyExpired() (duration cap), destroyAllForAccount()
(account suspension).
session.failed
Fires when a session transitions to errored (driver failure,
unrecoverable error during navigate / interact / capture / etc.).
The session’s destroyed_at is set; subsequent ops on the session
return 410.
{
"session_id": "ses_<uuid>",
"duration_ms": 12300,
"operation": "navigate",
"error_name": "SessionTimeoutError",
"error_message": "The session operation timed out."
}
error_name and error_message are a closed, classed set — not the
underlying driver error. The server maps every failure onto one of four
classes and sends fixed copy for it, deliberately, so that internal driver
detail never reaches a customer endpoint. The complete set of values you
can receive:
error_name |
error_message |
|---|---|
SessionTimeoutError |
The session operation timed out. |
DriverError |
The browser operation failed. |
DriverNotIntegratedError |
The browser driver was unavailable. |
UnknownError |
The session operation failed. |
Branch on error_name; do not parse error_message and do not expect
specifics such as a timeout value or a URL — the message is a constant per
class. operation is one of navigate, interact, gui_input, wait,
state_capture, capture, extract, search, login, or unknown.
session_id and duration_ms are omitted when they cannot be resolved.
Emitter: runWithFailureCapture() in services/sessions.ts, projected
through projectSessionFailedData() in lib/session-event-metadata.ts.
api_key.revoked
Fires whenever an API key is revoked, regardless of who initiated
the revocation (account_owner via DELETE /v1/api-keys/:id OR
driftstack_internal_admin via POST /v1/admin/api-keys/:id/revoke).
The revoking party is not carried in this event — refer to the
audit log for full provenance.
{
"api_key_id": "key_<uuid>",
"name": "production",
"revoked_at": "2026-05-05T12:34:56.789Z"
}
Emitter: apps/server/src/services/api-keys.ts revoke().
test.ping
Synthetic test event emitted by POST /v1/webhooks/:id/test
. Fires REGARDLESS of subscription so customers can verify
their handler signature-checks correctly without subscribing to it.
Customers cannot subscribe to test.ping (the create / update Zod
schemas reject it); the test endpoint dispatches once per call.
Payload:
{
"id": "<uuid>",
"type": "test.ping",
"created_at": "2026-05-09T22:30:00.000Z",
"data": {
"message": "Test event from the Driftstack dashboard.",
"endpoint_id": "whk_<endpoint-uuid>",
"triggered_by_account_id": "acc_<caller-account-uuid>"
}
}
Sent over the same delivery infrastructure as production events:
HMAC-signed, retried on failure per the standard backoff schedule,
audit-logged as webhook_delivery.replayed with
payload.via: send_test_event.
session.egress_capability_changed
Fires when the WebKit-fork harness emits an
egress.capability_report event for a SOCKS5 session and the
control plane ingests it. Carries the same shape as the
egress_capabilities field on GET /v1/sessions/{id} —
subscribers can branch on udp_associate, dns_remote_resolve,
quic_route, or warnings without a follow-up GET.
Subscribable — add it to your webhook endpoint’s events array
to wire proxy-health visibility into your own observability
surface.
{
"id": "<uuid>",
"type": "session.egress_capability_changed",
"created_at": "2026-05-18T12:00:00Z",
"data": {
"session_id": "ses_<uuid>",
"egress_capabilities": {
"udp_associate": true,
"quic_route": "proxy",
"dns_remote_resolve": false,
"warnings": []
}
}
}
crypto.order.paid
crypto.order.failed
Fires when a NowPayments-backed crypto checkout order transitions to a terminal state. Wired end-to-end 2026-05-22 (migration 0064 + bootstrap WebhooksService emitter sink).
crypto.order.paid:
{
"type": "crypto.order.paid",
"data": {
"order_id": "ord_a1b2c3d4e5f6",
"product": "solo_manual",
"price_cents": 7900,
"price_currency": "USD",
"payment_id": "12345678",
"paid_at": "2026-05-22T10:30:00Z"
}
}
crypto.order.failed:
{
"type": "crypto.order.failed",
"data": {
"order_id": "ord_a1b2c3d4e5f6",
"product": "solo_manual",
"price_cents": 7900,
"price_currency": "USD",
"payment_id": "12345678",
"failed_at": "2026-05-22T10:35:00Z",
"reason": "expired"
}
}
reason is one of: ipn (a NowPayments IPN reported a terminal
non-paid status — a failed, refunded, or timed-out payment all surface
here), expired (the payment window — 60 minutes at checkout — elapsed
before payment landed and an operator expired the order), or swept (admin / cron cleanup of a stuck pending order past
the staleness threshold). These are the three values
CryptoOrdersService emits; the underlying NowPayments sub-status
(timeout / refunded / cancelled) is collapsed into ipn.
See Crypto checkout API for the full
order lifecycle + status state machine. The webhook event mirrors
the same events[] log shape returned by GET /v1/billing/crypto- orders.
session.challenge_detected
Fires when the in-session harness ChallengeDetector flags a bot-check (DataDome / Arkose / PerimeterX / AWS-WAF / GeeTest / … — 14 types) on the page the session is navigating. The harness auto-pauses the session (no further action intents run) and surfaces the challenge; resolve it (e.g. in the live view) and the session resumes. Subscribable so you can route challenge alerts into your own ops/notification surface. The relay resolves the owning account and enqueues the webhook when the session harness reports the challenge.
{
"type": "session.challenge_detected",
"data": {
"session_id": "ses_a1b2c3d4e5f6",
"challenge_id": "chl_9f8e7d6c",
"challenge": {
"type": "datadome",
"confidence": 0.94,
"detail": "interstitial captcha"
}
}
}
session.profile_save_failed
Fires when a profile-backed session’s save-back does not replace the
stored profile at teardown. The browsing session itself succeeded.
For failure reasons, the updated store (cookies / logins / browser
state from this run) could not be persisted, so the next restore of
this profile will be stale; the harness’s internal upload retry is
already exhausted and there is no later retry path. reason is one of serialize_failed, seal_failed,
too_large (the sealed store exceeded the 256 MiB cap),
upload_failed, or degenerate_dump (the dump was empty/malformed
and would have clobbered a known-good prior store — the prior is
preserved, so this one is reassuring rather than data loss), or
superseded (a newer profile write won and the stale conditional save
was safely refused; the next restore uses the newer state, so this is
benign and not data loss). An unrecognized harness reason is folded into
upload_failed rather than dropping the event. Customers relying on
persisted profile state can subscribe and alert on it.
{
"type": "session.profile_save_failed",
"data": {
"session_id": "ses_a1b2c3d4e5f6",
"profile_id": "prof_1f2e3d4c",
"reason": "upload_failed",
"detail": "presigned PUT returned 503"
}
}
Subscribing to events
Customers register webhook endpoints via
POST /v1/webhooks { url, events: [...], description? }. The
events array is a closed enum subset — the response 400s if any
unknown event type is supplied. Adding or removing events on an
existing endpoint is an in-place PATCH /v1/webhooks/:id with the new
events array — no delete/re-create needed.
The plaintext signing secret is returned once in the create
response. Store it server-side; the Driftstack API never returns it
again. To rotate without downtime, call
POST /v1/webhooks/:id/rotate-secret — Driftstack dual-signs every
delivery for a 24-hour grace window so you can roll the new secret out
before the old one stops working.
Verification
Every SDK ships a verification helper:
- TS:
verifyWebhookSignature({ secret, header, body, toleranceSec })inpackages/sdk-typescript/src/webhook-signature.ts. - Go:
VerifyWebhookSignatureinpackages/sdk-go/webhook_signature.go. - Python:
verify_webhook_signatureinpackages/sdk-python/src/driftstack/webhook_signature.py.
All three follow the same Stripe-adjacent pattern: parse t= and
v1= from the header, recompute HMAC-SHA256(<t>.<body>), constant-
time compare.
Verifying without an SDK
If you integrate from a language without a Driftstack SDK, the scheme is small enough to implement directly. Three rules matter:
- Sign the RAW request body — recompute the HMAC over the exact bytes you received, before any JSON parse/re-serialize. A re-serialized body almost never matches byte-for-byte and is the single most common verification failure.
- Reject stale timestamps — if
now - texceeds your tolerance (the SDKs default to 300 seconds), treat the delivery as a possible replay and reject it. - Constant-time compare, and accept if any
v1=matches — the header carries two during the 24-hour secret-rotation grace window.
Node.js, no SDK:
const crypto = require('node:crypto');
function verifyWebhook(secret, header, rawBody, toleranceSec = 300) {
// header: "t=<unix-seconds>,v1=<hex>[,v1=<hex>]"
const fields = header.split(',').map((p) => p.trim());
const t = Number(fields.find((p) => p.startsWith('t='))?.slice(2));
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay guard
// Recompute over the RAW body — not a re-serialized JSON object.
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
// Accept if ANY v1= matches (two are present during a rotation grace window).
return fields
.filter((p) => p.startsWith('v1='))
.map((p) => p.slice(3))
.some(
(sig) =>
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
}
Failure modes
A delivery is considered “successful” only if your endpoint returns HTTP 2xx within the 10s timeout. Any other outcome (5xx, timeout, connection refused, DNS failure) marks the attempt failed; the delivery scheduler picks it up at the next retry slot.
After 6 failed attempts (the initial delivery plus 5 retries) the
delivery lands in DLQ. DLQ deliveries
are visible in the admin panel
(admin.driftstack.dev/webhook-dlq) — staff can manually requeue
them after investigating the failure.
The endpoint is auto-disabled after 50 consecutive failed
deliveries. When consecutive_failures crosses 50 the worker sets
disabled_at and stops delivering to it. A disabled endpoint is a
sticky tombstone — it is not automatically re-enabled by a later
success; you mint a new endpoint to resume delivery. Monitor the
consecutive_failures field on GET /v1/webhooks to catch a
drifting endpoint before it trips the auto-disable threshold.
Subscription model
Two related but distinct enums in packages/api-types/src/webhooks.ts:
WebhookEventType— every event the server CAN emit. Includestest.ping.SubscribableWebhookEventType— events a customer can subscribe to viaPOST /v1/webhooksor update viaPATCH /v1/webhooks/:id. Excludestest.ping.
The distinction matters because test.ping only fires from the
explicit POST /v1/webhooks/:id/test endpoint regardless of
subscription — subscribing to it would be meaningless. The
update-subscription validator rejects test.ping with a 400
validation-failed problem detail.
Subscribing to a subset
POST /v1/webhooks
{
"url": "https://your-app.example/driftstack-hook",
"events": ["session.completed", "session.failed"]
}
The endpoint receives ONLY events whose type matches the subscription set. Adding more events later via PATCH is a no- historical-replay operation — past deliveries against the old subscription stay delivered/failed/DLQ as they were; only events created after the update use the new selection.
Subscribing to every (subscribable) event
Pass the full subscribable enum:
POST /v1/webhooks
{
"url": "https://your-app.example/driftstack-hook",
"events": [
"session.completed",
"session.failed",
"api_key.revoked",
"session.egress_capability_changed",
"crypto.order.paid",
"crypto.order.failed",
"session.challenge_detected",
"session.profile_save_failed"
]
}
There’s no shorthand for “subscribe to all” — the explicit list is the only way. An endpoint receives only the event types it selected.
test.ping separately
POST /v1/webhooks/:id/test
No request body. The endpoint dispatches a one-off
test.ping event with a short stub payload through the same
delivery infrastructure (HMAC-signed, retried on failure,
audit-logged). Lets customers verify their handler signature-
checks correctly before relying on it for production events.
Related
- Webhook resource:
apps/server/src/routes/webhooks.ts - Webhook delivery service:
apps/server/src/services/webhooks.ts+apps/server/src/services/durable-webhook-delivery.ts - DLQ admin operations:
apps/admin-panel/src/pages/webhook-dlq.astro— adds theendpoint_iddrill-down filter - Stripe webhook signature (the inverse direction — Stripe → us):
apps/server/src/lib/stripe-signing.tsanddocs/deployment/stripe-webhook-testing.md