waverify
v0.1.4
Published
BSP-agnostic WhatsApp OTP delivery and verification, with a Meta AUTHENTICATION template linter, mock simulator, and cost guard. Zero runtime dependencies.
Maintainers
Readme
WaVerify
BSP-agnostic WhatsApp OTP delivery and verification for Node.js — with a Meta AUTHENTICATION template linter, real Cloud API sending, webhook signature verification, SMS fallback, a cost guard, and a CLI. Zero runtime dependencies.
Send and verify a one-time code over WhatsApp behind two calls: start() and verify().
📖 Live Docs & Interactive Playground →
Why
Sending WhatsApp OTPs correctly is deceptively fiddly. Meta forces a fixed, non-editable authentication-template shape, bans URLs/media/emojis, requires a specific OTP button, and expects the code to appear twice in every send payload — get any of it wrong and your message is rejected. On top of that you need secure code handling, single-use verification, attempt caps, spend limits, and a trustworthy way to process delivery webhooks. WaVerify packages all of that so you don't have to rediscover each gotcha yourself.
Install
npm install waverifyRequires Node 18+ (uses built-in fetch and crypto). Zero runtime dependencies.
Quick start
import { WaVerify, CloudApiAdapter, getTemplate } from "waverify";
const wa = new WaVerify({
adapter: new CloudApiAdapter({
phoneNumberId: process.env.WA_PHONE_NUMBER_ID!,
accessToken: process.env.WA_ACCESS_TOKEN!,
}),
secret: process.env.WAVERIFY_SECRET!,
});
// 1. Issue + deliver a code over WhatsApp
const { requestId } = await wa.start("+919876543210", getTemplate("copy-code")!);
// 2. Later, check what the user typed
const result = await wa.verify("+919876543210", requestId, userInput);
if (result.valid) {
// authenticated
}No credentials yet? Swap CloudApiAdapter for MockAdapter and the whole flow runs in-process with no network, no cost, and deterministic behaviour — ideal for tests and local development.
Features
- Real WhatsApp sending via Meta's Cloud API (v23.0), with the exact authentication-template payload built for you — including the two quirks that trip everyone up (code appears twice; OTP button sent as
sub_type: "url"). - Template linter — validate an AUTHENTICATION template against Meta's rules before you submit it for approval.
- Mock simulator — build and test your entire flow with zero network and zero credentials.
- Webhook verification — constant-time signature checks and clean, typed delivery events.
- SMS fallback — degrade to SMS when WhatsApp can't deliver, at send time or when a webhook reports failure.
- Cost guard — per-destination rate limits and a global budget cap stop runaway spend and abuse.
- CLI — lint templates and preview payloads from the terminal.
- Zero runtime dependencies — only Node's built-in modules.
Lint a template before you submit it to Meta
import { lintAuthTemplate } from "waverify";
const { ok, issues } = lintAuthTemplate(template);
// issues: [{ level, code, message }, ...]The linter encodes Meta's authentication-template rules: mandatory OTP button (COPY_CODE / ONE_TAP / ZERO_TAP), one-tap/zero-tap needing an Android package name + signature hash, the expiry range (1–90 minutes), and the URL/media/emoji ban.
Real messages (Meta Cloud API)
CloudApiAdapter sends genuine WhatsApp messages and builds Meta's exact payload for you.
import { WaVerify, CloudApiAdapter } from "waverify";
const wa = new WaVerify({
adapter: new CloudApiAdapter({
phoneNumberId: process.env.WA_PHONE_NUMBER_ID!,
accessToken: process.env.WA_ACCESS_TOKEN!,
// apiVersion defaults to "v23.0"
}),
secret: process.env.WAVERIFY_SECRET!,
});Getting credentials (free)
- Create a Meta app at developers.facebook.com and add the WhatsApp product.
- In WhatsApp → API Setup you get a test phone number, a Phone Number ID, and a temporary 24-hour token — enough to message your own verified number immediately.
- Create an AUTHENTICATION template in WhatsApp Manager and wait for approval (lint it first).
- For production, generate a permanent token via a System User with the
whatsapp_business_messagingpermission.
CLI
A zero-dependency CLI, available via npx:
npx waverify templates # list built-in template presets
npx waverify template copy-code # print a preset as JSON
npx waverify template copy-code > otp.json # save it to customize
npx waverify lint otp.json # validate against Meta's rules (exit 0/1)
npx waverify preview otp.json --code 483920 # print the Cloud API send payloadlint exits non-zero on errors, so it drops straight into CI to catch a bad template before you submit it to Meta.
Built-in templates
The same presets are available in code:
import { getTemplate, listTemplates } from "waverify";
listTemplates(); // ["copy-code", "copy-code-minimal", "one-tap", "hindi"]
const template = getTemplate("copy-code"); // an independent, mutable copyEvery preset passes lintAuthTemplate. Rename name to match the template you register in WhatsApp Manager, and for one-tap fill in your real Android packageName and signatureHash.
Delivery receipts & webhook verification
Meta POSTs delivery/read/failure updates to a webhook you host. WaVerify verifies those requests and hands you clean, typed status events.
Next.js App Router (no extra dependencies)
// app/api/whatsapp/webhook/route.ts
import { createWebhookHandler } from "waverify";
const handler = createWebhookHandler({
verifyToken: process.env.WA_VERIFY_TOKEN!,
appSecret: process.env.WA_APP_SECRET!,
onStatus: (e) => {
// e.status is "sent" | "delivered" | "read" | "failed"
if (e.status === "failed") {
console.error("Undeliverable:", e.messageId, e.errors);
}
},
});
export const GET = (req: Request) => handler(req); // handshake
export const POST = (req: Request) => handler(req); // signed eventsThe handler is built on the Web-standard Request/Response API, so it also works unchanged in any Fetch-based runtime (edge functions, Hono, Bun, Deno).
Express (use the pure helpers)
Keep the raw body — signature verification fails against re-serialized JSON:
import express from "express";
import { verifySignature, parseStatusEvents } from "waverify";
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifySignature({
rawBody: req.body, // Buffer, untouched
signatureHeader: req.headers["x-hub-signature-256"] as string,
appSecret: process.env.WA_APP_SECRET!,
});
if (!ok) return res.sendStatus(401);
for (const e of parseStatusEvents(JSON.parse(req.body.toString("utf8")))) {
// handle e
}
res.sendStatus(200);
});Security note: signatures are HMAC-SHA256 over the raw request bytes with your App Secret. WaVerify's helpers only accept the raw body and compare in constant time, so the most common webhook vulnerability — verifying a parsed-then-re-stringified body, or skipping verification entirely — can't happen.
SMS fallback
Give WaVerify an SMS adapter and it degrades gracefully when WhatsApp can't deliver.
import { WaVerify, CloudApiAdapter, MockSmsAdapter } from "waverify";
const wa = new WaVerify({
adapter: new CloudApiAdapter({ /* ... */ }),
smsAdapter: new MockSmsAdapter(), // bring your own SMS provider
smsText: "{{code}} is your verification code.",
secret: process.env.WAVERIFY_SECRET!,
});
const res = await wa.start("+919876543210", template);
if (res.usedFallback) {
// WhatsApp failed at send time; the SAME code went out over SMS instead.
}Two fallback moments, both handled:
- Synchronous — if the WhatsApp send throws,
start()retries over SMS with the same code and setsusedFallback: true. SetsmsFallback: falseto opt out. - Asynchronous — if Meta accepts the message but a webhook later reports
failed(e.g. the recipient has no WhatsApp), callresendViaSms()from youronStatushandler. It issues a fresh code (plaintext is never stored, so the original can't be re-sent) and refreshes the challenge.
Bring any SMS provider by implementing the one-method SmsAdapter interface. MockSmsAdapter ships for tests and simulation; a first-party Twilio SMS adapter is on the roadmap.
Architecture
Every piece is an interface with an in-the-box default, so each is swappable:
| Concern | Contract | Default |
|--------------------|-------------------|----------------------|
| Delivery | WhatsAppAdapter | MockAdapter |
| SMS fallback | SmsAdapter | MockSmsAdapter |
| Challenge storage | OtpStore | InMemoryOtpStore |
| Spend / abuse | CostGuard | sane in-memory caps |
Codes are generated with a CSPRNG and stored only as an HMAC — plaintext never touches the store. Verification is single-use, time-boxed, attempt-capped, and uses constant-time comparison.
Development
These scripts are for working on WaVerify itself (after cloning the repo):
npm test # node:test unit suite (no credentials needed — fetch is mocked)
npm run smoke # end-to-end pipeline through the mock adapter
npm run build # emit dist/Roadmap
Shipping today: OTP engine, template linter, mock simulator, Meta Cloud API adapter, webhook signature verification, SMS fallback, cost guard, built-in templates, and CLI.
Planned:
- First-party Twilio adapter (WhatsApp + SMS)
- Shared Postgres/Redis store and cost guard for multi-instance deployments
- Hardening: request timeouts, retry-on-transient, and webhook event de-duplication
License
MIT
