---
title: "Errors"
description: "Every failure throws. Two classes, and a request id on every API error."
url: "https://openemail.uk/docs/sdk/errors"
area: "SDK"
category: "Getting started"
---

# Errors

Every failure throws. Two classes, and a request id on every API error.

## Catching one

**catch-errors.ts**

```
import { OpenEmailApiError, OpenEmailNetworkError, openemail } from '@openemail/sdk'

try {
  await openemail.emails.send(message)
} catch (error) {
  if (error instanceof OpenEmailApiError) {
    if (error.isValidation) console.error(error.code, error.param, error.message)
    if (error.isPermission) console.error(await openemail.addresses.list())
    if (error.isRateLimited) console.error('try again in', error.retryAfterSeconds, 'seconds')

    console.error(error.status, error.requestId)
  }

  if (error instanceof OpenEmailNetworkError && error.isTimeout) console.error('no answer in time')

  throw error
}
```

A `permission_error` on a send is usually the KEY’s send scope, a domain or an address it was not given, rather than the workspace, which is why the sample prints what `addresses.list()` says this key may send as.

## The classes

| Class | When |
| --- | --- |
| `OpenEmailApiError` | The API answered, and not with a success. Carries `status`, `type`, `code`, `param`, `docUrl`, `requestId` and `retryAfterSeconds`. |
| `OpenEmailNetworkError` | No response arrived: DNS, TLS, a dropped connection, the timeout, or your own `AbortSignal`. Carries `cause`, and `isTimeout` is true when the timeout was the reason. |
| `Error` | Thrown before anything is sent: a missing or malformed key, an unusable `baseUrl`, a browser, an empty id. |

| Getter | True when |
| --- | --- |
| `isAuth` | `type` is `authentication_error`, a 401: no key, the wrong kind of credential, or a key we did not issue. |
| `isPermission` | `permission_error`, a 403: a real key without the scope or the From address it needs. |
| `isScopeMissing` | `code` is `insufficient_scope`, the 403 that names a missing scope. |
| `isInvalidRequest` | `invalid_request_error`, a 400: a request that could not be understood. A message over the size ceiling comes back as a 422 `message_too_large`, so `isValidation` is the getter that catches it. |
| `isValidation` | `validation_error`, a 422: the schema refused it, and `param` names the field. |
| `isNotFound` | `not_found_error`, a 404: no such resource. |
| `isConflict` | `conflict_error`, a 409: the resource is past the point where this could be done to it. |
| `isRateLimited` | `rate_limit_error`, a 429. `retryAfterSeconds` holds the wait when the server named one. |
| `isServerError` | `status` is 500 or above. Quote `requestId` if you contact support. |
| `isRetryable` | `status` is 408, 429, 500, 502, 503 or 504. |

The getters read `type`, the frozen half of the envelope. `code` stays a string, because the API guarantees it is open and additive, so treat one you do not recognise as its `type`. A closed union would make an SDK upgrade the price of reading a new failure mode.

> A body that is not the API’s error envelope still becomes an `OpenEmailApiError`, with `type` inferred from the status and `code` set to `unrecognised_response`. A success whose body is not JSON throws one too.

> An abort is an `OpenEmailNetworkError` as well, whether it lands during the request or during the wait before a retry, and the abort is kept on `cause`. Check `signal.aborted` when you need to tell your own cancellation from a network failure.

## requestId

Every `OpenEmailApiError` carries the request id the server sent, from the error body or the `x-request-id` header, and it is the only thing tying your failure to a line in the server's log. A success resolves to the parsed body alone, so there is no request id to read on one.
