> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mailchannels.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> Learn about common Email API errors, what they mean, and how to handle them.

The handling pattern depends on the language you use:

* **curl** — inspect the HTTP status returned by `curl -w "%{http_code}"` and parse the response body with `jq`.
* **JavaScript SDK** — SDK methods return `{ data, error }` (or `{ success, error }`). The `error` object has `message`
  and `statusCode` fields; branch on `error?.statusCode`.
* **Python SDK** — `mailchannels.Emails.send` (and other resource methods) raise typed exceptions that subclass
  `mailchannels.exceptions.MailChannelsError`. Catch the specific subclass to react to each status.
* **PHP SDK** — SDK methods throw typed exceptions that subclass `MailChannels\Exception\MailChannelsException`.
  Catch the specific subclass to react to each status; use `getStatusCode()` and `getMessage()` on the caught exception.

The examples below show how each endpoint's errors might be handled together in production code: surface transient
5xx failures so the caller (or a retry layer) can react, and fail fast on 4xx so the underlying request can be
corrected.

## Send API errors

| Status                      | Meaning                                                                                                                               | What to do                                                                                                               |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `400 Bad Request`           | The JSON body is malformed, a required field is missing, an email address is invalid, or non-transactional sending rules are not met. | Compare the payload with the [Email Structure](/email-api/how-emails-are-structured) guide and the API reference.        |
| `403 Forbidden`             | The API key is missing, invalid, scoped incorrectly, not attached to the expected account, or the sending domain is not authorized.   | Confirm the `X-Api-Key` header, check the key scope, and verify [Domain Lockdown](/email-api/domain-lockdown).           |
| `413 Payload Too Large`     | The encoded message, headers, and attachments exceed 30 MB.                                                                           | Reduce attachment size or count. See [Attachments](/email-api/attachments).                                              |
| `500 Internal Server Error` | A transient MailChannels service error.                                                                                               | Retry with exponential backoff. Check [status.mailchannels.net](https://status.mailchannels.net/) if the error persists. |
| `502 Bad Gateway`           | A transient infrastructure or network error.                                                                                          | Retry with backoff and alert if repeated attempts fail.                                                                  |

<CodeGroup>
  ```bash cURL theme={null}
  status=$(curl -s -o /tmp/mc-resp.json -w "%{http_code}" \
    -X POST https://api.mailchannels.net/tx/v1/send \
    -H "Content-Type: application/json" \
    -H "X-Api-Key: $MAILCHANNELS_API_KEY" \
    -d @payload.json)

  case "$status" in
    2*) ;;
    400|403|413)
      echo "Send rejected ($status):"
      jq -r '.errors // .message // empty' /tmp/mc-resp.json
      exit 1
      ;;
    500|502)
      echo "Send failed transiently ($status). Retry later."
      exit 1
      ;;
    *)
      echo "Unexpected status $status"
      exit 1
      ;;
  esac
  ```

  ```javascript Node.js theme={null}
  const { data, error } = await mailchannels.emails.send(payload);

  if (error) {
    switch (error.statusCode) {
      case 400: // Invalid payload — compare with the Email Structure guide.
      case 403: // Bad API key, wrong scope, or missing Domain Lockdown TXT record.
      case 413: // Message + attachments exceed 30 MB.
        console.error(`Send rejected (${error.statusCode}):`, error.message);
        throw error;
      case 500: // Transient MailChannels failure — surface for a retry layer.
      case 502: // Transient infrastructure / network failure — surface for a retry layer.
        console.error(`Send failed transiently (${error.statusCode}):`, error.message);
        throw error;
      default:
        throw error;
    }
  }
  ```

  ```python Python theme={null}
  import logging

  import mailchannels
  from mailchannels.exceptions import (
      ForbiddenError,
      InvalidRequestError,
      PayloadTooLargeError,
      ServerError,
  )

  log = logging.getLogger(__name__)

  try:
      response = mailchannels.Emails.send(payload)
  except (InvalidRequestError, ForbiddenError, PayloadTooLargeError) as e:
      # 400 / 403 / 413 — caller error. Fix the request before retrying.
      log.error("Send rejected (%s): %s", e.status_code, e.message)
      raise
  except ServerError as e:
      # 500 / 502 — transient. Surface so a retry layer can react.
      log.error(
          "Send failed transiently (%s, request_id=%s): %s",
          e.status_code,
          e.request_id,
          e.message,
      )
      raise
  ```

  ```php PHP theme={null}
  <?php

  require_once __DIR__ . '/vendor/autoload.php';

  use MailChannels\Exception\ForbiddenException;
  use MailChannels\Exception\InvalidRequestException;
  use MailChannels\Exception\PayloadTooLargeException;
  use MailChannels\Exception\ServerException;

  try {
      $response = $client->emails->send($payload);
  } catch (InvalidRequestException | ForbiddenException | PayloadTooLargeException $e) {
      // 400 / 403 / 413 — caller error. Fix the request before retrying.
      error_log(sprintf('Send rejected (%d): %s', $e->getStatusCode(), $e->getMessage()));
      throw $e;
  } catch (ServerException $e) {
      // 500 / 502 — transient. Surface so a retry layer can react.
      error_log(sprintf(
          'Send failed transiently (%d, request_id=%s): %s',
          $e->getStatusCode(),
          $e->getRequestId(),
          $e->getMessage(),
      ));
      throw $e;
  }
  ```
</CodeGroup>

## Suppression API errors

| Status                        | Meaning                                                       | What to do                                                                                                                                   |
| ----------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 Bad Request`             | A recipient, source, date filter, or request body is invalid. | Validate recipient formatting and use a supported source: `api`, `unsubscribe_link`, `list_unsubscribe`, `hard_bounce`, or `spam_complaint`. |
| `409 Conflict`                | One or more suppression entries already exist.                | Treat existing entries as success during idempotent imports, or query the suppression list before creating entries.                          |
| `413 Payload Too Large`       | The create request exceeds the 1,000-entry limit.             | Split imports into batches of 1,000 entries or fewer.                                                                                        |
| `500 Internal Server Error`   | A transient MailChannels service error.                       | Retry with exponential backoff. Check [status.mailchannels.net](https://status.mailchannels.net/) if the error persists.                     |
| `503 Temporarily Unavailable` | Suppression service maintenance or temporary unavailability.  | Retry later with backoff.                                                                                                                    |

<CodeGroup>
  ```bash cURL theme={null}
  # Assumes entries.json is already chunked to <= 1,000 entries per request.
  status=$(curl -s -o /tmp/mc-resp.json -w "%{http_code}" \
    -X POST https://api.mailchannels.net/tx/v1/suppressions \
    -H "Content-Type: application/json" \
    -H "X-Api-Key: $MAILCHANNELS_API_KEY" \
    -d @entries.json)

  case "$status" in
    2*) ;;
    409) ;;  # Entries already exist — idempotent success.
    400|413)
      echo "Suppression import rejected ($status):"
      jq -r '.message // empty' /tmp/mc-resp.json
      exit 1
      ;;
    500|503)
      echo "Suppression import failed transiently ($status). Retry later."
      exit 1
      ;;
    *)
      echo "Unexpected status $status"
      exit 1
      ;;
  esac
  ```

  ```javascript Node.js theme={null}
  const BATCH_SIZE = 1000;

  function chunked(items, size) {
    const out = [];
    for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
    return out;
  }

  for (const batch of chunked(entries, BATCH_SIZE)) {
    const { success, error } = await mailchannels.suppressions.create({ entries: batch });
    if (success) continue;

    switch (error.statusCode) {
      case 409: // Entries already exist — idempotent, treat as success.
        continue;
      case 400: // Invalid recipient, source, or filter.
      case 413: // Batch exceeds 1,000 entries.
        console.error(`Suppression import rejected (${error.statusCode}):`, error.message);
        throw error;
      case 500: // Transient failure — surface for a retry layer.
      case 503: // Suppression service maintenance — surface for a retry layer.
        console.error(`Suppression import failed transiently (${error.statusCode}):`, error.message);
        throw error;
      default:
        throw error;
    }
  }
  ```

  ```python Python theme={null}
  import logging

  import mailchannels
  from mailchannels.exceptions import (
      ConflictError,
      InvalidRequestError,
      PayloadTooLargeError,
      ServerError,
  )

  log = logging.getLogger(__name__)
  BATCH_SIZE = 1000


  def chunked(items, size):
      for i in range(0, len(items), size):
          yield items[i : i + size]


  for batch in chunked(entries, BATCH_SIZE):
      try:
          mailchannels.Suppressions.create(entries=batch)
      except ConflictError:
          # 409 — one or more entries already exist. Idempotent: treat as success.
          continue
      except (InvalidRequestError, PayloadTooLargeError) as e:
          # 400 / 413 — caller error. Fix the batch before retrying.
          log.error("Suppression import rejected (%s): %s", e.status_code, e.message)
          raise
      except ServerError as e:
          # 500 / 503 — transient. Surface so a retry layer can react.
          log.error(
              "Suppression import failed transiently (%s, request_id=%s): %s",
              e.status_code,
              e.request_id,
              e.message,
          )
          raise
  ```

  ```php PHP theme={null}
  <?php

  require_once __DIR__ . '/vendor/autoload.php';

  use MailChannels\Exception\ConflictException;
  use MailChannels\Exception\InvalidRequestException;
  use MailChannels\Exception\PayloadTooLargeException;
  use MailChannels\Exception\ServerException;

  const BATCH_SIZE = 1000;

  function chunked(array $items, int $size): array
  {
      return array_chunk($items, $size);
  }

  foreach (chunked($entries, BATCH_SIZE) as $batch) {
      try {
          $client->suppressions->create($batch);
      } catch (ConflictException) {
          // 409 — one or more entries already exist. Idempotent: treat as success.
          continue;
      } catch (InvalidRequestException | PayloadTooLargeException $e) {
          // 400 / 413 — caller error. Fix the batch before retrying.
          error_log(sprintf('Suppression import rejected (%d): %s', $e->getStatusCode(), $e->getMessage()));
          throw $e;
      } catch (ServerException $e) {
          // 500 / 503 — transient. Surface so a retry layer can react.
          error_log(sprintf(
              'Suppression import failed transiently (%d, request_id=%s): %s',
              $e->getStatusCode(),
              $e->getRequestId(),
              $e->getMessage(),
          ));
          throw $e;
      }
  }
  ```
</CodeGroup>

## Webhook API errors

| Status                      | Meaning                                                                                                                                                                 | What to do                                                                                                   |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `400 Bad Request`           | A webhook URL, batch ID, filter, or validation request is invalid.                                                                                                      | Check the URL, filter values, and batch ID before retrying.                                                  |
| `404 Not Found`             | The requested webhook batch or signing key does not exist. Applies to `POST /webhook-batch/{batch_id}/resend`, `GET /webhook/public-key`, and `POST /webhook/validate`. | Confirm the resource ID exists before retrying.                                                              |
| `409 Conflict`              | A webhook endpoint is already enrolled for this customer (`POST /webhook` only).                                                                                        | Treat as success for idempotent enrollment, or query existing webhooks with `GET /webhook` before enrolling. |
| `500 Internal Server Error` | A transient MailChannels service error.                                                                                                                                 | Retry and check [status.mailchannels.net](https://status.mailchannels.net/) if failures continue.            |

<CodeGroup>
  ```bash cURL theme={null}
  status=$(curl -s -o /tmp/mc-resp.json -w "%{http_code}" \
    -X POST "https://api.mailchannels.net/tx/v1/webhook?endpoint=$WEBHOOK_URL" \
    -H "X-Api-Key: $MAILCHANNELS_API_KEY")

  case "$status" in
    2*) ;;
    409) ;;  # Endpoint already enrolled — idempotent success.
    400|404)
      echo "Webhook request rejected ($status):"
      jq -r '.message // empty' /tmp/mc-resp.json
      exit 1
      ;;
    500)
      echo "Webhook request failed transiently. Retry later."
      exit 1
      ;;
    *)
      echo "Unexpected status $status"
      exit 1
      ;;
  esac
  ```

  ```javascript Node.js theme={null}
  const { success, error } = await mailchannels.webhooks.enroll(webhookUrl);

  if (error) {
    switch (error.statusCode) {
      case 409: // Endpoint already enrolled — idempotent, treat as success.
        break;
      case 400: // Invalid URL, filter, or batch ID.
      case 404: // Missing batch / signing key (resendBatch, publicKey, validate).
        console.error(`Webhook request rejected (${error.statusCode}):`, error.message);
        throw error;
      case 500: // Transient failure — surface for a retry layer.
        console.error("Webhook request failed transiently:", error.message);
        throw error;
      default:
        throw error;
    }
  }
  ```

  ```python Python theme={null}
  import logging

  import mailchannels
  from mailchannels.exceptions import ConflictError, InvalidRequestError, ServerError

  log = logging.getLogger(__name__)

  try:
      mailchannels.Webhooks.create(endpoint=webhook_url)
  except ConflictError:
      # 409 — endpoint already enrolled. Idempotent: treat as success.
      pass
  except InvalidRequestError as e:
      # 400 — invalid URL, filter, or batch ID.
      # 404 — missing batch or signing key (Webhooks.resend_batch /
      # public_key / validate). Both surface as InvalidRequestError;
      # inspect e.status_code to distinguish.
      log.error("Webhook request rejected (%s): %s", e.status_code, e.message)
      raise
  except ServerError as e:
      # 5xx — transient. Surface so a retry layer can react.
      log.error(
          "Webhook request failed transiently (%s, request_id=%s): %s",
          e.status_code,
          e.request_id,
          e.message,
      )
      raise
  ```

  ```php PHP theme={null}
  <?php

  require_once __DIR__ . '/vendor/autoload.php';

  use MailChannels\Exception\ConflictException;
  use MailChannels\Exception\InvalidRequestException;
  use MailChannels\Exception\NotFoundException;
  use MailChannels\Exception\ServerException;

  try {
      $client->webhooks->create($webhook_url);
  } catch (ConflictException) {
      // 409 — endpoint already enrolled. Idempotent: treat as success.
  } catch (InvalidRequestException | NotFoundException $e) {
      // 400 — invalid URL, filter, or batch ID.
      // 404 — missing batch or signing key (resendBatch, validate).
      // Both surface as different exceptions; inspect getStatusCode() to distinguish.
      error_log(sprintf('Webhook request rejected (%d): %s', $e->getStatusCode(), $e->getMessage()));
      throw $e;
  } catch (ServerException $e) {
      // 5xx — transient. Surface so a retry layer can react.
      error_log(sprintf(
          'Webhook request failed transiently (%d, request_id=%s): %s',
          $e->getStatusCode(),
          $e->getRequestId(),
          $e->getMessage(),
      ));
      throw $e;
  }
  ```
</CodeGroup>
