AgentMessage

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"
    }
  }
}
FieldMeaning
error.codeStable, machine-readable. Safe to branch on.
error.messageHuman-readable. May evolve; do not parse it.
error.request_idAlso on the X-Request-Id response header. Quote it when contacting support.
error.detailsPer-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):

CodeReturned byMeaning
RESELLER_REQUIREDPOST /v1/brandsBy default an organization can register one brand (its own). Registering a second requires enabling the on-behalf-of-other-companies mode first.
RESELLER_NOT_ELIGIBLEPATCH /v1/organizationEnabling the mode requires a commercial organization; personal-tenant organizations cannot enable it.
RESELLER_HAS_BRANDSPATCH /v1/organizationThe 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.

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:

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.