# Send an order update on WhatsApp

> The complete order-shipped flow — approved template, idempotent send, error branching, and the webhooks that tell you it landed and that the customer replied.

Source: https://docs.amomic.in/recipes/order-updates

---

This is the integration most workspaces build first: your order system marks a shipment dispatched, the customer gets a WhatsApp message about it, and when they reply with "where is it exactly?" your agent answers without a human touching it.

It is a small amount of code. Almost all of the work is in the four things around the send — picking a template instead of text, making the retry safe, branching on the failure, and closing the loop with webhooks. This page builds the whole thing.

## What you need before you start

- **An approved template.** Meta approves templates, not us, and only an approved one can be sent. Create it in **Channels → WhatsApp → Templates** and wait for `approved`. See [Templates](/channels/whatsapp-templates).
- **A connected WhatsApp sender.** `GET /channels` tells you what your workspace has. See [Set up WhatsApp](/channels/whatsapp).
- **An API key with `messages:send`**, plus `conversations:read` if you want to read replies. Tick **Send messages** (and **Read conversations**) under **What this key can do** when you create the key: a key's scopes can't be changed afterwards. See [Scopes](/api/scopes).
- **A webhook endpoint** subscribed to `message.status` and `message.received`. See [Getting events](/webhooks/overview).

## The flow end to end

1. Your order system transitions an order to `shipped` and enqueues a job.
2. The job sends an approved template through `POST /messages` with an `Idempotency-Key` derived from the order.
3. `message.status` webhooks arrive as WhatsApp moves the message through `sent` → `delivered` → `read`.
4. The customer replies. That inbound message opens a 24-hour window and fires `message.received`, which tells you who wrote — not what they wrote.
5. Your deployed agent answers inside that window, in the same thread. Nothing further is required from your backend. If you want the reply's text, read it from the conversation.

## Why a template and not text

WhatsApp only lets a business send free-form text inside a **24-hour customer service window**, which opens when the customer messages you and closes 24 hours after their last message. An order shipping is a business event, not a reply — the window is almost never open when it happens.

Send free-form text with the window closed and you get:

```json title="409 Conflict"
{
  "error": {
    "code": "conflict",
    "message": "The 24-hour session window is closed; only approved template messages can be sent.",
    "details": { "reason": "session_window_closed" },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

Templates bypass the window. That is the entire reason they exist. [The 24-hour window](/channels/whatsapp-window) covers the rule in full.

::: warning Do not "try text first, fall back to a template"
It looks clever and it is a bug. You spend a round trip to discover a state you already know, and if the window happens to be open you send a differently-worded message to some customers and not others. Notification flows send a template every time. Reply flows send text.
:::

The template you send here has two body variables:

```mock
{"component": "WhatsAppMock", "props": {"body": "Hi Asha, your order AM-4821 has shipped and should arrive in 2-3 days. Reply here if you need anything.", "time": "10:04", "caption": "The approved template with variables 1 and 2 substituted. The customer's reply to this message opens the 24-hour window."}}
```

## Deriving the Idempotency-Key

`POST /messages` honours `Idempotency-Key`. Within 24 hours, a repeat of the same key with the same body replays the stored response instead of sending a second message. That is what makes a retry after a timeout safe.

Derive the key from something your own system already treats as unique. The rule that matters: **one key per real-world event**, not per attempt and not per order.

| Key | Verdict |
|---|---|
| `order-4821-shipped` | Correct. One shipment event, retried as many times as needed. |
| `order-4821` | Wrong. The "delivered" message later reuses it, hits a different body, and 409s. |
| `crypto.randomUUID()` | Wrong. Every retry is a new key, so every retry sends another message. |
| `2026-08-01T10:04:00Z-4821` | Wrong if the timestamp is generated at attempt time — the retry gets a different key. |

Two more properties to code against:

- **The key is scoped to your workspace, that API key, and that endpoint.** `order-4821-shipped` on `POST /messages` and on `POST /tickets` do not collide.
- **The body must be byte-identical on the retry.** The match is a hash of the raw request bytes. Re-serialising a dictionary with a different key order counts as a different body and returns `409` with `idempotency_key_reuse`. Serialise the JSON **once** and resend the same string.

After 24 hours the record expires and the same key sends a fresh message — which is the correct behaviour for a retry queue that only just drained, and the wrong behaviour for a job you replayed from an archive.

[Idempotency](/api/idempotency) has the full contract.

## The send

Build the body once, POST it, branch on the outcome. Both versions below are complete and runnable.

::: codegroup

```javascript Node.js
// send-order-update.js
const BASE_URL = "https://services.amomic.in/api/v1";
const API_KEY = process.env.AMOMIC_API_KEY;

// Worth retrying with the same key and bytes: no response at all, a 429
// (refused before the key was claimed), and "already in progress". Any other
// error response is a decision, not a blip — see "The retry story" below.
// The last wait clears the 429's minute window.
const RETRYABLE = (status, reason) =>
  status === 429 || (status === 409 && reason === "idempotency_in_progress");
const BACKOFF_MS = [2_000, 15_000, 61_000];

class AmomicError extends Error {
  constructor(status, error) {
    super(`${error.code}: ${error.message}`);
    this.status = status;
    this.code = error.code;
    this.reason = error.details?.reason ?? null;
    this.details = error.details ?? {};
    this.requestId = error.request_id;
  }
}

async function sendOrderShipped(order) {
  // Serialise once. The idempotency match is on raw bytes, so every retry
  // must send this exact string.
  const body = JSON.stringify({
    to: order.customerPhone,               // "+919876543210" — digits and an optional +
    template: {
      name: "order_shipped",
      language: "en_US",
      variables: [order.customerName, order.reference],
    },
    metadata: { order_id: order.id },      // your own reference; never shown to the customer
  });

  const headers = {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `order-${order.id}-shipped`,
    "X-Correlation-Id": order.traceId,     // joins your logs to ours
  };

  for (let attempt = 0; ; attempt += 1) {
    let response;
    try {
      response = await fetch(`${BASE_URL}/messages`, { method: "POST", headers, body });
    } catch (networkError) {
      // No response at all. The send may or may not have happened — the
      // idempotency key is what makes retrying this safe.
      if (attempt < BACKOFF_MS.length) {
        await sleep(BACKOFF_MS[attempt]);
        continue;
      }
      throw networkError;
    }

    if (response.ok) {
      const sent = await response.json();
      // status: "sent" means accepted by WhatsApp, not delivered to the handset.
      // Delivery arrives later, on the message.status webhook.
      await recordOutboundMessage(order.id, sent.message_id);
      return sent;
    }

    const { error } = await response.json();

    if (RETRYABLE(response.status, error.details?.reason) && attempt < BACKOFF_MS.length) {
      await sleep(BACKOFF_MS[attempt]);
      continue;
    }

    throw new AmomicError(response.status, error);
  }
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
```

```python Python
# send_order_update.py
import os
import json
import time
import requests

BASE_URL = "https://services.amomic.in/api/v1"
API_KEY = os.environ["AMOMIC_API_KEY"]

BACKOFF_S = [2, 15, 61]   # the last wait clears the 429's minute window


def retryable(status: int, reason: str | None) -> bool:
    # No response at all is handled separately. A 429 is refused before the
    # key is claimed; "in progress" means the first request may still finish.
    return status == 429 or (status == 409 and reason == "idempotency_in_progress")


class AmomicError(Exception):
    def __init__(self, status: int, error: dict):
        super().__init__(f"{error['code']}: {error['message']}")
        self.status = status
        self.code = error["code"]
        self.details = error.get("details") or {}
        self.reason = self.details.get("reason")
        self.request_id = error.get("request_id")


def send_order_shipped(order) -> dict:
    # Serialise once. The idempotency match is on raw bytes, so every retry
    # must send this exact payload.
    body = json.dumps(
        {
            "to": order.customer_phone,          # "+919876543210"
            "template": {
                "name": "order_shipped",
                "language": "en_US",
                "variables": [order.customer_name, order.reference],
            },
            "metadata": {"order_id": order.id},
        }
    ).encode("utf-8")

    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": f"order-{order.id}-shipped",
        "X-Correlation-Id": order.trace_id,
    }

    for attempt in range(len(BACKOFF_S) + 1):
        try:
            response = requests.post(
                f"{BASE_URL}/messages", headers=headers, data=body, timeout=30
            )
        except requests.RequestException:
            # No response at all. The idempotency key makes the retry safe.
            if attempt < len(BACKOFF_S):
                time.sleep(BACKOFF_S[attempt])
                continue
            raise

        if response.ok:
            sent = response.json()
            # status "sent" is acceptance by WhatsApp, not delivery.
            record_outbound_message(order.id, sent["message_id"])
            return sent

        error = response.json()["error"]
        reason = (error.get("details") or {}).get("reason")
        if retryable(response.status_code, reason) and attempt < len(BACKOFF_S):
            time.sleep(BACKOFF_S[attempt])
            continue

        raise AmomicError(response.status_code, error)
```

:::

```json title="201 Created"
{
  "message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSMkI5RUE4...",
  "channel": "whatsapp",
  "to": "+919876543210",
  "status": "sent"
}
```

Store `message_id` against your order. It is the join key for every `message.status` webhook that follows.

::: note `status: "sent"` is an acceptance, not a receipt
It means WhatsApp took the message. Whether a handset ever displayed it is a separate fact that arrives asynchronously on `message.status`. Code that treats the 201 as proof of delivery will report a switched-off phone as a success.
:::

## Handling the failures that actually happen

Branch on `error.code` for the class of problem and `error.details.reason` for the specific cause. These cover almost every real incident.

```responses
[
  {"status": 404, "code": "not_found", "reason": "template_not_found", "description": "No approved template by that name exists in this workspace."},
  {"status": 400, "code": "invalid_argument", "reason": "template_variables", "description": "Wrong number or shape of variables for that template's components."},
  {"status": 412, "code": "failed_precondition", "reason": "channel_not_connected", "description": "This workspace has no WhatsApp sender connected."},
  {"status": 412, "code": "failed_precondition", "reason": "allowance_exhausted", "description": "The plan's conversations for this month are used up and there are no prepaid credits left. Nothing was sent. See Credits."},
  {"status": 412, "code": "failed_precondition", "reason": "channel_dormant", "description": "The plan no longer includes WhatsApp; the number is being held and the sender has stopped sending."},
  {"status": 403, "code": "permission_denied", "reason": "suppressed", "description": "The customer has opted out of your WhatsApp messages."},
  {"status": 409, "code": "conflict", "reason": "session_window_closed", "description": "Free-form text was sent with the 24-hour window closed."},
  {"status": 409, "code": "conflict", "reason": "idempotency_key_reuse", "description": "The same Idempotency-Key arrived with a different request body."},
  {"status": 409, "code": "conflict", "reason": "idempotency_in_progress", "description": "A request with this key has not completed. See The retry story."},
  {"status": 429, "code": "rate_limited", "reason": "rate_limited", "description": "More than 120 requests in one minute on this key."}
]
```

`allowance_exhausted` and `channel_dormant` are billing states, not message problems: every send fails the same way until someone acts in the console. See [Credits](/operate/credits#what-happens-when-you-run-out).

### `template_not_found` — 404

`"No approved WhatsApp template named 'order_shipped'."`

Three causes, in the order they occur in practice:

1. **The template is not approved yet.** Only `approved` templates send. `pending` and `rejected` both produce this 404 — the API does not distinguish them, so check the status in the console.
2. **The name is spelled differently.** Template names are `^[a-z0-9_]+$`, and `order_shipped` is not `Order_Shipped`.
3. **It was approved in a different workspace.** Templates belong to the WhatsApp Business Account connected to *this* workspace.

This is not retryable. Fail the job, alert, and fix the template. A retry loop against a 404 will run until your queue gives up and will not have sent anything.

### `template_variables` — 400

The template's body has two `{{n}}` placeholders and you sent one, or three. Header and button variables are separate fields — a template with a variable in its header needs `header_variables`, not a third entry in `variables`:

```json
{
  "to": "+919876543210",
  "template": {
    "name": "order_shipped",
    "language": "en_US",
    "header_variables": ["Asha"],
    "variables": ["AM-4821", "2-3 days"],
    "button_variables": ["4821"]
  }
}
```

`variables` accepts a positional array or a numbered object — `["Asha", "AM-4821"]` and `{"1": "Asha", "2": "AM-4821"}` are the same request. Not retryable; it is a code defect.

### `channel_not_connected` — 412

`"This workspace has no WhatsApp sender connected."` Nothing your integration sends will work until someone connects a number in the console. Treat it as a configuration alarm rather than a message failure — every message you attempt will fail identically until it is fixed.

If your workspace has several senders and you want a specific one, pass `sender_id` from `GET /channels`. Omit it and the workspace default is used.

### `allowance_exhausted` and `channel_dormant`: 412

Both are billing states, not message failures. `allowance_exhausted` means the workspace has used every conversation its plan includes this month and has no prepaid [credits](/operate/credits) left; add credits or upgrade, and sends work again. `channel_dormant` means a plan change took WhatsApp away; upgrade the plan to restore the sender. Like `channel_not_connected`, every send fails the same way until someone fixes it, so alert rather than retry.

### `idempotency_key_reuse` — 409

You reused a key with a different body. Almost always one of:

- The body is rebuilt per attempt and a timestamp, trace id, or dictionary ordering changed inside it.
- One key is being used for two different messages about the same order.

Fix the key derivation. Do not retry with a fresh key to "get past it": if the first request succeeded, that is exactly the double-send the header exists to prevent.

An **error response** is different: it releases the key. A request that got a `400`, `404`, or `412` back did not send, so fix the cause and send again with the same key. After a `5xx` the same key is free too, but first confirm the message did not go out (below). See [Idempotency](/api/idempotency#after-an-error-response-retry-with-the-same-key).

### `rate_limited` — 429

120 requests per minute per key, counted in a fixed one-minute window. There is no `Retry-After` header. Back off to the next minute boundary and resume; if you are sending in bulk, pace at roughly two per second rather than bursting and absorbing 429s. See [Rate limits](/api/rate-limits).

## The retry story, in one place

The key is claimed the moment a request arrives, the response is stored only when the send **succeeds**, and a request that fails releases the key. That shapes every retry:

| Outcome | Retry? | Why |
|---|---|---|
| Connection error, timeout, no response | Yes, same key, same bytes | The send may have happened. A completed send replays its `201`; one still running answers `idempotency_in_progress`. |
| `429 rate_limited` | Yes, same key, after backing off past the minute boundary | Refused before the key was claimed, and transient by definition. |
| `409 idempotency_in_progress` | Yes, same key, after `details.retry_after_seconds` (2), a few times | The first request is still running. Once it finishes you get its `201`, or a fresh attempt if it failed. |
| `500` / `502 provider_error` | **No automatic retry** — flag it | Whether the message went out is unknown: a `5xx` can come after the send. The key is released, so once you know the message did not arrive, re-run the job with the same key. |
| `400`, `401`, `403`, `404`, `409 idempotency_key_reuse`, `412` | No | Retrying cannot change the answer. Alert a human. |

::: warning A failed request frees its key, so a retry sends for real
If the first request with a key ends in an error — a `4xx` refusal or a `5xx` — the key is released. Sending the same key again runs a fresh request, with nothing to replay.

- After a **`4xx` refusal** (a missing template, a closed window, an opted-out customer), nothing was sent. Fix the cause, then send again with the same key.
- After a **`5xx`**, it is not known whether the message went out: the error can come after the send. Check the conversation in the inbox, or wait for a `message.status`, before sending again.

The sample code above follows these rules: it retries only no-response, `429` and `idempotency_in_progress`, and gives up after three tries.
:::

And one boundary: idempotency records live for **24 hours**. A job replayed from a dead-letter queue three days later will send the message again. If your queue can do that, check your own "already notified" flag before calling the API.

## Closing the loop with `message.status`

Delivery is asynchronous. Subscribe to `message.status` and you get one event per state change:

```json title="POST to your endpoint"
{
  "id": "evt_9f2c4b1e7a3d5c8f0b2e6d41",
  "type": "message.status",
  "created_at": "2026-08-01T10:04:12Z",
  "tenant_id": "ten_01JQRS4T8XZ0AB1CD2EF3GH4",
  "data": {
    "channel": "whatsapp",
    "message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSMkI5RUE4...",
    "status": "delivered",
    "recipient": "919876543210",
    "event_id": "evt_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

The statuses you will see are `sent`, `delivered`, `read`, and `failed`. WhatsApp owns that vocabulary and it is passed through unchanged, so **write a default branch**. A `failed` status says only that — the payload carries no reason.

`message.status` is sent for **every** message from your number, including the agent's replies and campaign messages. Look the `message_id` up in your own records and ignore ids you never stored.

Fields with no value are left out of `data` rather than sent as `null`. Read defensively.

Two rules for the handler itself:

- **Verify the signature against the raw request body.** Parsing and re-serialising changes the bytes and the HMAC will not match. This is the single most common webhook bug.
- **Deduplicate on the envelope `id`.** Delivery is at-least-once; the same event can arrive twice.

::: codegroup

```javascript Node.js
// webhook.js
import express from "express";
import crypto from "node:crypto";

const app = express();
const SIGNING_SECRET = process.env.AMOMIC_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_S = 300;

function verify(rawBody, header) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => {
      const i = p.indexOf("=");
      return [p.slice(0, i), p.slice(i + 1)];
    }),
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_S) return false;

  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw, not express.json — the signature covers the exact bytes.
app.post("/hooks/amomic", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(raw, req.get("Amomic-Signature") ?? "")) {
    return res.status(401).end(); // a non-429 4xx stops retries — correct here
  }

  const event = JSON.parse(raw);

  // Acknowledge first, work afterwards. Ten seconds is the whole budget.
  res.status(200).end();

  if (await alreadyProcessed(event.id)) return;   // at-least-once delivery
  await markProcessed(event.id);

  switch (event.type) {
    case "message.status": {
      const { message_id, status } = event.data;
      await updateOrderNotification(message_id, status); // sent | delivered | read | failed
      break;
    }
    case "message.received": {
      // No text in the payload: a worker reads it (see "When the customer replies").
      await jobs.enqueue("read-reply", { from: event.data.from, after: event.created_at });
      break;
    }
    default:
      break; // includes the console's "ping" test event
  }
});

app.listen(3000);
```

```python Python
# webhook.py
import os
import time
import hmac
import hashlib
import json
from fastapi import FastAPI, Request, Response

app = FastAPI()
SIGNING_SECRET = os.environ["AMOMIC_WEBHOOK_SECRET"]  # whsec_…
TOLERANCE_S = 300


def verify(raw_body: bytes, header: str) -> bool:
    parts = {}
    for piece in header.split(","):
        key, _, value = piece.partition("=")
        parts[key] = value

    try:
        timestamp = int(parts.get("t", ""))
    except ValueError:
        return False
    if abs(time.time() - timestamp) > TOLERANCE_S:
        return False

    expected = hmac.new(
        SIGNING_SECRET.encode(),
        f"{timestamp}.{raw_body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))


@app.post("/hooks/amomic")
async def receive(request: Request) -> Response:
    raw = await request.body()  # raw bytes, never a re-serialised parse
    if not verify(raw, request.headers.get("Amomic-Signature", "")):
        return Response(status_code=401)  # a non-429 4xx stops retries

    event = json.loads(raw)

    if await already_processed(event["id"]):  # at-least-once delivery
        return Response(status_code=200)
    await mark_processed(event["id"])

    if event["type"] == "message.status":
        data = event["data"]
        await update_order_notification(data["message_id"], data.get("status"))
    elif event["type"] == "message.received":
        # No text in the payload: a worker reads it (see "When the customer replies").
        await jobs.enqueue("read-reply", {"from": event["data"]["from"], "after": event["created_at"]})

    return Response(status_code=200)
```

:::

::: warning Return 2xx quickly, then work
The delivery times out at 10 seconds. A slow handler burns four attempts over roughly a minute and, after 15 consecutive failed events, the endpoint is disabled automatically and receives nothing until an admin re-enables it. Acknowledge, enqueue, process. [Retries and failures](/webhooks/reliability) explains the ladder.
:::

## When the customer replies

The reply is the interesting half, and it mostly happens without you.

The customer taps your order update and types "it says out for delivery but nothing arrived". Three things follow:

1. **The 24-hour window opens.** From this moment you may send free-form text to this number, until 24 hours after their most recent message.
2. **`message.received` fires** with the sender's number and WhatsApp's message id.
3. **Your deployed agent answers**, if an agent is deployed to that WhatsApp number. It has your knowledge base and the conversation history; it does not need your backend.

```json title="message.received"
{
  "id": "evt_3d8a1f5c9b2e7a4d6c0f8b13",
  "type": "message.received",
  "created_at": "2026-10-14T05:01:44Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "channel": "whatsapp",
    "message_id": "wamid.HBgMOTE5ODQwMzMxMjc2FQIAEhgUM0E3RjMxQjJDOUQ4RTA1RjQxNkEA",
    "from": "+919840331276",
    "type": "text",
    "event_id": "evt_01JF9W4K8N2Q6T0W4Y8A2C6E0G"
  }
}
```

::: warning The event does not carry the text
`message.received` tells you **who** wrote and **when**, not what they wrote. There is no `text` and no `conversation_id` in the payload. If your backend needs the words, read them from the conversation, as below.
:::

So what should your backend do with it? Usually one of three things.

**Nothing.** The agent handles it. This is the default and the right answer for "where is my order" — the agent can answer from your knowledge base faster than a webhook round trip.

**Note that the customer replied.** Match `from` to your customer record and mark the order "customer responded". That needs nothing beyond the event.

**Read what they wrote.** Do it in a worker, not in the webhook handler, because it takes a few API calls. The key needs `conversations:read`.

1. Find the conversation: walk `GET /conversations` (newest activity first) and take the first `whatsapp` conversation whose `customer.phone` equals `from`.
2. Read `GET /conversations/{id}/messages` to the last page, and take the newest turn with `role: "user"`.
3. The event can arrive a moment before the message is stored. If the newest `user` turn is older than the event, wait a few seconds and try again.

::: codegroup

```javascript Node.js
// read-reply.js — runs in a worker, fed by the message.received handler
const BASE_URL = "https://services.amomic.in/api/v1";
const HEADERS = { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` }; // conversations:read

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function get(path) {
  const response = await fetch(`${BASE_URL}${path}`, { headers: HEADERS });
  const body = await response.json();
  if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);
  return body;
}

async function findConversation(phone) {
  let cursor = null;
  for (let page = 0; page < 4; page += 1) {           // the thread just moved, so it is near the top
    const query = new URLSearchParams({ limit: "50", ...(cursor ? { cursor } : {}) });
    const { items, next_cursor } = await get(`/conversations?${query}`);
    const match = items.find((c) => c.channel === "whatsapp" && c.customer?.phone === phone);
    if (match) return match;
    if (!next_cursor) return null;
    cursor = next_cursor;
  }
  return null;
}

async function newestUserTurn(conversationId) {
  let cursor = null;
  let newest = null;
  for (;;) {                                          // oldest first, so walk to the end
    const query = new URLSearchParams({ limit: "200", ...(cursor ? { cursor } : {}) });
    const { items, next_cursor } = await get(`/conversations/${conversationId}/messages?${query}`);
    for (const m of items) if (m.role === "user") newest = m;
    if (!next_cursor) return newest;
    cursor = next_cursor;
  }
}

export async function readReply({ from, after }) {
  const notBefore = Date.parse(after) - 60_000;        // a little slack for clocks
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const conversation = await findConversation(from);
    if (conversation) {
      const turn = await newestUserTurn(conversation.conversation_id);
      if (turn && Date.parse(turn.created_at) >= notBefore) {
        return { conversationId: conversation.conversation_id, text: turn.text ?? "" };
      }
    }
    await sleep(3_000);                                // not stored yet — try again
  }
  return null;                                         // log it; the inbox still has it
}
```

```python Python
# read_reply.py — runs in a worker, fed by the message.received handler
import os
import time
from datetime import datetime, timedelta

import requests

BASE_URL = "https://services.amomic.in/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"}  # conversations:read


def _ts(value: str) -> datetime:
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


def _get(path: str, params: dict) -> dict:
    response = requests.get(f"{BASE_URL}{path}", headers=HEADERS, params=params, timeout=30)
    body = response.json()
    if not response.ok:
        error = body["error"]
        raise RuntimeError(f"{error['code']}: {error['message']} ({error['request_id']})")
    return body


def find_conversation(phone: str) -> dict | None:
    cursor = None
    for _ in range(4):  # the thread just moved, so it is near the top
        params = {"limit": 50, **({"cursor": cursor} if cursor else {})}
        page = _get("/conversations", params)
        for conversation in page["items"]:
            customer = conversation.get("customer") or {}
            if conversation.get("channel") == "whatsapp" and customer.get("phone") == phone:
                return conversation
        cursor = page["next_cursor"]
        if not cursor:
            return None
    return None


def newest_user_turn(conversation_id: str) -> dict | None:
    cursor, newest = None, None
    while True:  # oldest first, so walk to the end
        params = {"limit": 200, **({"cursor": cursor} if cursor else {})}
        page = _get(f"/conversations/{conversation_id}/messages", params)
        for message in page["items"]:
            if message.get("role") == "user":
                newest = message
        cursor = page["next_cursor"]
        if not cursor:
            return newest


def read_reply(sender: str, after: str) -> dict | None:
    not_before = _ts(after) - timedelta(seconds=60)  # a little slack for clocks
    for _ in range(4):
        conversation = find_conversation(sender)
        if conversation:
            turn = newest_user_turn(conversation["conversation_id"])
            if turn and turn.get("created_at") and _ts(turn["created_at"]) >= not_before:
                return {"conversation_id": conversation["conversation_id"], "text": turn.get("text") or ""}
        time.sleep(3)  # not stored yet — try again
    return None  # log it; the inbox still has it
```

:::

For a photo, voice note or other media, the stored turn's `text` may not be words the customer typed; check `type` on the event. If the number is set to **Off** with **Don't keep these conversations**, nothing is stored and there is nothing to read — see [Reply modes](/channels/whatsapp-reply-modes#off).

**Send free-form text yourself,** now that the window is open. Same endpoint, `text` instead of `template`:

```bash
curl -X POST https://services.amomic.in/api/v1/messages \
  -H "Authorization: Bearer $AMOMIC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4821-tracking-detail" \
  -d '{
    "to": "+919840331276",
    "text": "Your parcel scanned at the Guindy hub at 09:12 and is out with the courier today."
  }'
```

Free-form text is capped at 4,096 characters; a longer `text` is refused with `400 invalid_argument`. Split it yourself.

::: note The window is per customer, not per order
It opens on *their* message and closes 24 hours after their *last* one. A second reply extends it. Do not model it as a fixed 24 hours from your notification — that clock is not the one WhatsApp is counting.
:::

If the customer needs a human, open a ticket: [Escalate a conversation to a ticket](/recipes/support-escalation).

## Opt-outs

A customer who replies `STOP` (also `unsubscribe`, `cancel`, `opt out`, `band karo`, `बंद करो`) is suppressed for your workspace. Subsequent sends to that number return:

```json title="403 Forbidden"
{
  "error": {
    "code": "permission_denied",
    "message": "Recipient has opted out.",
    "details": { "reason": "suppressed" },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

This applies to templates too — an opt-out is not a window problem and cannot be worked around by switching message type. Record the suppression against your customer record so you stop trying. `START` resubscribes them.

## Going to production

- Send to a **real number you control** before launch, and read the whole thread on the handset. A template that reads fine in the console can read oddly with real variable values.
- Alert on `channel_not_connected` and `template_not_found` **separately** from generic failures. They are configuration incidents, and a generic "message failed" alert buries them.
- Log `error.request_id` on every failure. Quoting it to support turns a thread into a lookup.
- Check the **request log** in **Developers → Logs** when something looks wrong from your side. Every call your keys made is there for 7 days with its status, error code, and timing.

## Next

::: cards

::: card The endpoint reference
[POST /messages](/api/messages) — every field, every constraint, every error.
:::

::: card The window rule in full
[The 24-hour window](/channels/whatsapp-window) — when text works, when only a template does, and how the clock actually runs.
:::

::: card Call them instead
[Trigger an AI call from your CRM](/recipes/crm-call) — for the updates a message cannot carry.
:::

::: card Before you launch
[Going live](/operate/going-live) — the pre-launch checklist this recipe assumes you have worked through.
:::

:::
