Webhooks
Driftstack delivers event notifications to your HTTPS endpoint as they happen. Use webhooks to react to session lifecycle, egress, challenge, profile-save, payment, and key-revocation events without polling.
An agent session (POST /v1/agent-sessions) sends
session.challenge_detected and
session.profile_save_failed only. It sends no
session.completed or session.failed: those
come from direct sessions (POST /v1/sessions). To learn
an agent session ended, poll
GET /v1/agent-sessions/<id> until
status is closed, and read
closed_reason.
Event types
| Event | When it fires |
|---|---|
session.completed | Direct sessions only (POST /v1/sessions); an agent session never sends it. Fires once per logical destroy of a non-terminal session — whether you called DELETE /v1/sessions/:id or it ended automatically: a plan time limit, the session ending where it ran, an account suspension, a failed renewal payment, an ended paid plan, or a machine taken out for maintenance. Payload carries { session_id, duration_ms }, plus { auto_destroyed: true, reason } on every automatic case, and reason_code (payment_failed, plan_ended or maintenance) on the last three; a suspension carries reason "account suspended" and no reason_code. Branch on auto_destroyed if you attribute completions. |
session.failed | Direct sessions only (POST /v1/sessions); an agent session never sends it. Fires when a session ends in an errored state. |
api_key.revoked | Fires when an API key is revoked from the dashboard. |
session.egress_capability_changed | Fires each time a session reports its egress capabilities — on every report, not only on a change (there is no change detection, so identical consecutive reports each emit an event). Compare against your last stored state rather than treating it as a transition. `warnings` also carries streaming faults, so it can fire when only the video stream degraded. |
crypto.order.paid | Fires when a crypto order transitions to the terminal paid state (the payment processor reported the payment settled). Subscribe to grant entitlements server-side as soon as a payment clears; the order itself is managed in the dashboard. |
crypto.order.failed | Fires when a crypto order transitions to the terminal failed state (address-window expired, or the payment processor reported it failed or refunded). Subscribe to drive support workflows on settlement-failure. |
session.challenge_detected | Fires when the session detects a bot-check (DataDome / Arkose / PerimeterX / AWS-WAF / GeeTest / …). The session auto-pauses; resolve the challenge and it resumes. Subscribe to route challenge alerts into your own ops surface. |
session.profile_save_failed | Fires when a profile-backed session could not save its updated profile state when the session ended (the session itself succeeded). Terminal — the next session that uses this profile will start from the older saved state. Subscribe if you rely on saved profile state. |
test.ping is an additional event type that exists
only for the POST /v1/webhooks/<id>/test endpoint;
it bypasses subscriptions, so you don't list it under
events when creating an endpoint.
Each account can have up to 10 endpoints; paused endpoints count toward the limit, and deleting one frees a place. Mint as many narrow-purpose endpoints as you need rather than one mega-endpoint that fans out — easier to retire individual integrations later.
Creating an endpoint
POST /v1/webhooks
Authorization: Bearer ds_live_…
Content-Type: application/json
{
"url": "https://yourapp.com/driftstack-webhook",
"events": ["session.completed", "session.failed"],
"description": "Production listener"
}
→ 201 Created
{
"id": "whk_…",
"secret": "whsec_…", ← shown ONCE; copy it now
"secret_prefix": "whsec_xxxx",
"url": "https://yourapp.com/driftstack-webhook",
"events": ["session.completed", "session.failed"],
"description": "Production listener",
"active": true,
"consecutive_failures": 0,
"delivery_counts": { "pending": 0, "delivered": 0, "failed": 0, "dlq": 0 },
"created_at": "2026-05-12T12:00:00.000Z",
…
} Important: the secret is shown
once in the create response and never again. Store it in your
config / secret manager. If you lose it, rotate via
POST /v1/webhooks/<id>/rotate-secret.
Subsequent GET /v1/webhooks/<id> responses
include only secret_prefix (first 12 plaintext
chars, non-sensitive) for display.
Endpoint URLs MUST be HTTPS. http:// is rejected
at registration time.
Delivery payload
Every event arrives as a JSON POST to your endpoint:
POST /driftstack-webhook
Content-Type: application/json
X-Driftstack-Event-Id: <uuid>
X-Driftstack-Event-Type: session.completed
X-Driftstack-Signature: t=1747051200,v1=<hex hmac>
{
"id": "<uuid>",
"type": "session.completed",
"created_at": "2026-05-12T12:00:00.000Z",
"data": { /* event-specific payload */ }
} Signature verification
Always verify the signature before trusting the payload — otherwise anyone who guesses your endpoint URL can spoof events.
The header is Stripe-style:
X-Driftstack-Signature: t=<unix-seconds>,v1=<hex hmac>.
For 24 hours after you rotate the signing secret it carries a second
v1= (t=…,v1=<new>,v1=<old>), so
accept the delivery when any v1= matches.
The HMAC is computed as:
hmac = HMAC-SHA256(
secret,
<unix-seconds> + "." + <raw request body>
)
The timestamp lives inside the header (the t=
component), not in a separate header. There is no
X-Driftstack-Emitted-At header: the delivery path
sends exactly three X-Driftstack-* headers —
Event-Id, Event-Type and Signature.
Server-side verification in Node.js looks like:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(req, secret) {
const header = req.headers['x-driftstack-signature']; // 't=…,v1=…[,v1=…]'
const body = req.rawBody; // RAW bytes — NOT parsed JSON
if (typeof header !== 'string' || !body) return false;
const fields = header.split(',').map((p) => p.trim());
const ts = Number(fields.find((p) => p.startsWith('t='))?.slice(2));
// For 24 hours after you rotate the secret, the header carries two v1=
// values: one made with the new secret, one with the old. Check every one.
const sigs = fields.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!ts || sigs.length === 0) return false;
// Reject replays — Driftstack signs with the current timestamp;
// anything older than 5 minutes is almost certainly a replay.
if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected = Buffer.from(
createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex'),
'hex',
);
return sigs.some((sig) => {
const got = Buffer.from(sig, 'hex');
return got.length === expected.length && timingSafeEqual(got, expected);
});
} Or use the SDK helpers — they handle the parsing, replay window, and previous-secret grace path for you:
- TypeScript / JS:
import { verifyWebhookSignature } from '@driftstack/sdk' - Python:
from driftstack import verify_webhook_signature - Go:
driftstack.VerifyWebhookSignature(...)
Retry policy
Driftstack retries non-2xx responses (and timeouts) with backoff. The schedule is fixed:
| Attempt | Delay before retry |
|---|---|
| 1 (initial) | — |
| 2 | 1 minute after attempt 1 |
| 3 | 5 minutes after attempt 2 |
| 4 | 15 minutes after attempt 3 |
| 5 | 30 minutes after attempt 4 |
| 6 (final) | 60 minutes after attempt 5 |
- After 6 attempts (the initial delivery plus
5 retries), the delivery moves to a dead-letter queue (DLQ).
You can replay it from the dashboard (Webhooks → your endpoint →
Deliveries → Replay) or with
POST /v1/webhook-deliveries/:id/replay. - After 50 consecutive failures across all deliveries
for one endpoint, the endpoint is auto-disabled. Its
disabled_atis set, and that is permanent: it cannot be re-enabled, updated, rotated or sent a test. Fix the receiving service, then create a new endpoint. - Request timeout is 10 seconds — slower than that, we consider the delivery failed and back off.
Your endpoint should:
- Return 2xx within 10 seconds.
- Be idempotent — we may retry the same
X-Driftstack-Event-Idif we retried before getting your response. - Acknowledge fast + process asynchronously. A 200 response means "I have the payload"; process it on your side after.
Secret rotation
When you suspect a leaked secret (or just for routine hygiene), rotate it:
POST /v1/webhooks/<id>/rotate-secret
Authorization: Bearer ds_live_…
→ 200 OK
{
"id": "whk_…",
"secret": "whsec_NEW…", ← shown ONCE; copy it now
"secret_prefix": "whsec_yyyy",
"prev_secret_prefix": "whsec_xxxx",
"grace_expires_at": "2026-05-13T12:00:00.000Z"
}
During the 24-hour grace window, Driftstack signs every
outbound delivery with both secrets inside the single
X-Driftstack-Signature header, as two
comma-separated v1= entries:
v1=<new>— signed with the new secret.v1=<old>— signed with the old secret.
X-Driftstack-Signature: t=…,v1=<new>,v1=<old>
Your verifier should accept the delivery if either HMAC
matches. The SDK helpers do this automatically — they iterate
every v1= entry in the one header, so no extra
argument is needed. Roll the new secret across your infra at
your own pace during the grace window — when it expires, the
old v1= entry drops and only the new-secret
signature is sent.
Lost the new secret before you deployed it? Rotate again. A
rotation during the grace window replaces only the new secret:
the old one keeps working until the same
grace_expires_at, and the secret you lost stops
working at once.
Testing
Use the test endpoint to fire a synthetic
test.ping event at any active endpoint, bypassing
subscription:
POST /v1/webhooks/<id>/test
Authorization: Bearer ds_live_…
→ 202 Accepted
{
"delivery_id": "wdl_…",
"event_id": "<uuid>",
"event_type": "test.ping"
} The test event arrives at your endpoint with the same headers + signature as a real event. Your endpoint's response is captured so you can debug from the dashboard.
Inspecting deliveries
Per-endpoint delivery log + counts available from the dashboard
under Webhooks → <endpoint> → Deliveries,
or from GET /v1/webhooks/<id>/deliveries.
Failed deliveries surface the response status + first 200 bytes
of your endpoint's response body so you can debug.
What to do when something goes wrong
- Signatures don't verify — confirm you're
hashing the raw request body, not the parsed JSON.
Most frameworks parse the body by default; you need a raw-body
middleware ahead of the JSON parser. Also confirm you're
reading the timestamp from inside
X-Driftstack-Signature(thet=component), not from a separate header. - Endpoint auto-disabled — fix the receiving
service, then create a new endpoint: a disabled one cannot be
re-enabled (an update answers
409, "Mint a fresh one instead"). Its dead-lettered deliveries stay with it, and a replay of one goes straight back to the DLQ. - Duplicate events — expected. Dedupe on
X-Driftstack-Event-Id. - Missing events — check the endpoint registration includes the event type you're expecting; events only fire to endpoints subscribed to that type.
Support
Questions or trouble integrating? [email protected].