Driftstack DRIFTSTACK docs
Docs

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

EventStatusWhen
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 creatingready
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/json
  • X-Driftstack-Signature: t=<unix-seconds>,v1=<hex> — HMAC-SHA256(<t>.<raw body>) keyed by the endpoint signing secret, where <t> is the t=<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-level id, 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 creatingready. 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 }) in packages/sdk-typescript/src/webhook-signature.ts.
  • Go: VerifyWebhookSignature in packages/sdk-go/webhook_signature.go.
  • Python: verify_webhook_signature in packages/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:

  1. 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.
  2. Reject stale timestamps — if now - t exceeds your tolerance (the SDKs default to 300 seconds), treat the delivery as a possible replay and reject it.
  3. 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. Includes test.ping.
  • SubscribableWebhookEventType — events a customer can subscribe to via POST /v1/webhooks or update via PATCH /v1/webhooks/:id. Excludes test.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.

  • 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 the endpoint_id drill-down filter
  • Stripe webhook signature (the inverse direction — Stripe → us): apps/server/src/lib/stripe-signing.ts and docs/deployment/stripe-webhook-testing.md