- curl — inspect the HTTP status returned by
curl -w "%{http_code}"and parse the response body withjq. - JavaScript SDK — SDK methods return
{ data, error }(or{ success, error }). Theerrorobject hasmessageandstatusCodefields; branch onerror?.statusCode. - Python SDK —
mailchannels.Emails.send(and other resource methods) raise typed exceptions that subclassmailchannels.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; usegetStatusCode()andgetMessage()on the caught exception.
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 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. |
413 Payload Too Large | The encoded message, headers, and attachments exceed 30 MB. | Reduce attachment size or count. See Attachments. |
500 Internal Server Error | A transient MailChannels service error. | Retry with exponential backoff. Check 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. |
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
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;
}
}
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
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;
}
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 if the error persists. |
503 Temporarily Unavailable | Suppression service maintenance or temporary unavailability. | Retry later with backoff. |
# 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
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;
}
}
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
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;
}
}
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 if failures continue. |
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
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;
}
}
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
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;
}

