Drıftstack docs
Docs

Webhook events — catalog + payload shapes

This is the customer-facing reference for webhook events emitted by Driftstack, plus the test event.

Quick index

Event When
session.completed A direct session (ses_…) is destroyed cleanly
session.failed A direct session (ses_…) ends errored
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 SOCKS5 proxy session reports what its proxy can do
crypto.order.paid A crypto order transitions to paid
crypto.order.failed A crypto order moves to terminal failed
session.challenge_detected The session detects a supported bot challenge
session.profile_save_failed Profile save-back fails to replace the stored profile

Agent sessions send two of these. An agent session (POST /v1/agent-sessions, ids agt_…) sends session.challenge_detected and session.profile_save_failed. It sends no session.completed and no session.failed: those come only from direct sessions (POST /v1/sessions, ids ses_…). To learn an agent session ended, poll GET /v1/agent-sessions/{id} until status is closed, and read closed_reason.

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). See Verification below.
  • 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 (the dead-letter queue) 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 replay 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

Direct sessions only (POST /v1/sessions); an agent session never sends it. Fires once per logical destroy of a session that was in a non-terminal state. The destroy path is idempotent. Several things destroy a session, and all of them 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 plan’s session limit ends it: 20 minutes a session on the free trial and the earlier Free plan, or the trial’s 2 hours of session time used up true auto-destroyed: free-tier session duration cap, or the session ended where it ran (a close at the plan’s limit may send either one)
The session ended on its own where it ran: 30 minutes without activity, its 4-hour time limit, the trial’s session limit or its used-up session time, the close about 12 hours after it was created, or the machine running it stopped it true the session ended where it ran
Your account is suspended and its live sessions are reclaimed true account suspended
Your paid plan’s renewal payment did not go through true auto-destroyed: the renewal payment did not go through (and reason_code: "payment_failed")
Your paid plan has ended true auto-destroyed: the paid plan has ended (and reason_code: "plan_ended")
The machine running it was taken out of service for maintenance true auto-destroyed: the machine running it was taken out of service for maintenance (and reason_code: "maintenance")

A customer-initiated destroy:

{
  "session_id": "ses_<uuid>",
  "duration_ms": 245000
}

An automatic destroy adds two fields (a destroy because the paid plan lapsed, or for a machine’s maintenance, adds a third, reason_code). Branch on auto_destroyed if you attribute session completions (billing, analytics, retry logic) — without it, a session ended by a time limit or a suspension is indistinguishable from a clean completion you requested:

{
  "session_id": "ses_<uuid>",
  "duration_ms": 1260000,
  "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.

session.failed

Direct sessions only (POST /v1/sessions); an agent session never sends it. Fires when a session transitions to errored (the browser failed, or an operation such as navigate / interact / capture hit an unrecoverable error). 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 fixed set — not a raw browser error. Every failure is mapped onto one of four classes with fixed text. 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.

api_key.revoked

A notification only: API keys are managed in the Driftstack dashboard. Fires whenever a key is revoked, whoever revoked it. The revoking party is not carried in this event — the dashboard’s audit log has the full provenance.

{
  "api_key_id": "key_<uuid>",
  "name": "production",
  "revoked_at": "2026-05-05T12:34:56.789Z"
}

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 API rejects 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 a session using a SOCKS5 proxy reports its proxy capabilities. 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, safeguards, or warnings without a follow-up GET.

warnings carries the same published codes here as it does on the session endpoints, and means the same thing — the full list, with what to do about each, is at /api/sessions. Read the codes as opaque strings: ignore one you do not recognise, and expect new ones to appear without an SDK upgrade.

safeguards is passed, failed, or unverified — the same tri-state described at egress_capabilities.safeguards on the session endpoints. It is absent on a payload built from a session reported before this field existed; do not treat an absent value as unverified or passed.

Subscribable — add it to your webhook endpoint’s events array to get proxy-health alerts in your own tooling.

{
  "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": true,
      "safeguards": "passed",
      "warnings": []
    }
  }
}

crypto.order.paid

crypto.order.failed

A notification only: crypto orders are placed, followed and cancelled in the Driftstack dashboard. Subscribe if your own systems need to know when a payment settled or failed.

crypto.order.paid fires once when an order transitions to paid — the payment processor reported the payment finished and the received amount reconciled against the quoted amount. A re-delivered provider notification for an already-paid order does not fire it again.

{
  "type": "crypto.order.paid",
  "data": {
    "order_id": "ord_a1b2c3d4e5f6",
    "product": "starter_v3",
    "price_cents": 9000,
    "price_currency": "USD",
    "payment_id": "12345678",
    "paid_at": "2026-05-22T10:30:00Z"
  }
}

crypto.order.failed fires once when an order moves to the terminal failed state:

{
  "type": "crypto.order.failed",
  "data": {
    "order_id": "ord_a1b2c3d4e5f6",
    "product": "starter_v3",
    "price_cents": 9000,
    "price_currency": "USD",
    "payment_id": "12345678",
    "failed_at": "2026-05-22T10:35:00Z",
    "reason": "expired"
  }
}
data field Type Description
order_id string The Driftstack order id (ord_…).
product string The tier the order was for (e.g. team_manual).
price_cents integer Order total in fiat cents.
price_currency string ISO 4217 fiat currency (USD).
payment_id string | null The payment processor’s payment id; on failed, null when the order never got as far as a payment.
paid_at ISO 8601 string paid only: server timestamp of the transition.
failed_at ISO 8601 string failed only: server timestamp of the transition.
reason string failed only: ipn, expired or swept.

reason is one of: ipn (a payment-processor notification reported a terminal non-paid status — a failed, refunded, or timed-out payment all surface here, including an order the processor reports expired), expired (a single unpaid order closed on our side), or swept (an order closed by the sweep: an unpaid order 24 hours after it was created, or a part-paid order whose rest did not arrive within 3 days, or a staff-run sweep). A cancelled order fires neither event.

Both ride the standard delivery pipeline, so the same event can arrive more than once: dedup on the envelope id (also the X-Driftstack-Event-Id header), or key your write on (order_id, status).

session.challenge_detected

Fires when the session detects a bot-check (DataDome / Arkose / PerimeterX / AWS-WAF / GeeTest / … — 14 types) on the page it is navigating. The session pauses automatically; resolve the challenge (e.g. in the live view) and it resumes. Subscribe to route challenge alerts into your own tooling.

{
  "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 session that runs a saved profile ends but its updated browser state (cookies, logins, and other state from this run) could not be saved back to the profile. The browsing session itself succeeded, but the next restore of this profile will be stale; Driftstack does not retry the save later. reason is one of serialize_failed, seal_failed, too_large (the encrypted state exceeded the 256 MiB cap), upload_failed, degenerate_dump (the saved state was empty or malformed and would have replaced a known-good earlier copy — the earlier copy is kept, so this one is reassuring rather than data loss), superseded (a newer profile write won and this older save was safely refused; the next restore uses the newer state, so this is benign and not data loss), or profile_not_loaded (the saved profile could not be loaded when the session started, so the session ran without it and its changes were not saved over it — the saved profile is kept as it was, and the next session that uses it starts from it). Any other failure is reported as upload_failed. If you rely on saved profile state, 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 }) from @driftstack/sdk.
  • Go: driftstack.VerifyWebhookSignature.
  • Python: from driftstack import verify_webhook_signature.

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; Driftstack retries it at the next retry slot.

After 6 failed attempts (the initial delivery plus 5 retries) the delivery lands in DLQ. You can replay DLQ deliveries yourself from Replay, or ask support to requeue them.

The endpoint is auto-disabled after 50 consecutive failed deliveries. When consecutive_failures crosses 50 Driftstack sets disabled_at and stops delivering to it. A disabled endpoint stays disabled — it is not automatically re-enabled by a later success; create 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 sets of event types, exported from the @driftstack/api-types package:

  • 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 API rejects test.ping in the events array with a 400 validation-failed problem.

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.