Skip to main content

Troubleshoot send-price and country gates

MAX_PRICE_EXCEEDED and COUNTRY_NOT_ALLOWED both refuse a send at pre-send with HTTP 422, before the wallet is billed — but they are different controls answering to different owners:
  • COUNTRY_NOT_ALLOWED is the org-wide country allowlist gate. It lives on your organization’s compliance settings, applies to every SMS, messaging-channel, and voice send with a phone destination, and the control surface is GET / PUT /api/v1/settings/compliance/country-allowlist (dashboard: Settings → Compliance → country allowlist).
  • MAX_PRICE_EXCEEDED is a per-send cost ceiling. It lives on the individual request payload as the max_price field and applies only to the send that carries it.
This page answers: which gate fired, why it fired, and the exact fix that turns a refused send into an accepted one.
Both gates fail closed before dispatch: nothing is sent and nothing is billed in the rejected state. Neither code is transient — until the named fix lands, retrying the same payload refuses identically.

Decode the envelope

Match error.code to a row. The details block tells you exactly which side of the comparison the send fell on. The scope of each gate is the other half of the diagnosis:
  • COUNTRY_NOT_ALLOWED checks the destination E.164 after normalization — the resolved country of the to value, for every send in the organization. A provisional phone format that normalizes to a different country than you expected is a common surprise.
  • MAX_PRICE_EXCEEDED checks only the one request that carried max_price. No max_price, no gate.

COUNTRY_NOT_ALLOWED — the org allowlist rejected the destination

The country allowlist is a tenant-owned opt-in gate: until you configure it, all destinations are allowed by default. Once you write a non-empty allowed_countries list, only destinations that resolve to a listed ISO-3166-1 alpha-2 code pass — everything else is hard-rejected with 422 COUNTRY_NOT_ALLOWED. The allow_all_countries: true flag exists so “we intentionally send anywhere” is a recorded decision, not an empty list someone hopes stays empty. Read the current posture before changing it:
Fix at claim time — two resolutions:
  1. Open the gate — if the org posture is “send anywhere”, record it explicitly with allow_all_countries: true (the flag short-circuits before the list is consulted), or clear the list:
  2. Add the ISO codes you actually need — the PUT replaces the list wholesale, so it must carry every country you intend to keep, plus the new one. Codes are upper-cased and deduplicated server-side:
The same panel is in the dashboard at Settings → Compliance → country allowlist — the write is owner-only either way. After the change, retry the send once; it dispatches.
The gate checks the destination after E.164 normalization. A to value that normalizes to a country outside your list refuses even when the raw digits looked domestic — confirm the normalized destination before deciding the list is wrong. Equally, a destination that cannot be resolved to a country at all passes: resolution failure never blocks. Do not treat the allowlist as a substitute for the IRSF prefix blocks or Fraud Shield; it composes with them, it does not replace them.
Never retry unchanged. The reject is deterministic: the same destination against the same list refuses identically until the list changes, and a retry loop only burns your rate-limit budget.

MAX_PRICE_EXCEEDED — the per-send price ceiling rejected the quote

max_price is an optional field on the messaging send request (POST /api/v1/messages) — a hard USD ceiling you set on that one call, capped at 100 USD per message to defend against unit mistakes. When the guard’s projected total cost exceeds your cap, the send refuses with 422 MAX_PRICE_EXCEEDED. SMS and MMS are priced per segment, so the projection multiplies the resolved per-unit rate by the counted segments of the body — a body that re-encoded into UCS-2 can trip a cap the GSM-7 version of the same text passed. A refused envelope looks like this:
Fix at claim time — compare the quote to the cap, then pick one: details.resolved_cost_usd is the projected total the guard computed (rate × segments); details.max_price_usd is the ceiling you sent. One of four resolutions fits every refusal:
  1. Raise the cap — re-issue with a max_price above resolved_cost_usd. This is the intended fix when the cap was a conservative default and the quote is legitimate.
  2. Drop the field — if you do not need a per-send ceiling on this lane, omit max_price; the guard only runs when the field is present. (Org-wide spend discipline lives elsewhere: daily spend caps and fraud rules.)
  3. Shorten the body — for SMS/MMS the projection scales with segment count, so trimming an emoji-flipped or UCS-2 body back into GSM-7 can collapse it from multiple segments to one. See SMS segments and encoding for the counting model.
  4. Change the lane — a cheaper destination or channel lowers the rate leg of the projection.
Then retry once — the corrected request returns the normal accepted response. Never retry unchanged: without one of the four fixes, the same projection exceeds the same cap identically.

Decision checklist

  1. Read error.code off the envelope.
  2. If COUNTRY_NOT_ALLOWED: GET the current allowlist, confirm the destination’s normalized country, and either add its ISO code (PUT with the full intended list) or set allow_all_countries: true. Owner-only. Retry once.
  3. If MAX_PRICE_EXCEEDED: read details.resolved_cost_usd vs details.max_price_usd; raise the cap, drop the field, shorten the body, or change the lane. Retry once.
  4. If the fix lands and the code still refuses, capture the envelope and escalate (below).

What NOT to do

  • Do not blind-retry either code. Both gates are deterministic — unchanged input, unchanged verdict — and a retry loop only burns your rate-limit budget.
  • Do not PUT a partial country list. The allowlist PUT replaces the list wholesale; a body carrying only the new ISO code silently drops every country you omitted. Always send the full intended set.
  • Do not confuse COUNTRY_NOT_ALLOWED with CHANNEL_COUNTRY_BLOCKED. The allowlist is one org-wide list; channel-per-country blocks are a separate tenant policy surface. Check the code, not the 422.
  • Do not treat max_price as org-wide spend policy. It caps one message. For daily ceilings use spend caps; for unit mistakes know the field itself is capped at 100 USD.

When to escalate

Escalate only if a corrected request still refuses, or if you cannot update the allowlist as the owner. Include:
  • Your tenant ID (GET /api/v1/me → organizationId).
  • The exact code, one full envelope, and meta.request_id.
  • For the country gate: the normalized destination and the current GET /api/v1/settings/compliance/country-allowlist output.
  • For the price gate: the details block (resolved_cost_usd, rate_per_unit_usd, segments, max_price_usd, channel).

See also