bananaleaf.ai · Developer docs

WhatsApp OTP API

Log people in to your app or website with a one-time code sent on WhatsApp, from your own business number. You call three endpoints from your server — send, verify, resend — and we take care of the WhatsApp message, the code, its expiry, retries and abuse limits.

View as Markdown
 Your app ──▶ Your server ──(API key)──▶ api.bananaleaf.ai/v1/otp/*  ──▶  WhatsApp ──▶ the person's phone
                   ▲                                                              │
                   └────────────────── they type the code into your app ◀────────┘
  • Base URL: https://api.bananaleaf.ai
  • Format: JSON in, JSON out
  • Auth: an API key in the Authorization header, from your server only

1. Before you start

Everything lives in the Developer console. In Bananaleaf, click Developers in the sidebar and the console opens in its own tab. Login codes are under Build → Login codes; keys, webhooks and activity are under Manage.

You need a WhatsApp number connected to your Bananaleaf account, and a WhatsApp balance (each delivered code is billed — see Pricing).

The Login codes playground

The strip at the top checks everything the API needs and says what to do about anything missing:

Check Ready when If not
Number a WhatsApp number is connected Connect WhatsApp opens Bananaleaf in a new tab
Code message a code message is approved by Meta Create it, or Check with Meta now while it is in review
API key there is a live key Create a key (see Your API key)
Webhook optional Set up (see Webhooks)

Create the code message

WhatsApp only lets a business send login codes with a message Meta has approved, and Meta writes the words (“482913 is your verification code.”). Until you have one, the playground is the form that creates it. You choose:

  • Name — your app sends this with every code, e.g. login_code;
  • Language — Meta writes the message in it;
  • Code lasts — 1 to 90 minutes; after that the code stops working;
  • Button — the label of the copy button;
  • the optional “For your security, do not share this code.” line.

The preview on the right is exactly what people receive. Press Send to Meta.

Creating the code message, with a live preview
Creating the code message, with a live preview

Approval usually takes a few minutes. While Meta reviews it the playground says so, and Check with Meta now refreshes it. If Meta rejects it, its reason is shown and you can create another. New code message (top of the playground) adds more later — your app names the one it wants on every call.

Try it on your phone

Once the code message is approved, enter your own WhatsApp number and press Send code. Type the code you receive and press Verify — the same path your app will use. Resend appears after 30 seconds (twice at most); each resend is a new code and the old one stops working.

A code sent, waiting to be typed in, resend counting down
A code sent, waiting to be typed in, resend counting down

Code (right-hand side) shows the three calls — send, verify, resend — in cURL, Node.js or Python, filled in with your template and the request you just made. Responses lists every call with the JSON your server would get back.

The playground, ready to send
The playground, ready to send

Digits in a code (under the playground) is the length used when your app doesn't send length.

API keys, webhooks and activity

  • Manage → API keys — create, copy and revoke keys. One key works for both login codes and the WhatsApp API. Keys made before 15 October 2026 have no copy button: only a fingerprint of them was ever kept; create a new one if you've lost it.
  • Manage → Webhooks — your server's URL, the events you want and the signing secret (see Webhooks).
  • Manage → Activity → Login codes — the last 14 days: codes sent, delivered and verified, the verified rate and the spend, and every code with its status. Click a row for the full record.
Activity: totals and every code, a click from its full record
Activity: totals and every code, a click from its full record

2. Your API key

Authorization: Bearer bl_live_ab12cd34_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Key Starts with What it does
Live bl_live_ Sends real WhatsApp codes. Billed.
Test bl_test_ Goes through every check and limit but sends nothing. The code is always all zeros (000000, or 0000 for length 4). Works even before Meta approves your template.
  • You can copy a key again under Developers → API keys any time. It is stored encrypted, and every copy is recorded. Keys made before 15 October 2026 can't be copied again.
  • Revoke a key on the same page; anything using it stops working immediately.
  • An account can hold up to 10 active keys — e.g. one per app or environment.
  • A missing, wrong or revoked key gets 401 with {"detail": "Invalid or missing API key."}.

Keep the key on your server. Never put it in a website's JavaScript or a mobile app. Anyone who finds it can send codes to strangers from your number, and Meta locks WhatsApp numbers that are used that way.

3. The login flow

  1. The person enters their phone number in your app. Your server calls POST /v1/otp/send with the number and your template's name, and keeps the request_id it gets back (in the user's session, not in the browser).
  2. They receive the code on WhatsApp and type it into your app. Your server calls POST /v1/otp/verify with the request_id and the code.
  3. If the reply says "verified": true, the number is theirs — log them in. The reply's phone is the verified number; use that, not the number the browser sent.
  4. Didn't get it? Show a Resend button after 30 seconds. It calls POST /v1/otp/resend — a new code; the previous one stops working. Twice at most.
  5. (Optional) If another of your services must confirm the login, hand it the token from step 3; it calls POST /v1/otp/token/verify once to get the phone back.

4. Endpoints

POST /v1/otp/send — send a code

Request

Field Type Required Notes
phone string yes Full international number: +919876543210. Spaces, dashes and brackets are ignored; 00 prefix is accepted. A number without a country code is refused.
template string yes The name of your approved code template, e.g. login_code. Always required — your app chooses which message is sent.
language string no Only checks the template's language (e.g. en_US). Leave it out normally.
length integer no Digits in the code, 4–8. Default 6 (changeable under Build → Login codes → Digits in a code).
expiry_minutes integer no 1–90. Ignored when the template has its own expiry line — the message tells the person when the code expires, so the code always matches it.
client_ip string no The end user's IP as your server saw it. Turns on a per-IP limit (20 codes an hour).
metadata object no Up to 20 keys of your own (e.g. {"user_id": "u_42"}), echoed back in webhooks.

Header (optional): Idempotency-Key: <your unique id> — if your server retries the same send within 10 minutes, you get the same request back and no second message goes out. Reusing the key for a different phone is 409.

Reply 200

{
  "request_id": "otp_Xmurq54EpNUI35GGYERxebeH",
  "phone": "+919876543210",
  "status": "sent",
  "channel": "whatsapp",
  "expires_at": "2026-10-14T11:20:00+00:00",
  "attempts_left": 5,
  "resends_left": 2,
  "delivered_at": null,
  "verified_at": null,
  "error": null,
  "test": false,
  "created_at": "2026-10-14T11:10:00+00:00"
}

Examples

curl -X POST https://api.bananaleaf.ai/v1/otp/send \
  -H "Authorization: Bearer $BANANALEAF_KEY" -H "Content-Type: application/json" \
  -d '{"phone": "+919876543210", "template": "login_code"}'
// Node.js 18+
const res = await fetch("https://api.bananaleaf.ai/v1/otp/send", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.BANANALEAF_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ phone: "+919876543210", template: "login_code" }),
});
const { request_id } = await res.json();
import os, requests
H = {"Authorization": f"Bearer {os.environ['BANANALEAF_KEY']}"}
r = requests.post("https://api.bananaleaf.ai/v1/otp/send", headers=H,
                  json={"phone": "+919876543210", "template": "login_code"})
request_id = r.json()["request_id"]

POST /v1/otp/verify — check the code

Request

Field Type Required Notes
request_id string yes From send.
code string yes What the person typed, 4–8 digits.

Reply 200 — right code

{
  "verified": true,
  "request_id": "otp_Xmurq54EpNUI35GGYERxebeH",
  "phone": "+919876543210",
  "verified_at": "2026-10-14T11:11:02+00:00",
  "token": "eyJhbGciOiJIUzI1NiIs…",
  "token_expires_in": 300
}

Reply 200 — wrong code (not an error; show it to the person)

{ "verified": false, "request_id": "otp_…", "attempts_left": 4 }

After 5 wrong codes the request is dead (429 too_many_attempts); start again with send. A code can be verified once — a second verify is 409 already_verified.

curl -X POST https://api.bananaleaf.ai/v1/otp/verify \
  -H "Authorization: Bearer $BANANALEAF_KEY" -H "Content-Type: application/json" \
  -d '{"request_id": "otp_Xmurq54EpNUI35GGYERxebeH", "code": "482913"}'

POST /v1/otp/resend — a new code

Request: {"request_id": "otp_…"}

  • A new code is sent; the previous one stops working.
  • The expiry restarts and wrong-code attempts reset to 5.
  • At least 30 seconds after the last send (429 resend_too_soon with retry_after), and twice at most per request (429 resend_limit).

Reply 200: the same shape as send, with resends_left counting down.

curl -X POST https://api.bananaleaf.ai/v1/otp/resend \
  -H "Authorization: Bearer $BANANALEAF_KEY" -H "Content-Type: application/json" \
  -d '{"request_id": "otp_Xmurq54EpNUI35GGYERxebeH"}'

POST /v1/otp/token/verify — redeem the proof (optional)

For when one service checks the code and another does the login. The token from a successful verify is a signed proof, valid 5 minutes, usable once.

Request: {"token": "eyJ…"} — any key of the same Bananaleaf account may redeem it.

Reply 200

{ "valid": true, "phone": "+919876543210", "request_id": "otp_…", "verified_at": "…" }

A second redeem is 409 token_used; an old one is 410 token_expired; anything else is 401 invalid_token.

GET /v1/otp/{request_id} — status

Returns the same shape as send, current: status is one of sent, delivered, read, verified, expired, failed. Handy for debugging; you don't need it for logins.

5. Rules and limits

Rule Value
Code length 4–8 digits, default 6, random for every code
Code lifetime The template's expiry (1–90 min), else expiry_minutes, else 10 min
Undelivered codes WhatsApp drops a code it couldn't deliver within its lifetime (max 15 min), so nobody receives a dead code
Wrong codes 5 per request
Resends 2 per request, 30 s apart
Codes to one number 3 per 10 minutes, 6 per hour
Codes per end-user IP 20 per hour (only when you send client_ip)
Calls per API key 60 per minute
Codes per account 500 per hour. Past that, sending pauses for an hour (503 otp_paused) and your account's admins get a push and an email — a sudden flood is almost always a leaked key, and it protects your number from being locked by Meta
Request history 7 days

Every 429 carries retry_after (seconds) in the body and a Retry-After header.

6. Errors

Errors from the OTP API look like this:

{ "error": "resend_too_soon", "message": "Wait a little before resending.", "retry_after": 18 }

Use error in your code; message is plain English you may show. Two exceptions come from the web framework itself and use its shape: a bad API key (401 {"detail": "Invalid or missing API key."}) and a malformed body, e.g. a missing phone (422 {"detail": [ … ]}).

HTTP error What it means What to do
400 invalid_phone Not a full international number Ask for the number with country code
400 template_required No template in the call. The reply lists your approved templates in templates Add "template": "<name>"
400 invalid_length · invalid_expiry · invalid_code · invalid_channel A value is out of range Fix the request
402 insufficient_balance WhatsApp balance too low Top up in Bananaleaf
404 template_not_found No code template with that name Check the name under Build → Login codes
404 language_not_found The template has no version in that language (reply lists languages) Drop language, or use one listed
404 not_found Unknown request_id (or another account's) Start again with send
409 template_not_approved Meta hasn't approved the template yet Wait, or use a test key meanwhile
409 template_ambiguous The same template name exists on two of your WhatsApp numbers and neither is the default Rename one
409 already_verified That code was already used Start again with send
409 idempotency_key_reused Same Idempotency-Key for a different phone Use a new key per send
409 token_used The proof token was already redeemed —
410 expired The code ran out of time Start again with send
410 failed The code was never delivered Start again with send
410 token_expired The proof token is older than 5 minutes Verify again
401 invalid_token Not a token from your account —
422 not_on_whatsapp The number can't receive WhatsApp messages Tell the person
422 test_number A reserved test-range number Use a real number
429 rate_limited Too many codes to this number / IP / key Wait retry_after
429 recipient_rate_limited WhatsApp is limiting messages to this number Wait a little
429 resend_too_soon Resend within 30 s Wait retry_after
429 resend_limit Two resends used Start again with send
429 too_many_attempts Five wrong codes Start again with send
500 template_misconfigured The template and the message don't match Contact us
502 send_failed WhatsApp didn't accept the message Retry once; contact us if it persists
503 otp_paused The hourly cap tripped — sending is paused for an hour Check your keys haven't leaked
503 throughput_limited WhatsApp's sending speed limit for your number Retry shortly
503 whatsapp_disconnected Your WhatsApp connection needs reconnecting Reconnect in Bananaleaf
503 whatsapp_account_locked · number_quality_blocked Meta has restricted your number Contact us

7. Webhooks (optional)

You don't need webhooks to log people in — verify tells you everything. They are for dashboards and support: we POST to your URL when a code is delivered, verified, failed or expired.

Set the URL under Developers → Webhooks (the Login codes card). It must be https:// on a public address. Tick the events you want, press Save, and the card shows your signing secret (whsec_…). Send test event posts an otp.test event to your URL right away; Rotate replaces the secret.

Webhook settings — URL, events and the signing secret
Webhook settings — URL, events and the signing secret

The request we send

POST /your/webhook HTTP/1.1
Host: yourapp.com
Content-Type: application/json
User-Agent: Bananaleaf-OTP-Webhooks/1
X-Bananaleaf-Signature: t=1760440262,v1=5f2b9a0c…c9

{"channel":"whatsapp","created_at":"2026-10-14T11:10:00+00:00","delivered_at":"2026-10-14T11:10:03+00:00","error":null,"event":"otp.verified","metadata":{"user_id":"u_42"},"phone":"+919876543210","request_id":"otp_Xmurq54EpNUI35GGYERxebeH","sent_at":"2026-10-14T11:10:41+00:00","status":"verified","verified_at":"2026-10-14T11:10:41+00:00"}

The body is compact JSON with its keys in alphabetical order. Check the signature against the raw bytes you received, before parsing them.

The events

event Sent when status in the body
otp.delivered WhatsApp confirms the code reached the person's phone. Once per code — a later read receipt doesn't send it again. delivered (or read, if the read receipt arrived first)
otp.verified Your server called verify with the right code. verified
otp.failed WhatsApp refused the message, or reported it couldn't deliver it. error says why. failed
otp.expired The code's lifetime ran out before it was verified. expired
otp.test You pressed Send test event under Developers → Webhooks. test

A code that is verified is never also reported as expired; a resend doesn't send events of its own (the request keeps its request_id).

The fields

Field Type What it is
event string One of the events above.
request_id string The code request, as returned by send. Use it to match the event to your login attempt.
phone string The number the code was sent to, + and digits.
status string The request's status when the event happened: sent, delivered, read, verified, expired, failed (or test).
channel string whatsapp.
error string or null On otp.failed, the reason as an error code — e.g. not_on_whatsapp, recipient_rate_limited, send_failed, whatsapp_account_locked. Otherwise null.
metadata object Whatever you passed as metadata to send (empty object if nothing).
delivered_at string or null When WhatsApp delivered it (ISO 8601, UTC).
verified_at string or null When it was verified.
created_at string When send was called.
sent_at string When this event happened. A retry carries the same value; only the signature's t is new.

One example of each

otp.delivered

{
  "event": "otp.delivered",
  "request_id": "otp_Xmurq54EpNUI35GGYERxebeH",
  "phone": "+919876543210",
  "status": "delivered",
  "channel": "whatsapp",
  "error": null,
  "metadata": { "user_id": "u_42" },
  "delivered_at": "2026-10-14T11:10:03+00:00",
  "verified_at": null,
  "created_at": "2026-10-14T11:10:00+00:00",
  "sent_at": "2026-10-14T11:10:03+00:00"
}

otp.verified

{
  "event": "otp.verified",
  "request_id": "otp_Xmurq54EpNUI35GGYERxebeH",
  "phone": "+919876543210",
  "status": "verified",
  "channel": "whatsapp",
  "error": null,
  "metadata": { "user_id": "u_42" },
  "delivered_at": "2026-10-14T11:10:03+00:00",
  "verified_at": "2026-10-14T11:10:41+00:00",
  "created_at": "2026-10-14T11:10:00+00:00",
  "sent_at": "2026-10-14T11:10:41+00:00"
}

otp.failed

{
  "event": "otp.failed",
  "request_id": "otp_7hQm2LkVc9RtYw3ZpN8sDfEa",
  "phone": "+918800123456",
  "status": "failed",
  "channel": "whatsapp",
  "error": "not_on_whatsapp",
  "metadata": {},
  "delivered_at": null,
  "verified_at": null,
  "created_at": "2026-10-14T11:20:00+00:00",
  "sent_at": "2026-10-14T11:20:02+00:00"
}

otp.expired

{
  "event": "otp.expired",
  "request_id": "otp_Lk3pQ8vNw2XyZa7RtB5cMdHe",
  "phone": "+919008055432",
  "status": "expired",
  "channel": "whatsapp",
  "error": null,
  "metadata": { "user_id": "u_77" },
  "delivered_at": "2026-10-14T11:30:04+00:00",
  "verified_at": null,
  "created_at": "2026-10-14T11:30:00+00:00",
  "sent_at": "2026-10-14T11:40:01+00:00"
}

otp.test — from Send test event under Developers → Webhooks; only these fields:

{
  "event": "otp.test",
  "request_id": "otp_test",
  "phone": "+910000000000",
  "status": "test",
  "sent_at": "2026-10-14T11:45:00+00:00"
}

The code itself is never in a webhook.

Check the signature

HMAC-SHA256 of "<t>.<raw body>" with your secret, and reject anything older than 5 minutes:

import crypto from "node:crypto";
function verifyBananaleaf(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ""));
}
import hmac, hashlib, time
def verify_bananaleaf(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Answering, and retries

Answer with any 2xx within 10 seconds. Anything else is retried after 10 s, 1 min, 5 min, 30 min, 2 h and 6 h, then dropped. An event is sent at most once per code per type, but a retry can arrive after a later event — order by the timestamps, not by arrival.

8. Testing without sending

Create a test key (bl_test_…) under Developers → API keys:

  • every rule and limit applies, but nothing is sent and nothing is billed;
  • the code is always zeros (000000);
  • it works while your template is still in Meta's review (the template must exist);
  • webhooks still fire, so you can test those too.

Switch to a live key when you go live — no other change.

9. Complete example

We have a complete, working login built on this API (Node.js + Express, users and sessions in SQLite — phone → code → logged in, with resend, logout and 14 tests); ask us for it. The core of it:

// POST /auth/start  — send the code, remember the request on the server side
app.post("/auth/start", async (req, res) => {
  const r = await otp("/v1/otp/send", { phone: req.body.phone, template: "login_code", client_ip: req.ip });
  req.session.otp = { requestId: r.request_id, phone: r.phone };   // NOT in the browser
  res.json({ phone: r.phone, resends_left: r.resends_left });
});

// POST /auth/verify — check the code, log in the number WE were told is verified
app.post("/auth/verify", async (req, res) => {
  const p = req.session.otp;
  const v = await otp("/v1/otp/verify", { request_id: p.requestId, code: req.body.code });
  if (!v.verified) return res.status(401).json({ attempts_left: v.attempts_left });
  const user = await users.findOrCreateByPhone(v.phone);           // v.phone, not req.body
  req.session.userId = user.id;
  delete req.session.otp;
  res.json({ user });
});

// POST /auth/resend
app.post("/auth/resend", async (req, res) => {
  const r = await otp("/v1/otp/resend", { request_id: req.session.otp.requestId });
  res.json({ resends_left: r.resends_left });
});

async function otp(path, body) {
  const res = await fetch(`https://api.bananaleaf.ai${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.BANANALEAF_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok) throw Object.assign(new Error(data.message || data.detail), { status: res.status, code: data.error });
  return data;
}

10. Security checklist

  • The API key lives in your server's environment, never in a browser or app.
  • The request_id is kept server-side (session or a signed, httpOnly cookie) — not something the browser can swap.
  • You log in the phone from the verify reply, not the one the browser sent.
  • You pass client_ip so one visitor can't fire codes at many numbers.
  • Your own login endpoints are rate-limited too.
  • Webhook signatures are checked, and old timestamps rejected.
  • A leaked key is revoked at once (Developers → API keys → Revoke).

11. Pricing

Each delivered code is billed: Meta's authentication price for the recipient's country plus a small Bananaleaf fee (in India about ₹0.22 per code). A code sent to another country from an Indian number is billed at Meta's higher authentication-international price for that country. Codes that fail, and everything sent with a test key, are free. Charges come out of your WhatsApp balance; Developers → Activity → Login codes shows the spend.

12. FAQ

The code didn't arrive on my laptop. Login codes are delivered only to the phone itself; WhatsApp Web and Desktop show them hidden.

Can I send a code to my business's own WhatsApp number? No — a number can't message itself.

Can the code autofill? On iPhones with iOS 26 or later, the keyboard offers the code from the notification automatically. Android one-tap/zero-tap autofill is not available yet.

Can I change the wording? No — Meta fixes it. You choose the language, the expiry line, the safety line and the button label.

One template in two languages? Create one per language with its own name (login_code, login_code_hi) and send the one that matches the person.

What happens if the same person asks again before using the first code? Call send again — that's a new request with a new code; the old one simply expires. Use resend when you want the same request (and its limits).

BananaLeaf AI (bananaleaf.ai) is the WhatsApp AI sales agent and CRM for Indian businesses, built by Krishav Technologies Pvt. Ltd., Hyderabad.© 2026 bananaleaf.aiPrivacy PolicyTerms & ConditionsRefund Policy