Skip to content
Get an API key

Get started

Webhooks

Nodium calls you when something happens. Signed, retried, in order, logged.

What you receive

JSON

{ "id": "evt_5f1c0a9e2b7d4c3a8e6f1b2c3d4e5f60", "event": "message.received", "workspace_id": "…", "external_id": "crm-4187", "occurred_at": "2026-09-28T09:12:44.120Z", "data": { "message_id": "…", "conversation_id": "…", "contact_id": "…", "channel_id": "…", "channel_type": "whatsapp", "from": "+33612345678", "profile_name": "Karim", "type": "text", "text": "Hello, is my order ready?", "media": null, "window_expires_at": "2026-09-29T09:12:44.000Z" }}
  • id is the event's own id: it stays the same when a delivery is retried or redelivered. Deduplicate on it.
  • Headers: X-Nodium-Event (the event name), X-Nodium-Delivery (this delivery), X-Nodium-Signature.
  • Answer with any 2xx within 10 seconds. Do the work afterwards.
  • Ordered per conversation: an event waits until the older ones of the same conversation are delivered. Template and channel events do not wait.
  • workspace_id names the client the event comes from, external_id is your own id for it (null for My company). A subscription on My company receives every client's events; one created with Nodium-Tenant or a pinned key receives that client's only.
  • The webhook is a doorbell, the event log is the truth. Every event is also written to a numbered log, kept 30 days: GET /events?after=<seq> returns the same envelopes (plus seq), in order, none skipped. Start without after to get the head, then read from next_after. A delivery that never arrived costs you nothing. The history imported from a coexistence phone (message.imported) is in the log only: it never rings.

Events

Subscribe to the ones you need with events (* means all). Open an event to see its fields and a sample payload. data.channel_id tells which number it concerns.

message.receivedA contact wrote to one of your numbers. Nothing a contact sends is dropped: a message WhatsApp cannot pass on arrives as type unsupported, a change of number as system, a reaction as type reaction (with reaction_to, the message it is on, and emoji, empty when removed), and a message from a blocked contact with blocked: true.
data.message_id
string. The message.
data.conversation_id
string. Its conversation.
data.contact_id
string, nullable. The contact.
data.channel_id
string. The channel (the number).
data.channel_type
string. The kind of channel, for example whatsapp.
data.provider_message_id
string, nullable. The provider's id for the message.
data.from
string, nullable. The sender's phone number.
data.profile_name
string, nullable. The name the sender gave their profile.
data.type
string. The kind of message: text, image, audio, document, location, interactive, system (the contact changed number), reaction (an emoji on an earlier message: reaction_to and emoji say which and what), unsupported (a kind WhatsApp does not pass on)…
data.text
string, nullable. The text, when there is one.
data.reply
object, nullable. The button or list choice the contact made, when it is a reply.
data.location
object, nullable. The location the contact shared, when it is one.
data.referral
object, nullable. Where the contact came from (a click-to-WhatsApp ad or post), as Meta gave it, when it is one.
data.media
object, nullable. The file: url (Nodium's copy, behind your key), mime_type, filename.
data.window_expires_at
string, nullable. When the 24-hour customer service window of this conversation closes (ISO 8601), counted from the contact's latest message; free-form replies are accepted until then. null when the window never opened (a system message does not open it).
data.blocked
boolean. Present and true when the sender is a blocked contact: the message is stored and announced, but nothing should answer it.
data.previous_number
string, nullable. On a system message where the contact changed number: their old number.
data.new_number
string, nullable. On a system message where the contact changed number: their new number.
data.reaction_to
string, nullable. On a reaction only, absent otherwise: the provider's id (provider_message_id) of the message the reaction is on. null when the provider did not name it.
data.emoji
string. On a reaction only, absent otherwise: the emoji, or an empty string when the reaction was removed.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "message.received", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "message_id": "b1a4e7c0-0000-4000-8000-000000000001", "conversation_id": "7c1e4a90-0000-4000-8000-000000000002", "contact_id": "c0ffee00-0000-4000-8000-000000000003", "channel_id": "5e1f0000-0000-4000-8000-000000000004", "channel_type": "whatsapp", "provider_message_id": "wamid.HBgM…", "from": "33612345678", "profile_name": "Karim", "type": "text", "text": "Bonjour", "reply": null, "location": null, "referral": null, "media": null, "window_expires_at": "2026-09-30T10:00:00.000Z", "blocked": true, "previous_number": null, "new_number": null, "reaction_to": "wamid.HBgM…", "emoji": "👍" }}
message.importedA past message of a coexistence number, imported from the phone when the number was connected. Not delivered to webhooks: it is written to the event log only (GET /events), one entry per message, with occurred_at set to when the message was actually written. history.progress tells how far the import is.
data.message_id
string. The message.
data.conversation_id
string. Its conversation.
data.contact_id
string, nullable. The contact.
data.channel_id
string. The channel (the number).
data.channel_type
string. The kind of channel, for example whatsapp.
data.provider_message_id
string, nullable. The provider's id for the message.
data.direction
string. inbound (the contact wrote) or outbound (written from the phone).
data.sender
string. contact, or phone when it was written from the phone.
data.from
string, nullable. The contact's phone number, on an inbound message; null otherwise.
data.to
string, nullable. The contact's phone number, on an outbound message; null otherwise.
data.type
string. The kind of message: text, image, audio, document, system, unsupported…
data.text
string, nullable. The text or caption, when there is one.
data.media
object, nullable. The file: url (Nodium's copy, behind your key), mime_type, filename.
data.status
string. Where the message stood when it was imported: delivered for what the contact wrote; sent, delivered, read or failed for what the phone sent.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "message.imported", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "message_id": "b1a4e7c0-0000-4000-8000-000000000001", "conversation_id": "7c1e4a90-0000-4000-8000-000000000002", "contact_id": "c0ffee00-0000-4000-8000-000000000003", "channel_id": "5e1f0000-0000-4000-8000-000000000004", "channel_type": "whatsapp", "provider_message_id": "wamid.HBgM…", "direction": "inbound", "sender": "contact", "from": "+33612345678", "to": null, "type": "text", "text": "Mes lunettes sont prêtes ?", "media": null, "status": "delivered" }}
message.echoedThe person holding the phone of a coexistence number wrote to a contact from the WhatsApp Business app (not through the API). A reaction put on a message from the phone comes the same way, as type reaction with reaction_to and emoji.
data.message_id
string. The message.
data.conversation_id
string. Its conversation.
data.contact_id
string, nullable. The contact.
data.channel_id
string. The channel (the number).
data.channel_type
string. The kind of channel, for example whatsapp.
data.provider_message_id
string, nullable. The provider's id for the message.
data.to
string. The recipient's phone number.
data.sender
string. Always phone.
data.type
string. The kind of message: text, image, audio, document, reaction…
data.text
string, nullable. The text or caption, when there is one (the emoji, on a reaction).
data.media
object, nullable. The file: url (Nodium's copy, behind your key), mime_type, filename.
data.reaction_to
string, nullable. On a reaction only, absent otherwise: the provider's id (provider_message_id) of the message the reaction is on. null when the provider did not name it.
data.emoji
string. On a reaction only, absent otherwise: the emoji, or an empty string when the reaction was removed.
data.sent_at
string, nullable. When the phone sent it (ISO 8601).

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "message.echoed", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "message_id": "b1a4e7c0-0000-4000-8000-000000000001", "conversation_id": "7c1e4a90-0000-4000-8000-000000000002", "contact_id": "c0ffee00-0000-4000-8000-000000000003", "channel_id": "5e1f0000-0000-4000-8000-000000000004", "channel_type": "whatsapp", "provider_message_id": "wamid.HBgM…", "to": "+33612345678", "sender": "phone", "type": "text", "text": "Bien reçu, à demain.", "media": null, "reaction_to": "wamid.HBgM…", "emoji": "👍", "sent_at": "2026-09-29T09:59:58.000Z" }}
message.editedA message was edited from the WhatsApp Business app. message_id and provider_message_id are those of the original message.
data.message_id
string. The message.
data.conversation_id
string. Its conversation.
data.contact_id
string, nullable. The contact.
data.channel_id
string. The channel (the number).
data.channel_type
string. The kind of channel, for example whatsapp.
data.provider_message_id
string, nullable. The provider's id of the original message.
data.sender
string. Always phone.
data.text
string, nullable. The text after the edit.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "message.edited", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "message_id": "b1a4e7c0-0000-4000-8000-000000000001", "conversation_id": "7c1e4a90-0000-4000-8000-000000000002", "contact_id": "c0ffee00-0000-4000-8000-000000000003", "channel_id": "5e1f0000-0000-4000-8000-000000000004", "channel_type": "whatsapp", "provider_message_id": "wamid.HBgM…", "sender": "phone", "text": "Bien reçu, à demain 9 h." }}
message.revokedA message was deleted for everyone from the WhatsApp Business app. message_id and provider_message_id are those of the original message.
data.message_id
string. The message.
data.conversation_id
string. Its conversation.
data.contact_id
string, nullable. The contact.
data.channel_id
string. The channel (the number).
data.channel_type
string. The kind of channel, for example whatsapp.
data.provider_message_id
string, nullable. The provider's id of the original message.
data.sender
string. Always phone.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "message.revoked", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "message_id": "b1a4e7c0-0000-4000-8000-000000000001", "conversation_id": "7c1e4a90-0000-4000-8000-000000000002", "contact_id": "c0ffee00-0000-4000-8000-000000000003", "channel_id": "5e1f0000-0000-4000-8000-000000000004", "channel_type": "whatsapp", "provider_message_id": "wamid.HBgM…", "sender": "phone" }}
message.sentA message left for the provider, whoever sent it: your code, the console, a broadcast. It carries the whole content, the idempotency_key it was sent with, so you can tell your own sends from the others, and to, who it went to: a send you did not make can be to a contact you have never heard from.
data.message_id
string. The message.
data.conversation_id
string. Its conversation.
data.contact_id
string, nullable. The contact.
data.channel_id
string. The channel (the number).
data.provider_message_id
string, nullable. The provider's id for the message.
data.channel_type
string. The kind of channel, for example whatsapp.
data.sender
string. Who sent it: api, user or system.
data.api_key_id
string, nullable. The API key it went through, when it was one.
data.to
string, nullable. Who the message went to: the contact's phone number in E.164 (+33612345678), or their WhatsApp id (wa:<id>) when they have no number. null only when the message has no contact with either.
data.idempotency_key
string, nullable. The idempotency key the message was sent with (yours, when your code sent it).
data.type
string. The kind of message: text, template, interactive, image, video, audio, document, location, contacts.
data.text
string, nullable. The text shown: the text itself, a template's rendered text, a file's caption, a form's body.
data.media
object, nullable. The file: kind, url (Nodium's copy, behind your key), mime_type, filename, caption; voice: true for a voice note.
data.template
object, nullable. The template: name, language, parameters (the values of its placeholders) and buttons. The rendered text is in text.
data.form
object, nullable. The interactive form (buttons, list, location request, Flow), as it was sent.
data.location
object, nullable. The location sent: latitude, longitude, name, address.
data.contacts
object, nullable. The contact cards sent, as a list.
data.status
string. sent.
data.error
string, nullable. The reason when it failed, otherwise null.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "message.sent", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "message_id": "b1a4e7c0-0000-4000-8000-000000000001", "conversation_id": "7c1e4a90-0000-4000-8000-000000000002", "contact_id": "c0ffee00-0000-4000-8000-000000000003", "channel_id": "5e1f0000-0000-4000-8000-000000000004", "provider_message_id": "wamid.HBgM…", "channel_type": "whatsapp", "sender": "api", "api_key_id": null, "to": "+33612345678", "idempotency_key": "coplace:b1a4e7c0", "type": "text", "text": "Votre dossier est confirmé.", "media": null, "template": null, "form": null, "location": null, "contacts": null, "status": "sent", "error": null }}
message.deliveredThe provider delivered a message to the contact's phone.
data.message_id
string. The message.
data.conversation_id
string. Its conversation.
data.contact_id
string, nullable. The contact.
data.channel_id
string. The channel (the number).
data.provider_message_id
string, nullable. The provider's id for the message.
data.channel_type
string. The kind of channel, for example whatsapp.
data.api_key_id
string, nullable. The API key it went through, when it was one.
data.status
string. delivered.
data.error
string, nullable. Always null.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "message.delivered", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "message_id": "b1a4e7c0-0000-4000-8000-000000000001", "conversation_id": "7c1e4a90-0000-4000-8000-000000000002", "contact_id": "c0ffee00-0000-4000-8000-000000000003", "channel_id": "5e1f0000-0000-4000-8000-000000000004", "provider_message_id": "wamid.HBgM…", "channel_type": "whatsapp", "api_key_id": null, "status": "delivered", "error": null }}
message.readThe contact read a message.
data.message_id
string. The message.
data.conversation_id
string. Its conversation.
data.contact_id
string, nullable. The contact.
data.channel_id
string. The channel (the number).
data.provider_message_id
string, nullable. The provider's id for the message.
data.channel_type
string. The kind of channel, for example whatsapp.
data.api_key_id
string, nullable. The API key it went through, when it was one.
data.status
string. read.
data.error
string, nullable. Always null.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "message.read", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "message_id": "b1a4e7c0-0000-4000-8000-000000000001", "conversation_id": "7c1e4a90-0000-4000-8000-000000000002", "contact_id": "c0ffee00-0000-4000-8000-000000000003", "channel_id": "5e1f0000-0000-4000-8000-000000000004", "provider_message_id": "wamid.HBgM…", "channel_type": "whatsapp", "api_key_id": null, "status": "read", "error": null }}
message.failedA message could not be sent, or the provider reported it undeliverable later. It carries the whole content, the idempotency_key and to, like message.sent.
data.message_id
string. The message.
data.conversation_id
string. Its conversation.
data.contact_id
string, nullable. The contact.
data.channel_id
string. The channel (the number).
data.provider_message_id
string, nullable. The provider's id for the message.
data.channel_type
string. The kind of channel, for example whatsapp.
data.sender
string. Who sent it: api, user or system.
data.api_key_id
string, nullable. The API key it went through, when it was one.
data.to
string, nullable. Who the message went to: the contact's phone number in E.164 (+33612345678), or their WhatsApp id (wa:<id>) when they have no number. null only when the message has no contact with either.
data.idempotency_key
string, nullable. The idempotency key the message was sent with (yours, when your code sent it).
data.type
string. The kind of message: text, template, interactive, image, video, audio, document, location, contacts.
data.text
string, nullable. The text shown: the text itself, a template's rendered text, a file's caption, a form's body.
data.media
object, nullable. The file: kind, url (Nodium's copy, behind your key), mime_type, filename, caption; voice: true for a voice note.
data.template
object, nullable. The template: name, language, parameters (the values of its placeholders) and buttons. The rendered text is in text.
data.form
object, nullable. The interactive form (buttons, list, location request, Flow), as it was sent.
data.location
object, nullable. The location sent: latitude, longitude, name, address.
data.contacts
object, nullable. The contact cards sent, as a list.
data.status
string. failed.
data.error
string, nullable. Why, in a sentence.
data.error_code
integer. The provider's error code, when it gave one.
data.error_info
object, nullable. The refusal explained, when the provider gave a code: { code, title, action, actor }. title says what happened and action what to do about it; actor says who acts: you (your code or console), client (the owner of the number: action is written to be passed on to them), nodium, or wait (the refusal is temporary). null when the failure carries no provider code.
data.reason
string, nullable. Why, as a stable code. Refused by Nodium when it was about to leave, nothing sent to the provider: channel_inactive (the number is switched off, by its owner, by Meta or by the plan), contact_blocked, window_closed (free text outside the 24-hour window, for a queued, scheduled or retried message). payment_missing when the client has no payment method at Meta.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "message.failed", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "message_id": "b1a4e7c0-0000-4000-8000-000000000001", "conversation_id": "7c1e4a90-0000-4000-8000-000000000002", "contact_id": "c0ffee00-0000-4000-8000-000000000003", "channel_id": "5e1f0000-0000-4000-8000-000000000004", "provider_message_id": "wamid.HBgM…", "channel_type": "whatsapp", "sender": "api", "api_key_id": null, "to": "+33612345678", "idempotency_key": "coplace:b1a4e7c0", "type": "text", "text": "Votre dossier est confirmé.", "media": null, "template": null, "form": null, "location": null, "contacts": null, "status": "failed", "error": "Meta refused: payment method missing (code 131042)", "error_code": 131042, "error_info": { "code": 131042, "title": "Missing or declined payment method on your client's WhatsApp account: Meta blocks paid messages.", "action": "Add a valid payment method to your WhatsApp Business account in WhatsApp Manager, under payment methods. Messages will then go out normally.", "actor": "client" }, "reason": "payment_missing" }}
conversation.openedA first message opened a new conversation with a contact on a channel.
data.conversation_id
string. Its conversation.
data.contact_id
string, nullable. The contact.
data.channel_id
string. The channel (the number).
data.channel_type
string. The kind of channel, for example whatsapp.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "conversation.opened", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "conversation_id": "7c1e4a90-0000-4000-8000-000000000002", "contact_id": "c0ffee00-0000-4000-8000-000000000003", "channel_id": "5e1f0000-0000-4000-8000-000000000004", "channel_type": "whatsapp" }}
contact.opted_inA contact's broadcast consent was recorded.
data.contact_id
string, nullable. The contact.
data.source
string. Where the consent came from: api, console, whatsapp…

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "contact.opted_in", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "contact_id": "c0ffee00-0000-4000-8000-000000000003", "source": "api" }}
contact.opted_outA contact withdrew their broadcast consent (for example by replying STOP).
data.contact_id
string, nullable. The contact.
data.source
string. Where the withdrawal came from.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "contact.opted_out", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "contact_id": "c0ffee00-0000-4000-8000-000000000003", "source": "whatsapp" }}
template.status_changedMeta changed the status or the category of a template at one of your clients.
data.template_id
string. The template.
data.name
string. Its name at Meta.
data.language
string. Its language code.
data.client_id
string. The client it concerns.
data.waba_id
string, nullable. The WhatsApp Business account.
data.status
string. The new status: approved, submitted, rejected, paused, disabled…
data.previous_status
string, nullable. The status before.
data.category
string, nullable. The category now.
data.previous_category
string, nullable. The category before.
data.category_changed
boolean. Present and true when Meta changed the category.
data.reason
string, nullable. Meta's reason, when it refused or paused.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "template.status_changed", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "template_id": "a11ce000-0000-4000-8000-000000000005", "name": "order_created", "language": "fr", "client_id": "d0c00000-0000-4000-8000-000000000006", "waba_id": "1827756917799351", "status": "approved", "previous_status": "submitted", "category": "UTILITY", "previous_category": "UTILITY", "category_changed": true, "reason": null }}
channel.connectedA number was connected to a client: by the client through a hosted connect link, or by the Nodium team.
data.channel_id
string. The channel (the number).
data.phone_number_id
string. Meta's id of the number: the one the events you receive carry, to tie a channel to its number.
data.display_number
string, nullable. The number as it is dialled.
data.coexistence
boolean. The number keeps the WhatsApp Business app on the phone.
data.connect_link_id
string, nullable. The connect link that was used, when there was one.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "channel.connected", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "channel_id": "5e1f0000-0000-4000-8000-000000000004", "phone_number_id": "1233522149840389", "display_number": "+33 7 43 61 20 85", "coexistence": false, "connect_link_id": null }}
channel.connect_failedA number could not be connected: the client went through Meta's window (hosted connect link or console) but a step failed on Nodium's side or at Meta. code is stable; reason is a sentence for a person. Nothing was connected: the client can try again, or you can reach out.
data.code
string. Why it failed: code_refused, waba_not_granted, no_number, numbers_unreadable, other_workspace, other_provider, subscribe_failed, system_user_blind, pin_mismatch, register_failed, signup_unavailable or unknown.
data.reason
string. The failure, as a sentence.
data.phone_number_id
string, nullable. Meta's id of the number, when Meta had already told us which one.
data.display_number
string, nullable. The number as it is dialled, when known.
data.connect_link_id
string, nullable. The connect link that was used, when there was one.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "channel.connect_failed", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "code": "register_failed", "reason": "Meta refused to register the number.", "phone_number_id": "1233522149840389", "display_number": "+33 7 43 61 20 85", "connect_link_id": null }}
channel.health_changedA number's quality rating or messaging tier changed, its payment method went missing or came back, or Meta changed the state of its WhatsApp account (application removed, account disabled or restored, review, display name).
data.channel_id
string. The channel (the number).
data.event
string. What happened: a Meta quality event, payment_missing, payment_restored, or a state of the account such as PARTNER_REMOVED, DISABLED_UPDATE, account_review_approved, name_rejected.
data.is_active
boolean, nullable. On account events: whether the channel is still switched on after the change. Meta cutting the number switches it off.
data.reason
string, nullable. On account events: the reason Meta gives, when it gives one.
data.quality_rating
string, nullable. The quality rating now: GREEN, YELLOW, RED.
data.previous_quality_rating
string, nullable. The rating before.
data.messaging_limit
string, nullable. The messaging tier now.
data.previous_messaging_limit
string, nullable. The tier before.
data.payment_missing
boolean, nullable. On payment events: whether the payment method is missing.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "channel.health_changed", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "channel_id": "5e1f0000-0000-4000-8000-000000000004", "event": "FLAGGED", "is_active": false, "reason": "ACCOUNT_VIOLATION", "quality_rating": "YELLOW", "previous_quality_rating": "GREEN", "messaging_limit": "TIER_1K", "previous_messaging_limit": "TIER_1K", "payment_missing": true }}
history.progressA chunk of the history of a coexistence number was imported (the conversations the phone already had). One event per chunk: the messages themselves go to the event log as message.imported, without ringing.
data.channel_id
string. The channel (the number).
data.progress
integer. How far the import is, from 0 to 100.
data.imported
integer. How many messages have been imported so far.
data.status
string. running, done, or declined when the owner of the phone refused to share the history.

JSON

{ "id": "evt_0123456789abcdef0123456789abcdef", "event": "history.progress", "workspace_id": "d0c00000-0000-4000-8000-000000000006", "external_id": "crm-4187", "occurred_at": "2026-09-29T10:00:00.000Z", "data": { "channel_id": "5e1f0000-0000-4000-8000-000000000004", "progress": 40, "imported": 312, "status": "running" }}

Choose the numbers

By default a subscription receives the events of every number of its client. Pass channelIds to POST /webhooks (or change it with PATCH /webhooks/{id}) to receive the events of those numbers only; an empty list means all numbers again. An event that belongs to no number — a template status, a consent change — is not delivered to a subscription limited to some numbers. A channel that is not yours is refused with 422.

Shell

# Only the events of two numberscurl https://nodium.io/api/v1/webhooks \ -H "Authorization: Bearer $NODIUM_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/hooks/nodium", "events": ["message.received", "message.failed"], "channelIds": ["<channel id>", "<another channel id>"] }'

Verify the signature

X-Nodium-Signature: t=<seconds>,v1=<hex>. The signature is HMAC-SHA256(secret, "<t>.<raw body>"). During a secret rotation the header carries two v1= values, newest first — accept the call if any matches. Reject calls older than 5 minutes.

With the TypeScript SDK, one call: verifyWebhook(rawBody, headers, secret) from @nodium.io/whatsapp/server checks the signature and the delay and returns the typed event (see TypeScript SDK). Without it:

Node.js

import crypto from 'node:crypto'// rawBody: the request body exactly as received, before any JSON parsing.export function verifyNodium(rawBody, header, secret) { const parts = header.split(',') const t = parts.find(p => p.startsWith('t='))?.slice(2) const signatures = parts.filter(p => p.startsWith('v1=')).map(p => p.slice(3)) if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex') return signatures.some(sig => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))}

Python

import hmac, hashlib, timedef verify_nodium(raw_body: bytes, header: str, secret: str) -> bool: parts = header.split(",") t = next((p[2:] for p in parts if p.startswith("t=")), None) signatures = [p[3:] for p in parts if p.startswith("v1=")] if not t or abs(time.time() - int(t)) > 300: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(sig, expected) for sig in signatures)

Develop locally without a tunnel

A webhook address must be a public https URL, which your laptop is not. Instead of a tunnel, let the nodium command (@nodium.io/cli) fetch your events and call your local server: each call carries the body and the headers of a real delivery, signed with the secret you give, so your verification code runs as in production.

Shell

export NODIUM_KEY=nod_…npx @nodium.io/cli listen \ --forward http://localhost:3000/webhook \ --secret whsec_… \ --events message.received,message.failed# 10:32:01 message.received 200 12 ms evt_5f1c0a9e…# 10:32:07 message.failed 500 4 ms evt_8c2d41b7…# Try it: send a message to your own phone, then answer it.npx @nodium.io/cli send --channel <channel id> --to +33612345678 --text "Hello"
  • It reads the event log (GET /events) from a cursor, so a dropped connection loses nothing. Ctrl+C stops it and prints the number to resume from (--from).
  • It needs no webhook subscription: Nodium calls nothing, the command asks. --secret can be the secret of a subscription or any string your code also verifies with; --events filters, --tenant names one client.
  • A call your server answers with a non-2xx status, or not at all, is shown and not retried. message.imported is replayed only when --events names it, as it never rings in production.
  • Without the command, do the same in any language: read GET /events?after=<seq> in a loop and call your own handler.

Retries, log, redelivery, pause

  • A call that fails or does not answer 2xx is retried after 1 min, 5 min, 30 min, 2 h and 12 h — six attempts in all — then marked failed.
  • GET /webhooks/deliveries lists every delivery with the status your server answered and the error. That settles "we never received it".
  • POST /webhooks/deliveries/{id}/redeliver sends it again, same payload, same id.
  • POST /webhooks/{id}/rotate-secret issues a new secret; the old one keeps signing for 24 hours so you can deploy without missing a call.
  • POST /webhooks/{id}/test fires one signed test call now and tells you what came back.
  • PATCH /webhooks/{id} with isActive: false pauses a subscription: deliveries wait, and resume when you set it back to true. After 50 failed attempts in a row with nothing delivered for 24 hours, Nodium pauses it on its own and says why in disabledReason.