Event catalogue
curl https://api.joinotify.com/webhook-endpoints/events \
-H 'Authorization: Bearer sk_live_xxx'
An endpoint with events: [] receives all of them.
Messages
| Event | When it fires |
|---|---|
messages | A message came in, or the status of one you sent changed |
smb_message_echoes | A message was sent from the phone app (coexistence) |
history | Chunks of the synced history |
smb_app_state_sync | Contacts added, renamed or removed on the phone |
messages carries the most. Its value may hold messages[] (the customer wrote) or
statuses[] (something changed about what you sent).
{
"field": "messages",
"value": {
"metadata": { "phone_number_id": "106540352242922" },
"contacts": [{ "profile": { "name": "Maria" }, "wa_id": "5541999999999" }],
"messages": [
{
"from": "5541999999999",
"id": "wamid.HBgN...",
"timestamp": "1785312000",
"type": "text",
"text": { "body": "Hi, did my order ship?" }
}
]
}
}
{
"field": "messages",
"value": {
"statuses": [
{
"id": "wamid.HBgN...",
"status": "delivered",
"timestamp": "1785312042",
"recipient_id": "5541999999999",
"conversation": { "id": "...", "origin": { "type": "service" } },
"pricing": { "billable": true, "category": "service" }
}
]
}
}
status | Meaning |
|---|---|
sent | Left Meta's infrastructure |
delivered | Reached the device |
read | Was opened, unless the customer disabled read receipts |
failed | Not delivered — the reason is in errors[] |
The pricing object says whether that conversation is billable and in which category.
It's the right source for reconciling cost.
Templates
| Event | When it fires |
|---|---|
message_template_status_update | Approved, rejected, paused or disabled |
message_template_quality_update | Quality changed (GREEN / YELLOW / RED) |
message_template_components_update | The template's content was changed |
template_category_update | Meta reclassified the category |
{
"field": "message_template_status_update",
"value": {
"event": "APPROVED",
"message_template_id": 1234567890,
"message_template_name": "confirmacao_pedido",
"message_template_language": "pt_BR"
}
}
Subscribe to message_template_status_update instead of polling GET /templates — that's
how approval reaches you.
template_category_update deserves attention: a reclassification from UTILITY to
MARKETING raises the cost of every send with that template, without warning.
Account
| Event | When it fires |
|---|---|
account_alerts | An alert from Meta about the account |
account_update | The business account's state changed |
account_review_update | Result of the account review |
account_settings_update | Settings were changed |
account_update covers bans and restrictions. Worth monitoring: it's the warning that
your operation is about to stop.
Number
| Event | When it fires |
|---|---|
phone_number_name_update | Result of a display name request |
phone_number_quality_update | The number's quality changed |
{
"field": "phone_number_quality_update",
"value": {
"display_phone_number": "5541987111527",
"event": "FLAGGED",
"current_limit": "TIER_1K"
}
}
FLAGGED means quality dropped to RED and the messaging limit may be reduced. That's
the moment to review what you're sending, not to send more.
Business
| Event | When it fires |
|---|---|
business_capability_update | Limits and capabilities changed |
business_status_update | The business verification status changed |
Other
| Event | When it fires |
|---|---|
security | Account security events |
flows | WhatsApp Flows interactions |
calls | Call events |
user_preferences | The customer opted out or opted back in |
user_preferences is opt-out. Ignoring it is a short path to your number's quality
collapsing — and, in many jurisdictions, a compliance problem.
Billing
Events we originate, like the wallet and engagement ones further down — the ones
above all come from Meta and are relayed. Hence the prefix in the name, and the
source: "joinotify" in the payload.
| Event | When it fires |
|---|---|
billing.past_due | Payment failed and the grace period started. Still sending |
billing.blocked | Access cut: sending routes start answering 402 |
billing.restored | Sending works again |
The trigger is the entitlement, not the payment status. The difference matters:
you can stop sending because the card failed, because the licence lapsed, because the
trial ended or because you hit the plan’s message cap. To whoever integrates, all four
are the same fact — and reason says which one it was.
{
"field": "billing.blocked",
"value": {
"state": "blocked",
"reason": "license_expired",
"message": "The license for this account has expired. Renew it to keep sending messages.",
"previous_state": "ok",
"billable_numbers": 2,
"grace_ends_at": null,
"source": "joinotify",
"occurred_at": "2026-08-15T21:00:00.000Z"
}
}
The event fires on the transition, never on repeat: a block that lasts a month
produces one billing.blocked, not one an hour.
They carry no wabaId and no phoneNumberId — billing belongs to the account, not to a
number. The consequence is that an endpoint filtered by number will not get them.
To receive billing events, register an endpoint with no filter.
Wallet and referrals
If the account takes part in the referral program, five more events describe the money
coming into and out of the wallet. They are account events too, and also carry
source: "joinotify".
| Event | When it fires |
|---|---|
wallet.credited | Balance came in: commission, operator adjustment or a failed payout back |
wallet.debited | Balance went out: applied to an invoice, or reserved by a payout request |
affiliate.approved | The enrolment was approved and the code is live |
affiliate.rejected | The enrolment was refused or suspended — reason says why |
payout.paid | The withdrawal was paid, with method (pix/paypal) and payment_ref |
{
"field": "wallet.credited",
"value": {
"amount_cents": 2000,
"currency": "brl",
"bucket": "pending",
"available_at": "2026-09-18T12:00:00.000Z",
"reason": "commission",
"referral_id": "ref_1a2b3c",
"source": "joinotify",
"occurred_at": "2026-08-19T12:00:00.000Z"
}
}
payout.paid also carries paid_amount_cents, paid_currency and fee_cents when the withdrawal
went out through PayPal — there the amount that lands is not the amount that left, because the fee
and the conversion happen on the other side. On a Pix the three come back null.
bucket is what avoids the most expensive confusion in this flow: a commission is born
held (pending) and only becomes withdrawable (available) once the hold expires — which
is what absorbs a refund arriving later. A wallet.credited with bucket: "pending" is not
spendable money yet, and available_at says when it will be.
Engagement
Nine events tell what happened in here to your contacts, campaigns, flows and
conversations. They also carry source: "joinotify", and they are what lets you react
without polling the API — "the flow captured a lead → create the customer in the ERP".
| Event | When it fires |
|---|---|
contact.created | A new contact: first message, API, flow or dashboard. Imports do not announce each row |
contact.opted_in | Marketing consent recorded — from the dashboard, the API or a flow |
contact.opted_out | Opt-out: a keyword, Meta's 131050 refusal, the dashboard, the API or a flow |
broadcast.completed | A campaign finished sending |
broadcast.paused | A campaign stopped — by someone, or on its own |
flow.run.completed | A contact reached the end of a flow |
flow.answer.captured | A Question or Wait-for-reply step stored an answer |
conversation.assigned | A conversation was handed to someone on the team |
conversation.closed | A conversation was closed, by a person or by a flow |
These events are only generated when someone subscribes to them: with no active endpoint that would receive them, nothing is written. After creating or editing an endpoint, count to thirty — that is how long the subscription list takes to be read again.
Contacts
Every event about a contact carries the same contact object: enough to find it again,
and no more personal data than that.
{
"field": "contact.opted_out",
"value": {
"contact": {
"id": "cm1c0ntact0001",
"phone": "+5541987111527",
"wa_id": "5541987111527",
"bsuid": null,
"first_name": "Ana",
"last_name": "Souza",
"email": null
},
"via": "keyword",
"reason": "PARAR",
"source": "joinotify",
"occurred_at": "2026-09-19T14:02:11.000Z"
}
}
via says where the change came from: manual (dashboard), api, flow,
inbound (the customer's first message created the contact), keyword (the person sent
an opt-out word) or meta_131050 (Meta refused a send because the person stopped
accepting marketing). reason only appears on the opt-out — for a keyword, it is the text
the person sent.
contact.* goes out without wabaId or phoneNumberId, like billing: a contact
belongs to the account, not to a number. An endpoint filtered by number does not
receive them.
Campaigns
{
"field": "broadcast.paused",
"value": {
"broadcast": {
"id": "cm1bro4dc4st01",
"name": "Black Friday",
"template": "promo_black_friday",
"language": "pt_BR",
"phone_number_id": "106540352242922",
"flow_id": null,
"recipients": 4800,
"sent": 1210,
"delivered": 1175,
"read": 640,
"failed": 12,
"clicked": 88,
"replied": 31,
"opted_out": 4
},
"reason": "throughput",
"detail": "130429",
"resume_after": "2026-11-27T13:55:00.000Z",
"source": "joinotify",
"occurred_at": "2026-11-27T13:40:00.000Z"
}
}
The numbers are those of the moment the event went out. A finished campaign's statuses
keep arriving for days, so broadcast.completed carries the picture at closing time, not
the final result. It has finished_at instead of reason.
reason | Why the campaign stopped |
|---|---|
manual | Someone paused it |
quality | The number's quality turned red, or Meta applied its spam limit (131048) |
template | The template was paused, disabled, deleted or turned red |
account | The number was disconnected, or the account is blocked, restricted or unpaid at Meta |
quota | The plan reached its monthly message cap |
billing | The account lost its entitlement |
errors | Too many failures in a row, or parameter errors right at the start — usually the variable mapping |
throughput | Meta held back the throughput for minutes. It resumes on its own after 15 min, 1 h and 4 h; resume_after says when |
send_window | Outside the configured send window. It resumes on its own at the next opening |
detail complements the reason — Meta's code, the template status (PAUSED,
DELETED, RED…) — and may be null.
Flows
{
"field": "flow.run.completed",
"value": {
"flow": { "id": "cm1fl0w000001", "name": "Captura de lead", "version": 4 },
"run": {
"id": "cm1run0000001",
"trigger_type": "api",
"end_reason": "end",
"started_at": "2026-09-19T14:00:02.000Z",
"finished_at": "2026-09-19T14:03:40.000Z"
},
"contact": {
"id": "cm1c0ntact0001",
"phone": "+5541987111527",
"wa_id": "5541987111527",
"bsuid": null,
"first_name": "Ana",
"last_name": "Souza",
"email": null
},
"variables": { "empresa": "Padaria Central", "interesse": "orçamento" },
"source": "joinotify",
"occurred_at": "2026-09-19T14:03:40.000Z"
}
}
variables carries every flow variable at the end of the run — including the answers
the contact gave. Test runs never generate events.
{
"field": "flow.answer.captured",
"value": {
"flow": { "id": "cm1fl0w000001", "name": "Captura de lead", "version": 4 },
"run_id": "cm1run0000001",
"node_id": "question_2",
"variable": "empresa",
"field": null,
"value": "Padaria Central",
"contact": {
"id": "cm1c0ntact0001",
"phone": "+5541987111527",
"wa_id": "5541987111527",
"bsuid": null,
"first_name": "Ana",
"last_name": "Souza",
"email": null
},
"source": "joinotify",
"occurred_at": "2026-09-19T14:01:15.000Z"
}
}
variable is the name of the flow variable that got the answer; field, the contact
field, when the step writes straight into one. Either may be null.
Conversations
{
"field": "conversation.assigned",
"value": {
"conversation": { "id": "cm1c0nv000001", "phone_number_id": "106540352242922" },
"contact": {
"id": "cm1c0ntact0001",
"phone": "+5541987111527",
"wa_id": "5541987111527",
"bsuid": null,
"first_name": "Ana",
"last_name": "Souza",
"email": null
},
"assigned_user_id": "cm1us3r000001",
"assigned_by": "flow:cm1fl0w000001",
"source": "joinotify",
"occurred_at": "2026-09-19T14:03:41.000Z"
}
}
assigned_by and closed_by say who acted: the id of a team member, flow:<id> when
it was a flow, or null when it was an API key. conversation.closed carries only
closed_by.
Campaigns, flows and conversations go out with the number (phoneNumberId) and its
business account, so an endpoint filtered by number or by WABA receives that number's
events.
Reading the history
If an event was lost before you had an endpoint live:
curl 'https://api.joinotify.com/events?field=messages&limit=50' \
-H 'Authorization: Bearer sk_live_xxx'
The payload field is the original change.value, untouched.