WhatsApp order notifications from your backend
Send WhatsApp order confirmations, shipping and delivery updates from your backend: utility templates, variables, idempotency and delivery statuses.
To notify customers about an order on WhatsApp, create one UTILITY-category template per step
(confirmed, shipped, delivered), get it approved by Meta, then call POST /api/v1/messages from
your backend with the order's variables. Free-form text is only allowed if the customer messaged
you within the last 24 hours.
Last updated October 8, 2026
What do I need before I start?
- A number connected to Genuka WA and its
connectionId— see the quickstart. - An API key, used server-side only — see Authentication.
- A payment method on the WhatsApp Business account, at Meta. Genuka is a Meta Tech Provider,
not a BSP: Meta bills the account directly, and a client onboarded by a Tech Provider must add
its own payment method
(Meta, Partners).
Without one, the templates get approved but every send comes back with error
131042— see error 131042.
Which templates should an online store create?
One template for each moment the customer is waiting for news about their order. Three are enough to start:
| Step | Template | Body variables | Button |
|---|---|---|---|
| Order confirmed | order_confirmed | first name, number, amount | URL "View my order" |
| Order shipped | order_shipped | first name, number, carrier | URL "Track my parcel" |
| Order delivered | order_delivered | first name, number | None: the message invites a reply |
To Meta, a utility template is triggered by a customer action or request, is specific to that customer, and contains nothing promotional. Order confirmations and shipping updates are the textbook examples; a template that mixes order information with an offer, an upsell or a renewal push gets recategorized as marketing (Meta docs, categorization). Keep promotions out of these messages.
How do I create a utility template?
Submit the template
Every body variable needs a sample in example.body_text, and a URL button's variable only
replaces the end of the address, with its own sample.
curl -X POST https://wa.genuka.com/api/v1/templates \
-H "Authorization: Bearer $GENUKA_WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"connectionId": "con_1",
"name": "order_shipped",
"language": "en",
"category": "UTILITY",
"components": [
{ "type": "BODY",
"text": "Hi {{1}}, your order {{2}} is on its way with {{3}}. Track it with the button below.",
"example": { "body_text": [["Awa", "ORD-1042", "DHL"]] } },
{ "type": "BUTTONS", "buttons": [
{ "type": "URL", "text": "Track my parcel",
"url": "https://shop.example.com/track/{{1}}",
"example": ["https://shop.example.com/track/ORD-1042"] }
] }
]
}'In the dashboard: Templates › New template, Utility category.
Wait for approval
Meta's verdict arrives as a template_status webhook (if your endpoint subscribes to
template.status_changed, or to every event), can be read on GET /api/v1/templates/{id}, and
can be caught up with POST /api/v1/templates/sync if a webhook went missing. Only send an
approved template.
Set a time-to-live if the message goes stale
If a message cannot be delivered, WhatsApp keeps retrying for the template's time-to-live: 30 days
by default for a utility template, configurable from 30 seconds to 12 hours
(Meta docs, time-to-live).
A "your courier arrives in 30 minutes" received the next day does more harm than good: for that
kind of template, pass messageSendTtlSeconds at creation.
How do I send the notification from my backend?
One call per notification. body fills {{1}}, {{2}}, {{3}} in order (variables is an
accepted alias), and buttons[].text completes the button's URL.
const API = "https://wa.genuka.com/api/v1";
type Order = { number: string; firstName: string; phone: string; carrier: string };
/** The response body: `data` on success, otherwise `error` (plus `meta` if Meta refused). */
type ApiResult = {
data?: { messageId: string };
error?: string;
message?: string;
meta?: { retryable?: boolean; code?: number };
};
export async function sendOrderShipped(order: Order): Promise<string> {
const response = await fetch(`${API}/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GENUKA_WA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
connectionId: process.env.GENUKA_WA_CONNECTION_ID,
to: order.phone, // international, with the +: "+237699001122"
template: {
name: "order_shipped",
language: "en", // the template's exact language, otherwise the send targets "en_US"
body: [order.firstName, order.number, order.carrier],
buttons: [{ type: "url", text: order.number }],
},
}),
signal: AbortSignal.timeout(15_000),
});
// A 5xx from the CDN is not JSON: without this catch, the parse error would lose `retryable`
// and the notification would be filed as a permanent failure.
const result = (await response.json().catch(() => ({}))) as ApiResult;
if (!response.ok || !result.data) {
throw Object.assign(new Error(result.message ?? result.error), {
status: response.status,
// A permanent refusal (template, number) is not worth retrying: meta.retryable says so.
retryable: response.status >= 500 || result.meta?.retryable === true,
});
}
return result.data.messageId;
}A 200 means Meta accepted the message; follow delivery by webhook, using this messageId. To
send the same template to a whole list at once, use campaigns instead; every
content type is described in Sending messages.
When can I send free-form text instead of a template?
When the customer messages you, a 24-hour customer service window opens, and every new message from them restarts it. While it is open you can reply with any message; once it closes, only approved templates go through (Meta docs).
The typical case: the customer replies to the shipping update with "I won't be home tomorrow". Your
answer can be plain text, quoted with replyTo:
{
"connectionId": "con_1",
"to": "+237699001122",
"replyTo": "wamid.HBg…",
"text": "Noted, the courier will come back Thursday between 2 and 4 pm."
}On the bill, Meta does not charge for non-template messages, and a utility template delivered inside an open window is free too (Meta pricing).
Outside the window, free-form text fails, but not at send time. In our tests on a live number,
Meta accepted it with a messageId and then dropped it: usually a failed status with error
131047 follows
(Meta error codes);
sometimes no status arrives at all. So do not wait for the failure: Genuka WA does not block the
send, but warns you in the response, and that warning is what to act on:
{
"data": {
"messageId": "wamid.HBg…",
"warning": { "code": "outside_service_window", "message": "…" }
}
}outside_service_window means the customer's last message is more than 24 hours old: send a
template instead. unverified_service_window only means we have no inbound message from that
contact on record, which does not prove the window is closed. Watch the message's status, and
resend as a template only if failed with 131047 arrives, or nothing does: resending right away
risks delivering the same notification twice.
How do I avoid sending the same notification twice?
The API has no idempotency key: your backend is what guarantees that an order receives each notification only once. The simplest way is a table whose primary key is the order + step pair.
create table order_notifications (
order_id text not null,
event text not null, -- confirmed | shipped | delivered
status text not null default 'pending',
message_id text unique, -- the wamid returned by Genuka WA
error_code integer,
created_at timestamptz not null default now(),
primary key (order_id, event)
);import { Pool } from "pg";
const db = new Pool();
export async function notifyOnce(orderId: string, event: string, send: () => Promise<string>) {
// 1. Claim: a second call for the same order and step does nothing.
const claimed = await db.query(
"insert into order_notifications (order_id, event) values ($1, $2) on conflict do nothing",
[orderId, event],
);
if (claimed.rowCount === 0) return;
try {
// 2. Send, 3. store the wamid: it is what links later statuses to this row.
const messageId = await send();
await db.query(
"update order_notifications set status = 'sent', message_id = $3 where order_id = $1 and event = $2",
[orderId, event, messageId],
);
} catch (error) {
if ((error as { retryable?: boolean }).retryable === true) {
// Release the claim: the job's next run will try again.
await db.query("delete from order_notifications where order_id = $1 and event = $2", [orderId, event]);
} else {
await db.query(
"update order_notifications set status = 'failed' where order_id = $1 and event = $2",
[orderId, event],
);
}
throw error;
}
}
// await notifyOnce(order.id, "shipped", () => sendOrderShipped(order));A network timeout is ambiguous: Meta may have accepted the message before the response got lost. This code files it as a failure rather than risk a duplicate. If, for your store, a rare duplicate beats a lost confirmation, treat timeouts as retryable too.
How do I track delivery of each notification?
Statuses reach your webhook with type message_status. data.id is the messageId returned at
send time, and data.status is sent, delivered, read or failed. On failure,
data.errors[0].code carries Meta's code
(Meta docs, statuses).
const RANK: Record<string, number> = { pending: 0, sent: 1, delivered: 2, read: 3, failed: 4 };
if (event.type === "message_status") {
const { id: messageId, status, errors } = event.data;
const row = await findNotificationByMessageId(messageId);
// Statuses can arrive out of order: a status is never downgraded,
// and `failed` is final.
if (row && row.status !== "failed" && (RANK[status] ?? -1) > RANK[row.status]) {
await setNotificationStatus(messageId, status, errors?.[0]?.code ?? null);
}
}To Meta, read means the message was displayed in an open chat on the customer's device; do not
build business logic that waits for it. Signature verification and de-duplication are covered in
Receive replies and statuses via webhook.
Which errors will I run into most?
| Error | Where to read it | Meaning | What to do |
|---|---|---|---|
400 missing_fields | Response | connectionId or to missing | Complete the request |
400 recipient_is_sender | Response | to is the sending number itself | Change the recipient |
402 subscription_past_due | Response | The trial or the paid period has lapsed: nothing is sent until it is renewed | Renew the plan, then resend what notifyOnce filed as failed |
402 plan_limit_messages | Response | The period's message allowance is used up | Add numbers or change plan |
404 connection_not_found | Response | The connectionId does not exist or is outside your key's scope | Check the id and the key |
409 number_released | Response | The number was released from your plan | Reconnect it through Embedded Signup |
403 account_deactivated | Response | The client that owns the number is suspended | Restore the client from its page in the dashboard |
132001 | meta.code or failed status | Template missing in that language, or not approved | Check name, language and status |
132000 | meta.code or failed status | Variable count differs from the template's | Match body to {{1}}…{{n}} |
131026 | failed status | Message undeliverable, e.g. number without WhatsApp | Fall back to email or SMS |
131047 | failed status, sometimes never received | Free-form text sent more than 24 h after the customer's last message | Send a template; act on the response's warning |
130429 | meta.code | Cloud API throughput reached | Retry with growing backoff |
131042 | meta.code or failed status | Payment method problem on the Meta account | The client fixes billing on Meta's side — see error 131042 |
The meanings of Meta's codes come from its error code list.
FAQ
How much does a WhatsApp order notification cost?
Meta charges for delivered templates by category and recipient country, billed straight to the merchant's WhatsApp Business account; a utility template delivered inside an open customer service window is free (Meta pricing). Genuka WA takes no markup on those rates: you pay a subscription per number, with a message allowance — see the plans.
Can I slip a promo code into the order confirmation?
No. An offer or an upsell inside a utility template gets it recategorized as marketing by Meta. Send the promotion separately, in a marketing template, to customers who agreed to receive them.
Do I need the customer's consent?
Yes. Meta asks you to state clearly that the person is opting in to receive messages from your business, and to name that business (Meta docs, opt-in). A "Get order updates from [Store name] on WhatsApp" checkbox at checkout, stored with the order, meets both conditions.
What if the customer has no WhatsApp account?
The status comes back failed with code 131026. Do not retry: send the same information by
email or SMS.
I run several stores: how do I pick the sending number?
Each store connects its number and gets its own connectionId; that is what decides where the
message comes from. A partner key reaches all your stores, a client key only one — see
Authentication and, to find a store's number,
Connecting a number.
Sources
Send WhatsApp OTP codes from Node.js
Send WhatsApp one-time passwords from Node.js with Genuka WA: authentication template, copy-code button, expiry, verification and SMS fallback.
Send a WhatsApp campaign through the API
Send a WhatsApp campaign through the API: marketing template, recipients, launch, plan quotas, Meta's marketing limits and delivery tracking.