# 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

```bash title="cURL"
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"
  }'
```


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](/guides/whatsapp#templates). 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

```bash title="cURL"
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
  }'
```


`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](/guides/email).

A trigger is this object:

**Trigger**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | enum | yes | `trigger` |
| `id` | string | yes |  |
| `contactEvent` | string | yes |  |
| `channel` | enum | yes | `whatsapp`, `email` |
| `templateId` | string or null | yes |  |
| `email` | TriggerEmail or null | yes |  |
| `variables` | map of string | yes |  |
| `onReply` | TriggerOnReply or null | yes |  |
| `instructions` | string or null | yes |  |
| `cooldownDays` | integer or null | yes |  |
| `onEvent` | TriggerOnEvent or null | yes |  |
| `cadence` | TriggerCadence or null | yes |  |
| `status` | enum | yes | `active`, `paused` |
| `createdAt` | string | yes |  |
| `updatedAt` | string | yes |  |

## 3. Report the fact

```bash title="cURL"
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" }
  }'
```

```ts title="Node.js"
const result = await lelemon.contactEvents.create(
  {
    type: 'customer.inactive_30_days',
    contact: { externalRef: 'cust-88123', name: 'Ana Rojas', phone: '+56987654321' },
    data: { coupon: 'VUELVE10' },
  },
  { idempotencyKey: 'inactive-30-cust-88123-2026-11' },
);
console.log(result.result, result.reason);
```


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

**ContactEvent**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | enum | yes | `contact_event` |
| `id` | string | yes |  |
| `type` | string | yes |  |
| `contactId` | string | yes |  |
| `result` | enum | yes | `sent`, `skipped` |
| `sent` | ContactEventSent[] | yes |  |
| `caseActions` | ContactEventCaseAction[] | yes | 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. |
| `reason` | string or null | yes | 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. |
| `createdAt` | string | yes |  |

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](/guides/best-practices#limit-who-can-receive-messages) blocks the destination |
| `missing_variable` | A template variable has no value |
| `cadence_active` | The contact is already in the [cadence](#repeat-the-message-with-a-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`.

```bash title="cURL"
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_..." } }
  }'
```


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

```bash title="cURL"
curl https://api.lelemon.cl/v2/triggers \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{
    "contactEvent": "account.connected",
    "onEvent": { "moveCase": { "stageId": "stg_..." } }
  }'
```


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

**The cadence of a trigger**

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.

```bash title="cURL"
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"]
    }
  }'
```


`cadence` is this object:

**TriggerCadence**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `attempts` | integer | yes |  |
| `intervalHours` | integer | yes |  |
| `window` | DailyWindow or null | yes |  |
| `stopOn` | enum[] | yes |  |

- `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](/guides/best-practices#limit-who-can-receive-messages), 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.

```bash title="cURL"
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" } }'
```


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

```json title="case.closed data"
{
  "case": { "id": "cse_7Hc2LmQ0x8bVw1TqS9mYp3", "pipelineId": "ppl_3PqA0x8bVw1TqS9mYp3RkL" },
  "reason": "inactividad_despedida",
  "status": "closed",
  "closedBy": "system",
  "closedAt": "2026-11-03T13:00:00.000Z"
}
```

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

```bash title="cURL"
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.
