Skip to main content

Troubleshooting: validation gate codes

Four codes share one behaviour: they refuse the request deterministically, before anything bills, queues, or reaches a carrier. The payload is the problem, never the platform — so a retry loop earns the same 422 every time, and the only working fix is to change the value the envelope names.
None of these is retriable as-is. The gate runs pre-send — nothing is billed, nothing is dispatched — so re-sending the unchanged body is a guaranteed second refusal. Fix the value first; re-send once.

Decode the envelope

A validation refusal always carries the field-level hint in error.details and a sentence in error.message. Read both before touching anything:
  • error.code tells you which gate tripped.
  • error.message names the field in prose.
  • error.details.field and error.details.value (when present) echo the exact value your integration sent — the Delivery Log records the refused body too, so you can compare against what you intended.
  • meta.request_id is what support needs if the value looks right to you but still refuses.

The canonical shape: E.164 and friends

Every phone gate on the platform enforces the same format: E.164 — a + followed by 2–15 digits, with a non-zero country code. The frequent rejections, in order of how often they appear: Email gates enforce a simpler shape: a non-empty local part, one @, and a domain with at least one dot. NOT AN EMAIL, " " (whitespace-only), and user@ all refuse with INVALID_EMAIL; user@example.com passes.

Pre-flight the value

You never need to learn a recipient is invalid from a refused send — two checks run before you send anything.

Number lookup

GET /api/v1/numbers/lookup/{phoneNumber} resolves a number’s line type, carrier, and validity. It is the authoritative answer for “will this value pass?”: a valid: false response means the E.164 gate refuses that value on every send surface, and the national-format echo shows the normalized form to store. URL-encode the leading + as %2B:
The full field set (line type, portability, SIM swap, reachability packages) is on Number Lookup; the data model behind it is Line type and reachability.

E.164 formatting at collection time

Normalize to E.164 the moment a number enters your system — at form collect, CSV import, or webhook intake — and store only the normalized form. Any libphonenumber port does the parse; pass the region the user typed the number in:
Node.js (libphonenumber-js)
Python (phonenumbers)
curl (normalize via lookup when a stored value can't be trusted)

The from_number source-id resolution post-check

INVALID_FROM_NUMBER is not a format gate — it is a source gate. The from you passed did not resolve to an active, dial- or send-capable source on your org: a caller-ID id that was never verified, a numeric sender that was released or disabled, or a field left empty where the endpoint needs one. The post-check pattern is: read the exact from out of the refused body, confirm it exists and is active for your org, then re-resolve — never the same value blind.
  1. List what your org can actually send from, and pick from that list; the softphone caller-ID dropdown and Make a Call dialog pre-filter to selectable, dial-ready sources.
  2. For platform numbers, confirm the row shows the capability you need (voice for calls, sms for messages) on Settings → Numbers.
  3. For an external caller ID, complete the register → challenge → confirm flow once (POST /api/v1/voice/caller-ids/verify) — the confirmed id is then selectable like an owned number.
When no from is usable at all, voice dial fails closed with a INVALID_FROM_NUMBER reading “No caller ID available” — buy a voice-capable number or set a default caller ID before dialing. That fail-closed behaviour is deliberate: guessing a source would route from the wrong identity.

The fix, per code

Every fix follows the same shape: find the rejected body, correct the one field the envelope names, re-send once.

INVALID_PHONE_NUMBER — recipient format

Rejected body:
The to is a UK national-format number with spaces — it fails the E.164 gate (+ plus 2–15 digits). Corrected body, which is accepted:
The same fix applies wherever a phone field is refused: POST /api/v1/messages/sms and the per-channel send variants, voice dial endpoints, POST /api/v1/voice/caller-ids/verify, and flow send nodes (to must be a valid E.164 phone number).

INVALID_FROM_NUMBER — the source you sent from

Rejected body — the numeric source passed in from is not an active source on your org:
Corrected body — re-resolved to an active sender your org actually owns (or a verified caller-ID id):

INVALID_EMAIL — email recipient format

The unified-send 422 naming a malformed recipient:
"noreply@ " (a trailing whitespace, no domain) refused; "noreply@example.com" passes. Trim whitespace and refuse empties in your own form validation so the bad value never reaches the API.

MISSING_REQUIRED_FIELD — an absent field

The endpoint’s schema requires a field your body omitted. The message names the field; the refusal is 400 or 422 depending on surface:
Add the named field and re-send. This class includes schema-level VALIDATION_ERROR siblings whose details.issues array lists every offending field at once — fix all of them in one edit, then a single re-send succeeds.

What not to do

  • Do not retry an unchanged body. Every gate above is deterministic; the second attempt returns the byte-identical refusal and burns your rate-limit budget on a decision only you can change.
  • Do not store national-format numbers and normalize at send time. Normalize at collection, store only E.164, and send the stored form — a send-time formatter is one more thing that can disagree with the gate.
  • Do not hand-roll a regex as your only E.164 validator. The gate checks country-code and numbering-plan validity, not just shape; a libphonenumber port (or the lookup endpoint for stored values you cannot trust) matches the gate. A regex accepts +999999999 — the gate does not.
  • Do not “fix” INVALID_FROM_NUMBER by trying other values in a loop. Pick from the selectable list of sources the dashboard already filters for you; a loop of guesses is a rate-limited way to learn the same thing.

Paste this into a support ticket

When the envelope’s details.value looks correct to you but the refusal persists — or lookup returns valid: false for a number you believe is assigned — open a ticket with: