Errors and retries
Every error has the same shape and a code you can test for. Know which requests are safe to send again before you add retries.
The error format
Errors use an HTTP status and a JSON body. Test for error.code; it is stable. The message is for people and its wording may change. Keep the request_id when you contact us.
{
"error": {
"code": "insufficient_funds",
"message": "The wallet does not have enough available balance for this.",
"request_id": "2f6a1c8e-5b93-4d07-a4e2-91c3d8b05f76"
}
}Codes
| Status | Code | What happened |
|---|---|---|
| 400 | validation_error | A field is missing, has the wrong type, or an unknown field was sent. The message says which. |
| 400 | invalid_days | Days given for a fixed package, missing for a package sold per day, or outside 1 to max_days. |
| 400 | invalid_quantity | Quantity is not 1 to 10. |
| 400 | invalid_cursor | The cursor is not one we gave you. Start the list again. |
| 400 | idempotency_key_required | POST /v1/orders needs an Idempotency-Key header. |
| 400 | invalid_idempotency_key | The key must be 8 to 100 letters, digits or the characters _ . : -. A UUID is ideal. |
| 400 | credentials_in_url | The key was sent in the address. Use the header. |
| 401 | invalid_api_key | The key is missing, wrong, expired or revoked. |
| 403 | insufficient_scope | The key lacks the scope this route needs. |
| 403 | organization_not_approved | The organisation is not approved, or has been suspended. |
| 403 | ip_not_allowed | The key is limited to other IP addresses. |
| 404 | package_not_found | No package with that id, or it cannot be bought now. |
| 404 | quote_not_found | No quote with that id for your organisation. |
| 404 | not_found | No order or eSIM with that id for your organisation, or no such address. |
| 409 | insufficient_funds | The available balance is lower than the order. Nothing was created. |
| 409 | quote_expired, quote_used | The quote is older than 5 minutes, or already has an order. Get a new quote. |
| 409 | quote_price_changed | The price moved since the quote. Get a new quote. |
| 409 | package_unavailable | The package cannot be bought now. |
| 413 | payload_too_large | The request body is larger than 64 KB. |
| 415 | unsupported_media_type | Send the body as application/json. |
| 422 | idempotency_key_reused | That key was used for a different order. |
| 429 | rate_limited | Too many requests. Wait for Retry-After seconds. |
| 500 | internal_error | Something went wrong on our side. Send the same request again; if it keeps happening, contact us with the request_id. |
| 503 | ordering_paused | Orders are paused for a short time. Nothing was charged. |
An order that was accepted and then could not be supplied is not an HTTP error. It comes back as an order with status failed and a failure.code, for example temporarily_unavailable. Nothing is charged.
What is safe to send again
| Request | Safe to repeat? |
|---|---|
| Every GET | Always. |
POST /v1/quotes | Yes. It only makes another quote; nothing is bought. |
POST /v1/orders | Yes, but only with the same Idempotency-Key and the same body. That returns the first order instead of buying again. |
Retry on a network failure, a timeout, a 5xx and a 429. Wait longer after each attempt, and use Retry-After when it is there. Do not retry other 4xx errors: the request itself needs to change.
# curl sends the same headers on every retry, so the Idempotency-Key stays the same.
curl https://api.travixorglobal.com/v1/orders \
--retry 5 --retry-all-errors --retry-delay 2 \
-H "Authorization: Bearer $TRAVIXOR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6b0f2c1e-8d4a-4c7e-9b53-1f2a7d90e4c8" \
-d '{"quote_id": "QUOTE_ID"}'// Retries a request only when it is safe: a network failure, a timeout, a 5xx, or a 429.
// For POST /v1/orders it is safe because every attempt carries the same Idempotency-Key.
async function sendWithRetry(url: string, init: RequestInit, attempts = 5): Promise<Response> {
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(30_000) })
const retryable = res.status === 429 || res.status >= 500
if (!retryable || attempt === attempts) return res
const wait = Number(res.headers.get('retry-after')) || 2 ** attempt
await new Promise((resolve) => setTimeout(resolve, wait * 1000))
} catch (error) {
if (attempt === attempts) throw error
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000))
}
}
}
const res = await sendWithRetry('https://api.travixorglobal.com/v1/orders', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.TRAVIXOR_API_KEY}`,
'content-type': 'application/json',
'idempotency-key': '6b0f2c1e-8d4a-4c7e-9b53-1f2a7d90e4c8',
},
body: JSON.stringify({ quote_id: 'QUOTE_ID' }),
})
console.log(res.status, await res.json())import os
import time
import requests
def send_with_retry(method, url, attempts=5, **kwargs):
"""Retries only when it is safe: a network failure, a timeout, a 5xx, or a 429.
For POST /v1/orders it is safe because every attempt carries the same Idempotency-Key."""
for attempt in range(1, attempts + 1):
try:
res = requests.request(method, url, timeout=30, **kwargs)
except (requests.ConnectionError, requests.Timeout):
if attempt == attempts:
raise
time.sleep(2**attempt)
continue
if (res.status_code == 429 or res.status_code >= 500) and attempt < attempts:
time.sleep(int(res.headers.get("Retry-After", 2**attempt)))
continue
return res
res = send_with_retry(
"POST",
"https://api.travixorglobal.com/v1/orders",
headers={
"Authorization": f"Bearer {os.environ['TRAVIXOR_API_KEY']}",
"Idempotency-Key": "6b0f2c1e-8d4a-4c7e-9b53-1f2a7d90e4c8",
},
json={"quote_id": "QUOTE_ID"},
)
print(res.status_code, res.json())Rate limits
Limits count requests from one IP address in a minute. Over the limit you get a 429 with a Retry-After header.
| Route | Per minute |
|---|---|
POST /v1/quotes | 60 |
POST /v1/orders | 30 |
| Everything else | 120 |
If you need more, tell us before you launch.