Skip to main content
darkship docs
Open console

Reference · Webhooks

Signed webhooks

Get an HTTPS POST when a flag, its targeting or a key changes. Every delivery is signed, retried until you acknowledge it, and safe to replay, as long as you verify the signature and deduplicate on the event id.

Updated Oct 28, 20265 min read Edit this page on GitHub (opens in a new tab)

How delivery works#

  • You register an HTTPS endpoint in the console (Webhooks) or with POST /v1/webhooks, choose events, and receive a signing secret (whsec_...) once.
  • When an event happens, our delivery worker POSTs a JSON body to your URL with the signature headers below. Success is any 2xx within 10 seconds.
  • Anything else (a non-2xx, a timeout, a connection error) is retried with exponential backoff and jitter. Delivery is at-least-once: expect duplicates.
  • Endpoints must be HTTPS and resolve to public addresses. Redirects to private ranges are refused.

Event catalogue#

Events in type.
EventFires whenNotable data fields
flag.createdA flag is created.flag
flag.updatedName, description, tags or variations change.flag_key, changes
flag.deletedA flag is archived or deleted.flag_key, archived
flag.environment.updatedTargeting, default variation or on/off changes in an environment.flag_key, environment, change, ruleset_version
sdk_key.rotatedA key rotation starts.key_id, new_key_id, rotation_ends_at
sdk_key.revokedA key is revoked.key_id, reason

Payloads contain ids, the actor, a before/after summary and the ruleset version. They never contain secrets, key values or evaluation context.

Payload explorer#

sample data
JSONPOST https://hooks.example.com/flags
{}

Payload schema#

Envelope shared by every event.
FieldTypeDescription
idstringEvent id, evt_.... Stable across retries; use it to deduplicate.
typestringOne of the events above.
created_atstringISO-8601 UTC time the change was committed.
org_id, project, environmentstringScope. environment is absent for project-level events.
actorobject{ type: "user" | "token" | "system", id, name }
dataobjectEvent-specific, with a before/after summary where applicable.

Request headers#

HTTPdelivery request
POST /darkship HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: darkship-webhooks/1
Darkship-Signature: t=1761661928,v1=3b5a7e0f9d4c...e21a
Darkship-Event-Id: evt_01J9ZK3Q8M4V
Darkship-Delivery-Id: dlv_01J9ZM2R5T7W
Darkship-Signature
Timestamp t (Unix seconds) and one or more v1 HMAC signatures. See below.
Darkship-Event-Id
Same id as id in the body. Identical across retries.
Darkship-Delivery-Id
Unique per attempt. Useful for support and the delivery log.

Retries and failure handling#

Respond 2xx quickly, before doing slow work: queue the event and process it later. If you can't, we retry. The schedule below is illustrative; exact timings are placeholders that will be tuned.

+0 sAttempt 1, immediately after the changeattempt 1
+10 sAttempt 2attempt 2
+1 minAttempt 3attempt 3
+5 minAttempt 4attempt 4
+30 minAttempt 5attempt 5
+2 h … +12 hAttempts 6 to 8, growing to hours, then give upfinal
  • Each interval has random jitter so a recovering endpoint isn't hit by every queued event at once.
  • An endpoint that fails continuously is automatically disabled and your organisation is emailed. Re-enable it in the console after fixing the cause.
  • Every attempt (status, latency, truncated response) is in the delivery log, also via the API, until your plan's retention window expires.
At-least-once means duplicates

A retry can arrive after your first attempt actually succeeded (for example, you replied slowly). Deduplicate on Darkship-Event-Id, and make handlers idempotent. Order is not guaranteed across events; use created_at and the ruleset_version to reason about which is newer.

Verify the signature#

Anyone can POST to your URL. Verify every delivery before trusting it.

  1. Parse the headerRead t and every v1 value from Darkship-Signature. During secret rotation there are two v1 values.
  2. Check the timestampReject if |now − t| is more than 5 minutes. This is the replay window; keep your server clock in sync (NTP).
  3. RecomputeHMAC_SHA256(secret, t + "." + raw_body), hex-encoded. Use the raw bytes of the body exactly as received.
  4. Compare in constant timeAccept if any v1 matches. Never use ==.
The raw body, not the parsed one

Re-serialising parsed JSON changes whitespace and key order, and the signature will not match. In Express use express.raw({ type: 'application/json' }) for this route; in Flask use request.get_data().

TypeScriptverify.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60;

export function verifyDarkship(rawBody: Buffer | string, header: string, secret: string, now = Date.now() / 1000): boolean {
  const parts = header.split(',').map((p) => p.trim().split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!Number.isFinite(t) || sigs.length === 0) return false;
  if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;           // replay window

  const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  return sigs.some((hex) => {
    const got = Buffer.from(hex, 'hex');
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}

// Express
app.post('/darkship', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyDarkship(req.body, req.get('Darkship-Signature') ?? '', process.env.DARKSHIP_WEBHOOK_SECRET!)) {
    return res.sendStatus(400);
  }
  const eventId = req.get('Darkship-Event-Id')!;
  if (await seen(eventId)) return res.sendStatus(200);               // duplicate: ack and skip
  enqueue(JSON.parse(req.body.toString()));                          // do real work off the request path
  res.sendStatus(202);
});

The SDKs expose the same check as a helper so you don't have to maintain it yourself.

Try it: signature checker#

Webhook signature checker#

pure JS, runs locally

Rotating the secret#

Rotate the endpoint's secret in the console. For an overlap window both secrets sign every delivery and the header carries two values, t=...,v1=<new>,v1=<old>. Deploy the new secret to your receiver, and when the window ends only the new signature is sent. Because the verifier accepts any matching v1, nothing breaks during the switch.

Testing tips#

  • Send a test event. POST /v1/webhooks/{id}/test or the console button delivers a signed flag.updated through the real pipeline.
  • Expose localhost with a tunnel while developing. The URL must be public HTTPS; we refuse private addresses and plain HTTP.
  • Test your verifier with the checker above, including the stale and tampered cases, and add them to your unit tests.
  • Replay from the log. Open a delivery in the console to see exactly what we sent and what you returned.
Common causes of failed verification.
SymptomLikely cause
Signature never matchesVerifying the parsed-and-re-serialised body; wrong secret (test vs production endpoint); secret has trailing whitespace.
Fails only some of the timeTwo secrets during rotation and you compared only the first v1.
Valid signatures rejected as oldServer clock drift beyond five minutes; fix NTP rather than widening the window.
Endpoint was disabledSustained failures. Fix, then re-enable and use the log to see what was missed.
Was this helpful?