# WhatsApp order notifications from your backend

URL: https://wa.genuka.com/en/docs/guides/order-notifications
Language: English

> 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](https://wa.genuka.com/en/docs/quickstart).
* An API key, used **server-side only** — see [Authentication](https://wa.genuka.com/en/docs/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](https://developers.facebook.com/documentation/business-messaging/whatsapp/solution-providers/overview)).
  Without one, the templates get approved but every send comes back with error `131042` — see
  [error 131042](https://wa.genuka.com/en/docs/errors/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](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/template-categorization)).
Keep promotions out of these messages.

## How do I create a utility template?

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

   ```bash title="POST /api/v1/templates"
   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.

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

3. ### 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](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/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.

**Node.js**

```ts title="send-order-shipped.ts"
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;
}
```

**Python**

```python title="send_order_shipped.py"
import os

import requests

API = "https://wa.genuka.com/api/v1"


class NotificationError(Exception):
    def __init__(self, status, body):
        super().__init__(body.get("message") or body.get("error"))
        # A permanent refusal (template, number) is not worth retrying: meta.retryable says so.
        self.retryable = status >= 500 or body.get("meta", {}).get("retryable") is True


def send_order_shipped(order):
    response = requests.post(
        f"{API}/messages",
        headers={
            "Authorization": f"Bearer {os.environ['GENUKA_WA_API_KEY']}",
            "User-Agent": "shop/1.0 (+https://shop.example.com)",
        },
        json={
            "connectionId": os.environ["GENUKA_WA_CONNECTION_ID"],
            "to": order["phone"],  # international, with the +: "+237699001122"
            "template": {
                "name": "order_shipped",
                "language": "en",
                "body": [order["first_name"], order["number"], order["carrier"]],
                "buttons": [{"type": "url", "text": order["number"]}],
            },
        },
        timeout=15,
    )
    try:
        body = response.json()
    except ValueError:  # a 5xx from the CDN is not JSON
        body = {}
    if not response.ok:
        raise NotificationError(response.status_code, body)
    return body["data"]["messageId"]
```

**PHP (Laravel)**

```php title="app/Notifications/SendOrderShipped.php"
<?php

use Illuminate\Support\Facades\Http;

function sendOrderShipped(Order $order): string
{
    $response = Http::withToken(config('services.genuka_wa.key'))
        ->timeout(15)
        ->post('https://wa.genuka.com/api/v1/messages', [
            'connectionId' => config('services.genuka_wa.connection_id'),
            'to' => $order->phone, // international, with the +: "+237699001122"
            'template' => [
                'name' => 'order_shipped',
                'language' => 'en',
                'body' => [$order->first_name, $order->number, $order->carrier],
                'buttons' => [['type' => 'url', 'text' => $order->number]],
            ],
        ]);

    if ($response->failed()) {
        // A permanent refusal (template, number) is not worth retrying: meta.retryable says so.
        $retryable = $response->serverError() || $response->json('meta.retryable') === true;
        throw new OrderNotificationFailed($response->json('message') ?? $response->json('error'), $retryable);
    }

    return $response->json('data.messageId');
}
```

Call it from a queued job (`ShouldQueue`) rather than inside the checkout request: the customer
should not wait on WhatsApp to see their confirmation page.

**curl**

```bash title="POST /api/v1/messages"
curl -X POST https://wa.genuka.com/api/v1/messages \
  -H "Authorization: Bearer $GENUKA_WA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "connectionId": "con_1",
    "to": "+237699001122",
    "template": {
      "name": "order_shipped",
      "language": "en",
      "body": ["Awa", "ORD-1042", "DHL"],
      "buttons": [{ "type": "url", "text": "ORD-1042" }]
    }
  }'
```

```json title="Response"
{ "data": { "messageId": "wamid.HBg…" } }
```

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](https://wa.genuka.com/en/docs/campaigns) instead; every
content type is described in [Sending messages](https://wa.genuka.com/en/docs/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](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/send-messages)).

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

```json title="POST /api/v1/messages"
{
  "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](https://developers.facebook.com/documentation/business-messaging/whatsapp/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](https://developers.facebook.com/documentation/business-messaging/whatsapp/support/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:

```json title="200 OK — with a warning"
{
  "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.

```sql title="schema.sql"
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)
);
```

```ts title="notify-once.ts"
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](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)).

```ts title="In your webhook receiver (signature already verified)"
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](https://wa.genuka.com/en/docs/guides/receive-messages-webhooks).

## 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](https://wa.genuka.com/en/docs/errors/131042) |

The meanings of Meta's codes come from its
[error code list](https://developers.facebook.com/documentation/business-messaging/whatsapp/support/error-codes).

## 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](https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing)).
Genuka WA takes no markup on those rates: you pay a subscription per number, with a message
allowance — see the [plans](https://wa.genuka.com/en#pricing).

### 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](https://developers.facebook.com/documentation/business-messaging/whatsapp/getting-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](https://wa.genuka.com/en/docs/authentication) and, to find a store's number,
[Connecting a number](https://wa.genuka.com/en/docs/onboarding).

## Sources

* [Meta — Template categorization](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/template-categorization)
* [Meta — Time-to-live](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/time-to-live)
* [Meta — Send messages (customer service window)](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/send-messages)
* [Meta — Pricing](https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing)
* [Meta — Status messages webhook](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)
* [Meta — Error codes](https://developers.facebook.com/documentation/business-messaging/whatsapp/support/error-codes)
* [Meta — Getting opt-in](https://developers.facebook.com/documentation/business-messaging/whatsapp/getting-opt-in)
* [Meta — Partners (Tech Providers and Solution Partners)](https://developers.facebook.com/documentation/business-messaging/whatsapp/solution-providers/overview)
