Skip to content
imessageapi

Testing a messaging integration without texting a real customer

There is no undo. A test loop that fires 400 reminders at 2am is a story every messaging team has, and it is entirely preventable.

7 min readUpdated August 23, 2026Getting started

Messaging has a property most of your stack does not: sends are irreversible and land on a real person's phone. A bad deploy to your web app shows a broken page to whoever looks. A bad deploy to your messaging job wakes four hundred customers up. Build the guard rails before you need them.

Rule 1 — Make it impossible to send from a dev machine

Not unlikely. Impossible. An allowlist that fails closed outside production is twenty lines and it is the single highest-value thing in this article.

lib/messaging/guard.ts
const ALLOWLIST = (process.env.TEST_RECIPIENTS ?? "")
.split(",")
.map((n) => n.trim())
.filter(Boolean);
 
export function assertSendable(to: string) {
if (process.env.VERCEL_ENV === "production") return;
 
// Outside production, refuse anything not explicitly allowlisted.
if (!ALLOWLIST.includes(to)) {
throw new Error(
`Refusing to send to ${to} outside production. ` +
`Add it to TEST_RECIPIENTS if this is your own phone.`,
);
}
}

Fail closed, never open

The check must throw when the environment is unknown, not send. A missing VERCEL_ENV on a colleague's laptop should stop the send, not wave it through. Most incidents in this space come from a guard that defaulted to permissive.

Rule 2 — Scrub your seed data

The classic disaster is a production database restored into staging, complete with real phone numbers, followed by a test run. If you copy production data anywhere, rewrite every phone number as part of the copy — not as a follow-up step someone remembers.

scrub.sql
-- Run as part of the restore, never as a separate manual step.
UPDATE customers
SET phone = '+1555000' || LPAD((id % 10000)::text, 4, '0'),
email = 'scrubbed+' || id || '@example.invalid';
 
-- And revoke consent wholesale, so nothing can target this data.
UPDATE consent_records SET status = 'opted_out';

Rule 3 — Use the provider's sandbox for shape, your phone for truth

A sandbox tells you whether your payload is well-formed. It cannot tell you whether the message reads well on a lock screen, whether the link preview unfurls, or whether it arrived blue. Send every template to your own iPhone and an Android handset before it goes near a customer — blue vs green. If you would rather not put a real account on the line to find out, the cheapest published tiers cost less than lunch.

Rule 4 — Dry-run every bulk job

Any job that sends to more than one person gets a dry-run mode that prints the recipient count and the rendered body of the first few messages without sending. Make it the default, so sending requires an explicit flag.

send-campaign.ts
const args = new Set(process.argv.slice(2));
const commit = args.has("--commit"); // sending is opt-in, not default
 
const audience = await buildAudience(campaign);
console.log(`${audience.length} recipients`);
console.log("Sample:\n" + render(audience[0]));
 
if (!commit) {
console.log("Dry run. Re-run with --commit to actually send.");
process.exit(0);
}

Rule 5 — Cap the blast radius in production too

A daily send limit enforced in your own code, independent of the provider's, turns a runaway loop into an alert instead of an incident. Set it a little above your real peak volume and page someone when it trips.

And test the webhook path with a tunnel against real messages before launch — webhooks covers what actually breaks there.

Next step

Generate a tagged link for whatever you send next with the UTM builder, see what this looks like in your industry, or compare the services that can send it on the providers page.