Genuka WA docs

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:

StepTemplateBody variablesButton
Order confirmedorder_confirmedfirst name, number, amountURL "View my order"
Order shippedorder_shippedfirst name, number, carrierURL "Track my parcel"
Order deliveredorder_deliveredfirst name, numberNone: 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.

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.

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.

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

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:

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

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:

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.

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

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.

Which errors will I run into most?

ErrorWhere to read itMeaningWhat to do
400 missing_fieldsResponseconnectionId or to missingComplete the request
400 recipient_is_senderResponseto is the sending number itselfChange the recipient
402 subscription_past_dueResponseThe trial or the paid period has lapsed: nothing is sent until it is renewedRenew the plan, then resend what notifyOnce filed as failed
402 plan_limit_messagesResponseThe period's message allowance is used upAdd numbers or change plan
404 connection_not_foundResponseThe connectionId does not exist or is outside your key's scopeCheck the id and the key
409 number_releasedResponseThe number was released from your planReconnect it through Embedded Signup
403 account_deactivatedResponseThe client that owns the number is suspendedRestore the client from its page in the dashboard
132001meta.code or failed statusTemplate missing in that language, or not approvedCheck name, language and status
132000meta.code or failed statusVariable count differs from the template'sMatch body to {{1}}…{{n}}
131026failed statusMessage undeliverable, e.g. number without WhatsAppFall back to email or SMS
131047failed status, sometimes never receivedFree-form text sent more than 24 h after the customer's last messageSend a template; act on the response's warning
130429meta.codeCloud API throughput reachedRetry with growing backoff
131042meta.code or failed statusPayment method problem on the Meta accountThe 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.

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

On this page