Skip to content

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.

Open WhatsApp templates

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.
  • A connected WhatsApp sender. GET /channels tells you what your workspace has. See Set up 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.
  • A webhook endpoint subscribed to message.status and message.received. See Getting events.

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:

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 covers the rule in full.

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:

Your order is on the way

Hi Asha, your order AM-4821 has shipped and should arrive in 2-3 days. Reply here if you need anything.

Reply STOP to unsubscribe

10:04
Track order
Talk to support

Rendered with the same rules WhatsApp applies — what you see is what your customer gets.

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 has the full contract.

The send #

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

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

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

404not_foundtemplate_not_foundNo approved template by that name exists in this workspace.
400invalid_argumenttemplate_variablesWrong number or shape of variables for that template's components.
412failed_preconditionchannel_not_connectedThis workspace has no WhatsApp sender connected.
412failed_preconditionallowance_exhaustedThe plan's conversations for this month are used up and there are no prepaid credits left. Nothing was sent. See Credits.
412failed_preconditionchannel_dormantThe plan no longer includes WhatsApp; the number is being held and the sender has stopped sending.
403permission_deniedsuppressedThe customer has opted out of your WhatsApp messages.
409conflictsession_window_closedFree-form text was sent with the 24-hour window closed.
409conflictidempotency_key_reuseThe same Idempotency-Key arrived with a different request body.
409conflictidempotency_in_progressA request with this key has not completed. See The retry story.
429rate_limitedrate_limitedMore 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.

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

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.

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.

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:

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

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

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

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.

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

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

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.

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:

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 #

The endpoint reference

POST /messages — every field, every constraint, every error.

The window rule in full

The 24-hour window — when text works, when only a template does, and how the clock actually runs.

Call them instead

Trigger an AI call from your CRM — for the updates a message cannot carry.

Before you launch

Going live — the pre-launch checklist this recipe assumes you have worked through.