# Send WhatsApp OTP codes from Node.js

URL: https://wa.genuka.com/en/docs/guides/whatsapp-otp-nodejs
Language: English

> Send WhatsApp one-time passwords from Node.js with Genuka WA: authentication template, copy-code button, expiry, verification and SMS fallback.

To send a WhatsApp one-time code from Node.js with Genuka WA, create an `AUTHENTICATION` template
with a copy-code button once, then call `POST /api/v1/messages` with the `otp` shorthand. Meta
writes the message text; you only supply the code, and your server owns expiry and verification.
The step people miss: the business must first pass Meta business verification (or another Meta
scaling path).

*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).
* Node.js 22 or later: a still-supported LTS line, with native `fetch`.
* A business that has passed **Meta business verification** (or another Meta scaling path). This
  is the requirement people find too late: without it Meta refuses to create the template, with an
  error message that blames the app rather than the business. The last section of this guide
  covers it.
* **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 template gets approved but every code comes back with error `131042` — see
  [error 131042](https://wa.genuka.com/en/docs/errors/131042).

## How does a WhatsApp OTP work?

A one-time code travels in an `AUTHENTICATION`-category template. Unlike the other categories, you
do not write its text: Meta supplies a preset body and inserts your code into it, and you only pick
the options around it
([Meta docs](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/authentication-templates/authentication-templates)).

| Part               | What you choose                              | Meta's rule                                                            |
| ------------------ | -------------------------------------------- | ---------------------------------------------------------------------- |
| Body               | Nothing — preset text, your code is inserted | No custom text                                                         |
| Security notice    | `add_security_recommendation: true`          | Optional                                                               |
| Footer             | `code_expiration_minutes`                    | 1 to 90 minutes                                                        |
| Button             | `OTP` with `otp_type: COPY_CODE`             | Label up to 25 characters                                              |
| Code               | Generated by your server                     | Up to 15 characters                                                    |
| Time-to-live (TTL) | `messageSendTtlSeconds`                      | Meta: 10 min by default, 30 s to 15 min; Genuka WA accepts 60 to 600 s |

URLs, media and emojis are not supported in this template type
([Meta docs, copy code button](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/authentication-templates/copy-code-button-authentication-templates)).

> [!NOTE]
> **Copy code, one-tap or zero-tap?**
>
> `COPY_CODE` works on every phone: the user taps the button and pastes the code. `ONE_TAP` and
> `ZERO_TAP` hand the code to your Android app and need its `package_name` and `signature_hash` —
> see the [API reference](https://wa.genuka.com/en/docs/api#templates). Start with `COPY_CODE`.

## How do I create the authentication template?

1. ### Submit the template

   One call, once. Note the language you choose: every send must use exactly the same one.

   **curl**

   ```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": "login_code",
       "language": "en",
       "category": "AUTHENTICATION",
       "messageSendTtlSeconds": 300,
       "components": [
         { "type": "BODY", "add_security_recommendation": true },
         { "type": "FOOTER", "code_expiration_minutes": 5 },
         { "type": "BUTTONS", "buttons": [
           { "type": "OTP", "otp_type": "COPY_CODE", "text": "Copy code" }
         ] }
       ]
     }'
   ```

   **Node.js**

   ```ts title="create-otp-template.ts"
   const response = await fetch("https://wa.genuka.com/api/v1/templates", {
     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,
       name: "login_code",
       language: "en",
       category: "AUTHENTICATION",
       // Past 5 minutes Meta stops trying to deliver: the code would have expired anyway.
       messageSendTtlSeconds: 300,
       components: [
         { type: "BODY", add_security_recommendation: true },
         { type: "FOOTER", code_expiration_minutes: 5 },
         {
           type: "BUTTONS",
           buttons: [{ type: "OTP", otp_type: "COPY_CODE", text: "Copy code" }],
         },
       ],
     }),
   });

   console.log(response.status, await response.json());
   ```

   The `201` response holds the stored template, with its `id` and `status`. In the dashboard, the
   same thing is **Templates** › **New template**, Authentication category, "Copy code" button.

2. ### Wait for approval

   Meta reviews the template. The verdict reaches you in three ways:

   * a `template_status` webhook whose `data.event` is `APPROVED` or `REJECTED`, if your endpoint
     subscribes to `template.status_changed` (or to every event);
   * `GET /api/v1/templates/{id}`, which returns the status and its history;
   * `POST /api/v1/templates/sync`, which re-reads statuses from Meta if a webhook went missing.

   Only an `approved` template can be sent.

## How do I send the code from Node.js?

The `otp` shorthand puts the code in both places Meta requires for an authentication template: the
body parameter and the button parameter. You do not write the `components` yourself.

**Node.js**

```ts title="otp.ts"
import crypto from "node:crypto";

const API = "https://wa.genuka.com/api/v1";
const CODE_TTL_MS = 5 * 60_000; // matches code_expiration_minutes and messageSendTtlSeconds
const MAX_ATTEMPTS = 5;

type OtpEntry = { codeHash: string; expiresAt: number; attempts: number; messageId: string };

/** The response body: `data` on success, otherwise `error` (plus `meta` if Meta refused). */
type ApiResult = {
  data?: { messageId: string };
  error?: string;
  message?: string;
  meta?: { errorClass?: string; retryable?: boolean; code?: number };
};

// In memory for the example. In production: Redis or your database, with the same expiry,
// or a second server will not know about the code the first one issued.
const store = new Map<string, OtpEntry>();

const hash = (phone: string, code: string) =>
  crypto.createHash("sha256").update(`${phone}:${code}`).digest("hex");

export async function sendOtp(phone: string): Promise<string> {
  // A cryptographic generator, never Math.random().
  const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");

  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: phone, // international format with the +, e.g. "+237699001122"
      // Always set `language`: without it the send targets "en_US".
      template: { name: "login_code", language: "en", otp: code },
    }),
  });

  // A 5xx from the CDN is not JSON: without this catch, the parse error would hide the status.
  const body = (await response.json().catch(() => ({}))) as ApiResult;
  if (!response.ok || !body.data) {
    // body.meta.errorClass tells you who can fix it: see "What if the code never arrives?"
    throw new Error(`OTP not sent: ${response.status} ${body.error ?? ""} — ${body.message ?? ""}`);
  }

  // A new code replaces the previous one: one valid code per number at a time.
  store.set(phone, {
    codeHash: hash(phone, code),
    expiresAt: Date.now() + CODE_TTL_MS,
    attempts: 0,
    messageId: body.data.messageId,
  });
  return body.data.messageId;
}
```

**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": "login_code", "language": "en", "otp": "482913" }
  }'
```

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

A `200` means Meta **accepted** the message, not that it arrived. Delivery is confirmed by webhook,
carrying the same `messageId`.

> [!WARNING]
> **The language must match exactly**
>
> To Meta, `en` and `en_US` are two distinct language codes
> ([supported languages](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/supported-languages)):
> a template approved in `en` does not exist in `en_US`. Sending in a language the template does
> not exist in, or is not approved in, fails with error `132001`
> ([Meta error codes](https://developers.facebook.com/documentation/business-messaging/whatsapp/support/error-codes)).

## How do I verify the code the user typed?

Verification never goes through WhatsApp: it happens entirely on your side.

```ts title="otp.ts (continued)"
export function verifyOtp(phone: string, input: string): boolean {
  const entry = store.get(phone);
  if (!entry || Date.now() > entry.expiresAt) return false;

  // Cap attempts: a 6-digit code falls to 1,000,000 guesses at worst, far fewer if nobody counts.
  if (entry.attempts >= MAX_ATTEMPTS) return false;
  entry.attempts += 1;

  // Two hex SHA-256 digests: same length, constant-time comparison.
  const ok = crypto.timingSafeEqual(
    Buffer.from(entry.codeHash, "hex"),
    Buffer.from(hash(phone, input.trim()), "hex"),
  );

  if (ok) store.delete(phone); // single use
  return ok;
}
```

Four rules hold it together: one code per number at a time, single use, a bounded number of
attempts, and an expiry decided by your server.

## How should I set expiry and time-to-live?

Three durations coexist. Set them to the same value.

| Duration                  | Where it is set | What it does                                |
| ------------------------- | --------------- | ------------------------------------------- |
| `code_expiration_minutes` | Template footer | What the user reads: "expires in 5 minutes" |
| `messageSendTtlSeconds`   | Template        | Past it, Meta stops trying to deliver       |
| Server expiry             | Your database   | The only one that counts when you verify    |

When a message cannot be delivered, WhatsApp retries for the template's time-to-live and then
drops it; if no `delivered` or `read` status arrived by then, treat the message as lost
([Meta docs, time-to-live](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/time-to-live)).
A code delivered after it expired is worse than one never delivered: the user types it, it is
refused, and they cannot tell why.

## What if the code never arrives?

Track the message by its `messageId` in your webhook receiver — see
[Receive replies and statuses via webhook](https://wa.genuka.com/en/docs/guides/receive-messages-webhooks) for signature
verification.

```ts title="In your webhook receiver (signature already verified)"
if (event.type === "message_status") {
  const { id: messageId, status, errors } = event.data;
  // `read` counts as delivered: Meta sometimes sends no `delivered` (see below).
  if (status === "delivered" || status === "read") await markOtpDelivered(messageId);
  if (status === "failed") await markOtpUndeliverable(messageId, errors?.[0]?.code);
}
```

Do not wait for `delivered` alone. When the user has the chat open as the message lands — the
usual case for a code they are waiting for — Meta treats it as delivered and read at once and
sends only `read`
([Meta docs, statuses](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)).

Then, in your UI:

* **Explicit failure**: offer another channel right away. Meta itself recommends letting users
  choose between WhatsApp, email and SMS
  ([Meta best practices](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/authentication-templates/authentication-best-practices)).
* **No `delivered` or `read` after about thirty seconds**: show "Get the code by SMS" rather than
  sending a second WhatsApp code to the same number.
* **Resend**: enforce a delay between requests, and invalidate the previous code on every send.

| Meta code | Meaning                                                             | Reaction                                                                                     |
| --------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `131026`  | Message undeliverable, for example a number without WhatsApp        | Switch to SMS, do not retry                                                                  |
| `131056`  | Too many messages to the same recipient in a short period           | Wait before resending                                                                        |
| `130429`  | Cloud API throughput reached                                        | Retry with growing backoff                                                                   |
| `132001`  | Template missing in that language or not approved                   | Fix the name or language                                                                     |
| `131042`  | Payment method problem on the client's Meta account: no code leaves | Switch to SMS; the client fixes billing at Meta — see [error 131042](https://wa.genuka.com/en/docs/errors/131042) |

When Meta refuses the send on the spot, or a template or number check blocks it before the call,
the response is a `400` (`409` for a configuration problem) whose `error` is `send_<class>`, with a
`meta` object carrying `errorClass` and Meta's `code`. The class settles it: `recipient_permanent`
(switch channel), `retryable` or `recipient_throttled` (try again later), `template` or `config`
(the problem is yours, not the user's — alert the team). Genuka WA's own refusals carry no
`meta`: read `error`. `400 missing_fields` and `400 recipient_is_sender` are bugs in the request.
The others block every code until the account is fixed: `402 subscription_past_due` (the trial or
the paid period has lapsed), `402 plan_limit_messages` (the period's allowance is used up),
`409 number_released`, `403 account_deactivated` and `404 connection_not_found`. On any of them,
send the code by SMS or email straight away and alert the team: the user is waiting at the sign-in
screen.

## Why does Meta refuse to create my authentication template?

Meta reserves the `AUTHENTICATION` category for accounts that meet two conditions: they have
completed one of its scaling paths — in practice Meta business verification or partner-led
verification — and they have a messaging limit of at least 2,000
([360dialog, authentication messages documentation](https://docs.360dialog.com/docs/resources/authentication-messages)).
The `MARKETING` and `UTILITY` categories are not affected.

Here is what we see on accounts connected to Genuka WA when the business is not verified:

* creation answers `meta_rejected` with Graph's message "Application does not have permission for
  this action". It names the app, but the cause is the client's business: no key or permission
  setting will change it;
* the WhatsApp Business account's `health_status` carries error **141010**, "The Business has not
  passed business verification".

The fix is to complete business verification in the client's Meta Business Suite, then create the
template again. Details are on the [error 141010](https://wa.genuka.com/en/docs/errors/141010) page.

You can also read a hint in `GET /api/v1/numbers/{connectionId}/health`: the `messagingLimitTier`
field. A new business portfolio starts at 250 unique recipients per 24 hours outside the customer
service window; business verification is one of the ways that takes it to 2,000
([Meta docs, messaging limits](https://developers.facebook.com/documentation/business-messaging/whatsapp/messaging-limits)).

## FAQ

### Can I customise the text of the OTP message?

No. The body of an authentication template is fixed by Meta; you only set the security notice, the
duration shown in the footer and the button label.

### How much does a WhatsApp OTP cost?

Meta charges for delivered templates by category and recipient country
([Meta pricing](https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing)),
billed straight to the client's WhatsApp Business account. Genuka WA adds no markup: each send
simply counts against your subscription's message allowance — see the [plans](https://wa.genuka.com/en#pricing).

### Does the user have to message me before receiving a code?

No: a template can be sent outside the 24-hour window. You do need the person's agreement, and
Meta sets two conditions: state clearly that they are opting in to receive messages from your
business, and name that business
([Meta docs, opt-in](https://developers.facebook.com/documentation/business-messaging/whatsapp/getting-opt-in)).
On the sign-in screen, a line such as "Get my sign-in code from \[Business name] on WhatsApp" next
to the phone number field covers both.

### What happens if the number has no WhatsApp account?

The status comes back `failed` with code `131026`. Do not retry on WhatsApp: offer SMS or email.

## Sources

* [Meta — Authentication templates](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/authentication-templates/authentication-templates)
* [Meta — Copy code authentication templates](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/authentication-templates/copy-code-button-authentication-templates)
* [Meta — Time-to-live](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/time-to-live)
* [Meta — Authentication best practices](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/authentication-templates/authentication-best-practices)
* [Meta — Supported languages](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/supported-languages)
* [Meta — Status messages webhook](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)
* [Meta — Getting opt-in](https://developers.facebook.com/documentation/business-messaging/whatsapp/getting-opt-in)
* [Meta — Error codes](https://developers.facebook.com/documentation/business-messaging/whatsapp/support/error-codes)
* [Meta — Partners (Tech Providers and Solution Partners)](https://developers.facebook.com/documentation/business-messaging/whatsapp/solution-providers/overview)
* [Meta — Messaging limits](https://developers.facebook.com/documentation/business-messaging/whatsapp/messaging-limits)
* [Meta — Pricing](https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing)
* [360dialog — Authentication messages](https://docs.360dialog.com/docs/resources/authentication-messages)
