Error Handling
Here's what can go wrong, what the response looks like in each case, and how to handle it in your code.
Branch on the HTTP status code and, for validation errors, on the field keys inside errors. Never branch on a success flag (not every error has one) or on message text (it can change, and validation messages may come back in Spanish).
HTTP status codes
- Name
200 OK- Description
The request succeeded.
- Name
201 Created- Description
A new order was created (
POST /orders/create).
- Name
401 Unauthorized- Description
Your token is missing, invalid, expired or revoked — or it is valid but isn't a merchant API token and you called a merchant-only endpoint. See Authentication.
- Name
403 Forbidden- Description
Only returned by
GET /orders/{reference}/eventswhen you ask for internal events and that option isn't enabled for your account.
- Name
404 Not Found- Description
The order, tracking number, locality or tariff doesn't exist — or the order doesn't belong to your account.
- Name
422 Unprocessable Entity- Description
Either the request failed validation (missing fields, wrong types, out-of-range values) or a business rule blocked it (locality not covered, order can't be cancelled, no active service).
- Name
429 Too Many Requests- Description
Rate limit exceeded: 100 requests per minute per account. Wait for
Retry-After.
- Name
500 Internal Server Error- Description
Something went wrong on our end. Retry with backoff.
The API never returns 400 Bad Request; malformed input is a 422.
Response shapes
There are two shapes, depending on where the error is produced.
Application errors (envelope)
Errors raised by the API's own logic — not found, business rules, locality not covered, merchant-only endpoint called with another kind of token — use the same envelope as successful responses:
- Name
success- Type
- boolean
- Description
Always
false.
- Name
message- Type
- string
- Description
Human-readable description, in English.
- Name
errors- Type
- null
- Description
Always
nullfor these errors.
- Name
timestamp- Type
- string
- Description
ISO 8601 UTC timestamp of the response.
422 — business rule (envelope)
{
"success": false,
"message": "Merchant Acme Store has no coverage for locality: Putre",
"errors": null,
"timestamp": "2025-02-03T10:30:00.000000Z"
}
Framework errors (no envelope)
Authentication, request validation, rate limiting and unexpected server errors are answered before the API logic runs. They carry a message and, for validation only, an errors map — but no success and no timestamp.
- Name
message- Type
- string
- Description
For validation: the first error, plus
(and N more errors)when there are several. Validation messages may be in Spanish — don't parse them.
- Name
errors- Type
- object
- Description
Validation only. Keys are the fields that failed; values are arrays of messages. Branch on the keys.
422 — validation (raw)
{
"message": "The sender name is required. (and 2 more errors)",
"errors": {
"sender_name": ["The sender name is required."],
"recipient_name": ["The recipient name is required."],
"weight": ["The weight must be a number."]
}
}
401 — missing or invalid token (raw)
{
"message": "Unauthenticated."
}
Common problems
Bad token
401 Unauthorized
Missing Authorization header, wrong format, or a token that is invalid, expired or revoked. The body is a bare message.
If the token is valid but was not issued to a merchant account, merchant-only endpoints (/orders/*, /services) answer with an envelope 401 and Authentication required. instead.
Fix it:
- Send
Authorization: Bearer {token}(with the space) - Ask your 4N NextDay administrator for a new token if yours expired or was revoked
401 — invalid, expired or revoked token
{
"message": "Unauthenticated."
}
401 — valid token, not a merchant token
{
"success": false,
"message": "Authentication required.",
"errors": null,
"timestamp": "2025-02-03T10:30:00.000000Z"
}
Invalid data
422 Unprocessable Entity (validation)
Something's wrong with the data you sent: a required field is missing, a type is wrong or a value is out of range. Nothing was processed.
Fix it:
- Read the keys of
errorsto find the fields to fix - Compare against the request body tables in the Orders API
- Treat message text as display-only
422 — validation
{
"message": "The weight must be a number. (and 1 more error)",
"errors": {
"weight": ["The weight must be a number."],
"service_id": ["The selected service does not exist."]
}
}
Business rules
422 Unprocessable Entity (envelope)
The payload is valid, but the action isn't allowed right now. errors is null.
Common cases:
- Your account doesn't deliver to that locality — check with
POST /rates/quotefirst - The order is already in a final state (
delivered,closed_with_returnornulled) and can't be cancelled or updated - Your account has no active service, or
service_idisn't one of your services
422 — business rule
{
"success": false,
"message": "There are no active services available for this merchant.",
"errors": null,
"timestamp": "2025-02-03T10:30:00.000000Z"
}
Not found
404 Not Found
The resource doesn't exist, or it isn't yours. Orders from other accounts are reported as not found, never as forbidden.
Fix it:
- Double-check the tracking number (
4NorSH+ 12 digits) - Make sure the order belongs to your account
- For quotes: the locality name didn't match anything, or it has no tariff configured yet
404 — order
{
"success": false,
"message": "Order not found.",
"errors": null,
"timestamp": "2025-02-03T10:30:00.000000Z"
}
404 — tracking
{
"success": false,
"message": "Tracking number not found.",
"errors": null,
"timestamp": "2025-02-03T10:30:00.000000Z"
}
Too many requests
429 Too Many Requests
You're calling the API too fast. The limit is 100 requests per minute per account.
Fix it:
- Wait the number of seconds in the
Retry-Afterheader, then retry - Watch
X-RateLimit-Remainingon every response to pace yourself - Use webhooks instead of polling, and cache reference data
429 — rate limited
{
"message": "Too Many Attempts."
}
Response headers
Retry-After: 42
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1738578642
Handle errors properly
1. Branch on the status code
One request helper that turns every failure into a typed error, whatever the body shape:
class ApiError extends Error {
constructor(status, body, response) {
super(body.message ?? `HTTP ${status}`)
this.status = status
// Only validation errors (422) carry `errors`; its keys are the failing fields
this.fields = body.errors ? Object.keys(body.errors) : []
this.retryAfter = Number(response.headers.get('Retry-After')) || null
}
}
async function callApi(path, options = {}) {
const response = await fetch(`https://api.4nortes.app/api/v1${path}`, {
...options,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers,
},
})
const body = await response.json().catch(() => ({}))
if (!response.ok) {
throw new ApiError(response.status, body, response)
}
return body.data
}
// Usage
try {
const order = await callApi('/orders/create', {
method: 'POST',
body: JSON.stringify(orderData),
})
} catch (error) {
if (error.status === 422 && error.fields.length > 0) {
// Validation: highlight error.fields in your form
} else if (error.status === 422) {
// Business rule: show error.message to the operator
} else if (error.status === 401) {
// Token problem: alert — nothing will work until it's replaced
} else {
throw error
}
}
2. Retry only when it makes sense
Retry 429 (after Retry-After) and 5xx (with exponential backoff). Never retry 401, 404 or 422 — the same request will fail the same way.
async function withRetry(fn, maxRetries = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await fn()
} catch (error) {
if (attempt >= maxRetries) throw error
if (error.status === 429) {
await sleep((error.retryAfter ?? 60) * 1000)
continue
}
if (error.status >= 500) {
await sleep(2 ** attempt * 1000)
continue
}
throw error
}
}
}
3. Log what you need to debug
console.error('4N NextDay API error', {
status: error.status,
path,
message: error.message,
fields: error.fields,
retryAfter: error.retryAfter,
at: new Date().toISOString(),
})