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.
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#
| Event | Fires when | Notable data fields |
|---|---|---|
flag.created | A flag is created. | flag |
flag.updated | Name, description, tags or variations change. | flag_key, changes |
flag.deleted | A flag is archived or deleted. | flag_key, archived |
flag.environment.updated | Targeting, default variation or on/off changes in an environment. | flag_key, environment, change, ruleset_version |
sdk_key.rotated | A key rotation starts. | key_id, new_key_id, rotation_ends_at |
sdk_key.revoked | A 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{}Payload schema#
| Field | Type | Description |
|---|---|---|
id | string | Event id, evt_.... Stable across retries; use it to deduplicate. |
type | string | One of the events above. |
created_at | string | ISO-8601 UTC time the change was committed. |
org_id, project, environment | string | Scope. environment is absent for project-level events. |
actor | object | { type: "user" | "token" | "system", id, name } |
data | object | Event-specific, with a before/after summary where applicable. |
Request headers#
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 morev1HMAC signatures. See below. - Darkship-Event-Id
- Same id as
idin 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.
- 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.
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.
- Parse the headerRead
tand everyv1value fromDarkship-Signature. During secret rotation there are twov1values. - Check the timestampReject if
|now − t|is more than 5 minutes. This is the replay window; keep your server clock in sync (NTP). - Recompute
HMAC_SHA256(secret, t + "." + raw_body), hex-encoded. Use the raw bytes of the body exactly as received. - Compare in constant timeAccept if any
v1matches. Never use==.
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().
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);
});import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_darkship(raw_body: bytes, header: str, secret: str, now: float | None = None) -> bool:
parts = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
sigs = [v for k, v in parts if k == "v1"]
if t is None or not t.isdigit() or not sigs:
return False
if abs((now if now is not None else time.time()) - int(t)) > TOLERANCE_SECONDS:
return False # replay window
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
# Flask
@app.post("/darkship")
def darkship_webhook():
raw = request.get_data() # raw bytes, before any parsing
if not verify_darkship(raw, request.headers.get("Darkship-Signature", ""), WEBHOOK_SECRET):
return "", 400
if already_processed(request.headers["Darkship-Event-Id"]):
return "", 200 # duplicate: ack and skip
queue.enqueue(process_event, raw)
return "", 202The 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 locallyRotating 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}/testor the console button delivers a signedflag.updatedthrough 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.
| Symptom | Likely cause |
|---|---|
| Signature never matches | Verifying the parsed-and-re-serialised body; wrong secret (test vs production endpoint); secret has trailing whitespace. |
| Fails only some of the time | Two secrets during rotation and you compared only the first v1. |
| Valid signatures rejected as old | Server clock drift beyond five minutes; fix NTP rather than widening the window. |
| Endpoint was disabled | Sustained failures. Fix, then re-enable and use the log to see what was missed. |
Thanks. This helps us prioritise the docs.