Skip to main content

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

EventWhen it fires
session.completedDirect 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.failedDirect sessions only (POST /v1/sessions); an agent session never sends it. Fires when a session ends in an errored state.
api_key.revokedFires when an API key is revoked from the dashboard.
session.egress_capability_changedFires 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.paidFires 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.failedFires 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_detectedFires 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_failedFires 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:

Retry policy

Driftstack retries non-2xx responses (and timeouts) with backoff. The schedule is fixed:

AttemptDelay before retry
1 (initial)—
21 minute after attempt 1
35 minutes after attempt 2
415 minutes after attempt 3
530 minutes after attempt 4
6 (final)60 minutes after attempt 5

Your endpoint should:

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:

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

Support

Questions or trouble integrating? [email protected].