Get started
Webhooks
Nodium calls you when something happens. Signed, retried, in order, logged.
What you receive
{ "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" }}idis 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
2xxwithin 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_idnames the client the event comes from,external_idis your own id for it (nullfor My company). A subscription on My company receives every client's events; one created withNodium-Tenantor 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 (plusseq), in order, none skipped. Start withoutafterto get the head, then read fromnext_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_toandemojisay 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.
nullwhen the window never opened (asystemmessage 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
systemmessage where the contact changed number: their old number. data.new_number- string, nullable. On a
systemmessage where the contact changed number: their new number. data.reaction_to- string, nullable. On a
reactiononly, absent otherwise: the provider's id (provider_message_id) of the message the reaction is on.nullwhen the provider did not name it. data.emoji- string. On a
reactiononly, absent otherwise: the emoji, or an empty string when the reaction was removed.
{ "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) oroutbound(written from the phone). data.sender- string.
contact, orphonewhen it was written from the phone. data.from- string, nullable. The contact's phone number, on an
inboundmessage;nullotherwise. data.to- string, nullable. The contact's phone number, on an
outboundmessage;nullotherwise. 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:
deliveredfor what the contact wrote;sent,delivered,readorfailedfor what the phone sent.
{ "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
reactiononly, absent otherwise: the provider's id (provider_message_id) of the message the reaction is on.nullwhen the provider did not name it. data.emoji- string. On a
reactiononly, 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).
{ "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.
{ "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.
{ "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,userorsystem. 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.nullonly 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: truefor a voice note. data.template- object, nullable. The template:
name,language,parameters(the values of its placeholders) andbuttons. The rendered text is intext. 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.
{ "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.
{ "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.
{ "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,userorsystem. 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.nullonly 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: truefor a voice note. data.template- object, nullable. The template:
name,language,parameters(the values of its placeholders) andbuttons. The rendered text is intext. 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 }.titlesays what happened andactionwhat to do about it;actorsays who acts:you(your code or console),client(the owner of the number:actionis written to be passed on to them),nodium, orwait(the refusal is temporary).nullwhen 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_missingwhen the client has no payment method at Meta.
{ "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.
{ "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…
{ "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.
{ "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.
{ "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.
{ "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_unavailableorunknown. 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.
{ "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 asPARTNER_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.
{ "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, ordeclinedwhen the owner of the phone refused to share the history.
{ "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.
# 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:
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)))}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.
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+Cstops it and prints the number to resume from (--from). - It needs no webhook subscription: Nodium calls nothing, the command asks.
--secretcan be the secret of a subscription or any string your code also verifies with;--eventsfilters,--tenantnames one client. - A call your server answers with a non-
2xxstatus, or not at all, is shown and not retried.message.importedis replayed only when--eventsnames 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
2xxis retried after 1 min, 5 min, 30 min, 2 h and 12 h — six attempts in all — then markedfailed. GET /webhooks/deliverieslists every delivery with the status your server answered and the error. That settles "we never received it".POST /webhooks/deliveries/{id}/redeliversends it again, same payload, sameid.POST /webhooks/{id}/rotate-secretissues a new secret; the old one keeps signing for 24 hours so you can deploy without missing a call.POST /webhooks/{id}/testfires one signed test call now and tells you what came back.PATCH /webhooks/{id}withisActive: falsepauses a subscription: deliveries wait, and resume when you set it back totrue. After 50 failed attempts in a row with nothing delivered for 24 hours, Nodium pauses it on its own and says why indisabledReason.