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/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). See Verification below.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 (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:
- 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; 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. 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
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.