Your staging environment probably can't receive WhatsApp webhooks, and that's by design: a Meta app has one webhook callback URL, and every WhatsApp Business Account subscribed to that app sends its events there. To test inbound messages anywhere other than production, give each environment its own Meta app and test number, or point a dedicated test number at staging with a webhook override. Don't forward production traffic to staging. Here's why, and the setup we'd use.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.
Why staging hears nothing
You set the webhook in the App Dashboard: one Callback URL and one Verify token for the app. When a business connects its WhatsApp number, its WhatsApp Business Account (WABA) subscribes to your app (POST /{WABA_ID}/subscribed_apps), and from then on its messages, statuses and other events go to that one URL.
So if production and staging use the same Meta app, every event goes to production. Staging can still send: if its database was copied from production, it may even hold the same phone number ID and access token. But nothing comes back. Inbound messages, delivery statuses and template updates never arrive, so the inbox, chatbots and campaign reports on staging quietly stop at the day the data was copied.
We hit exactly this. Staging could send from the real number, while every reply went to production.
Don't fix it by forwarding production webhooks
The tempting fix is a relay: production receives each webhook and also forwards it to staging. Think about what staging does with an inbound message. It runs its rules, its chatbots, its AI agent and its drip campaigns, using a live access token on the same number. Real customers get two replies, one from each environment, and staging's experiments go out to them.
The fact that staging couldn't hear real customers was the safe failure. Keep it that way and test with numbers you own.
Option 1: one Meta app per environment
The cleanest setup is a separate Meta app for each environment:
- Its own callback URL pointing at that environment.
- Its own app secret, which signs that environment's webhooks.
- Its own test number. Meta's getting-started flow creates a test business phone number and test account for a new app, which you can message from your own phone.
Meta's webhooks overview also advises: "Make sure your app is in Live mode; some webhooks will not be sent if your app is in Dev mode." That catches people out on test apps too.
Because the app secret differs per environment, your signature check is environment-specific for free: a payload signed for production fails on staging.
Option 2: point one test number at staging with an override
If you'd rather keep one app, Meta supports alternate callback URLs per WABA and per phone number. Meta's override docs describe the order it checks: the phone number's override first, then the WABA's, and finally the app's callback URL.
Point a test WABA at staging:
curl -X POST "https://graph.facebook.com/v26.0/$TEST_WABA_ID/subscribed_apps" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "override_callback_uri": "https://staging.example.com/webhooks/whatsapp", "verify_token": "'"$STAGING_VERIFY_TOKEN"'" }'
Or just one phone number:
curl -X POST "https://graph.facebook.com/v26.0/$TEST_PHONE_NUMBER_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "webhook_configuration": { "override_callback_uri": "https://staging.example.com/webhooks/whatsapp", "verify_token": "'"$STAGING_VERIFY_TOKEN"'" } }'
Check what's in effect with GET /{PHONE_NUMBER_ID}?fields=webhook_configuration. Remove a WABA override by calling subscribed_apps again with no body, and a phone number override by setting override_callback_uri to an empty string.
Three things to know before relying on it:
- It moves events; it doesn't copy them. The number's events go to staging instead of production. Use it on a test number, never on a customer's.
- Not every field follows the override. Template and account-level webhooks still go to the app's callback URL.
- It's still one app, so one app secret. Meta's override docs don't describe a separate signing key, so staging verifies with the same app secret as production, which is a good reason to prefer separate apps if you can.
Option 3: local development through a tunnel
The same override works for a laptop: run a tunnel (cloudflared, ngrok or similar) and point your personal test number at its URL. Two rules:
- Keep signature verification on. A tunnel makes your local endpoint public. Skipping the check "because it's just development" means anyone who finds the URL can post fake messages into your database.
- Remove the override when you're done, or the test number's messages vanish into a closed tunnel. Meta retries for up to seven days, so they'll arrive in a burst when you start it again.
Option 4: replay recorded webhooks
For most tests you don't need a live message at all. Save real payloads (with personal data removed) as fixtures, then replay them at any environment, re-pointed at your test number and signed with that environment's secret, so they go through the same handler and signature check as the real thing:
// signature.ts
import { createHmac, timingSafeEqual } from "node:crypto";
// Meta signs the raw body with your app secret: X-Hub-Signature-256: sha256=<hex>.
export const signPayload = (rawBody: string | Buffer, appSecret: string) =>
`sha256=${createHmac("sha256", appSecret).update(rawBody).digest("hex")}`;
export const isValidSignature = (rawBody: Buffer, header: string | undefined, appSecret: string) => {
if (!header?.startsWith("sha256=")) return false;
const expected = Buffer.from(signPayload(rawBody, appSecret));
const received = Buffer.from(header);
return expected.length === received.length && timingSafeEqual(expected, received);
};
// replay.ts
import { readFile } from "node:fs/promises";
import { signPayload } from "./signature.ts";
type Rewrites = { phoneNumberId: string; wabaId: string; customer: string };
// Send a recorded webhook to any environment, re-pointed at your test number and
// signed with that environment's app secret, so it goes through the real handler.
export const replayWebhook = async (fixturePath: string, url: string, appSecret: string, to: Rewrites) => {
const payload = JSON.parse(await readFile(fixturePath, "utf8"));
for (const entry of payload.entry ?? []) {
entry.id = to.wabaId;
for (const change of entry.changes ?? []) {
const value = change.value ?? {};
if (value.metadata) value.metadata.phone_number_id = to.phoneNumberId;
for (const contact of value.contacts ?? []) contact.wa_id = to.customer;
for (const message of value.messages ?? []) {
message.from = to.customer;
message.id = `wamid.replay.${Date.now()}.${Math.random().toString(36).slice(2)}`;
}
}
}
const body = JSON.stringify(payload);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Hub-Signature-256": signPayload(body, appSecret) },
body,
});
return res.status;
};
- Fresh message IDs stop your de-duplication from silently dropping the second replay.
- A recipient you own, so any automated reply goes to your phone, not a customer's.
- Fixtures become regression tests. Keep one per message type you've had a bug with: a button reply, a Flow submission, a delivery failure, a status that arrived before the message.
The App Dashboard's Test button next to each webhook field sends Meta's own sample payload too, which is useful for checking that a new environment is wired up at all.
Guard outbound sends outside production
Whatever you choose, the last line of defence belongs in your send path. In any environment other than production, refuse to message anyone who isn't on a list of your own test numbers:
// send-guard.ts
// Outside production, a send to anyone but your own test devices is a bug, not a feature.
export const createSendGuard = (environment: string, allowedRecipients: string[]) => {
const allowed = new Set(allowedRecipients.map((n) => n.replace(/\D/g, "")));
return (to: string) => {
if (environment === "production") return;
if (!allowed.has(to.replace(/\D/g, ""))) {
throw new Error(`Blocked send to ${to} from ${environment}: not an allowed test recipient`);
}
};
};
Call it before every request to /messages: chats, campaigns, chatbots, drips. Staging databases are usually copied from production, and a copied campaign or drip with real customers in it is one scheduler tick away from sending.
Checklist
- Accept that one Meta app has one callback URL; plan environments around it.
- Prefer a separate Meta app per environment, with its own test number and app secret, in Live mode.
- Use overrides only on test WABAs or numbers, and remove them when you're done.
- Never forward production webhooks to another environment that can send.
- Keep signature verification on everywhere, tunnels included.
- Replay recorded, signed, anonymised payloads for repeatable tests.
- Block outbound sends to anyone but test numbers outside production.
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: handling webhooks in Node.js and build vs buy.
Top comments (0)