Webhooks

Receive score and DRS events over signed HTTPS webhooks: timestamped HMAC-SHA256 signatures, replay protection, idempotent delivery.

COHESION delivers score and decision-risk events to HTTPS endpoints you register. Every delivery is signed with HMAC-SHA256 over the delivery timestamp plus the body, so receivers can authenticate the sender, detect tampering, and reject replayed deliveries.

Register an endpoint

Register a receiver with POST /v1/admin/webhook/register. You supply the HTTPS URL and a shared secret (16 characters minimum). COHESION derives the webhook signing key from your secret at registration time and stores only that derived value at rest, never your raw secret. The 201 response returns that derived key once as signing_secret: this is the HMAC key COHESION signs every delivery with, so store it and use it to verify signatures. Do not verify with the raw secret you supplied, only the returned signing_secret will match. Treat it like a whsec_ key (Svix or Stripe): it is shown once and is the value your receiver passes to the SDK verifier. Endpoints can be listed and deactivated through the companion admin routes.

Delivery format

Each delivery is an HTTP POST with a JSON body and these headers:

HeaderMeaning
X-COHESION-Signaturesha256=<hex>: HMAC-SHA256 over {timestamp}.{body} keyed with the signing_secret returned at registration
X-COHESION-TimestampUnix seconds at signing time; bound into the signature
X-COHESION-Idempotency-KeyUUID unique per delivery; duplicate keys mean a retry of the same delivery
User-AgentCOHESION-Webhook/1.3
Content-Typeapplication/json

The body always carries a top-level event discriminator: score.committed (the /v1/score envelope plus the event key), drs.must_review, or drs.escalation.

The SDK typed parsers (parseWebhookPayload / parse_webhook_payload) model the score.committed shape only. drs.must_review and drs.escalation carry different fields and are not yet typed in either SDK: verify the signature with the SDK helper as usual, then parse the raw body with plain JSON (JSON.parse / json.loads) and branch on event.

Verify the signature

Signature scheme (2026-06-09 onward, following the Standard Webhooks pattern):

  1. Read the raw request body BEFORE any JSON parsing.
  2. Concatenate the X-COHESION-Timestamp header value, a literal ., and the raw body.
  3. Compute HMAC-SHA256 over that string with the signing_secret returned at registration (not the raw secret you supplied) and compare against the hex in X-COHESION-Signature using a constant-time comparison.
  4. Reject the delivery if the timestamp is more than 5 minutes from your current time, in either direction. This closes the replay window: a captured delivery stops verifying once the window passes.
  5. Reject the delivery if the X-COHESION-Timestamp header is missing, empty, or non-numeric. Never fall back to verifying the body alone: a body-only fallback lets an attacker strip the timestamp header and replay captured deliveries indefinitely.

Both SDK helpers implement all five steps, fail closed on a missing timestamp, and expose the tolerance as a parameter (default 300 seconds).

TypeScript (@cohesionauth/sdk):

import { verifyWebhookSignature, parseWebhookPayload } from "@cohesionauth/sdk";

app.post("/cohesion-webhook", express.text({ type: "*/*" }), async (req, res) => {
  // COHESION_SIGNING_SECRET is the `signing_secret` returned by the register
  // 201 response, NOT the raw secret you supplied at registration.
  const ok = await verifyWebhookSignature(
    process.env.COHESION_SIGNING_SECRET,
    req.body,
    req.headers["x-cohesion-signature"] ?? "",
    req.headers["x-cohesion-timestamp"] ?? "",
    { toleranceSeconds: 300 },
  );
  if (!ok) return res.status(401).json({ error: "Invalid signature" });

  const { event } = JSON.parse(req.body) as { event: string };
  if (event === "score.committed") {
    const score = parseWebhookPayload(req.body);
    // handle the typed score event, e.g. score.jis
  } else {
    // drs.must_review and drs.escalation are not yet typed:
    // handle the parsed JSON directly.
  }
  res.status(200).json({ received: true });
});

Python (cohesion-sdk):

import json

from cohesion.webhooks import verify_webhook_signature, parse_webhook_payload

@app.post("/cohesion-webhook")
async def handle_webhook(request: Request):
    raw_body = (await request.body()).decode()
    # COHESION_SIGNING_SECRET is the `signing_secret` returned by the register
    # 201 response, NOT the raw secret you supplied at registration.
    ok = verify_webhook_signature(
        COHESION_SIGNING_SECRET,
        raw_body,
        request.headers.get("x-cohesion-signature", ""),
        request.headers.get("x-cohesion-timestamp", ""),
        tolerance_seconds=300,
    )
    if not ok:
        raise HTTPException(status_code=401, detail="Invalid signature")

    event = json.loads(raw_body)
    if event["event"] == "score.committed":
        score = parse_webhook_payload(raw_body)
        # handle the typed score event, e.g. score.jis
    else:
        # drs.must_review and drs.escalation are not yet typed:
        # handle the parsed dict directly.
        ...
    return {"received": True}

Idempotency

Retries reuse the same X-COHESION-Idempotency-Key. Store recently seen keys and treat a duplicate as already processed: respond 200 without re-running side effects. Keys older than your tolerance window can be evicted, because the timestamp check rejects anything older anyway.

Retries

Failed deliveries (network error or non-2xx response) are retried up to 3 attempts with backoff. Every attempt is signed fresh, so each retry carries a new timestamp and a new signature over the same body and idempotency key.

Breaking change (2026-06-09)

Before 2026-06-09 the signature covered the body alone and no timestamp header was sent. That wire format is retired: every delivery is now signed over {timestamp}.{body}. The SDK verify helpers fail closed, so a call without the timestamp header never verifies; pass the X-COHESION-Timestamp value on every verification. No webhook endpoints were registered in production when the format changed, so no live receiver was affected.