Errors & response format
The standard response envelope, the error object, and how to resolve every code the API can return.
Every API response shares the same structure, whether it succeeds or fails. Learn it once and it applies to every endpoint.
The response envelope
Successful responses wrap the result in data, with optional metadata in meta:
{
"success": true,
"data": { "id": "clxq1w2e3r4t5y6u7i8o9p0a", "status": "DRAFT" },
"meta": { "page": 1, "limit": 20, "total": 47, "totalPages": 3 }
}
Errors carry success: false and an error object:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"errors": {
"contactName": ["contactName must be longer than or equal to 1 characters"],
"postalCode": ["Postal code must be 4-6 digits"]
}
},
"timestamp": "2026-07-17T12:00:00.000Z",
"requestId": "req_abc123"
}
}
| Field | Description |
|---|---|
code |
Stable, machine-readable code — program against this, not message |
message |
Human-readable description; may change without notice |
details |
Error-specific context (invalid fields, hints, IDs) |
timestamp |
When the error occurred |
requestId |
Request identifier — include it when contacting support |
Every error code
| HTTP | Code | When it happens | How to resolve it |
|---|---|---|---|
| 400 | VALIDATION_ERROR |
Body or params failed validation | Check details.errors: it lists each invalid field and why |
| 400 | INVALID_INPUT |
Invalid combination (e.g. address by ID and inline at once) | Check details; send exactly one variant per field. Some cases carry a more specific code in details.code |
| 400 | API_VERSION_UNSUPPORTED |
You asked for an API version that reached its sunset date | Move to the current version. See versioning |
| 401 | UNAUTHORIZED |
Credential missing or malformed | Send your key in X-API-Key or Authorization: Bearer |
| 401 | INVALID_API_KEY |
Key doesn’t exist or was revoked | Verify the key; generate a new one if revoked |
| 401 | EXPIRED_API_KEY |
Key expired | Generate a new key and update your integration |
| 402 | INSUFFICIENT_BALANCE |
Wallet can’t cover the operation | Fund your wallet and retry |
| 403 | FORBIDDEN |
Valid credential without sufficient permissions (role or org) | Use a credential with the right role/organization |
| 403 | INSUFFICIENT_SCOPE |
Key is missing scopes (listed in details) |
Add the missing scopes to the key |
| 403 | IP_NOT_ALLOWED |
Source IP is not on the key’s allowlist | Add the IP to the key’s ipAllowlist or call from an allowed IP |
| 403 | PLAN_LIMIT_REACHED |
You hit your plan’s cap (API keys, webhook endpoints, or members) | Upgrade your plan; details carries resource, limit, and plan — see plans |
| 403 | SOLE_OWNER_OF_ORGANIZATION |
You are deleting your account while sole OWNER of a team organization | Transfer ownership to another member, then retry |
| 404 | RESOURCE_NOT_FOUND |
Resource doesn’t exist or belongs to another organization | Verify the ID and your key’s organization |
| 409 | SHIPMENT_ALREADY_PROCESSED |
Shipment is no longer in an editable state | Check the current status; only DRAFT shipments can be edited |
| 409 | IDEMPOTENCY_KEY_REUSED |
Idempotency-Key reused with a different body or endpoint | Use a fresh key for each distinct operation |
| 409 | IDEMPOTENCY_KEY_IN_USE |
A concurrent request with the same key is still in flight | Wait a moment and retry with the same key |
| 409 | SHIPMENT_LABEL_IN_PROGRESS |
Two concurrent purchases on the same shipment | Retry in 1–2 seconds |
| 410 | RATES_EXPIRED |
The shipment’s rates expired (24 h) | GET /v1/shipments/:id/rates to refresh, then pick a new rateId |
| 422 | CARRIER_NOT_SUPPORTED |
You asked to track a carrier SendIt cannot poll | Use DHL, FEDEX, or ESTAFETA. See trackers |
| 422 | CARRIER_CREDENTIALS_REQUIRED |
That carrier needs account credentials to track | Connect your credentials. See carrier accounts |
| 422 | ONE_CALL_BUY_RATES_PENDING |
Carriers took longer than 8 s to quote during a one-call buy | Poll ratesPollUrl and buy with the rateId |
| 422 | ONE_CALL_BUY_NO_MATCHING_RATE |
No rate matched your purchase selection |
Pick one from details.availableRates |
| 429 | RATE_LIMIT_EXCEEDED |
You exceeded the request limit | Honor Retry-After with exponential backoff — see rate limits |
| 500 | INTERNAL_ERROR |
Server error | Retry; if it persists, contact support with the requestId |
| 502 | CARRIER_ERROR |
The carrier failed to generate the label | Any charge was already refunded; retry or pick another rate |
| 503 | CHECKOUT_NOT_CONFIGURED |
The plan you requested has no self-serve checkout | Contact sales for that plan |
Handle errors by code, not by message
const res = await fetch(url, options);
const body = await res.json();
if (!body.success) {
switch (body.error.code) {
case "RATES_EXPIRED":
// refresh rates and retry
break;
case "INSUFFICIENT_BALANCE":
// notify your operations team
break;
default:
log.error(body.error.requestId, body.error.code);
}
}
Check details.code too
Some validations return 400 INVALID_INPUT with a more specific code nested in error.details.code. Read it when you need to tell the exact case apart:
details.code |
When it happens |
|---|---|
PRECONDITION_FAILED |
An order’s If-Match does not match its current etag |
ORDER_HAS_ACTIVE_LABELS |
You tried to cancel an order that still has valid labels |
SHIPMENTS_NOT_FOUND |
A batch references shipments that do not exist or are not yours |
PRODUCT_NOT_FOUND |
A line item points at a product that does not exist |
LINE_ITEM_INCOMPLETE |
An inline line item is missing name or unitPrice |