Errors & status codes
Errors & status codes
Dial uses standard HTTP status codes. Successful reads return 200; successful creates (a sent message, a placed call, a provisioned number) return 201.
Status codes
Error shape
Errors carry an error field. For most failures it’s a string:
For 400 validation failures it can be an object describing the invalid fields:
Always branch on the HTTP status first, then read error for detail.
Restricted destinations
A 403 with unsupported_region means the destination’s region isn’t reachable from Dial — either it’s embargoed, or calling and messaging permissions for that country aren’t enabled on the account placing the request. It’s a property of the number you asked for, not of your request, so retrying won’t change the outcome:
POST /api/v1/messages returns the same code with wording for messages. Where the restriction is a permission rather than an embargo, the message says so and how to ask — some regions can be enabled on request.
Unexpected failures
Rarely, a request fails in a way Dial can’t classify. Those responses carry a generic message with a reference code:
The ref_… code identifies that exact failure in Dial’s logs. Quote it to support or on Discord and we can look up what happened without needing you to reproduce it. The HTTP status still describes the class of failure, so keep branching on status as usual.
Retries and idempotency
Reads are safe to retry. GET requests — list numbers, messages, calls, account, usage — have no side effects, so retry them freely.
Placing a call is safe to retry with an Idempotency-Key. POST /api/v1/calls accepts an optional Idempotency-Key header — a unique client-generated key (e.g. a UUID) identifying the call attempt:
- If the original attempt placed the call, a retry with the same key returns the original call with
200instead of dialing again. (A fresh call returns201, so you can tell them apart.) - If a duplicate arrives while the original is still processing, it waits for the original and then replays its result. If the original is still going when the wait window closes, the duplicate gets
409— retry with the same key shortly. - A non-2xx response guarantees no live call: if the call already fired when the failure happened, Dial cancels it before responding. Retrying with the same key is always safe.
Other writes are not idempotent. Without an Idempotency-Key, every accepted POST is a distinct action and a retry creates a new resource:
- Retrying
POST /api/v1/messagessends a second SMS. - Retrying
POST /api/v1/callswithout the header places a second call. - Retrying
POST /api/v1/numbersprovisions another number.
The SDKs and CLI make a single attempt per call and never retry on your behalf, so each retry is a deliberate choice you make.
When a non-idempotent write fails ambiguously — a network timeout, a dropped connection, or an unexpected 5xx — you can’t tell whether it took effect. Don’t blind-retry. Confirm first: list recent messages or calls (GET /api/v1/messages, GET /api/v1/calls) and re-send only if the action isn’t already there. Retrying is always safe after a 400 or 401, because the request was rejected before any action ran.
Billing: PAYG vs FIXED
Billing is per phone number. A number is either:
- Pay-as-you-go (PAYG) — its calls, SMS, and monthly ownership are billed against your account’s credit wallet. When the wallet balance is at or below zero, new spend on a PAYG number is rejected with
402: placing a call, sending an SMS, and provisioning a number all return402; inbound calls are declined. Add credit to continue. - Subscription (FIXED) — covered by an active subscription; never gated by the wallet.
Check balances and per-number mode with GET /api/v1/billing (or dial billing).
Free accounts
An account that has never added credit and never subscribed runs with two guardrails:
- Call length — every call is hard-capped at 5 minutes, regardless of any
maxCallDurationSecondsconfigured on the call, the number, or the account. - Concurrent calls — at most 2 calls in progress at once, inbound and outbound combined. An outbound call over the limit is rejected with
429(error codecall_limit_reached); an inbound call over the limit is declined.
Both limits lift permanently the first time you add credit or start a subscription.
Read the exact values from limits on GET /api/v1/account rather than hardcoding them — it’s null once the account has paid:
Asking for more than the cap
A maxCallDurationSeconds you send explicitly above limits.maxCallDurationSeconds is rejected with 400 — on POST /api/v1/calls, PATCH /api/v1/numbers/{id}, and PATCH /api/v1/account alike. The request is refused rather than quietly given a shorter call than you asked for.
A cap inherited from the number or account chain is not an error: it’s clamped down to the free ceiling and the call proceeds. So a number whose cap was set to 1800 before the account hit the free tier still places calls — they just end at 5 minutes.
Handling timeouts
A events/wait call returns 408 when no matching event arrives in the window — this is expected, not a failure. Retry the wait, or keep a live stream open instead. The CLI’s dial wait-for exits non-zero on timeout for the same reason.