Skip to content

Signing & verification

Signing & verification

Every delivery is signed with the endpoint's signing secret (shown once when you register the endpoint or rotate its secret — store it in your integration's config). The scheme is deliberately symmetric with the app's own inbound verifier: the same primitive (hash_hmac('sha256', …)) and the same constant-time comparison (hash_equals).

The envelope

The request body is a JSON object:

{
  "id": "invoice:CBX-2026-0001",
  "type": "invoice.issued",
  "data": { "number": "CBX-2026-0001", "account": "org_acme", "currency": "DKK", "...": "..." },
  "delivery_id": "01JABC…",
  "created_at": "2026-07-19T10:00:00+00:00"
}
  • id — the source event's stable idempotency key (dedupe your side-effects on this).
  • type — the event type from the catalog.
  • data — the event payload.
  • delivery_id — the per-delivery id, stable across retries of the same delivery.

The headers

Content-Type: application/json
X-Cbox-Timestamp: 1752921600
X-Cbox-Signature: t=1752921600,v1=<hex hmac>
X-Cbox-Event-Type: invoice.issued
X-Cbox-Delivery-Id: 01JABC…

The signature is computed over the string "{timestamp}.{body}" — binding the signature to a moment so you can reject a replayed delivery outside your tolerance window:

v1 = hex( HMAC_SHA256( key = secret, message = timestamp + "." + raw_request_body ) )

Verifying a delivery

Recompute the MAC from the raw body and the t= value in the header, then compare in constant time. Reject if the timestamp is outside your tolerance (300 seconds is a reasonable default).

function verify(string $rawBody, string $sigHeader, string $secret, int $tolerance = 300): bool
{
    // Parse "t=…,v1=…"
    $t = null; $v1 = null;
    foreach (explode(',', $sigHeader) as $part) {
        [$k, $val] = array_pad(explode('=', trim($part), 2), 2, null);
        if ($k === 't' && ctype_digit((string) $val)) { $t = (int) $val; }
        if ($k === 'v1') { $v1 = $val; }
    }
    if ($t === null || $v1 === null) { return false; }
    if (abs(time() - $t) > $tolerance) { return false; }        // replay window

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);

    return hash_equals($expected, $v1);
}

Always verify against the raw request bytes, before any JSON re-encoding — re-encoding can change whitespace/key order and break the MAC.

Rotating the secret

Rotating (Settings → Webhooks → Rotate secret) mints a new secret and invalidates the old one immediately. Update your integration in the same window to avoid a verification gap. The plaintext is shown once, rendered directly into the response — it is never stored in plaintext or flashed through a session.