# Sell on WhatsApp with a catalog and a cart

**You will:** Run a WhatsApp sales agent end to end: qualify, show catalog products, take a cart, hand over to a person and send a campaign.

**You need:**
- An API key with the scopes in this page
- Node.js 20 or later for the TypeScript steps
- The id of your Meta catalog (connected in WhatsApp Manager)

A store gets a WhatsApp message, the agent greets, asks what the person is looking for, shows products from the catalog, receives the cart the person builds, and tells the team to close the sale. When the person asks for a human, the case moves to a stage where your team answers. Weeks later, a template campaign brings quiet contacts back.

**Who it is for.** Stores and service businesses that sell from a catalog and close the sale with a person. **Scopes.** `agents:write`, `pipelines:write`, `knowledge:write`, `skills:write`, `actions:write`, `webhooks:write`, `whatsapp:write`, `cases:read`, `cases:write`, `cases:converse`, `turns:read`, `contacts:write`, `files:write` is not needed. Use a key with all of them, or the one the [setup guide](/get-started/setup-from-zero) creates.

```text title="The flow"
Customer (WhatsApp)          Lelemon                         Your backend / team
  | "busco una lámpara"  ->   agent qualifies, saves fields
  |                           check_stock  ------------------>  GET /stock (signed)
  | <- product message        send_products (your catalog)
  | cart (order)  ---------->  message.received type=order --->  create the order, PATCH case, move stage
  | "quiero una persona" ->   transfer_to_human  --------------> case.escalated, stage "Needs a person"
  |                                                              team replies from your inbox
  Template campaign  <------  campaign to your contacts   <----  you launch it
```

The runnable version of every step is in [`examples/use-cases/whatsapp-sales-agent`](https://github.com/Lelemon-studio/lelemon-app/tree/main/apps/docs/examples/use-cases/whatsapp-sales-agent). The TypeScript blocks of this page come from that script, which ran against a real number. It stops before the campaign, because that step messages people.

## 0. Set up

Every example reads the key from `LELEMON_API_KEY` and the TypeScript steps use the [SDK](/tools/sdks). The cURL tabs send the same requests.

```ts title="Node.js"
import { Lelemon } from '@lelemon-ai/sdk';

const lelemon = new Lelemon({ apiKey: process.env.LELEMON_API_KEY });
```

## 1. Write the agent

The prompt is the product. This one gives the agent a role, a voice, the order in which it asks for data, how it uses the catalog, hard limits and exactly when to hand over. The catalog id and the retailer ids go in the prompt: `send_products` takes them from there, so the agent never invents one.

```bash title="cURL"
curl https://api.lelemon.cl/v2/agents \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: almendra-agent-1" \
  --json '{
    "name": "Almendra",
    "description": "Sales assistant of Casa Almendra on WhatsApp",
    "instructions": "$(cat instructions.md)",
    "llmConfig": { "model": "anthropic/claude-sonnet-5.5", "temperature": 0.3 },
    "channels": ["whatsapp", "api"],
    "behaviors": { "displayName": "Almendra" }
  }'
```

```ts title="Node.js"
const catalogId = '1234567890123456';
const instructions = `You are Almendra, the sales assistant of Casa Almendra, a home decor store in Santiago, Chile. You talk with customers on WhatsApp.

## Goal
Help the person find the right piece, build an order and, when they are ready, pass it to the team to close. You sell lamps, ceramics, textiles and small furniture. You do not give interior design advice beyond what helps to choose a product.

## Voice
- Reply in Chilean Spanish, using "tú". Warm, direct, no slang beyond "hola" and "dale".
- Short messages: two or three sentences, one question at a time. No lists of more than three items in plain text; use the catalog messages instead.
- No emojis, except one at the end of a greeting if the customer used one first.

## What you need to learn, in this order
1. What they are looking for (interest): lamps, ceramics, textiles, furniture or something else.
2. Approximate budget in Chilean pesos. Ask once. If they do not want to say, move on.
3. When they need it (purchase_timeframe): this week, this month or just exploring.
4. The comuna where they want delivery (delivery_comuna), only when they decide to buy.
Save each answer with save_case_data as soon as you have it. Never ask for something you already saved.

## How you show products
- When the person asks to see products, send them with send_products using the retailer ids of the catalog below. One product if they named one; two to four of the same category if they asked for a category.
- Run check_stock for a product before you show it. Do not show a product with available = 0.
- Never invent a price, a stock level or a delivery date. Prices and stock come from the catalog and from check_stock.
- After the catalog message write one short sentence inviting them to add what they like to the cart and send it.

## Catalog
The catalog id is ${catalogId}. Pass it as catalog_id every time you call send_products. The retailer ids are:
- LAMP-ARCO-01: Arco floor lamp, black metal. $59.990
- LAMP-MESA-02: brass table lamp with linen shade. $34.990
- CER-TAZA-04: set of 4 stoneware mugs. $24.990
- CER-JARRON-07: tall hand-glazed vase. $29.990
- TEX-LINO-03: linen cushion cover 50x50. $12.990
- TEX-MANTA-05: wool blanket, natural. $44.990
Prices are final and include IVA.

## When the person sends a cart
Follow the skill "handle-order". In short: confirm the items back in one message, ask for the comuna if you do not have it and tell them a person from the team will confirm payment and delivery.

## Limits
- You cannot take payments, change an order that was already confirmed, promise a delivery date or give discounts. Say so and offer the team.
- Do not discuss other stores, politics or anything unrelated to Casa Almendra.
- Never ask for card numbers, RUT or passwords. If the person sends one, tell them not to share it here.

## When to hand over to a person
Use transfer_to_human, without asking for permission first, when:
- the person asks for a human, a manager or says they are upset;
- they ask for a discount, a bulk price (more than 10 units) or a custom piece;
- they report a problem with an order that already arrived;
- you answered the same question twice and the person is still confused.`;

const agent = await lelemon.agents.create({
  name: 'Almendra',
  description: 'Sales assistant of Casa Almendra on WhatsApp',
  instructions,
  llmConfig: { model: 'anthropic/claude-sonnet-5.5', temperature: 0.3 },
  channels: ['whatsapp', 'api'],
  behaviors: { displayName: 'Almendra' },
});
```

```json title="MCP"
{
  "tool": "agents.create",
  "arguments": {
    "name": "Almendra",
    "instructions": "<the prompt above>",
    "llmConfig": { "model": "anthropic/claude-sonnet-5.5", "temperature": 0.3 },
    "channels": ["whatsapp", "api"]
  }
}
```


The agent starts as a `draft`. The `channels` say where it can answer: `whatsapp` for the number, `api` for cases you open yourself. A low `temperature` keeps prices and policies stable. See [Agents](/guides/agents) for every field.

What you get:

```text
agent {"id":"agt_4zhi3Ep39ipRoNmOqg4DJS","status":"draft","version":1}
```

## 2. Create the pipeline and its stages

A pipeline is the path of a sale. The entry stage is where every new case starts and where the agent works. `Needs a person` is where a handed-over case lands, and the next steps use `stageId` to refer to a stage by its name.

```bash title="cURL"
curl https://api.lelemon.cl/v2/pipelines \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: sales-pipeline-1" \
  --json '{
    "name": "WhatsApp sales",
    "description": "From the first message to a confirmed order",
    "stages": [
      { "name": "Qualifying", "entry": true, "agentId": "agt_..." },
      { "name": "Proposal" },
      { "name": "Order placed" },
      { "name": "Needs a person" },
      { "name": "Closed" }
    ]
  }'
```

```ts title="Node.js"
const pipeline = await lelemon.pipelines.create({
  name: 'WhatsApp sales',
  description: 'From the first message to a confirmed order',
  stages: [
    { name: 'Qualifying', entry: true, agentId: agent.id },
    { name: 'Proposal' },
    { name: 'Order placed' },
    { name: 'Needs a person' },
    { name: 'Closed' },
  ],
});
const stages = await lelemon.pipelines.stages.list(pipeline.id);
const stageId = (name: string) => stages.data.find((s) => s.name === name)!.id;
```


What you get:

```text
pipeline {"id":"ppl_18UEE9xEomyeUh4R879h7v","stages":["Qualifying (entry)","Proposal","Order placed","Needs a person","Closed"]}
```

## 3. Create the fields of the case

Fields are the data the sale needs. `source: "agent"` means the agent fills them with `save_case_data` while it talks; `source: "system"` means only your backend writes them. A stage can require fields before a case enters it: `Order placed` needs the comuna and the order reference, so a case cannot jump there half empty.

```bash title="cURL"
curl https://api.lelemon.cl/v2/pipelines/ppl_.../fields \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: field-interest-1" \
  --json '{
    "key": "interest",
    "type": "select",
    "label": "Interest",
    "source": "agent",
    "instruction": "What the person wants to buy. Pick the closest option.",
    "options": [
      { "value": "lamps", "label": "Lamps" },
      { "value": "ceramics", "label": "Ceramics" },
      { "value": "textiles", "label": "Textiles" },
      { "value": "furniture", "label": "Small furniture" },
      { "value": "other", "label": "Something else" }
    ]
  }'

curl -X PATCH https://api.lelemon.cl/v2/pipelines/ppl_.../stages/stg_... \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "requiredFields": ["delivery_comuna", "order_reference"] }'
```

```ts title="Node.js"
await lelemon.pipelines.fields.create(pipeline.id, {
  key: 'interest',
  type: 'select',
  label: 'Interest',
  source: 'agent',
  instruction: 'What the person wants to buy. Pick the closest option.',
  options: [
    { value: 'lamps', label: 'Lamps' },
    { value: 'ceramics', label: 'Ceramics' },
    { value: 'textiles', label: 'Textiles' },
    { value: 'furniture', label: 'Small furniture' },
    { value: 'other', label: 'Something else' },
  ],
});
await lelemon.pipelines.fields.create(pipeline.id, {
  key: 'budget_clp',
  type: 'text',
  label: 'Budget (CLP)',
  source: 'agent',
  instruction: 'The approximate amount in Chilean pesos, as a number without dots. Ask once.',
});
await lelemon.pipelines.fields.create(pipeline.id, {
  key: 'purchase_timeframe',
  type: 'select',
  label: 'When they need it',
  source: 'agent',
  instruction: 'When the person wants to receive it.',
  options: [
    { value: 'this_week', label: 'This week' },
    { value: 'this_month', label: 'This month' },
    { value: 'exploring', label: 'Just exploring' },
  ],
});
await lelemon.pipelines.fields.create(pipeline.id, {
  key: 'delivery_comuna',
  type: 'text',
  label: 'Delivery comuna',
  source: 'agent',
  instruction: 'The comuna for delivery. Ask only when the person decides to buy.',
});
await lelemon.pipelines.fields.create(pipeline.id, {
  key: 'order_reference',
  type: 'text',
  label: 'Order in the store system',
  source: 'system',
});
await lelemon.pipelines.stages.update(pipeline.id, stageId('Order placed'), {
  requiredFields: ['delivery_comuna', 'order_reference'],
});
const fields = await lelemon.pipelines.fields.list(pipeline.id);
```


What you get:

```text
fields ["interest (agent)","budget_clp (agent)","purchase_timeframe (agent)","delivery_comuna (agent)","order_reference (system)"]
```

## 4. Give the agent knowledge

Policies that change and are too long for a prompt go in a knowledge collection. The agent searches only the collections you grant it.

```bash title="cURL"
curl https://api.lelemon.cl/v2/knowledge-collections \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: kb-policies-1" \
  --json '{ "name": "Casa Almendra policies", "description": "Shipping, returns and payment" }'

curl -X PUT https://api.lelemon.cl/v2/agents/agt_.../knowledge-collections/kbc_... \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const policies = await lelemon.knowledgeCollections.create({
  name: 'Casa Almendra policies',
  description: 'Shipping, returns and payment',
});
await lelemon.knowledgeCollections.documents.create(policies.id, {
  name: 'shipping-and-returns.md',
  text: `# Shipping and returns

Delivery in Gran Santiago takes 2 to 3 business days and costs $3.990 CLP. It is free for orders over $60.000 CLP.
Delivery to other regions takes 4 to 7 business days and the cost depends on the comuna: the team confirms it.
Orders confirmed before 14:00 leave the next business day.

Returns: 10 days after delivery, unused and in the original box. The customer pays the return shipping unless the piece arrived damaged.
Ceramics that arrive broken are replaced at no cost if the customer sends a photo within 48 hours.

# Payment

We accept bank transfer and credit or debit card through a payment link that the team sends. We do not take payments in this chat.
Installments: 3 interest-free installments with credit cards on orders over $40.000 CLP.`,
});
await lelemon.agents.knowledgeCollections.grant(agent.id, policies.id);
const documents = await lelemon.knowledgeCollections.documents.list(policies.id);
```


A document starts as `processing`. Listen for `knowledge_document.ready` or `knowledge_document.failed` before you count on it. See [Knowledge](/guides/knowledge).

## 5. Write the skills

A skill is a procedure the agent opens only when the conversation needs it. Only its name and description travel in the prompt; the steps are loaded on demand, which keeps the prompt short.

```bash title="cURL"
curl https://api.lelemon.cl/v2/agents/agt_.../skills \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: skill-handle-order-1" \
  --json '{
    "name": "Handle an order",
    "slug": "handle-order",
    "description": "Use when the person sends a cart or says they want to buy.",
    "content": "# Handle an order\n\n1. Repeat the items and quantities of the cart in one message."
  }'
```

```ts title="Node.js"
await lelemon.agents.skills.create(agent.id, {
  name: 'Delivery and returns',
  slug: 'delivery-and-returns',
  description: 'Use when the person asks how long delivery takes, what it costs, or how to return something.',
  content: `# Delivery and returns

1. Search the knowledge base for "shipping and returns" and answer only with what it says.
2. For delivery, ask for the comuna if you do not have it. Gran Santiago and regions have different times and costs.
3. If the order is over $60.000 CLP, say the delivery is free in Gran Santiago.
4. For a return, say the 10 days and that the piece must be unused. If it arrived broken, ask for a photo and hand over to a person.
5. Never promise a date. Say the range and that the team confirms it.`,
});
await lelemon.agents.skills.create(agent.id, {
  name: 'Handle an order',
  slug: 'handle-order',
  description: 'Use when the person sends a cart or says they want to buy. Explains what to confirm before the team takes over.',
  content: `# Handle an order

1. Repeat the items and quantities of the cart in one message. Do not repeat prices.
2. If delivery_comuna is empty, ask for it and save it with save_case_data.
3. Tell the person that someone from the team will send the payment link and confirm the delivery date. Do not promise a time.
4. Do not ask for the address here: the team collects it with the payment.`,
});
```


:::note
There is no tool to grant for skills. As soon as an agent has one, Lelemon gives it the tool that opens them on every turn, and `GET /v2/agents/{id}/skills` is the list of what it can open.
:::

## 6. Register the stock check as an action

An action is an HTTP endpoint of yours that the agent calls as a tool. This one answers how many units are in stock. Lelemon signs every call; your server verifies it with the signing secret of the action ([Actions and MCP](/guides/actions-and-mcp#verify-the-signature)). `audience: "customers"` lets an agent that talks to the public use it.

```bash title="cURL"
curl https://api.lelemon.cl/v2/actions \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: action-check-stock-1" \
  --json '{
    "name": "check_stock",
    "description": "Check how many units of a product are in stock. Use it before showing a product.",
    "audience": "customers",
    "request": { "method": "GET", "url": "https://shop.example.com/stock" },
    "input": {
      "type": "object",
      "required": ["sku"],
      "properties": { "sku": { "type": "string", "description": "The retailer_id of the product." } }
    }
  }'
```

```ts title="Node.js"
const checkStock = await lelemon.actions.create({
  name: 'check_stock',
  description: 'Check how many units of a product are in stock. Use it before showing a product.',
  audience: 'customers',
  request: { method: 'GET', url: 'https://shop.example.com/stock' },
  input: {
    type: 'object',
    required: ['sku'],
    properties: { sku: { type: 'string', description: 'The retailer_id of the product, such as LAMP-ARCO-01.' } },
  },
});
const signing = await lelemon.actions.retrieveSigningSecret(checkStock.id);
```


`origin` in the answer is the site your key must be allowed to call. If it is not, the call answers `403 action_origin_not_allowed` and an administrator adds the site to the key.

## 7. Grant the tools

A tool does nothing until you grant it to the agent. `save_case_data` limits which fields the agent may write. `transfer_to_human` takes the stage the case moves to and the goodbye the contact reads, in and out of business hours. The goodbye is written by the tool, not the model, so the agent cannot promise what the team will not do.

```bash title="cURL"
curl -X PUT https://api.lelemon.cl/v2/agents/agt_.../tools/transfer_to_human \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{
    "config": {
      "stageId": "stg_...",
      "businessHours": { "timezone": "America/Santiago", "startHour": 9, "endHour": 19, "activeSaturday": true, "activeSunday": false },
      "farewell": {
        "duringBusinessHours": "Te paso con una persona del equipo. Te escribe en unos minutos.",
        "outsideBusinessHours": "Te paso con una persona del equipo. Hoy ya cerramos: te escribe mañana desde las 9."
      }
    }
  }'
```

```ts title="Node.js"
await lelemon.agents.tools.grant(agent.id, checkStock.id);
await lelemon.agents.tools.grant(agent.id, 'send_products');
await lelemon.agents.tools.grant(agent.id, 'send_buttons');
await lelemon.agents.tools.grant(agent.id, 'save_case_data', {
  config: { fields: ['interest', 'budget_clp', 'purchase_timeframe', 'delivery_comuna'] },
});
await lelemon.agents.tools.grant(agent.id, 'transfer_to_human', {
  config: {
    stageId: stageId('Needs a person'),
    businessHours: { timezone: 'America/Santiago', startHour: 9, endHour: 19, activeSaturday: true, activeSunday: false },
    farewell: {
      duringBusinessHours: 'Te paso con una persona del equipo. Te escribe en unos minutos.',
      outsideBusinessHours: 'Te paso con una persona del equipo. Hoy ya cerramos: te escribe mañana desde las 9.',
    },
  },
});
const granted = await lelemon.agents.tools.list(agent.id, { query: { granted: 'true' } });
```


What you get:

```text
granted ["check_stock","send_products","send_buttons","save_case_data","transfer_to_human"]
```

## 8. Receive events

Register an endpoint for what your backend must react to: `message.received` (it carries the type, `order` for a cart, never the text), `case.escalated` and `case.stage_entered`. Your server verifies each delivery as in [Verify signatures](/webhooks/verify).

```bash title="cURL"
curl https://api.lelemon.cl/v2/webhook-endpoints \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: webhook-sales-1" \
  --json '{
    "url": "https://example.com/lelemon",
    "description": "Sales events",
    "enabledEvents": ["message.received", "message.created", "case.stage_entered", "case.escalated", "case.turn_completed", "phone_number.connected"]
  }'
```

```ts title="Node.js"
const endpoint = await lelemon.webhookEndpoints.create({
  url: 'https://example.com/lelemon',
  description: 'Sales events',
  enabledEvents: ['message.received', 'message.created', 'case.stage_entered', 'case.escalated', 'case.turn_completed', 'phone_number.connected'],
});
```


## 9. Activate and connect the number

`activate` turns the agent on and says in `readiness.missing` what still keeps people from reaching it. An empty list means it is reachable.

```bash title="cURL"
curl -X POST https://api.lelemon.cl/v2/agents/agt_.../activate \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const readiness = await lelemon.agents.activate(agent.id);

const connection = await lelemon.whatsapp.connections.create({
  phone: '+56912345679',
  agentId: agent.id,
  connectionType: 'dedicated',
});
const numbers = await lelemon.whatsapp.phoneNumbers.list();
const number = numbers.data.find((n) => n.agentId === agent.id)!;
```


`setupUrl` opens the Meta onboarding for the number you pass in `phone`; the number appears in `GET /v2/whatsapp/phone-numbers` when the owner finishes, and `phone_number.connected` fires. The script does not connect a number: it reads the id of one you already connected from `LELEMON_WHATSAPP_PHONE_NUMBER_ID`.

## 10. Connect the catalog

The catalog is created in Meta Commerce Manager and connected in WhatsApp Manager. Save its id on the number, and turn on the cart button and the catalog icon. A product message only goes out when the `catalog_id` of the message is the one saved here; otherwise it answers `422 catalog_not_connected`.

```bash title="cURL"
curl -X PATCH https://api.lelemon.cl/v2/whatsapp/phone-numbers/pn_... \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "catalogId": "1234567890123456" }'

curl -X PATCH https://api.lelemon.cl/v2/whatsapp/phone-numbers/pn_.../commerce-settings \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "isCartEnabled": true, "isCatalogVisible": true }'
```

```ts title="Node.js"
const withCatalog = await lelemon.whatsapp.phoneNumbers.update(number.id, { catalogId });
const commerce = await lelemon.whatsapp.phoneNumbers.commerceSettings.update(number.id, {
  isCartEnabled: true,
  isCatalogVisible: true,
});
```


What you get:

```text
catalog {"catalogId":"1234567890123456","cart":true,"icon":true}
```

## 11. A customer writes first

The customer writes to the number from WhatsApp. The agent answers and your webhook receives `message.received`.

```ts title="Node.js"
const [kase] = (await lelemon.cases.list({ query: { limit: 1 } })).data;
const caseId = kase!.id;
const thread = await lelemon.caseMessages.list(caseId, { query: { order: 'asc' } });
const turns = await lelemon.caseTurns.list(caseId);
```

The agent answers in a few seconds. `case.turn_completed` tells you the turn ended; then read the thread and the turn. The turn shows which tools ran, with their input and output:

```text
webhook {"type":"message.received","data":{"case":{"id":"cse_5OTq2YJhBq1dTWenpJ9KNm","stageId":"stg_1Hbz5rXer2I1d6KylWuscW","pipelineId":"ppl_18UEE9xEomyeUh4R879h7v"},"message":{"id":"msg_0K2n6DTeNPo9J6Fz3ZSRIT","type":"text","order":null,"reply":null,"channel":"whatsapp","createdAt":"2026-11-01T03:20:22.112Z","contentType":"text"}}}
    contact Hola! Busco una lámpara de pie para el living, algo negro y simple
    agent [product]
    agent ¡Hola! Te mostré la lámpara Arco de metal negro, en stock. Si te gusta, puedes agregarla al carrito y enviarlo por aquí.

¿Cuál es tu presupuesto aproximado en pesos?
    turn [{"status":"completed","steps":[{"tool":"save_case_data","input":{"datos":{"interest":"lamps"}},"output":"{\"ok\":true,\"data\":{\"guardados\":[\"interest\"],\"faltan\":[{\"clave\":\"budget_clp\",\"nombre\":\"Budget (CLP)\"},{\"clave\":\"purchase_timeframe\",\"nombre\":\"When they need it\"},{\"clave\":\"delivery_comuna\",\"nombre\":\"Delivery comuna\"}]},\"hint\":\"Lo que falta pídelo junto, en un mensaje, no de a un campo.\"}","status":"ok","durationMs":18},{"tool":"check_stock","input":{"sku":"LAMP-ARCO-01"},"output":"{\"sku\":\"LAMP-ARCO-01\",\"available\":7,\"priceClp\":59990}","status":"ok","durationMs":20},{"tool":"send_products","input":{"catalog_id":"1234567890123456","product_ids":["LAMP-ARCO-01"]},"output":"{\"ok\":true,\"data\":{\"sent\":true,\"type\":\"product\"},\"hint\":\"The person already sees it. Do not repeat the options in your text; add at most one short sentence.\"}","status":"ok","durationMs":23}]}]
    case {"stage":"stg_1Hbz5rXer2I1d6KylWuscW","control":"agent","state":"waiting_for_contact","fields":{"interest":"lamps"}}
```

The agent saved `interest`, checked the stock with the signed call to your server, and sent a `product` message from the catalog. Its wording changes from run to run; the tools it calls do not.

## 12. The customer sends a cart

When the person fills the cart from the catalog and sends it, the message has `order: { catalogId, items }`. The webhook says `type: "order"`; read the items from the message. Your backend creates the order in its own system, writes the reference in the field with `source: "system"` and moves the case.

```bash title="cURL"
curl -X PATCH https://api.lelemon.cl/v2/cases/cse_... \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "fields": { "order_reference": "ORD-1042" } }'

curl https://api.lelemon.cl/v2/cases/cse_.../move \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: move-order-1" \
  --json '{ "stageId": "stg_..." }'
```

```ts title="Node.js"
const afterCart = await lelemon.caseMessages.list(caseId, { query: { order: 'asc' } });
const order = afterCart.data.find((m) => m.order)!.order!;
await lelemon.cases.update(caseId, { fields: { order_reference: 'ORD-1042' } });
const placed = await lelemon.cases.move(caseId, { stageId: stageId('Order placed') });
```


If the case lacks a required field, `move` answers `422 required_fields_missing` with the field in `param`. Here the agent already saved `delivery_comuna` from the message, and your backend wrote `order_reference`, so the move succeeds:

```text
webhook {"type":"message.received","message":{"id":"msg_1dU63m9IyYEib3OLZA2oXe","type":"order","order":{"items":[{"price":59990,"currency":"CLP","quantity":1,"productId":"LAMP-ARCO-01"}],"catalogId":"1234567890123456"},"reply":null,"channel":"whatsapp","createdAt":"2026-11-01T03:20:38.687Z","contentType":"text"}}
    order {"text":"Lo quiero. Lo despachan a Providencia.","items":[{"price":59990,"currency":"CLP","quantity":1,"productId":"LAMP-ARCO-01"}],"catalogId":"1234567890123456"}
    agent said Perfecto, anoté la lámpara Arco negra ($59.990, 7 unidades disponibles) con despacho a Providencia. Un miembro del equipo confirmará el pago y la entrega.

Para dejar el pedido listo, ¿me confirmas en un solo mensaje?
- Tu presupuesto aproximado en pesos
- Cuándo la necesitas: esta semana, este mes o solo estás explorando

case {"stage":"stg_7E4Jbz9mM3JPbMp7QcWWVM","fields":{"interest":"lamps","delivery_comuna":"Providencia","order_reference":"ORD-1042"}}
```

## 13. The customer asks for a person

The prompt lists when to hand over: a human requested, a discount, a bulk order. The agent calls `transfer_to_human`. The case goes to human control and to `Needs a person`, the contact reads the goodbye of the tool and `case.escalated` fires with the history.

```bash title="cURL"
curl https://api.lelemon.cl/v2/cases/cse_.../messages \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: team-reply-1" \
  --json '{ "author": "team", "authorName": "Camila", "text": "Hola Ana, soy Camila del equipo. Te puedo hacer un precio especial por 12 unidades." }'

curl -X POST https://api.lelemon.cl/v2/cases/cse_.../release-control \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const handedOver = await lelemon.cases.retrieve(caseId);
const reply = await lelemon.caseMessages.create(caseId, {
  author: 'team',
  authorName: 'Camila',
  text: 'Hola Ana, soy Camila del equipo. Para 12 unidades te puedo hacer un precio especial. ¿Te llamo?',
});
```


A person writes with `author: "team"` while the case is in human control; it needs the 24-hour window of WhatsApp. `release-control` hands the case back to the agent, which then answers with the messages of the team in its context.

```text
webhook {"type":"case.escalated","data":{"case":{"id":"cse_5OTq2YJhBq1dTWenpJ9KNm","channel":"whatsapp","pipelineId":"ppl_18UEE9xEomyeUh4R879h7v"},"contact":{"id":"ctc_0jEmUaPPMxXksKqHiqe7v9","name":"Ana Rojas","email":null,"phone":"56911112222","externalRef":null},"history":[{"role":"user","content":"Hola! Busco una lámpara de pie para el living, algo negro y simple","createdAt":"2026-11-01T03:20:22.112Z"},{"role":"assistant","content":"[product]","createdAt":"2026-11-01T03:20:36.471Z"},{"r …
    case {"stage":"stg_6K37FCS5PSfRcoj6Sbe5g1","control":"human","state":"escalated"}
    team message {"id":"msg_5V9k59ptqoyJuIo2C21KVb","status":"pending"}
```

## 14. Re-engage contacts with a template campaign

Outside the 24-hour window only an approved template goes out. Create it, wait for `approved` (WhatsApp reviews it), then send a campaign to a list of contacts. `onReply` names the agent that continues when someone answers.

```bash title="cURL"
curl https://api.lelemon.cl/v2/whatsapp/templates \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: tpl-spring-1" \
  --json '{
    "name": "spring_lamps",
    "language": "es",
    "category": "marketing",
    "body": "Hola {{name}}, llegó la colección de primavera a Casa Almendra. Usa el código {{coupon}} para un 10% de descuento esta semana.",
    "examples": { "name": "Ana", "coupon": "PRIMAVERA10" },
    "phoneNumberIds": "all"
  }'

curl https://api.lelemon.cl/v2/campaigns \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: cmp-spring-1" \
  --json '{
    "name": "Spring collection",
    "templateId": "tpl_...",
    "audience": { "externalRefs": ["cust-1", "cust-2"] },
    "variables": { "name": "contact.name", "coupon": "PRIMAVERA10" },
    "schedule": { "at": "now" },
    "onReply": { "agentId": "agt_..." }
  }'

curl -X POST https://api.lelemon.cl/v2/campaigns/cmp_.../preview \
  -H "Authorization: Bearer $LELEMON_API_KEY"

curl -X POST https://api.lelemon.cl/v2/campaigns/cmp_.../launch \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: launch-spring-1"
```

```ts title="Node.js"
const template = await lelemon.whatsapp.templates.create({
  name: `spring_lamps_`,
  language: 'es',
  category: 'marketing',
  body: 'Hola {{name}}, llegó la colección de primavera a Casa Almendra. Usa el código {{coupon}} para un 10% de descuento esta semana.',
  examples: { name: 'Ana', coupon: 'PRIMAVERA10' },
  phoneNumberIds: 'all',
});
const approved = await lelemon.whatsapp.templates.retrieve(template.id);
const luis = await lelemon.contacts.create({ name: 'Luis Soto', phone: '+56912345680', externalRef: 'cust-1' });
const marta = await lelemon.contacts.create({ name: 'Marta Díaz', phone: '+56912345681', externalRef: 'cust-2' });
for (const contact of [luis, marta]) {
}
const campaign = await lelemon.campaigns.create({
  name: 'Spring collection',
  templateId: approved.id,
  audience: { externalRefs: ['cust-1', 'cust-2'] },
  variables: { name: 'contact.name', coupon: 'PRIMAVERA10' },
  schedule: { at: 'now' },
  onReply: { agentId: agent.id },
});
const preview = await lelemon.campaigns.preview(campaign.id);
await lelemon.campaigns.launch(campaign.id);
const finished = await lelemon.campaigns.retrieve(campaign.id);
const recipients = await lelemon.campaigns.recipients.list(campaign.id);
```


The preview shows who gets the message, who is skipped and why, and a rendered sample before anything goes out. Launching freezes the list. See [Run a campaign](/guides/campaigns).

```text
template {"id":"tpl_0Jey2724PwoDA0iksK3Kp5","status":"in_review"}
    template {"id":"tpl_0Jey2724PwoDA0iksK3Kp5","status":"approved","variables":["name","coupon"]}
    preview {"recipients":2,"skipped":{},"sample":"Hola Luis Soto, llegó la colección de primavera a Casa Almendra. Usa el código PRIMAVERA10 para un 10% de descuento esta semana."}
    campaign {"status":"completed","counts":{"recipients":2,"pending":0,"queued":0,"sent":0,"delivered":2,"read":0,"replied":0,"failed":0,"skipped":0}}
    recipients ["delivered","delivered"]
```

## Notes

- A wrong catalog id shows up as a failed message.
- Past the 24-hour window only an approved template goes out.

## What you see in the dashboard

- **Inbox:** the case of Ana with the thread, the product message, the cart and who answered (agent or team). The fields `interest`, `delivery_comuna` and `order_reference` show in the card.
- **Pipeline board:** the case moves from `Qualifying` to `Order placed`, and a handed-over case waits in `Needs a person`.
- **Agent, Activity:** each turn with the tools it called, tokens and cost.
- **WhatsApp, Campaigns:** the recipients of the campaign with their status.

## Before customers write

1. Save the real catalog id on the number.
2. Turn on the [recipient allowlist](/guides/best-practices#limit-who-can-receive-messages) while you validate.

## What's next

- [Schedule meetings](/use-cases/meeting-scheduling) from the same conversation.
- [Approve agent actions](/use-cases/support-with-approvals) before an agent moves money.
- [WhatsApp messages](/guides/whatsapp): every format and what the person can send back.
- API reference: [`POST /v2/agents`](/api/v2), [`POST /v2/pipelines`](/api/v2), [`PUT /v2/agents/{id}/tools/{toolId}`](/api/v2), [`POST /v2/campaigns`](/api/v2).
