# Error Handling

> What to do when something goes wrong. Status codes, the response shapes you will actually see, and how to handle them.

Source: https://api-doc.4nortes.app/errors

Here's what can go wrong, what the response looks like in each case, and how to handle it in your code.

> **Warning:**
>
> 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).

<a id="status-codes"></a>

## HTTP status codes

- `200 OK`: The request succeeded.
- `201 Created`: A new order was created (`POST /orders/create`).
- `401 Unauthorized`: 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](https://api-doc.4nortes.app/authentication.md).
- `403 Forbidden`: Only returned by `GET /orders/{reference}/events` when you ask for internal events and that option isn't enabled for your account.
- `404 Not Found`: The order, tracking number, locality or tariff doesn't exist — or the order doesn't belong to your account.
- `422 Unprocessable Entity`: 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).
- `429 Too Many Requests`: Rate limit exceeded: 100 requests per minute per account. Wait for `Retry-After`.
- `500 Internal Server Error`: Something went wrong on our end. Retry with backoff.

The API never returns `400 Bad Request`; malformed input is a `422`.

---

<a id="error-response"></a>

## 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:

- `success` (boolean): Always `false`.
- `message` (string): Human-readable description, in English.
- `errors` (null): Always `null` for these errors.
- `timestamp` (string): ISO 8601 UTC timestamp of the response.

```json
{
  "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`**.

- `message` (string): For validation: the first error, plus `(and N more errors)` when there are several. Validation messages may be in Spanish — don't parse them.
- `errors` (object): Validation only. Keys are the fields that failed; values are arrays of messages. Branch on the keys.

```json
{
  "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."]
  }
}
```

```json
{
  "message": "Unauthenticated."
}
```

---

<a id="common-errors"></a>

## 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

```json
{
  "message": "Unauthenticated."
}
```

```json
{
  "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](https://api-doc.4nortes.app/api/orders.md)
- Treat message text as display-only

```json
{
  "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`](https://api-doc.4nortes.app/api/references.md#quote-rates) 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

```json
{
  "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

```json
{
  "success": false,
  "message": "Order not found.",
  "errors": null,
  "timestamp": "2025-02-03T10:30:00.000000Z"
}
```

```json
{
  "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](https://api-doc.4nortes.app/webhooks.md) instead of polling, and cache [reference data](https://api-doc.4nortes.app/api/references.md#caching)

```json
{
  "message": "Too Many Attempts."
}
```

```http
Retry-After: 42
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1738578642
```

---

<a id="handling-errors"></a>

## Handle errors properly

### 1. Branch on the status code

One request helper that turns every failure into a typed error, whatever the body shape:

```js
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.

```js
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

```js
console.error('4N NextDay API error', {
  status: error.status,
  path,
  message: error.message,
  fields: error.fields,
  retryAfter: error.retryAfter,
  at: new Date().toISOString(),
})
```
