Error Handling

The AgentPhone API uses standard HTTP status codes and returns detailed error information as JSON.

Error response format

Most API errors follow this structure:

1{
2 "error": {
3 "message": "Human-readable error message",
4 "code": "ERROR_CODE",
5 "type": "error_type",
6 "details": []
7 }
8}
FieldDescription
messageHuman-readable description of the error
codeMachine-readable error code (see below)
typeError category (validation_error, not_found, etc.)
detailsArray of field-level validation errors (present on 422 validation errors)

Some responses use a plain {"detail": "..."} body with no error code instead. This includes authentication errors (401/403), endpoint throttling (429 with a Retry-After header), the outbound-call concurrency cap (429, no Retry-After), insufficient balance (402), and number-related 409s. Robust error handling should check for both shapes.

HTTP status codes

CodeMeaningWhen it occurs
200OKSuccessful GET or POST request
201CreatedSuccessful POST /v1/contacts request (resource created)
400Bad RequestInvalid request parameters or validation error
401UnauthorizedMissing or invalid API key
402Payment RequiredInsufficient balance for a paid action
404Not FoundResource doesn’t exist or you don’t have access
409ConflictNumber limit reached, the requested number is unavailable, or a number is at its per-number concurrent-call limit
422Unprocessable EntityValidation error (invalid data format)
429Too Many RequestsRate limit or messaging cap exceeded (Retry-After header present on endpoint throttles)
500Internal Server ErrorServer error (see retry guidance below)
502Bad GatewayUpstream provider error (the operation may have executed; see retry guidance below)
503Service UnavailableThe server is temporarily unable to handle the request. Retry with exponential backoff.
504Gateway TimeoutThe server did not receive a timely response from an upstream service. Retry with exponential backoff.

Error codes

VALIDATION_ERROR

Request validation failed. Check the details field for specific field errors.

1{
2 "error": {
3 "message": "Validation error",
4 "code": "VALIDATION_ERROR",
5 "type": "validation_error",
6 "details": [
7 {
8 "field": "country",
9 "message": "Country must be a 2-letter ISO code",
10 "type": "value_error"
11 }
12 ]
13 }
14}

Number limit reached

Self-serve accounts can provision up to 10 numbers. POST /v1/numbers returns HTTP 409 with a plain {"detail": "..."} body when you hit the cap. Contact us to increase your limit.

Insufficient balance

Your balance is too low to complete a paid action. Returned as HTTP 402 with a plain {"detail": "..."} body; the message describes the balance requirement (for example, the minimum needed to provision a number and your current balance). Add funds from the Billing page or enable auto-recharge. Provisioning a number requires at least $3.00.

Rate limiting (429)

429 responses come in three flavors:

  • Endpoint throttles return a plain {"detail": "Too many requests. Please try again in N seconds."} body with a Retry-After header. Wait that long, then retry.
  • Call concurrency caps on POST /v1/calls return a plain {"detail": "Concurrent outbound call limit reached (N). Please wait for an active call to end."} body with no Retry-After header. This clears as your active calls end, so wait briefly and retry.
  • Messaging limits return the error envelope with a specific code and no Retry-After header. RATE_LIMITED is transient; the cap codes (CONVERSATION_STREAK_LIMIT, CONVERSATION_AWAITING_REPLY, CONVERSATION_INACTIVE, OUTBOUND_LIMIT_REACHED, NEW_CONVERSATION_LIMIT_REACHED, described below) clear only when the recipient replies or a daily window resets, never on retry.

RATE_LIMITED

Sending too fast. Returned with HTTP 429. This one is transient: slow down and retry shortly. No Retry-After header is included.

PHONE_NUMBER_NOT_FOUND

The requested phone number doesn’t exist or you don’t have access to it.

CONVERSATION_STREAK_LIMIT

You’ve sent too many messages in a row to one contact without a reply. Returned with HTTP 429.

Retrying will not clear this. The count only resets when the contact replies, and the limit applies to that one conversation, so your other threads keep sending. See Messaging Rate Limits for details.

OUTBOUND_LIMIT_REACHED

You’ve reached the daily cap for messaging contacts who have never messaged your line. Returned with HTTP 429.

Contacts who have messaged you before are not affected, so replies and re-engagement still go through. The cap resets daily and can be raised on request.

NEW_CONVERSATION_LIMIT_REACHED

You’ve reached the daily cap for starting new conversations. Returned with HTTP 429. Replies to existing conversations still go through, and the cap resets daily.

CONVERSATION_AWAITING_REPLY

Up to 3 messages can be sent to a brand-new recipient before they respond. Returned with HTTP 429. Retrying does not clear it; it unlocks when the recipient replies.

CONVERSATION_INACTIVE

The conversation has been inactive for over 14 days and its single re-engagement message was already sent. Returned with HTTP 429. It unlocks when the recipient replies.

INBOUND_ONLY

This line can’t send the first message to a new recipient. Returned with HTTP 422. Ask the recipient to message you first, or contact support to enable first-touch sends on the line.

WHATSAPP_NOT_ENABLED

WhatsApp has been switched off for this account. Returned with HTTP 403. WhatsApp is on by default for everyone, so this only appears if it was disabled manually. Email [email protected] to have it restored.

WhatsApp send errors

WhatsApp sends are rejected by Meta rather than by a carrier, and the reason comes back in the error message. The ones worth handling in code:

Meta codeWhat happenedWhat to do
131047The 24-hour window is closed. The recipient hasn’t messaged you in over a daySend an approved template instead
131042Your WhatsApp Business Account has no valid payment methodAdd one in WhatsApp Manager. Session messages are free, so this usually surfaces on your first template send
131026The recipient can’t receive messages. Often not a WhatsApp user, or blocked youDon’t retry. Fall back to SMS if you have consent
131051Unsupported message type for this recipientCheck the message shape against the WhatsApp guide
100Malformed request, usually a template whose variable shape doesn’t match how it was authoredPositional templates take an array, named templates take an object

Template sends outside the window are the single most common WhatsApp failure. Check capabilities.whatsappWindowExpiresAt on the conversation before a free-form send rather than discovering it from a 422.

Provider errors

SMS_PROVIDER_ERROR, MESSAGE_PROVIDER_ERROR, and TELEPHONY_PROVIDER_ERROR indicate a failure at an upstream messaging or telephony provider, usually returned with HTTP 502. These are typically temporary, but the operation may have executed before the failure. See the retry guidance below before resending.

Handling errors

Check response status

Handle both body shapes: the error envelope and the plain {"detail": "..."} form.

1import requests
2
3response = requests.post(url, headers=headers, json=data)
4if not response.ok:
5 body = response.json()
6 message = body["error"]["message"] if "error" in body else body.get("detail", response.reason)
7 print(f"API Error ({response.status_code}): {message}")
8 raise Exception(message)
9data = response.json()
1const response = await fetch(url, options);
2if (!response.ok) {
3 const body = await response.json().catch(() => ({}));
4 const message = body.error?.message ?? body.detail ?? response.statusText;
5 console.error(`API Error (${response.status}):`, message);
6 throw new Error(message);
7}
8const data = await response.json();

Handle rate limits

Only retry 429s that retrying can actually clear: endpoint throttles (which carry a Retry-After header), call concurrency caps (which clear as active calls end), and the transient RATE_LIMITED code. The messaging-cap codes don’t reset on retry, so surface them instead.

1import time, requests
2
3NON_RETRIABLE_429 = {
4 "CONVERSATION_STREAK_LIMIT", "CONVERSATION_AWAITING_REPLY", "CONVERSATION_INACTIVE",
5 "OUTBOUND_LIMIT_REACHED", "NEW_CONVERSATION_LIMIT_REACHED",
6}
7
8def request_with_retry(url, headers, json=None, max_retries=3):
9 for i in range(max_retries):
10 response = requests.post(url, headers=headers, json=json)
11 if response.status_code == 429:
12 body = response.json()
13 code = body.get("error", {}).get("code")
14 if code in NON_RETRIABLE_429:
15 # A retry won't clear these caps; handle them in your app logic
16 raise Exception(body["error"]["message"])
17 # Endpoint throttle (has Retry-After), call concurrency cap,
18 # or transient RATE_LIMITED: wait, then retry
19 time.sleep(int(response.headers.get("Retry-After", 30)))
20 continue
21 response.raise_for_status()
22 return response.json()
23 raise Exception("Max retries exceeded")
1const NON_RETRIABLE_429 = new Set([
2 "CONVERSATION_STREAK_LIMIT", "CONVERSATION_AWAITING_REPLY", "CONVERSATION_INACTIVE",
3 "OUTBOUND_LIMIT_REACHED", "NEW_CONVERSATION_LIMIT_REACHED",
4]);
5
6async function requestWithRetry(url, options, maxRetries = 3) {
7 for (let i = 0; i < maxRetries; i++) {
8 const response = await fetch(url, options);
9 if (response.status === 429) {
10 const body = await response.json().catch(() => ({}));
11 const code = body.error?.code;
12 if (NON_RETRIABLE_429.has(code)) {
13 // A retry won't clear these caps; handle them in your app logic
14 throw new Error(body.error?.message ?? "Messaging cap reached");
15 }
16 // Endpoint throttle (has Retry-After), call concurrency cap,
17 // or transient RATE_LIMITED: wait, then retry
18 const retryAfter = parseInt(response.headers.get("Retry-After") || "30");
19 await new Promise(r => setTimeout(r, retryAfter * 1000));
20 continue;
21 }
22 if (!response.ok) throw new Error(`Request failed: ${response.status}`);
23 return response.json();
24 }
25 throw new Error("Max retries exceeded");
26}

Retry transient errors

For 429, 500, 502, 503, and 504 errors on idempotent requests (GETs, and retries of the same update), implement exponential backoff:

1from requests.adapters import HTTPAdapter
2from urllib3.util.retry import Retry
3
4session = requests.Session()
5retry_strategy = Retry(
6 total=3,
7 backoff_factor=1,
8 status_forcelist=[429, 500, 502, 503, 504]
9)
10adapter = HTTPAdapter(max_retries=retry_strategy)
11session.mount("https://", adapter)

The API does not currently support idempotency keys. A 5xx on a send (POST /v1/messages, POST /v1/calls) can occur after the operation has already executed, so blindly retrying can double-send or double-bill. Before retrying a failed send, confirm whether it went through (for example, list recent messages in the conversation).

If you’re using the official SDKs, retry logic is built in. The TypeScript SDK automatically retries on 408, 429, and 5xx errors with exponential backoff (default: 2 retries).