Webhook events — catalog + payload shapes
— comprehensive reference for every webhook event type the
Driftstack control plane emits (or will emit). Source-of-truth for
the customer-facing /api/webhook-events docs page on the marketing
site (when it lands as a Tier 3 visual surface).
Status notation: events are tagged [LIVE] (declared in the enum + fired by a service emitter today), [DECLARED] (declared in the enum but no production emitter wired), [PLANNED] (not yet in the enum; queued for V-NNN).
Quick index
| Event | Status | When |
|---|---|---|
session.completed | [LIVE] | Session is destroyed cleanly |
session.failed | [LIVE] | Session terminates in errored state |
api_key.revoked | [LIVE] | API key revoked (customer or admin) |
quota.warning_80pct | [DECLARED] | Account hits 80% of tier quota |
quota.exceeded | [DECLARED] | Account hits 100% of tier quota |
test.ping | [LIVE] | Synthetic test event from POST /v1/webhooks/:id/test |
session.egress_capability_changed | [DECLARED] | Harness emitted an egress.capability_report for a SOCKS5 session |
crypto.order.paid | [LIVE] | NowPayments-backed order transitioned to paid (V-666) |
crypto.order.failed | [LIVE] | Crypto order moved to terminal failed (timeout/refund/expired) |
session.challenge_detected | [DECLARED] | Harness ChallengeDetector flagged a bot-check (DataDome/Arkose/…) |
session.profile_save_failed | [DECLARED] | Profile save-back failed at session teardown (next restore stale) |
session.created | [PLANNED] | Session transitions creating → ready |
session.destroyed | [PLANNED] | Distinct from session.completed (no semantic shift) |
profile.created | [PLANNED] | New profile created |
profile.deleted | [PLANNED] | Profile deleted |
api_key.minted | [PLANNED] | New API key issued |
subscription.changed | [PLANNED] | Tier changed via Stripe |
subscription.cancelled | [PLANNED] | Subscription cancelled |
webhook_endpoint.created | [PLANNED] | New webhook endpoint registered |
webhook_endpoint.deleted | [PLANNED] | Webhook endpoint deleted |
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
(see docs/api/webhooks.md and the admin /webhook-dlq page).
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.
Event payloads
session.completed [LIVE]
Fires when DELETE /v1/sessions/:id lands on a session in a
non-terminal state. The destroy path is idempotent; this event fires
exactly once per logical destroy.
{
"session_id": "ses_<uuid>",
"duration_ms": 245000
}
Emitter: apps/server/src/services/sessions.ts destroy().
session.failed [LIVE]
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": "DriverTimeoutError",
"error_message": "Page load exceeded 30000ms"
}
Emitter: runWithFailureCapture() in services/sessions.ts.
api_key.revoked [LIVE]
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().
quota.warning_80pct [DECLARED]
Will fire when an account’s metered usage hits 80% of the tier’s quota. Currently declared in the enum but not wired to a usage- threshold check — see V-NNN follow-up.
Planned shape:
{
"tier": "api_builder",
"metric": "session_minutes",
"used": 4000,
"limit": 5000,
"percentage": 80,
"period_start": "2026-05-01T00:00:00.000Z",
"period_end": "2026-06-01T00:00:00.000Z"
}
quota.exceeded [DECLARED]
Will fire when an account hits 100% of the tier quota. Same wiring
gap as quota.warning_80pct.
Planned shape:
{
"tier": "api_builder",
"metric": "session_minutes",
"used": 5000,
"limit": 5000,
"percentage": 100,
"period_start": "2026-05-01T00:00:00.000Z",
"period_end": "2026-06-01T00:00:00.000Z"
}
test.ping [LIVE]
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 [DECLARED]
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.
[DECLARED] because the schema + pgEnum + emitter plumbing is in
place but the harness side (Agent 1 scope per planning 133) has
not yet shipped the event source. Once the harness emits, this
moves to [LIVE].
{
"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 [LIVE]
crypto.order.failed [LIVE]
V-666 — 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 [DECLARED]
W393 — 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. In the enum (migration 0070); the relay
emitter is wired (resolves the owning account → enqueues this webhook)
and fires once the fleet control plane is live (gated behind
FLEET_CONTROL_PLANE_ENABLED).
{
"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 [DECLARED]
Fires when a profile-backed session’s save-back fails at session
teardown — the browsing session itself succeeded, but the updated
profile store (cookies / logins / browser state from this run) could
not be persisted, so the next restore of this profile will be
stale. The failure is terminal: the harness’s internal upload retry
is already exhausted before this event is emitted, 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). An
unrecognized future harness reason is folded into upload_failed
rather than dropping the event. Subscribable so customers relying on persisted profile
state can alert on it. In the enum (migration 0073); the relay emitter
is wired and fires once the fleet control plane is live (gated behind
FLEET_CONTROL_PLANE_ENABLED).
{
"type": "session.profile_save_failed",
"data": {
"session_id": "ses_a1b2c3d4e5f6",
"profile_id": "prof_1f2e3d4c",
"reason": "upload_failed",
"detail": "presigned PUT returned 503"
}
}
Planned events (not yet in enum)
The following events are queued for future V-NNN entries. Adding a new event type is a Class A schema migration (additive enum value) plus an emitter in the relevant service plus an SDK type bump across TS / Python / Go.
session.created [PLANNED]
Fires when a session transitions creating → ready. Distinct
from the API-call response on POST /v1/sessions, which blocks
through driver dispatch and returns the session already at ready
— the caller doesn’t need this event, but a non-calling consumer
(a separate alerting or provisioning pipeline) could observe
session readiness without polling.
session.destroyed [PLANNED]
A more general counterpart to session.completed. Where
session.completed semantically means “successful end-of-life”,
session.destroyed would mean “any end-of-life including
admin-forced destroy”. Worth landing if customers want the
super-set; today the existing pair (completed for happy path,
failed for error path) covers admin-destroy as failed. Defer.
profile.created / profile.deleted [PLANNED]
Mirror of api_key.revoked for profile lifecycle. Useful when
profiles are managed programmatically via the SDK and a separate
system tracks them.
api_key.minted [PLANNED]
Counterpart to api_key.revoked. Useful for SOC2-adjacent customer
auditing where the customer wants every key issuance recorded
externally.
subscription.changed / subscription.cancelled [PLANNED]
Stripe webhook handler-driven. When the customer changes tier or cancels via the Stripe customer portal, a Driftstack-side webhook fires so the customer’s own systems can react (e.g. update billing dashboards).
webhook_endpoint.created / webhook_endpoint.deleted / webhook_endpoint.secret_rotated [PLANNED]
Self-meta events: a webhook fires when a webhook endpoint is registered, deleted, or its signing secret rotated . Useful for change-tracking systems. Recursion risk is low (the endpoint that fires the event is one of multiple endpoints, not the one being created/deleted/rotated).
For now these events land in the audit log
(webhook_endpoint.created / .deleted / .updated /
.secret_rotated) but are not delivered as webhooks. If you want
to react programmatically to webhook config changes today, poll
the GET /v1/account/audit-log?action=webhook_endpoint.created
filter.
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 future events count.
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",
"quota.warning_80pct",
"quota.exceeded",
"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. This is intentional: when we add a new subscribable event in the future (per the [PLANNED] queue above), existing endpoints don’t auto-subscribe and start receiving deliveries the customer didn’t expect. Customers opt-in to new events explicitly.
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