Lelemon
  • Get started
  • Use cases
  • Guides
  • API basics
  • Webhooks
  • SDK, CLI and MCP
  • API reference
  • Changelog
  • v1 (legacy)
Configure your account
Talk to people
    Build your own inboxWhatsApp messagesWrite by emailMessage on eventsRun a campaignSchedule meetingsShow who is online
Run and protect
powered by Zudoku
Talk to people

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.

You will

Send a message when something happens in your system, and let the agent continue if the contact answers.

You need

  • A key with events:write and triggers:write
  • A WhatsApp number connected to an agent

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
  • objectenumrequired

    trigger

  • idstringrequired

    Example trg_1Qa0x8bVw1TqS9mYp3RkLd

  • contactEventstringrequired
  • channelenumrequired

    whatsappemail

  • templateIdstring or nullrequired
  • emailTriggerEmail or nullrequired
    TriggerEmail3 fields
    • subjectstringrequired

      Subject. {{variable}} placeholders are filled from variables.

      Example We miss you, {{name}}

    • textstringrequired

      Text of the first email, with {{variable}} placeholders.

      Example Hi {{name}}, here is a coupon: {{coupon}}.

    • fromstringoptional

      The sender. It must belong to a verified domain. Defaults to the first verified domain.

      Example [email protected]

  • variablesmap of stringrequired
  • onReplyTriggerOnReply or nullrequired
    TriggerOnReply2 fields
    • agentIdstring or nullrequired
    • stageIdstring or nullrequired
  • instructionsstring or nullrequired
  • cooldownDaysinteger or nullrequired
  • onEventTriggerOnEvent or nullrequired
    TriggerOnEvent2 fields
    • openCaseTriggerOpenCase or nullrequired
      TriggerOpenCase2 fields
      • pipelineIdstringrequired

        Example ppl_3PqA0x8bVw1TqS9mYp3RkL

      • stageIdstringrequired

        Example stg_3PqA0x8bVw1TqS9mYp3RkL

    • moveCaseTriggerMoveCase or nullrequired
      TriggerMoveCase1 field
      • stageIdstringrequired

        Example stg_3PqA0x8bVw1TqS9mYp3RkL

  • cadenceTriggerCadence or nullrequired
    TriggerCadence4 fields
    • attemptsintegerrequired
    • intervalHoursintegerrequired
    • windowDailyWindow or nullrequired
      DailyWindow3 fields
      • fromstringrequired

        Example 10:00

      • tostringrequired

        Example 21:00

      • timezonestringoptional

        Default America/Santiago

        Example America/Santiago

    • stopOnenum[]required

      replyopt_outstage_changedclosed

  • statusenumrequired

    activepaused

  • createdAtstring (date-time)required
  • updatedAtstring (date-time)required

3. Report the fact

The contact is created if it is new. The answer is a contact event:

ContactEventobject, 9 fields
  • objectenumrequired

    contact_event

  • idstringrequired

    Example cev_02y0e5MANqacVbmtn1YyeJ

  • typestringrequired
  • contactIdstringrequired
  • resultenumrequired

    sentskipped

  • sentContactEventSent[]required
    ContactEventSent3 fields
    • triggerIdstringrequired
    • whatsappMessageIdstring or nullrequired
    • emailMessageIdstring or nullrequired
  • caseActionsContactEventCaseAction[]required

    What 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
    • triggerIdstringrequired
    • actionenumrequired

      openedmovedunchangednone

    • caseIdstring or nullrequired
  • reasonstring or nullrequired

    Why 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:

reasonMeaning
no_triggerNo trigger listens to this event type
opted_outThe contact opted out
no_phoneThe contact has no phone
template_not_approvedMeta has not approved the template
human_in_controlA person is handling the contact's case
cooldownThe trigger already fired for this contact recently
no_whatsapp_numberNo active number can send the template
not_allowedThe recipient allowlist blocks the destination
missing_variableA template variable has no value
cadence_activeThe contact is already in the cadence of this trigger
outside_windowThe first attempt of a cadence waits for the next time window
nullThe 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.

onEventWhat happens
openCaseOpens 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.
moveCaseMoves 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 systemTrigger3 attempts, 24 hContactPOST /v2/contact-eventsaccount.stuck1Attempt 1: the template2The same event again202 reason: cadence_active3Wait intervalHoursan attempt outside the window waits for from4Attempt 2: the template5The contact replies6Stops before attempt 3reply, stage_changed, closed, opt_out7trigger.attemptedresult: stopped8
A cadence repeats a template until something says stop. Each attempt follows the same rules as any send, and the end of the cadence is reported by webhook.
  1. Your system reports the event. The first attempt goes out when the event arrives.
  2. The template goes to the contact, inside the time window of the cadence.
  3. Reporting the event again while the contact is in the cadence changes nothing.
  4. The next attempt waits 24 hours. An attempt that falls outside window waits for the next from.
  5. The second attempt goes out, with the same rules as any send.
  6. The contact writes back. reply is in stopOn, so no attempt 3 is sent.
  7. 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.
  8. 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
  • attemptsintegerrequired
  • intervalHoursintegerrequired
  • windowDailyWindow or nullrequired
    DailyWindow3 fields
    • fromstringrequired

      Example 10:00

    • tostringrequired

      Example 21:00

    • timezonestringoptional

      Default America/Santiago

      Example America/Santiago

  • stopOnenum[]required

    replyopt_outstage_changedclosed

  • window keeps attempts inside a daily time range. An attempt that falls outside waits for the next from.
  • The cadence ends before an attempt when stopOn says 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_out always 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 202 with cadence_active.
  • cooldownDays applies 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
{ "case": { "id": "cse_7Hc2LmQ0x8bVw1TqS9mYp3", "pipelineId": "ppl_3PqA0x8bVw1TqS9mYp3RkL" }, "reason": "inactividad_despedida", "status": "closed", "closedBy": "system", "closedAt": "2026-11-03T13:00:00.000Z" }
FieldMeaning
caseThe case id and its pipelineId.
reasonThe 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.
statusAlways closed.
closedBysystem 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.
closedAtWhen 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.

TerminalCode
curl -X PATCH https://api.lelemon.cl/v2/cases/$CASE_ID \ -H "Authorization: Bearer $LELEMON_API_KEY" \ --json '{ "fields": { "typification": "Contacto no válido - Cliente no responde" } }'

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.

Write by emailRun a campaign
On this page
  • 1. Create the template
  • 2. Link a fact to the template
  • 3. Report the fact
  • If the contact answers
  • Open or move the case when the event arrives
    • Act on the case without sending a message
  • Repeat the message with a cadence
  • Send one template directly
  • React when a case closes
curl https://api.lelemon.cl/v2/whatsapp/templates \ -H "Authorization: Bearer $LELEMON_API_KEY" \ -H "Idempotency-Key: tpl-we-miss-you" \ --json '{ "name": "we_miss_you", "language": "es", "category": "marketing", "body": "Hola {{name}}, te echamos de menos. Usa el código {{coupon}} en tu próximo pedido.", "examples": { "name": "Ana", "coupon": "VUELVE10" }, "phoneNumberIds": "all" }'
curl https://api.lelemon.cl/v2/triggers \ -H "Authorization: Bearer $LELEMON_API_KEY" \ --json '{ "contactEvent": "customer.inactive_30_days", "channel": "whatsapp", "templateId": "tpl_...", "variables": { "name": "contact.name", "coupon": "data.coupon" }, "cooldownDays": 30 }'
curl https://api.lelemon.cl/v2/contact-events \ -H "Authorization: Bearer $LELEMON_API_KEY" \ -H "Idempotency-Key: inactive-30-cust-88123-2026-11" \ --json '{ "type": "customer.inactive_30_days", "contact": { "externalRef": "cust-88123", "name": "Ana Rojas", "phone": "+56987654321" }, "data": { "coupon": "VUELVE10" } }'
curl https://api.lelemon.cl/v2/triggers \ -H "Authorization: Bearer $LELEMON_API_KEY" \ --json '{ "contactEvent": "account.stuck", "templateId": "tpl_...", "variables": { "name": "contact.name" }, "onEvent": { "openCase": { "pipelineId": "ppl_...", "stageId": "stg_..." } } }'
curl https://api.lelemon.cl/v2/triggers \ -H "Authorization: Bearer $LELEMON_API_KEY" \ --json '{ "contactEvent": "account.connected", "onEvent": { "moveCase": { "stageId": "stg_..." } } }'
curl https://api.lelemon.cl/v2/triggers \ -H "Authorization: Bearer $LELEMON_API_KEY" \ --json '{ "contactEvent": "account.stuck", "templateId": "tpl_...", "variables": { "name": "contact.name" }, "onEvent": { "openCase": { "pipelineId": "ppl_...", "stageId": "stg_..." } }, "cadence": { "attempts": 3, "intervalHours": 24, "window": { "from": "10:00", "to": "19:00", "timezone": "America/Santiago" }, "stopOn": ["reply", "stage_changed", "closed"] } }'
curl https://api.lelemon.cl/v2/whatsapp/messages \ -H "Authorization: Bearer $LELEMON_API_KEY" \ -H "Idempotency-Key: wa-cust-88123-1" \ --json '{ "to": { "externalRef": "cust-88123" }, "templateId": "tpl_...", "variables": { "name": "Ana" } }'
JSON