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}/events when 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 null for 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 errors to 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/quote first
  • The order is already in a final state (delivered, closed_with_return or nulled) and can't be cancelled or updated
  • Your account has no active service, or service_id isn'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 (4N or SH + 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-After header, then retry
  • Watch X-RateLimit-Remaining on 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(),
})

Was this page helpful?