Errors
Every customer-facing error the AgentMessage API returns, with status codes, stable error codes, example envelopes, and how to handle each one.
Every /v1/* response, success or error, uses the same envelope. On errors,
success is false, data is omitted (except where a typed payload is
documented below), and error carries the details:
{
"success": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "validation failed",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM",
"details": {
"to": "must be E.164"
}
}
}| Field | Meaning |
|---|---|
error.code | Stable, machine-readable. Safe to branch on. |
error.message | Human-readable. May evolve; do not parse it. |
error.request_id | Also on the X-Request-Id response header. Quote it when contacting support. |
error.details | Per-field map, present on validation-class errors. Absent otherwise. |
Handle errors in this order: branch on the HTTP status class first, then on
error.code for the statuses you care about. Treat unknown codes by their
status class so new codes do not break your integration. Internal 5xx errors
collapse to a generic message and never leak internal detail; retry those with
backoff and include the request_id if you contact support.
400 Bad Request
INVALID_INPUT
The request is structurally wrong for the route: an unsupported field on
PATCH /v1/phone-numbers/{id} (only inbound_url, status_url, label,
and campaign_id are updatable), or unknown, empty, or duplicate scopes on
POST /v1/api-keys.
{
"success": false,
"error": {
"code": "INVALID_INPUT",
"message": "api key: unknown scope \"messages:write\": UNKNOWN_SCOPE: invalid input",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: fix the request before retrying. Retrying unchanged input
returns the same error. Note that INVALID_INPUT also appears with status
422 on the metrics endpoints when a page-2 cursor disagrees with the query
parameters that minted it; see 422.
VALIDATION_FAILED (400 variant)
Some routes return VALIDATION_FAILED with status 400 instead of 422, for
example a malformed (non-UUID) path id on GET /v1/messages/{id} and other
get-by-id routes. The body shape is identical to the
422 form: per-field explanations in error.details.
Handle it: treat 400 and 422 VALIDATION_FAILED the same way. Branch on
the code, read details, fix the offending fields.
401 Unauthorized
UNAUTHORIZED
Authentication failed: missing Authorization header, malformed bearer,
unknown or revoked API key, or an invalid session token. The message is the
same canonical "authentication failed" for every failure mode by design, so
the response cannot be used to probe which part failed.
{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "authentication failed",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: do not retry with the same credentials. Check that the header
is exactly Authorization: Bearer am_live_... and that the key has not been
revoked. Keys are managed in the dashboard under Settings, then API keys.
402 Payment Required
INSUFFICIENT_CREDIT
Your wallet balance cannot cover the charge the request would incur. Returned
by POST /v1/phone-numbers/orders and POST /v1/phone-numbers/bulk (the
number's first month of rent), POST /v1/brands (the one-time $5.00
registration charge; no brand is created), and POST /v1/lookup. The gated
action is rejected before any change is made.
{
"success": false,
"error": {
"code": "INSUFFICIENT_CREDIT",
"message": "insufficient credit",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: top up via
POST /v1/billing/topup or enable
auto top-up via PUT /v1/billing/wallet/auto-topup,
then retry.
BELOW_MINIMUM_CREDIT
The chosen top-up option is below your plan's min_topup_cents floor.
Returned by POST /v1/billing/topup and PUT /v1/billing/wallet/auto-topup.
Under pay-as-you-go the floor is NULL, so every listed option is accepted
and this branch does not trip.
{
"success": false,
"error": {
"code": "BELOW_MINIMUM_CREDIT",
"message": "balance below tier minimum",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: pick a larger option from
GET /v1/billing/topup/products.
NO_PLAN
The organization has no plan on file. Returned by
PUT /v1/billing/wallet/auto-topup with status 402, and by
POST /v1/billing/topup with status 404 (same code, different status on that
route).
{
"success": false,
"error": {
"code": "NO_PLAN",
"message": "subscription plan required",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: not retryable. Complete account setup in the dashboard; if the
organization should already have a plan, contact support with the
request_id.
403 Forbidden
FORBIDDEN
The credentials authenticated but lack the scope the route requires, for
example a key without messages:send calling POST /v1/messages, or a key
with only numbers:write passing ?return_to_carrier=true on
DELETE /v1/phone-numbers/{id}.
{
"success": false,
"error": {
"code": "FORBIDDEN",
"message": "missing required scope",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: not retryable with the same key. Create or rotate a key that carries the required scope (each operation's reference page lists it).
USER_REQUIRED
The route requires a signed-in user session and cannot be called with an API
key: GET/PATCH /v1/me/notifications and POST /v1/legal-acceptances.
API keys are organization-scoped, not user-scoped, so they cannot act on a
user's behalf here.
{
"success": false,
"error": {
"code": "USER_REQUIRED",
"message": "endpoint requires a user session",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: perform the action from the dashboard (or any client holding a user session token) instead of an API key.
PLAN_REQUIRED
The organization's plan tier is below the floor a route requires. Distinct
from FORBIDDEN (scope denied). Pay-as-you-go, the universal default, clears
every surviving plan gate; only organizations still on a dormant legacy tier
can receive this on the attested-consent endpoints (POST /v1/consent and
POST /v1/consent/bulk).
{
"success": false,
"error": {
"code": "PLAN_REQUIRED",
"message": "Tier 3 consent capture requires Growth or higher; use double-opt-in (POST /v1/consent/double-opt-in) or the hosted form (POST /v1/consent/hosted) on Starter",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: the message names the alternative. For consent capture, use double opt-in or the hosted form, both available on every tier.
PLATFORM_REQUIRED
A non-staff caller sent the staff-only X-Target-Org header (any value), or
attempted a staff-only operation. The response is the same regardless of the
header's value, so it cannot be used to probe whether an organization exists.
{
"success": false,
"error": {
"code": "PLATFORM_REQUIRED",
"message": "X-Target-Org: non-staff principal: platform scope required",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: remove the X-Target-Org header. Customer requests are always
scoped to the organization that owns the credentials; there is nothing to set.
404 Not Found
NOT_FOUND
The resource does not exist, or it belongs to another organization. Cross-org misses deliberately collapse to 404 rather than 403 so resource ids cannot be enumerated.
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "not found",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: verify the id and that it was created by your organization. A released phone number also returns 404 afterward, because it no longer belongs to you.
POST /v1/billing/topup returns status 404 with code NO_PLAN when the
organization has no plan; see 402 NO_PLAN.
409 Conflict
CONFLICT
The requested state transition is already in effect, for example enabling
auto top-up when it is already enabled (PUT /v1/billing/wallet/auto-topup).
{
"success": false,
"error": {
"code": "CONFLICT",
"message": "auto-topup already enabled for org",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: usually safe to treat as success. GET the resource to confirm
the current state.
INSUFFICIENT_INVENTORY
A phone-number order could not be fully satisfied from the available pool
(POST /v1/phone-numbers/orders, POST /v1/phone-numbers/bulk). The order is
all or nothing: nothing was assigned. On the orders route the envelope also
carries a typed data payload with available_count and the unavailable
list.
{
"success": false,
"error": {
"code": "INSUFFICIENT_INVENTORY",
"message": "insufficient inventory: requested 2, available 1",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
},
"data": {
"available_count": 1,
"unavailable": ["+14155550456"]
}
}Handle it: drop the unavailable numbers, run a fresh
POST /v1/phone-numbers/search, and
retry. Search results are a preview, not a reservation, so this is an expected
race.
MESSAGE_NOT_CANCELABLE
DELETE /v1/messages/{id} could not cancel the message: it has already been
claimed for sending or reached a terminal state. Unknown and cross-org ids
also surface as this 409 (not 404) so a guessed id cannot be used as a probe.
Repeating the DELETE on an already-canceled message is idempotent and
returns 200.
{
"success": false,
"error": {
"code": "MESSAGE_NOT_CANCELABLE",
"message": "message is not cancelable",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: GET /v1/messages/{id} to read the current status and stop
retrying the cancel.
NUMBER_NOT_ASSIGNED
Campaign attach or detach targeted a phone number that is not currently in
the assigned state (POST/DELETE /v1/phone-numbers/{id}/campaign).
{
"success": false,
"error": {
"code": "NUMBER_NOT_ASSIGNED",
"message": "phone number is not assigned",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: check the number via GET /v1/phone-numbers/{id}; only numbers
your organization currently holds can carry a campaign.
CAMPAIGN_NOT_ACTIVE
The referenced campaign exists but is not ACTIVE (it is PENDING or
EXPIRED). Returned when attaching a campaign to a number and when ordering
numbers with a campaign_id set.
{
"success": false,
"error": {
"code": "CAMPAIGN_NOT_ACTIVE",
"message": "campaign is not active",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: wait for the campaign to reach ACTIVE (poll
GET /v1/campaigns/{id} or subscribe to campaign events via
POST /v1/webhooks), then retry.
Carriers control review timelines.
BRAND_NOT_VERIFIED
The campaign's linked brand does not have identity status VERIFIED or
VETTED_VERIFIED. Returned by POST /v1/campaigns, campaign attach, and
number orders that reference such a campaign.
{
"success": false,
"error": {
"code": "BRAND_NOT_VERIFIED",
"message": "brand identity is not verified",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: check GET /v1/brands/{id} and retry once the brand is
verified. Brand verification completes asynchronously after registration.
LIMIT_EXCEEDED
Your organization already holds the maximum of 50 active webhook endpoints
(POST /v1/webhooks).
{
"success": false,
"error": {
"code": "LIMIT_EXCEEDED",
"message": "webhook endpoint limit reached (max 50)",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: delete unused endpoints
(DELETE /v1/webhooks/{id}) before
creating new ones.
NO_STRIPE_CUSTOMER
POST /v1/billing/portal was called before the organization has ever made a
payment, so there is no billing-provider customer record to open a portal for.
{
"success": false,
"error": {
"code": "NO_STRIPE_CUSTOMER",
"message": "organization has no stripe customer; subscribe first",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: complete a first top-up via
POST /v1/billing/topup, then open the
portal.
410 Gone
EVENT_EXPIRED
POST /v1/webhooks/deliveries/{id}/replay targeted a delivery whose source
event has aged out of retention. There is nothing left to replay.
{
"success": false,
"error": {
"code": "EVENT_EXPIRED",
"message": "source event has been pruned and cannot be replayed",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: permanent for that delivery; stop retrying. Reconstruct state
from the current resource (for example GET the underlying object) instead of
the replay.
422 Unprocessable Entity
VALIDATION_FAILED
The request shape failed per-field validation. error.details carries one
entry per offending field with a human-readable explanation. This is the most
common error in the API; on some get-by-id routes the same code is returned
with status 400 for malformed path ids.
{
"success": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "validation failed",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM",
"details": {
"to": "must be E.164"
}
}
}Handle it: map details keys back to your request fields, fix them, and
retry. Retrying unchanged input returns the same error.
LINE_TYPE_BLOCKED
POST /v1/messages rejected the send because the most recent successful
lookup classified the recipient as a confirmed FIXED (landline) line, which
cannot receive SMS. The block is automatic and on by default. Your wallet is
not debited and no message record is created.
{
"success": false,
"error": {
"code": "LINE_TYPE_BLOCKED",
"message": "recipient phone number is a confirmed FIXED line and is not SMS-capable",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM",
"details": {
"phone_number": "+15551234567",
"line_type": "FIXED",
"lookup_id": "0190a1b2-c3d4-e5f6-a7b8-c9d0e1f2a3b4",
"looked_up_at": "2026-04-15T12:00:00Z"
}
}
}Handle it: not retryable; remove the number from your send list. Fetch the
underlying classification with GET /v1/lookup/{lookup_id} if you need the
evidence. MOBILE, VOIP, and VOIP-FIXED lines are not blocked.
QUIET_HOURS
POST /v1/messages rejected the send because the recipient's local time falls
inside your organization's configured quiet-hours window and the
organization's quiet-hours mode is reject. Your wallet is not debited and no
message record is created. details carries the resolved timezone, the
window, and next_send_at, the next allowable send time in RFC 3339.
Organizations in defer mode never see this code: the send is rescheduled
automatically and returns 202.
{
"success": false,
"error": {
"code": "QUIET_HOURS",
"message": "send falls inside quiet-hours window",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM",
"details": {
"recipient_timezone": "America/New_York",
"quiet_hours_start": "21:00",
"quiet_hours_end": "08:00",
"next_send_at": "2026-04-27T12:00:00Z"
}
}
}Handle it: resubmit with scheduled_at set to details.next_send_at (or
later), or switch the organization to defer mode so the platform reschedules
for you.
QUIET_HOURS_BYPASS_FORBIDDEN
The request set bypass_quiet_hours: true but the credentials do not hold the
messages:bypass_quiet_hours scope. Deliberately distinct from 403
FORBIDDEN so clients can prompt for the specific missing capability.
{
"success": false,
"error": {
"code": "QUIET_HOURS_BYPASS_FORBIDDEN",
"message": "bypass_quiet_hours requires the messages:bypass_quiet_hours scope",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: drop the flag, or use a key that carries the
messages:bypass_quiet_hours scope.
INVALID_INPUT (422 variant)
On cursor-paginated metrics endpoints, a next-page request whose query
parameters disagree with the cursor (the cursor pins the query shape) returns
422 INVALID_INPUT.
Handle it: when paging, keep every query parameter identical to the request that produced the cursor.
INVALID_TIMEZONE
PATCH /v1/me received a timezone that is not a valid IANA timezone name.
{
"success": false,
"error": {
"code": "INVALID_TIMEZONE",
"message": "invalid timezone",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM",
"details": {
"timezone": "unknown IANA timezone"
}
}
}Handle it: send a canonical IANA name such as America/New_York.
UNKNOWN_NOTIFICATION_KEY
PATCH /v1/me/notifications received a key outside the documented schema (an
unknown channel, or an unknown toggle inside email). Typos fail fast instead
of silently doing nothing.
{
"success": false,
"error": {
"code": "UNKNOWN_NOTIFICATION_KEY",
"message": "unknown notification key",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM",
"details": {
"email.product_news": "unknown toggle"
}
}
}Handle it: use only the keys returned by GET /v1/me/notifications.
IMMUTABLE_NOTIFICATION_KEY
PATCH /v1/me/notifications attempted to set email.security_alerts.
Critical security alerts are not user-toggleable.
{
"success": false,
"error": {
"code": "IMMUTABLE_NOTIFICATION_KEY",
"message": "notification key is immutable",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM",
"details": {
"email.security_alerts": "security alerts cannot be disabled"
}
}
}Handle it: remove that key from the patch.
LOOKUP_UNSUPPORTED_REGION
POST /v1/lookup received a number outside the supported regions. Lookups
cover US and Canada E.164 numbers.
{
"success": false,
"error": {
"code": "LOOKUP_UNSUPPORTED_REGION",
"message": "lookup region not supported",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: not retryable for that number; only submit +1 numbers.
GRANULARITY_NOT_AVAILABLE
A /v1/metrics/* request asked for a finer granularity than the
organization's plan tier allows. details.max_granularity names the finest
granularity you can request.
{
"success": false,
"error": {
"code": "GRANULARITY_NOT_AVAILABLE",
"message": "granularity not available at this plan tier",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM",
"details": {
"max_granularity": "day"
}
}
}Handle it: re-request at details.max_granularity or coarser.
WINDOW_TOO_LARGE
A /v1/metrics/* window would produce more than 10,000 buckets at the
requested granularity.
{
"success": false,
"error": {
"code": "WINDOW_TOO_LARGE",
"message": "window too large for granularity; downsample or narrow the window",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM",
"details": {
"bucket_count": "44640",
"max_buckets": "10000"
}
}
}Handle it: narrow the window or use a coarser granularity so
bucket_count falls at or under max_buckets.
METRIC_NOT_AVAILABLE
GET /v1/metrics/lookup was called by an organization that has never run a
lookup. Returned instead of a misleading all-zeros series so dashboards can
render a clear empty state.
{
"success": false,
"error": {
"code": "METRIC_NOT_AVAILABLE",
"message": "metric has no eligible source rows for this org",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: treat as an empty state, not a failure.
RESELLER_REQUIRED, RESELLER_NOT_ELIGIBLE, RESELLER_HAS_BRANDS
These three codes gate the organization mode for registering brands on behalf
of other companies (the is_reseller setting on
PATCH /v1/organization):
| Code | Returned by | Meaning |
|---|---|---|
RESELLER_REQUIRED | POST /v1/brands | By default an organization can register one brand (its own). Registering a second requires enabling the on-behalf-of-other-companies mode first. |
RESELLER_NOT_ELIGIBLE | PATCH /v1/organization | Enabling the mode requires a commercial organization; personal-tenant organizations cannot enable it. |
RESELLER_HAS_BRANDS | PATCH /v1/organization | The mode cannot be turned off while any brand still exists. |
Handle it: enable the mode via PATCH /v1/organization with
is_reseller: true (commercial organizations only) before registering brands
for other companies; remove brands before turning the mode off.
429 Too Many Requests
RATE_LIMITED
A per-organization rate limit was exhausted. The response always carries a
Retry-After header with a positive whole number of seconds.
HTTP/1.1 429 Too Many Requests
Retry-After: 1{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "rate limited",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: wait at least Retry-After seconds, then retry. See
Rate limits and throughput for which routes are limited
and how the send ceiling grows.
453 Consent Required
CONSENT_REQUIRED
POST /v1/messages was rejected because the recipient has no opt-in on file
with your organization. Consent gating is on by default for every send: no
consent record, or a record flipped to opted-out (for example after the
recipient texted STOP), blocks the send before anything is queued or charged.
The status is non-standard on purpose. Consent is neither an authorization problem (403) nor a validation problem (422); giving it a dedicated status lets clients branch on it cleanly without string-matching, and makes missing consent impossible to confuse with a missing scope.
{
"success": false,
"error": {
"code": "CONSENT_REQUIRED",
"message": "consent required",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: put consent on file before sending, using whichever capture flow fits your product:
POST /v1/consent/double-opt-insends a confirmation SMS; the recipient replies to confirm.POST /v1/consent/hostedmints a hosted consent form URL you hand to the end user.POST /v1/consentrecords an opt-in you already captured, with a full evidence bundle.
Once the opt-in is recorded, retry the send. Do not retry on a loop without capturing consent; the answer will not change.
502 Bad Gateway
LOOKUP_FAILED
POST /v1/lookup hit a platform-side failure. Your wallet is refunded
automatically.
{
"success": false,
"error": {
"code": "LOOKUP_FAILED",
"message": "internal server error",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: retry later. Send an Idempotency-Key header on lookups so a
retry that races a slow success returns the existing result without a second
charge.
BILLING_PORTAL_UNAVAILABLE
POST /v1/billing/portal could not create a portal session with the billing
provider.
{
"success": false,
"error": {
"code": "BILLING_PORTAL_UNAVAILABLE",
"message": "billing portal session could not be created",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: transient; retry later.
503 Service Unavailable
MMS_DISABLED
POST /v1/messages/media-upload was called while MMS is not available.
{
"success": false,
"error": {
"code": "MMS_DISABLED",
"message": "mms subsystem disabled",
"request_id": "01JTBQH2FZ8K1RXC0WJ4Z9P3VM"
}
}Handle it: treat MMS as unavailable and fall back to SMS, or retry later.
5xx and reserved codes
Unhandled server errors return status 500 with code INTERNAL_ERROR and a
generic message; internal detail is never exposed. Retry with backoff and
include the request_id when contacting support.
The spec's master code list also reserves the following. They are not returned by the customer endpoints above in normal operation (several belong to staff-only surfaces), but a forward-compatible client should fall back to the HTTP status class when it sees a code it does not recognize:
NOT_IMPLEMENTED, METHOD_NOT_ALLOWED, TIMEOUT, CLIENT_CLOSED_REQUEST,
SESSION_INVALID, PLAN_LIMIT_EXCEEDED, TIER_MISMATCH,
HAS_ACTIVE_CAMPAIGNS, REFERENCE_ID_CONFLICT, INVALID_FIELD,
NUMBER_ASSIGNED, ORG_NOT_FOUND.