Verify webhook signatures
célula.in signs every event, verification, and test request with the endpoint secret. Verify the signature before parsing the JSON body or performing any side effect.
Signature headers
Seção intitulada “Signature headers”Every request includes these standard headers:
| Header | Meaning |
|---|---|
Webhook-Id |
Stable message ID. It is the event ID for production and manual redeliveries, and the delivery ID for control messages. |
Webhook-Timestamp |
Unix time in decimal seconds when this request attempt was signed. |
Webhook-Signature |
One or more space-separated v1,<base64-signature> values. |
The signed content is the byte sequence:
<Webhook-Id>.<Webhook-Timestamp>.<exact raw request body bytes>For each signing secret, the signature is:
Base64(HMAC-SHA256(raw_secret_bytes, signed_content))The secret returned by the management API has the form whsec_<padded-standard-base64>.
Node.js example
Seção intitulada “Node.js example”Install the conformance-tested Standard Webhooks verifier:
npm install --save-exact standardwebhooks@1.0.0Pass the complete whsec_... secret, the exact raw request body, and the three authentication headers to the official library:
import { Webhook } from "standardwebhooks";
export function verifyCelulainWebhook({ rawBody, webhookId, webhookTimestamp, webhookSignature, secret,}) { const verifier = new Webhook(secret);
return verifier.verify(rawBody, { "webhook-id": webhookId, "webhook-timestamp": webhookTimestamp, "webhook-signature": webhookSignature, });}verify() returns the parsed JSON document when verification succeeds and throws WebhookVerificationError when a required header, timestamp, or signature is invalid. The library enforces a five-minute tolerance for both old and future timestamps.
Configure your framework to preserve the request body before JSON parsing. Framework JSON parsers can replace whitespace, escaping, or key ordering, so passing JSON.stringify(parsedBody) will fail for valid requests.
The Express example below receives three message classes separately and injects acceptEventOnce, which your application must implement with durable shared storage:
import express from "express";import { Webhook, WebhookVerificationError } from "standardwebhooks";
export function mountCelulainWebhookRoute( app, { secret, acceptEventOnce },) { if (typeof acceptEventOnce !== "function") { throw new TypeError("acceptEventOnce must be a durable acceptance function"); }
const verifier = new Webhook(secret);
app.post( "/webhooks/celulain", express.raw({ type: "application/vnd.api+json", limit: 256 * 1024, // 256 KiB }), async (request, response, next) => { try { const webhookId = request.get("Webhook-Id"); const message = verifier.verify(request.body, { "webhook-id": webhookId, "webhook-timestamp": request.get("Webhook-Timestamp"), "webhook-signature": request.get("Webhook-Signature"), });
if (message?.data?.type === "webhook-controls") { const controlType = message.data.attributes?.["control-type"];
if (controlType === "webhook.verification") { return response.status(200).json({ meta: { challenge: message.data.attributes.challenge }, }); }
if (controlType === "webhook.test") { return response.status(204).send(); }
return response .status(400) .json({ error: "Unsupported webhook control" }); }
if (message?.data?.type !== "webhook-events") { return response .status(400) .json({ error: "Unsupported webhook message" }); }
await acceptEventOnce({ webhookId, event: message }); return response.status(204).send(); } catch (error) { if (error instanceof WebhookVerificationError) { return response.status(400).json({ error: "Invalid webhook" }); }
return next(error); } }, );}Mount this route before any application-wide JSON parser. The explicit raw-body limit accepts the complete 256 KiB webhook contract instead of Express’s smaller default.
acceptEventOnce must atomically use webhookId as a unique idempotency key and persist the verified event to a durable shared inbox or queue. It must resolve only after that commit succeeds; a previously committed duplicate should resolve without creating more business work. If storage is unavailable, it must reject so Express returns a 5xx response and célula.in retries. The handler returns 204 only after durable acceptance.
Verification and test controls are handled before the production-event path. Every valid verification retry returns its challenge again, test controls create no business work, and unknown control or message types fail closed. See Getting started for the full verification flow.
Protect against replay
Seção intitulada “Protect against replay”After the signature is valid:
- For production and manual-redelivery events, use
Webhook-Idas a durable idempotency key. Retain successfully processed IDs for at least 30 days and return2xxwithout repeating the business side effect when one is received again. - Store or enqueue a new event durably before returning
2xx. - Handle control messages separately. In particular, verify every verification retry and return its matching challenge again even when its
Webhook-Idwas already seen.
The timestamp changes and the body is re-signed for every attempt. Webhook-Id remains stable across automatic retries and manual redelivery of the same event. The service’s manual-redelivery eligibility expires with the original event, no later than 30 days after capture. Retaining each successfully processed ID for 30 days is deliberately conservative and covers any late redelivery of that event; this retention is not required for verification or test control IDs.
Secret rotation
Seção intitulada “Secret rotation”For 48 hours after a normal secret rotation, célula.in signs requests with both the new and previous secret. Webhook-Signature then contains two v1,... values separated by a space. Accept the request when any supported signature matches a secret you currently trust.
A destination URL change behaves differently: it generates a new secret, invalidates the old secret immediately, and requires verification of the new destination. There is no overlap for a URL change.
Optional diagnostic headers
Seção intitulada “Optional diagnostic headers”The HMAC authenticates only Webhook-Id, Webhook-Timestamp, and the exact raw body. The following headers are outside that authenticated input. Treat them as optional convenience metadata, not as a trusted source for routing, dispatch, authorization, or deduplication:
| Header | Convenience value | Authenticated source |
|---|---|---|
X-Celulain-Delivery-Id |
ID of this delivery record. A manual redelivery has a new delivery ID. | Verified body meta.delivery-id. |
X-Celulain-Event-Id |
Event ID. Present only for event deliveries. | Verified body data.id; it must also equal the authenticated Webhook-Id. |
X-Celulain-Message-Type |
Event type such as person.updated, or control type such as webhook.verification. |
Verified body data.attributes.event-type or data.attributes.control-type. |
User-Agent |
Usually Celulain-Webhooks/1.0. |
No signed equivalent; informational only. |
Verify the request first, then route and process it using the authenticated body and standard headers. If you read an X-Celulain-* value for convenience, compare it with the corresponding verified body field and reject or ignore it when they differ.
