Errors
The error envelope, every status code, and what is safe to retry.
Every error is JSON. The shape never changes based on Accept, and never becomes
an HTML error page.
{
"success": false,
"error": "Mailbox not found",
"code": "mailbox_not_found"
}| Field | Always present | Notes |
|---|---|---|
success | yes | Always false on an error. |
error | yes | Human-readable message. Do not match on this string; it can change. |
code | no | Machine-readable code. Present on every error a caller can act on. |
issues | no | Only on 400 validation failures. |
Validation errors carry a per-field breakdown instead of a code:
{
"success": false,
"error": "Validation error",
"issues": [
{ "path": "to.0.address", "message": "invalid email address format" },
{ "path": "subject", "message": "subject is required" }
]
}path is dot-joined, so nested and array fields read as to.0.address. Map it
straight onto your form fields.
Status codes
| Status | Meaning | Retry? |
|---|---|---|
400 | Body failed validation. issues[] lists each field. | No. Fix the request. |
401 | Missing or malformed Authorization, or an invalid, expired, or unrecognised credential. | No. |
403 | Authenticated, but lacking the required permission, or a pb_tok_ addressed a mailbox other than its own. | No. |
404 | The resource does not exist, or does not belong to your project or org. | No. |
409 | Conflicts with something that exists: duplicate mailbox local-part, duplicate domain, duplicate folder name. | No. |
429 | You hit a rate limit or your plan's allowance. | Yes, with backoff. |
5xx | Something broke on our side. | Yes, with backoff. |
On 404 versus 403
A resource in another org returns 404, not 403. This is intentional. A 403
would confirm that the id exists, which leaks the existence of other tenants'
resources to anyone willing to enumerate ids. If you get a 404 on an id you are
sure about, check your X-Project-Id before assuming the resource is gone.
Send error codes
A refused send always tells you why in code, so you can branch without
parsing prose. These are stable; the error message is not.
code | Status | Meaning | Retry? |
|---|---|---|---|
invalid_sender | 400 | The from address is not a parseable address. | No. |
account_suspended | 403 | The account is suspended. Nothing may be sent. | No. |
sending_paused | 403 | Sending is paused while your sending reputation is under action. | No. Contact support. |
sender_not_authorized | 403 | The from domain is not registered to your account. | No. |
domain_not_verified | 409 | The domain is yours, but its DNS records have not passed yet. | No. Finish verification. |
mailbox_not_found | 404 | No active mailbox matches the from address. | No. |
mailbox_scope_forbidden | 403 | A mailbox-scoped token tried to send as another mailbox. | No. |
all_recipients_suppressed | 422 | Every recipient is on your suppression list, so there is nothing to deliver. | No. |
invalid_local_part | 400 | The mailbox local part (before the @) has invalid characters. | No. Fix the request. |
project_archived | 409 | The project is archived. Restore it to send again. | No. |
rate_limited | 429 | A sending cap (burst, hourly, or daily). Retry-After carries the real window. | Yes, after Retry-After. |
plan_limit_reached | 429 | Your plan's monthly email allowance is used up, or overage needs a payment method. | Not until the period rolls over or you upgrade. |
mailbox_limit_reached | 409 | Your plan's mailbox allowance is used up. | No. Upgrade. |
domain_limit_reached | 409 | Your plan's domain allowance is used up. | No. Upgrade. |
A 429 that carries Retry-After means the wait is known: honour it rather than
backing off blindly. plan_limit_reached is the exception, since waiting out a
monthly allowance is not a retry strategy.
What is safe to retry
Retry 5xx and network failures. Do not retry 4xx, since the same request will
fail the same way.
For sends, retry only with an Idempotency-Key. Without one, a retry after a
timeout can send the message twice: your connection died, but ours may have
succeeded. With one, the retry returns the original result. See
Sending.
Use exponential backoff with jitter. Retrying a struggling service on a fixed interval from every client at once is how a partial outage becomes a full one.
In the SDK
The client raises typed errors, so you can branch on the class instead of parsing status codes:
import {
PostboxError,
ValidationError,
RateLimitError,
AuthenticationError,
} from "@postbox/sdk";
try {
await pb.messages.send({ from, to, subject, bodyHtml });
} catch (err) {
if (err instanceof ValidationError) {
for (const issue of err.issues) {
console.error(`${issue.path}: ${issue.message}`);
}
} else if (err instanceof RateLimitError) {
// back off and retry
} else if (err instanceof AuthenticationError) {
// the key is wrong; retrying will not help
} else if (err instanceof PostboxError) {
console.error(err.message, err.requestId);
}
}Every error carries a requestId. Include it when you contact support; it is how
we find your exact request.
| Class | Status |
|---|---|
ValidationError | 400 |
AuthenticationError | 401 |
PermissionError | 403 |
NotFoundError | 404 |
ConflictError | 409 |
RateLimitError | 429 |
ServerError | 5xx |
NotImplementedError | 501 |
TimeoutError | The per-attempt timeout elapsed. |
NetworkError | The request never got a response. |
SignatureError | A webhook signature failed verification. |
The client retries safe failures for you, up to maxRetries (default 2, so 3
attempts total). By the time an error reaches your catch, it has already been
retried.