Stop 400 invalid_recipient Errors — Validating Phone Numbers Before You Send
A 400 invalid_recipient response feels like a small thing — one failed send, retry with a fixed number, move on. But every rejected send also adds to your account's risk score, and if the bad numbers are coming from a messy import (a CRM export, a spreadsheet someone typed by hand), you can rack up dozens of these in a single batch job without noticing. The fix is cheap: validate the number format in your own code before it ever reaches the API.
What TextMeFlow actually requires
The anti-spam pipeline enforces strict E.164 formatting on every to field. Only one shape is accepted:
+32472932208— valid: country code, no spaces, no leading zero, no separators.0472932208— invalid: missing country code.32472932208— invalid: missing the leading+.+32-472-93-22-08— invalid: dashes aren't allowed.
Short codes, premium-rate numbers and toll-free ranges are refused outright, regardless of formatting — WhatsApp Web doesn't run on those number types, so there's nothing to pair with on the other end. Any number that fails these checks comes back as 400 invalid_recipient from POST /v1/messages, and it adds +10 to your account's risk score every time it happens (see the risk score breakdown for how that number adds up toward suspension).
Validate before you call the API, not after
The cheapest place to catch a malformed number is the moment it enters your system — a signup form, a CSV import, a CRM sync — not at send time. A simple regex catches the vast majority of formatting mistakes:
// Node.js
const E164_RE = /^\+[1-9]\d{7,14}$/;
function isValidE164(phone) {
return typeof phone === 'string' && E164_RE.test(phone.trim());
}
function normalizeToE164(raw, defaultCountryCode) {
const digits = raw.replace(/[^\d+]/g, '');
if (digits.startsWith('+')) return digits;
if (digits.startsWith('00')) return '+' + digits.slice(2);
if (digits.startsWith('0')) return `+${defaultCountryCode}${digits.slice(1)}`;
return `+${defaultCountryCode}${digits}`;
}
<?php
function isValidE164(string $phone): bool
{
return (bool) preg_match('/^\+[1-9]\d{7,14}$/', trim($phone));
}
function normalizeToE164(string $raw, string $defaultCountryCode): string
{
$digits = preg_replace('/[^\d+]/', '', $raw);
if (str_starts_with($digits, '+')) {
return $digits;
}
if (str_starts_with($digits, '00')) {
return '+' . substr($digits, 2);
}
if (str_starts_with($digits, '0')) {
return '+' . $defaultCountryCode . substr($digits, 1);
}
return '+' . $defaultCountryCode . $digits;
}
The normalizer handles the two most common real-world messes — a national number with a leading zero (0472932208) and the international dial prefix instead of + (0032472932208) — but it can't guess a country code that was never in the data. Store the country alongside the raw number wherever you collect it (a country dropdown next to a phone field is worth the extra click), and reject anything left over as unresolvable rather than guessing further.
Don't just skip — log and report
When a batch import fails validation, resist the urge to silently drop the row. Collect the rejects and surface them:
const results = contacts.map((c) => ({
...c,
e164: isValidE164(c.phone) ? c.phone : normalizeToE164(c.phone, defaultCc),
}));
const rejected = results.filter((r) => !isValidE164(r.e164));
if (rejected.length) {
console.warn(`${rejected.length} contacts could not be normalized to E.164`, rejected);
}
That log line is what tells you the CRM export has a data-quality problem before it burns through your risk-score budget one 400 at a time.
Combine with opt-out checks
Number formatting is only half the pre-flight check. If a recipient has replied STOP in the past, TextMeFlow rejects the send with recipient_opted_out and adds +50 to your risk score — five times the penalty of a bad number. Subscribe to the opt_out webhook event and keep a local table of opted-out recipients so your own code can skip them before attempting a send, rather than relying on the API to catch it after the fact every single time.
The pattern
Two cheap checks, run before every POST /v1/messages: is this a well-formed E.164 number, and has this recipient opted out. Both take microseconds locally and cost nothing; both save you a risk-score hit and a wasted API round-trip when they catch something. For anything you can't resolve — a number with no country code and no way to guess one — flag it for a human rather than sending it and hoping.
Want to see the validation errors yourself before wiring this into production? Sign up for the free plan — 50 messages a month, no card required — and send a deliberately malformed number to see the exact 400 invalid_recipient response shape.
Zelf WhatsApp-berichten versturen via API?
Gratis voor altijd tot 50 berichten/maand. QR scannen en binnen 5 minuten verstuur je je eerste bericht.
Gratis voor altijd