Skip to main content

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​

EventWhen it fires
messagesA message came in, or the status of one you sent changed
smb_message_echoesA message was sent from the phone app (coexistence)
historyChunks of the synced history
smb_app_state_syncContacts 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).

Incoming message
{
"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?" }
}
]
}
}
Delivery status
{
"field": "messages",
"value": {
"statuses": [
{
"id": "wamid.HBgN...",
"status": "delivered",
"timestamp": "1785312042",
"recipient_id": "5541999999999",
"conversation": { "id": "...", "origin": { "type": "service" } },
"pricing": { "billable": true, "category": "service" }
}
]
}
}
statusMeaning
sentLeft Meta's infrastructure
deliveredReached the device
readWas opened, unless the customer disabled read receipts
failedNot 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​

EventWhen it fires
message_template_status_updateApproved, rejected, paused or disabled
message_template_quality_updateQuality changed (GREEN / YELLOW / RED)
message_template_components_updateThe template's content was changed
template_category_updateMeta 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​

EventWhen it fires
account_alertsAn alert from Meta about the account
account_updateThe business account's state changed
account_review_updateResult of the account review
account_settings_updateSettings were changed

account_update covers bans and restrictions. Worth monitoring: it's the warning that your operation is about to stop.

Number​

EventWhen it fires
phone_number_name_updateResult of a display name request
phone_number_quality_updateThe 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​

EventWhen it fires
business_capability_updateLimits and capabilities changed
business_status_updateThe business verification status changed

Other​

EventWhen it fires
securityAccount security events
flowsWhatsApp Flows interactions
callsCall events
user_preferencesThe 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.

EventWhen it fires
billing.past_duePayment failed and the grace period started. Still sending
billing.blockedAccess cut: sending routes start answering 402
billing.restoredSending 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.

billing.blocked
{
"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.

These are account events

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".

EventWhen it fires
wallet.creditedBalance came in: commission, operator adjustment or a failed payout back
wallet.debitedBalance went out: applied to an invoice, or reserved by a payout request
affiliate.approvedThe enrolment was approved and the code is live
affiliate.rejectedThe enrolment was refused or suspended — reason says why
payout.paidThe withdrawal was paid, with method (pix/paypal) and payment_ref
wallet.credited
{
"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".

EventWhen it fires
contact.createdA new contact: first message, API, flow or dashboard. Imports do not announce each row
contact.opted_inMarketing consent recorded — from the dashboard, the API or a flow
contact.opted_outOpt-out: a keyword, Meta's 131050 refusal, the dashboard, the API or a flow
broadcast.completedA campaign finished sending
broadcast.pausedA campaign stopped — by someone, or on its own
flow.run.completedA contact reached the end of a flow
flow.answer.capturedA Question or Wait-for-reply step stored an answer
conversation.assignedA conversation was handed to someone on the team
conversation.closedA 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.

contact.opted_out
{
"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 events belong to the account

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​

broadcast.paused
{
"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.

reasonWhy the campaign stopped
manualSomeone paused it
qualityThe number's quality turned red, or Meta applied its spam limit (131048)
templateThe template was paused, disabled, deleted or turned red
accountThe number was disconnected, or the account is blocked, restricted or unpaid at Meta
quotaThe plan reached its monthly message cap
billingThe account lost its entitlement
errorsToo many failures in a row, or parameter errors right at the start — usually the variable mapping
throughputMeta held back the throughput for minutes. It resumes on its own after 15 min, 1 h and 4 h; resume_after says when
send_windowOutside 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​

flow.run.completed
{
"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.

flow.answer.captured
{
"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​

conversation.assigned
{
"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.