Webhooks
Each webhook endpoint gets a whsec_... secret shown exactly once, when it is created. The body of every delivery is signed with no later normalization — the signature covers the exact bytes that were sent.
Signature headers
Every delivery arrives with four headers:
X-Hub-Delivery-Id— the delivery UUID: the same across all its attempts, new on a manual replay.X-Hub-Timestamp— the moment it was sent, in seconds since the Unix epoch.X-Hub-Signature-Version— which signature format was used:v2for the native format andevohub-v1for the EvoHub compatibility format. The two sign different content — read this header before computing the HMAC.X-Hub-Signature-256— the HMAC-SHA256 signature, in the formatsha256=HEX.
Verifying in Node
In the native format (v2, the default for every endpoint created with delivery_format: "native") the signature does not cover the body alone: it covers `${timestamp}.${delivery_id}.${body}`, using the exact X-Hub-Timestamp and X-Hub-Delivery-Id values of that delivery. Binding the signature to both is what stops a captured body from being transplanted onto another delivery.
Compare with timingSafeEqual, never with === — a plain string comparison leaks execution time per byte position, which gives an attacker a way to discover the correct signature by trial and error.
import { createHmac, timingSafeEqual } from 'node:crypto'
const timestamp = request.headers['x-hub-timestamp']
const deliveryId = request.headers['x-hub-delivery-id']
const signed = `${timestamp}.${deliveryId}.${rawBody}`
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET).update(signed).digest('hex')
const received = request.headers['x-hub-signature-256']?.replace('sha256=', '')
const valid = !!received &&
/^[0-9a-f]{64}$/.test(received) &&
timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'))EvoHub compatibility format
An endpoint created with delivery_format: "evohub" — directly on POST /api/v1/webhooks, or implicitly through the compatible channel creation contract — signs the raw body only, with no timestamp and no delivery id. It is a deliberate exception, for consumers coming from EvoHub that cannot change their handler.
Identify the format by the header: X-Hub-Signature-Version: evohub-v1 instead of v2. X-Hub-Delivery-Id and X-Hub-Timestamp are still sent and still deduplicate, but they are not part of the HMAC.
import { createHmac, timingSafeEqual } from 'node:crypto'
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET).update(rawBody).digest('hex')
const received = request.headers['x-hub-signature-256']?.replace('sha256=', '')
const valid = !!received &&
/^[0-9a-f]{64}$/.test(received) &&
timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'))The native format is stronger: prefer native for new integrations and use evohub only for as long as the migration lasts.
Receiving rules
- Read the raw body before parsing JSON — reserializing the JSON can change the expected signature.
- Reject any delivery without the signature header, or with an invalid signature.
- Deduplicate by the event
idin the body (innormalizedmode): it repeats on retries and on replays.X-Hub-Delivery-Idonly repeats on retries. Ameta_rawbody is Meta's payload, with no top-levelid: use the messageid(wamid) inside it. - Respond
2xxwithin 10 seconds — process the event asynchronously if it takes longer.
Retries and pausing
A delivery that fails (timeout, a response outside 2xx, connection error) is resent up to nine times, ten attempts over about four days. After 20 consecutive failures the endpoint pauses automatically. Once you have fixed what was blocking delivery, re-enable it with PATCH /api/v1/webhooks/{id} and {"status":"active"}. Events that happen while it is paused get no delivery and cannot be replayed: recover those messages with GET /api/v1/channels/{id}/messages and direction=inbound.
Blocked destinations
A webhook endpoint URL goes through a destination check. These are blocked:
- Plain HTTP — only HTTPS is accepted, and only on port 443.
- URLs with an embedded username and password.
- Internal networks (loopback, link-local and private IP ranges).
- Redirects — delivery does not follow an HTTP redirect.
- Private DNS — a domain that resolves to an internal IP is refused. The check runs again on every delivery: a domain that starts pointing to an internal IP, or stops resolving, makes the delivery fail.
Payload modes
Each endpoint picks one of two payload modes:
normalized(default) — the CRPRO Hub envelope:{"id", "event", "channel_id", "occurred_at", "data"}, identical for every event.meta_raw— Meta’s raw payload for the event; with no raw counterpart, it falls back to the normalized envelope.
Payload mode and delivery format are independent: payload_mode picks what goes in the body, delivery_format picks how that body is signed.
EvoHub lifecycle payload
On an evohub endpoint, channel lifecycle events (channel.connected, channel.degraded and channel.disconnected) arrive in their own envelope, with event_type set to channel_connected or channel_disconnected. The Meta connection fields appear in four places at once, on purpose — at the top level, under channel, under data and under meta_connection — so that a handler written against any of those shapes keeps working.
{
"event_type": "channel_connected",
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"external_id": "11111111-1111-4111-8111-111111111111",
"phone_number_id": "109876543210987",
"waba_id": "102233445566778",
"display_phone_number": "+55 11 90000-0000",
"verified_name": "Loja Exemplo",
"meta_connection": {
"phone_number_id": "109876543210987",
"waba_id": "102233445566778",
"display_phone_number": "+55 11 90000-0000",
"verified_name": "Loja Exemplo",
"phone_number": "+55 11 90000-0000"
},
"channel": {
"id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"external_id": "11111111-1111-4111-8111-111111111111",
"phone_number_id": "109876543210987",
"waba_id": "102233445566778",
"display_phone_number": "+55 11 90000-0000",
"verified_name": "Loja Exemplo"
},
"data": {
"external_id": "11111111-1111-4111-8111-111111111111",
"phone_number_id": "109876543210987",
"waba_id": "102233445566778",
"display_phone_number": "+55 11 90000-0000",
"verified_name": "Loja Exemplo",
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e"
}
}The channel token (hub_ch_...) never appears in a webhook body. To rotate it, use POST /api/v1/channels/{id}/regenerate-token.
Event types
An endpoint subscribes to one or more of the following events:
- channel.created
- channel.connected
- channel.degraded
- channel.disconnected
- message.received
- message.sent
- message.status
- message.echo
- template.status
- phone.quality
- account.update
- meta.raw
- webhook.test
meta.raw is only delivered to an endpoint with payload_mode: "meta_raw" — subscribing to it on a normalized endpoint delivers nothing. The other way round, a meta_raw endpoint still receives channel.* and webhook.test, but none of the message, template, quality or account events.