Genuka WA docs

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.

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.
  • An API key, used server-side only — see 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). Without one, the template gets approved but every code comes back with error 131042 — see error 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).

PartWhat you chooseMeta's rule
BodyNothing — preset text, your code is insertedNo custom text
Security noticeadd_security_recommendation: trueOptional
Footercode_expiration_minutes1 to 90 minutes
ButtonOTP with otp_type: COPY_CODELabel up to 25 characters
CodeGenerated by your serverUp to 15 characters
Time-to-live (TTL)messageSendTtlSecondsMeta: 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).

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. Start with COPY_CODE.

How do I create the authentication template?

Submit the template

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

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" }
      ] }
    ]
  }'

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.

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.

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

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

The language must match exactly

To Meta, en and en_US are two distinct language codes (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).

How do I verify the code the user typed?

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

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.

DurationWhere it is setWhat it does
code_expiration_minutesTemplate footerWhat the user reads: "expires in 5 minutes"
messageSendTtlSecondsTemplatePast it, Meta stops trying to deliver
Server expiryYour databaseThe 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). 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 for signature verification.

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

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).
  • 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 codeMeaningReaction
131026Message undeliverable, for example a number without WhatsAppSwitch to SMS, do not retry
131056Too many messages to the same recipient in a short periodWait before resending
130429Cloud API throughput reachedRetry with growing backoff
132001Template missing in that language or not approvedFix the name or language
131042Payment method problem on the client's Meta account: no code leavesSwitch to SMS; the client fixes billing at Meta — see error 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). 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 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).

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

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

On this page