CRPRO Hub

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: v2 for the native format and evohub-v1 for 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 format sha256=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.

verify-signature.js
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.

verify-signature-evohub.js
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 id in the body (in normalized mode): it repeats on retries and on replays. X-Hub-Delivery-Id only repeats on retries. A meta_raw body is Meta's payload, with no top-level id: use the message id (wamid) inside it.
  • Respond 2xx within 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.

evohub-lifecycle.json
{
  "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.