DEV Community

Cover image for Designing a Public API for a WhatsApp Platform: Tokens, Scopes, Rate Limits and Idempotent Sends
WAPGROWAI for WAPGROWAI

Posted on

Designing a Public API for a WhatsApp Platform: Tokens, Scopes, Rate Limits and Idempotent Sends

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:

  1. Authenticate the token.
  2. Check the scope for this endpoint.
  3. Check the limits: a short-window rate limit and a monthly cap.
  4. Validate the body.
  5. Run the handler, at most once per idempotency key.
  6. 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}.`);
  }
};
Enter fullscreen mode Exit fullscreen mode
  • 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)}`);
Enter fullscreen mode Exit fullscreen mode

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 INCR with 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. Add Retry-After on 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;
  }
};
Enter fullscreen mode Exit fullscreen mode
  • The client sends an Idempotency-Key header, such as order-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(req.headers.authorization, deps.findByHash, deps.now());
    requireScope(token, route.scope);
    headers = checkLimits(deps.counters, deps.limitsFor(token.accountId), token.id, token.accountId, groupFor(req.method, req.path), deps.now());
    const auth = token;
    const result = await withIdempotency(deps.idempotency, token.accountId, req.headers["idempotency-key"], req, () => route.handler(auth, req));
    if (result.replayed) headers["Idempotent-Replayed"] = "true";
    return { status: result.status, headers, body: result.body };
  } catch (error) {
    if (!(error instanceof ApiError)) throw error;
    return { status: error.status, headers: { ...headers, ...error.headers }, body: error.toBody() };
  } finally {
    if (token) recordCall(deps.counters, token.accountId, deps.now());
  }
};
Enter fullscreen mode Exit fullscreen mode

What the tests check:

  • Tokens: only the hash and a display prefix are stored; the raw token never appears in the stored record.
  • 401s: missing, unknown and revoked tokens each return their own code.
  • Scopes: a token without messages:send gets 403, and the handler never runs.
  • Rate limits: the third contacts call in a 10-second window gets 429 with Retry-After: 9, a send in the same window still goes through, and the contacts budget is back after the window resets.
  • Monthly cap: remaining goes 2, then 1, then the call is refused with MONTHLY_API_LIMIT_EXCEEDED, and it opens again in the next month.
  • Idempotency: a retried send with the same key sends once and replays the first response; the same key with a different recipient gets 422; a concurrent duplicate gets 409.
  • Errors: every failure has the same { success, code, message } shape.

Errors: pick one envelope before the first endpoint

Every error should look the same, with a machine-readable code that doesn't change when you reword the message:

{ "success": false, "code": "INSUFFICIENT_SCOPE", "message": "This API token is missing the required scope: messages:send." }
Enter fullscreen mode Exit fullscreen mode

This is the one we'd most like to redo. In our API, 401, 403 and 429 carry a code, but 400 and 404 don't, because they came from the app's shared response helpers that existed before the public API. Our docs say so plainly and tell integrators to branch on the HTTP status, but it would have cost almost nothing to decide the envelope on day one.

Inputs: say exactly what you accept

The field that caused the most questions wasn't a complex one. It was phone. A number like 9876543210 has no country code, and the same digits can be valid in more than one country. Our API reads a number without a country code as Indian, because most of our customers are in India, accepts an explicit country_code field, and rejects numbers that aren't valid anywhere with a 400 instead of sending them somewhere unintended. Whatever your rule is, put it in the docs next to the field, with an example.

Docs: serve the spec from the API

Our OpenAPI spec is built in the API's own code and served at GET /api/public/openapi.json. The docs page in the app renders it, so the docs and the API are deployed together.

One detail that bit us: build the spec's server URL from configuration, not from the incoming request. Behind a TLS-terminating proxy, the request looks like plain http, and a spec that advertises an http:// base URL invites clients to send their bearer token over an unencrypted first hop before the redirect.

Logs: per call, and safe to keep

Each authenticated call writes a log row: method, endpoint, status, latency, a trimmed request body and a short summary of the response. The tenant ID is removed from the stored body, and the response summary is a shape (array(25) total:340, id:…), never the data itself. That's enough to answer "why did my integration fail at 3 AM?" without keeping customer data in a log table.

Checklist

  • Store token hashes, show the secret once, and keep a display prefix.
  • Return distinct codes for missing, invalid, revoked and expired tokens.
  • One scope per capability, with sending messages separate from reading.
  • Check auth and scope before rate limits.
  • Rate-limit per endpoint group, with a shared counter.
  • Return limit headers on every response, and Retry-After on 429.
  • Accept an Idempotency-Key on sends, and refuse a reused key with a different body.
  • One error envelope with stable codes, decided before the first endpoint.
  • Document input rules, especially phone numbers.
  • Serve the OpenAPI spec from the API, with a configured base URL.
  • Log every call without storing customer data.

WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: WhatsApp Cloud API rate limits in practice and routing WhatsApp webhooks in a multi-tenant SaaS.

Top comments (0)