Pular para o conteúdo

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.

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>.

Install the conformance-tested Standard Webhooks verifier:

Terminal window
npm install --save-exact standardwebhooks@1.0.0

Pass 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.

After the signature is valid:

  1. For production and manual-redelivery events, use Webhook-Id as a durable idempotency key. Retain successfully processed IDs for at least 30 days and return 2xx without repeating the business side effect when one is received again.
  2. Store or enqueue a new event durably before returning 2xx.
  3. Handle control messages separately. In particular, verify every verification retry and return its matching challenge again even when its Webhook-Id was 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.

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.

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.