You can't test a WhatsApp campaign sender end to end against the real API: every test sends real messages to real phones, costs money, and counts against the number's limits and quality. Unit tests with a mocked HTTP client don't catch the bugs that matter either, because those live in timing, queues and retries. The middle path is a fake Graph API: a small local HTTP server that answers like Meta, records every request with the time it arrived, and lets your real sender, queue and workers run unchanged. Then assert on what the fake received.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.
Why mocks miss the bugs
A campaign sender is rarely one function. It's an API that creates the campaign, a queue that holds recipients, workers that build each message, and a rate gate in front of the calls to Meta. The failures we've seen weren't in any one of those pieces; they were in how they behave together over time:
- sends bunching up past the number's rate limit,
- retries that fire immediately instead of backing off,
- a worker that picks up a job twice,
- an error code handled as success.
A mocked fetch returns instantly and in order, so none of that shows up. A real HTTP server on localhost, called through the same code path as production, does show it.
The fake Graph API
It needs very little: match POST /<version>/<phone-number-id>/messages, record the request and the time it arrived, and answer with a message ID, or with an error for recipients you've chosen to fail.
// A stand-in for graph.facebook.com that records every send and answers like Meta.
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
export type RecordedSend = { at: number; phoneNumberId: string; to: string; body: any };
export type FakeOptions = { errorFor?: Record<string, { code: number; message: string }> };
export const startFakeGraph = async ({ errorFor = {} }: FakeOptions = {}) => {
const sends: RecordedSend[] = [];
let next = 0;
const server = createServer((req, res) => {
const match = req.url?.match(/^\/v[\d.]+\/(\d+)\/messages$/);
if (req.method !== "POST" || !match) {
res.writeHead(404).end();
return;
}
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
const body = JSON.parse(raw);
sends.push({ at: performance.now(), phoneNumberId: match[1], to: body.to, body });
const error = errorFor[body.to];
res.writeHead(error ? 400 : 200, { "Content-Type": "application/json" });
res.end(JSON.stringify(error
? { error: { message: error.message, code: error.code } }
: { messaging_product: "whatsapp", messages: [{ id: `wamid.FAKE.${++next}` }] }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return {
baseUrl: `http://127.0.0.1:${port}`,
sends,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
};
The fake's error body uses the same shape as Meta's ({ "error": { "code", "message" } }), so your real error handling runs, not a test-only branch. To test webhook handling too, have the fake post statuses events to your webhook endpoint after each send. That's how you exercise late delivered and failed events without a real phone.
Point the app at it, and refuse anything else
Make the Graph base URL configuration rather than a constant, then add a guard so that a test or staging environment cannot start pointed at the real API:
// Outside production, refuse to start unless sends go to a local fake.
export const assertSafeGraphUrl = (graphBaseUrl: string, environment: string) => {
if (environment === "production") return;
const host = new URL(graphBaseUrl).hostname;
if (host !== "127.0.0.1" && host !== "localhost") {
throw new Error(`Refusing to start: ${environment} would send real WhatsApp messages via ${host}`);
}
};
This is the same idea as an outbound send guard on staging, applied at startup: a misconfigured environment fails loudly before it has the chance to message a customer.
Assert on what Meta would have seen
The point of recording arrival times at the fake is that they're the only timestamps that matter. Your own logs say when a job was picked up or a message row was created; Meta's rate limits care about when requests arrived. Two checks cover the limits that bite campaigns:
// Assertions over what the fake Meta actually received, timed at the fake.
import type { RecordedSend } from "./fake-graph.ts";
// The most sends one number made inside any one-second window.
export const peakPerSecond = (sends: RecordedSend[], phoneNumberId: string) => {
const times = sends.filter((s) => s.phoneNumberId === phoneNumberId).map((s) => s.at).sort((a, b) => a - b);
let peak = 0;
for (let start = 0, end = 0; end < times.length; end++) {
while (times[end] - times[start] >= 1000) start++;
peak = Math.max(peak, end - start + 1);
}
return peak;
};
// The shortest gap between two messages to the same customer.
export const minGapToSameCustomer = (sends: RecordedSend[]) => {
const last = new Map<string, number>();
let min = Infinity;
for (const s of [...sends].sort((a, b) => a.at - b.at)) {
const prev = last.get(s.to);
if (prev !== undefined) min = Math.min(min, s.at - prev);
last.set(s.to, s.at);
}
return min;
};
peakPerSecond slides a one-second window over the arrivals for one number. minGapToSameCustomer finds the closest two messages to one customer, for the pair-rate limit.
The bug it caught
When we ran our real sender against a fake like this, the peak per second came out above the number's limit. The sender paced each batch: send up to N messages, then wait out the rest of the second. That looks right, but the database work before each send took a varying amount of time, so the end of one batch and the start of the next could land inside the same second.
Here are both versions, with the per-recipient work passed in:
// Two campaign senders: one paces each batch, one paces each request.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, Math.max(0, ms)));
export type SendFn = (to: string) => Promise<{ ok: boolean; code?: number }>;
export type Work = () => Promise<void>; // the per-recipient database work before a send
// Bug: one check per batch. The work inside each batch takes a varying time,
// so the end of one batch and the start of the next can bunch together.
export const sendPacedByBatch = async (recipients: string[], perSecond: number, send: SendFn, work: Work) => {
for (let i = 0; i < recipients.length; i += perSecond) {
const batchStart = performance.now();
for (const to of recipients.slice(i, i + perSecond)) {
await work();
await send(to);
}
await sleep(1000 - (performance.now() - batchStart));
}
};
// Fix: a permit immediately before every request.
export const createPermit = (perSecond: number) => {
const recent: number[] = [];
return async () => {
for (;;) {
const now = performance.now();
while (recent.length && now - recent[0] >= 1000) recent.shift();
if (recent.length < perSecond) {
recent.push(now);
return;
}
await sleep(1000 - (now - recent[0]) + 1);
}
};
};
export const sendPacedByRequest = async (recipients: string[], perSecond: number, send: SendFn, work: Work) => {
const permit = createPermit(perSecond);
for (const to of recipients) {
await work();
await permit();
await send(to);
}
};
The test gives the first half of each batch slow database work and the second half fast work, and sends 40 messages at a limit of 20 per second. Across three runs, the batch-paced sender peaked at 22, 25 and 29 sends in one second. The request-paced sender peaked at 20 every time. In our app, moving to a per-number permit checked before every request fixed it; the unit tests had passed all along.
Timing tests can be noisy on a busy CI machine. Keep the assertion one-sided (never more than the limit) and use a margin when you check that something is fast.
What else to test this way
- Error codes. Make the fake return 130429 for a few requests and check the sender backs off; return 131026 for one recipient and check it's recorded as failed, not retried forever.
- Duplicates. Count sends per recipient at the fake. A recipient who received two messages in one campaign is a bug, whether it came from a retry or a worker picking up the same job twice.
- Crash and resume. Stop a worker halfway, restart it, and check the fake saw each recipient exactly once.
-
Statuses. Post
sent,delivered,readandfailedto your webhook in different orders and check the stored state.
Checklist
- Make the Graph base URL configuration, not a constant.
- Run a local fake that records each request and its arrival time, and answers with Meta-shaped responses and errors.
- Guard startup so non-production environments can only talk to a local fake.
- Drive the real API, queue and workers, not mocked functions.
- Assert on the fake's arrival times: peak per second per number, and the gap between messages to one customer.
- Test error codes, duplicates and restarts the same way.
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: rate limits in practice and testing webhooks outside production.
Top comments (0)