Message on events
WhatsApp allows free text only within 24 hours of the contact's last message. Outside that window you send an approved template. A trigger keeps that logic out of your code: your backend reports a fact such as customer.inactive_30_days, and the trigger decides what to send.
POST /v2/contact-events is the fact that comes in. It is different from /v2/events, the webhooks that go out.
1. Create the template
Variables are named and each needs an example for Meta's review. A template can also have an image or video header, a link button that ends in a variable, a catalog, multi-product or Flow button, or a carousel: see the WhatsApp guide. Meta reviews every template. Marketing templates always carry an opt-out button, in the language of the template ("No recibir más" in Spanish, "Stop messages" by default); to word it yourself, send a quick reply with payload lelemon_stop_messages. Tapping it, or writing stop or baja, opts the contact out. Use POST /v2/whatsapp/templates/sync to refresh statuses; a sync also runs every 5 minutes.
2. Link a fact to the template
variables maps each template variable to a path in the event: data.<key> or contact.name, contact.phone, contact.email. cooldownDays stops a second send to the same contact within that period. Two events arriving in parallel never send twice.
A trigger with "channel": "email" sends the text by email and opens a case the agent continues. See Write by email.
A trigger is this object:
Triggerobject, 15 fields
objectenumrequiredtriggeridstringrequiredcontactEventstringrequiredchannelenumrequiredwhatsappemailtemplateIdstring or nullrequiredemailTriggerEmail or nullrequiredTriggerEmail3 fields
subjectstringrequiredSubject. {{variable}} placeholders are filled from variables.
textstringrequiredText of the first email, with {{variable}} placeholders.
fromstringoptionalThe sender. It must belong to a verified domain. Defaults to the first verified domain.
variablesmap of stringrequiredonReplyTriggerOnReply or nullrequiredTriggerOnReply2 fields
agentIdstring or nullrequiredstageIdstring or nullrequired
instructionsstring or nullrequiredcooldownDaysinteger or nullrequiredonEventTriggerOnEvent or nullrequiredTriggerOnEvent2 fields
openCaseTriggerOpenCase or nullrequiredTriggerOpenCase2 fields
pipelineIdstringrequiredstageIdstringrequired
moveCaseTriggerMoveCase or nullrequiredTriggerMoveCase1 field
stageIdstringrequired
cadenceTriggerCadence or nullrequiredTriggerCadence4 fields
attemptsintegerrequiredintervalHoursintegerrequiredwindowDailyWindow or nullrequiredDailyWindow3 fields
fromstringrequiredtostringrequiredtimezonestringoptional
stopOnenum[]requiredreplyopt_outstage_changedclosed
statusenumrequiredactivepausedcreatedAtstring (date-time)requiredupdatedAtstring (date-time)required
3. Report the fact
The contact is created if it is new. The answer is a contact event:
ContactEventobject, 9 fields
objectenumrequiredcontact_eventidstringrequiredtypestringrequiredcontactIdstringrequiredresultenumrequiredsentskippedsentContactEventSent[]requiredContactEventSent3 fields
triggerIdstringrequiredwhatsappMessageIdstring or nullrequiredemailMessageIdstring or nullrequired
caseActionsContactEventCaseAction[]requiredWhat each matching trigger with onEvent did to the case of the contact: opened a new case, moved the open one, left it where it was (unchanged), or found no open case to move (none). Empty when no trigger has onEvent.
ContactEventCaseAction3 fields
triggerIdstringrequiredactionenumrequiredopenedmovedunchangednonecaseIdstring or nullrequired
reasonstring or nullrequiredWhy nothing was sent: null when the matching triggers only act on the case and have no message, no_trigger, opted_out, no_phone, template_not_approved, human_in_control, cooldown, no_whatsapp_number, not_allowed or missing_variable. Cadence triggers add cadence_active (the contact is already in this cadence) and outside_window (the first attempt waits for the next window). Email triggers add no_email, recipient_not_allowed, no_verified_domain and open_case_elsewhere.
createdAtstring (date-time)required
The response is 201 when something was sent and 202 when nothing was, with reason:
reason | Meaning |
|---|---|
no_trigger | No trigger listens to this event type |
opted_out | The contact opted out |
no_phone | The contact has no phone |
template_not_approved | Meta has not approved the template |
human_in_control | A person is handling the contact's case |
cooldown | The trigger already fired for this contact recently |
no_whatsapp_number | No active number can send the template |
not_allowed | The recipient allowlist blocks the destination |
missing_variable | A template variable has no value |
cadence_active | The contact is already in the cadence of this trigger |
outside_window | The first attempt of a cadence waits for the next time window |
null | The triggers that matched only act on the case and have no message |
Email triggers add no_email, recipient_not_allowed, no_verified_domain and open_case_elsewhere. None of these is an error and your code does not need to retry.
If the contact answers
Set onReply on the trigger, or on a single event, to say which agent continues. The case moves to the stage in onReply.stageId, and the agent gets the template text plus a context turn with the trigger's instructions and the event's data. An event can replace the trigger's instructions with onReply.instructions.
Open or move the case when the event arrives
By default a case opens only when the contact answers. Set onEvent on the trigger to act on the case the moment the event arrives, without waiting for a reply. Send exactly one of openCase or moveCase.
onEvent | What happens |
|---|---|
openCase | Opens a case for the contact in that pipeline and stage. If the contact already has an open case in the pipeline, it stays where it is. |
moveCase | Moves the contact's open case in the pipeline of that stage to the stage. With no open case there, nothing happens. |
POST /v2/contact-events answers with caseActions: one entry per trigger with onEvent, with action set to opened, moved, unchanged or none. The case is handled even when the message cannot go out, for example when the contact opted out. If the trigger has no onReply.stageId, the contact's answer continues in the stage onEvent left the case in. Only WhatsApp triggers have onEvent.
Act on the case without sending a message
Leave templateId out and send only onEvent to move or open the case and send nothing. It is for events that report progress, such as a customer who connected an account, where writing to them would be noise.
When POST /v2/contact-events reports that event, the case moves, caseActions says what happened and sent is empty. It answers 202 with result set to skipped and reason set to null. No message is created and nothing is charged. A trigger with neither templateId nor onEvent is rejected because it would do nothing, and one without a template has no variables, onReply, instructions, cooldownDays or cadence. To add a message later, PATCH the trigger with a templateId and its variables.
Repeat the message with a cadence
- Your system reports the event. The first attempt goes out when the event arrives.
- The template goes to the contact, inside the time window of the cadence.
- Reporting the event again while the contact is in the cadence changes nothing.
- The next attempt waits 24 hours. An attempt that falls outside window waits for the next from.
- The second attempt goes out, with the same rules as any send.
- The contact writes back. reply is in stopOn, so no attempt 3 is sent.
- The cadence ends before an attempt when stopOn says so: reply, stage_changed or closed. opt_out always applies. It also ends when the trigger is paused or deleted, or when the previous attempt could not be delivered.
- Every attempt, and the end of the cadence, fires trigger.attempted. result is sent, skipped or stopped, and reason says why when it is not sent.
cadence sends the template up to attempts times (1 to 5), intervalHours apart. The first attempt goes out when the event arrives.
cadence is this object:
TriggerCadenceobject, 4 fields
attemptsintegerrequiredintervalHoursintegerrequiredwindowDailyWindow or nullrequiredDailyWindow3 fields
fromstringrequiredtostringrequiredtimezonestringoptional
stopOnenum[]requiredreplyopt_outstage_changedclosed
windowkeeps attempts inside a daily time range. An attempt that falls outside waits for the nextfrom.- The cadence ends before an attempt when
stopOnsays so:reply(the contact wrote back),stage_changed(the case left the stage it was in),closed(the case was closed). By default all of them apply.opt_outalways applies: nobody who opted out gets another attempt. - Each attempt follows the same rules as any send: opt-outs, the recipient allowlist, a person in control of the case and the approved template. If an attempt cannot go out, the cadence ends.
- A cadence also ends when the trigger is paused or deleted, or when the previous attempt could not be delivered.
- Reporting the event again while the contact is in the cadence changes nothing and answers
202withcadence_active. cooldownDaysapplies to the first attempt only.- Every attempt is a template, so the 24-hour window for free text does not apply.
Each attempt, and the end of a cadence, fires the trigger.attempted webhook event. result is sent, skipped or stopped, and reason says why when it is not sent.
A number that replies or opts out ends the cadence, and a number without WhatsApp stops it after the first attempt.
Send one template directly
For a one-off, skip the trigger. The response is 202: accepted, not yet delivered.
to takes exactly one of contactId, externalRef or phone. Add sendAt to schedule it, and POST /v2/whatsapp/messages/{id}/cancel to cancel before it goes out.
React when a case closes
Subscribe a webhook endpoint to case.closed to know the moment a case closes, whoever closes it. It fires once per case that really changes from open to closed, after the closing is saved. It does not fire for a case that was already closed.
Code
| Field | Meaning |
|---|---|
case | The case id and its pipelineId. |
reason | The closing reason saved in the case, or null if there is none. Examples: inactividad, inactividad_despedida, inactividad_sin_despedida, seguimiento_agotado, the reason of a closing stage such as Resuelto or the outcome you send to POST /v2/cases/{id}/close. |
status | Always closed. |
closedBy | system for the inactivity sweeps and automatic flows, stage when the case entered a closing stage, agent when the agent ended the conversation, api for POST /v2/cases/{id}/close and user when a person closes it in the dashboard. |
closedAt | When it closed. |
Use it to complete data on the case. For example, when a case closes by inactivity and has no typification yet, write it. PATCH /v2/cases/{id} writes any field the pipeline declares, as long as the value is valid for the field: for a field that depends on another one, send the parent in the same call.
Code
Read the case first with GET /v2/cases/{id} and write only when the field is empty, because a person or the agent may have filled it already. Closing is not reversed by this: the case stays closed.