Once a WhatsApp platform has customers, they want to connect it to everything else: a CRM that adds contacts, a store that sends order updates, a script that pulls analytics into a spreadsheet. That means a public API, and a public API on top of WhatsApp has a few properties a normal CRUD API doesn't: every send can cost money, a duplicate message lands in a real customer's chat, and one noisy integration shouldn't use up an account's whole budget. This post walks through the decisions we made for WAPGROWAI's public API, the ones we'd make differently, and small TypeScript versions of each pattern with tests.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.
The request pipeline
Every public request goes through the same steps, in this order:
- Authenticate the token.
- Check the scope for this endpoint.
- Check the limits: a short-window rate limit and a monthly cap.
- Validate the body.
- Run the handler, at most once per idempotency key.
- Log and count the call, whatever the outcome.
The order is deliberate. A revoked token or a missing scope fails before it touches the rate limiter, so a misconfigured integration gets a clear 401 or 403 instead of a confusing 429. And the call is counted after the response, so failed calls are visible in usage too.
Tokens: store the hash, show the secret once
import { createHash, randomBytes } from "node:crypto"; import { ApiError } from "./errors.ts"; const PREFIX = "wa_live_"; export type StoredToken = { id: string; accountId: string; hash: string; displayPrefix: string; scopes: string[]; revokedAt?: Date; expiresAt?: Date; }; export const hashToken = (raw: string) => createHash("sha256").update(raw).digest("hex"); // The raw token is shown once; only its hash and a short display hint are stored. export const createToken = (id: string, accountId: string, scopes: string[]) => { const secret = randomBytes(32).toString("base64url"); const raw = `${PREFIX}${secret}`; const stored: StoredToken = { id, accountId, hash: hashToken(raw), displayPrefix: `${PREFIX}${secret.slice(0, 8)}`, scopes }; return { raw, stored }; }; export const authenticate = (header: string | undefined, findByHash: (hash: string) => StoredToken | undefined, now = new Date()) => { if (!header?.startsWith("Bearer ")) throw new ApiError(401, "TOKEN_MISSING", "Use: Authorization: Bearer <token>."); const token = findByHash(hashToken(header.slice(7).trim())); if (!token) throw new ApiError(401, "TOKEN_INVALID", "Invalid API token."); if (token.revokedAt) throw new ApiError(401, "TOKEN_REVOKED", "This API token has been revoked."); if (token.expiresAt && token.expiresAt <= now) throw new ApiError(401, "TOKEN_EXPIRED", "This API token has expired."); return token; }; export const requireScope = (token: StoredToken, scope: string) => { if (!token.scopes.includes(scope)) { throw new ApiError(403, "INSUFFICIENT_SCOPE", `This API token is missing the required scope: ${scope}.`); } }; - Only a SHA-256 hash is stored. The token is looked up by its hash, so there is nothing to decrypt and no secret to compare. A database leak doesn't hand out working tokens.
- A short, non-secret prefix is kept for display, so the settings page can show which token is which ("wa_live_3fK9aQ2x…") without storing the secret.
- Missing, invalid, revoked and expired get different codes. All are 401, but "revoked" tells the integrator to ask for a new token, while "invalid" usually means a copy-paste mistake.
- A
wa_live_prefix makes leaked tokens easy to spot in logs and easy for secret scanners to match.
Scopes: one per capability
Scopes should match what an integration actually does. A reporting tool needs to read analytics; it should never be able to send a message. Our API has 20 scopes, named resource:action: contacts:read and contacts:write, messages:read and messages:send, campaigns:write and campaigns:send, and so on. Creating a campaign and launching it are separate scopes, and so are reading messages and sending them, so the token you paste into a dashboard tool can't message your customers.
Check the scope per route, not per router: it's one line on each endpoint, and a reviewer can see exactly what each one needs.
Limits: two budgets, both in the headers
import { ApiError } from "./errors.ts"; export type Group = "messages_send" | "messages_read" | "contacts" | "general"; export type Limits = { windowSeconds: number; perGroup: Record<Group, number>; monthly: number | null }; // Each endpoint group gets its own budget, so polling contacts can't starve sends. export const groupFor = (method: string, path: string): Group => { if (path.startsWith("/messages")) return method === "GET" ? "messages_read" : "messages_send"; if (path.startsWith("/contacts")) return "contacts"; return "general"; }; export const monthKey = (at: Date) => at.toISOString().slice(0, 7); export type CounterStore = { incr: (key: string) => number; get: (key: string) => number; }; export const memoryStore = (): CounterStore => { const counts = new Map<string, number>(); return { incr: (key) => { const next = (counts.get(key) ?? 0) + 1; counts.set(key, next); return next; }, get: (key) => counts.get(key) ?? 0, }; }; // Fixed-window limit per (token, group), plus a monthly cap per account. export const checkLimits = (store: CounterStore, limits: Limits, tokenId: string, accountId: string, group: Group, now: Date) => { const headers: Record<string, string> = {}; const used = store.get(`month:${accountId}:${monthKey(now)}`); if (limits.monthly != null) { headers["X-MonthlyLimit-Limit"] = String(limits.monthly); headers["X-MonthlyLimit-Remaining"] = String(Math.max(0, limits.monthly - used)); if (used >= limits.monthly) { throw new ApiError(429, "MONTHLY_API_LIMIT_EXCEEDED", "Monthly API limit reached for this plan.").withHeaders(headers); } } const windowMs = limits.windowSeconds * 1000; const windowStart = Math.floor(now.getTime() / windowMs) * windowMs; const resetAt = windowStart + windowMs; const limit = limits.perGroup[group]; const count = store.incr(`rate:${tokenId}:${group}:${windowStart}`); headers["X-RateLimit-Limit"] = String(limit); headers["X-RateLimit-Remaining"] = String(Math.max(0, limit - count)); headers["X-RateLimit-Reset"] = String(Math.floor(resetAt / 1000)); if (count > limit) { headers["Retry-After"] = String(Math.ceil((resetAt - now.getTime()) / 1000)); throw new ApiError(429, "API_RATE_LIMIT_EXCEEDED", "Too many requests. Retry after the reset time.").withHeaders(headers); } return headers; }; // Counted after the response, so failed calls are counted too. export const recordCall = (store: CounterStore, accountId: string, now: Date) => store.incr(`month:${accountId}:${monthKey(now)}`); There are two separate limits, because they protect different things:
- A short-window limit per endpoint group protects the platform from bursts. Groups matter: an integration polling contacts every second shouldn't block the same account's order-confirmation sends. In WAPGROWAI the window is 10 seconds by default, and contacts, reading messages, sending messages, conversations, templates, campaigns, media, operators and analytics each get their own budget.
- A monthly cap per account comes from the subscription plan.
Some details that matter in production:
- The counter has to be shared. An in-memory map only works with one process. Use Redis
INCRwith an expiry, or a row per (token, group, window) incremented under a row lock, which is what we do. - Fixed windows allow a burst at the boundary: up to twice the limit across the end of one window and the start of the next. That's fine for fairness between integrations. If you need a hard ceiling, use a sliding window or a token bucket.
- The monthly check reads before the call and counts after it, so concurrent requests can go slightly over. That's acceptable for a plan limit; it isn't for anything you bill per call.
- Send the numbers on every response, not just on a 429:
X-RateLimit-Limit,-Remaining,-Reset, and the monthly equivalents. Integrators can slow down before they hit the wall. AddRetry-Afteron the 429 as well: our API doesn't send it yet, and it's on our list. - Decide what happens when the limiter itself fails. Ours fails open and logs the error: a bug in the limiter shouldn't take every integration down. The cost is that a broken limiter doesn't limit, so alert on that log line.
Idempotency: a retry must not send twice
Networks drop responses. An integration that sent "Your order has shipped" and never got the reply will retry, and without protection the customer gets it twice. On WhatsApp, the duplicate is a second message in their chat, and a second template message is billed like the first.
import { createHash } from "node:crypto"; import { ApiError } from "./errors.ts"; type Stored = { fingerprint: string; state: "pending" } | { fingerprint: string; state: "done"; status: number; body: unknown }; export type IdempotencyStore = Map<string, Stored>; const fingerprint = (method: string, path: string, body: unknown) => createHash("sha256").update(`${method} ${path} ${JSON.stringify(body)}`).digest("hex"); // Runs `send` at most once per (account, key); a repeat gets the first response back. export const withIdempotency = async ( store: IdempotencyStore, accountId: string, key: string | undefined, req: { method: string; path: string; body: unknown }, send: () => Promise<{ status: number; body: unknown }>, ) => { if (!key) return { ...(await send()), replayed: false }; const storeKey = `${accountId}:${key}`; const fp = fingerprint(req.method, req.path, req.body); const existing = store.get(storeKey); if (existing) { if (existing.fingerprint !== fp) { throw new ApiError(422, "IDEMPOTENCY_KEY_REUSED", "This Idempotency-Key was used with a different request."); } if (existing.state === "pending") { throw new ApiError(409, "IDEMPOTENCY_IN_PROGRESS", "A request with this Idempotency-Key is still being processed."); } return { status: existing.status, body: existing.body, replayed: true }; } store.set(storeKey, { fingerprint: fp, state: "pending" }); try { const result = await send(); store.set(storeKey, { fingerprint: fp, state: "done", ...result }); return { ...result, replayed: false }; } catch (error) { // Contract: `send` throws only if nothing reached WhatsApp, so a retry is safe. store.delete(storeKey); throw error; } }; - The client sends an
Idempotency-Keyheader, such asorder-1042-shipped. - First request: the key is marked in progress, the message is sent, and the response is stored.
- Same key, same request: the stored response comes back with
Idempotent-Replayed: true, and nothing is sent. - Same key, different request: 422. That's almost always a bug in the client, and replaying the old response would hide it.
- Same key while the first is still running: 409, so a double-click or a parallel retry can't send twice.
- Keys are scoped to the account, so two customers using the same key never collide.
The hard case is a failure partway through. If the send to WhatsApp may have gone out, the key must stay used; freeing it lets a retry send again. The snippet frees the key only because its contract says the handler throws only when nothing was sent. In a real system, store the failure, or the WhatsApp message ID if you got one, rather than deleting the key. Keep keys for at least a day, and store them where every instance can see them.
Not every endpoint needs a key. Make an operation idempotent by design where you can: our pin and unpin endpoints set a state instead of toggling it, so repeating either is harmless. Sends are where keys matter, and our public send endpoint doesn't accept one yet. It's the next change on this list.
Putting it together
import { ApiError } from "./errors.ts"; import { authenticate, requireScope, type StoredToken } from "./tokens.ts"; import { checkLimits, groupFor, recordCall, type CounterStore, type Limits } from "./limits.ts"; import { withIdempotency, type IdempotencyStore } from "./idempotency.ts"; export type Request = { method: string; path: string; headers: Record<string, string | undefined>; body?: unknown }; export type Response = { status: number; headers: Record<string, string>; body: any }; export type Route = { scope: string; handler: (token: StoredToken, req: Request) => Promise<{ status: number; body: unknown }> }; export type Deps = { findByHash: (hash: string) => StoredToken | undefined; counters: CounterStore; idempotency: IdempotencyStore; limitsFor: (accountId: string) => Limits; now: () => Date; }; // Order matters: who are you, may you do this, can you afford it, then do it once. export const handle = async (deps: Deps, route: Route, req: Request): Promise<Response> => { let headers: Record<string, string> = {}; let token: StoredToken | undefined; try { token = authenticate...