Webhook signing
Every webhook delivery is signed with HMAC-SHA256 using your endpoint’s signing secret. Verify the signature before parsing the payload into business logic, and keep the raw request body bytes. Verification depends on the exact payload sent, so re-serializing parsed JSON will produce a different digest.Signature headers
X-Cope-Signature is a structured value, not a bare digest. Parse out the t and v1 components before comparing. Comparing the raw header value against a computed digest always fails.
Verification steps
- Read the raw request body bytes before any JSON parsing.
- Parse
t(timestamp) andv1(hex signature) fromX-Cope-Signature. - Reject the delivery if
tis outside your tolerance window. COPE recommends 5 minutes to guard against replay. - Compute
HMAC_SHA256(signing_secret, "{t}.{raw_body}")and hex-encode it. - Compare against
v1with a constant-time comparison.
Worked example
For a delivery with:{"id":"evt_123",...}, the expected signature is:
v1 component, 5f6e...9c1d. Copy-paste verification snippets for Node, Python, PHP, Ruby, and Go are shown in the dashboard when an endpoint is created.
Common verification mistakes
- Comparing the raw
X-Cope-Signaturevalue against the digest. Extractv1=first. - Signing only the body. The signed string is
"{timestamp}.{body}", joined with a literal.. - Re-serializing the body. Parse-then-stringify changes key order, whitespace, or Unicode escaping; always HMAC the raw bytes as received.
- Using the wrong encoding. The signature is lowercase hex, not base64, and carries no
sha256=prefix. - Using the wrong secret. Secrets are per endpoint, and after a rotation only the new secret verifies: the old one stops at once.
Consumer checklist
- Read the raw request body before JSON parsing.
- Verify the signature header with the endpoint secret configured for the subscription.
- Reject missing, malformed, or stale signatures.
- Deduplicate only after verification.
- Return 2xx only after durable acceptance.
Rotating the signing secret
Rotate an endpoint’s signing secret withPOST /v1/webhooks/endpoints/{id}/secret-rotations, sent from your server with an Idempotency-Key header and a secret API key that can make changes (a read-only key is refused). The 201 response carries the new secret as signing_secret. The COPE dashboard does not rotate secrets.
- The new secret replaces the old one immediately. There is no overlap: every delivery sent after the rotation — new events, automatic retries and replays alike — is signed with the new secret, and the old secret no longer verifies.
- Store the secret from the first response. Retrying the request with the same
Idempotency-Keyreturns the201again but withoutsigning_secret, because COPE keeps no copy to return. The rotation itself did happen, so if you lost the response, rotate again with a newIdempotency-Key. - Recover what the switch rejected. Deploy the new secret to your receiver as soon as the response arrives. A delivery your endpoint rejects with a 4xx in the meantime is recorded as failed and not retried automatically. Once your receiver verifies with the new secret, replay those deliveries from the webhook details view in the dashboard, one at a time with
POST /v1/webhooks/deliveries/{delivery_id}/replay, or for a time window withPOST /v1/webhooks/replay-jobs. A replay is signed with the current secret.