Postbox docs

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"
}
FieldAlways presentNotes
successyesAlways false on an error.
erroryesHuman-readable message. Do not match on this string; it can change.
codenoMachine-readable code. Present on every error a caller can act on.
issuesnoOnly 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

StatusMeaningRetry?
400Body failed validation. issues[] lists each field.No. Fix the request.
401Missing or malformed Authorization, or an invalid, expired, or unrecognised credential.No.
403Authenticated, but lacking the required permission, or a pb_tok_ addressed a mailbox other than its own.No.
404The resource does not exist, or does not belong to your project or org.No.
409Conflicts with something that exists: duplicate mailbox local-part, duplicate domain, duplicate folder name.No.
429You hit a rate limit or your plan's allowance.Yes, with backoff.
5xxSomething 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.

codeStatusMeaningRetry?
invalid_sender400The from address is not a parseable address.No.
account_suspended403The account is suspended. Nothing may be sent.No.
sending_paused403Sending is paused while your sending reputation is under action.No. Contact support.
sender_not_authorized403The from domain is not registered to your account.No.
domain_not_verified409The domain is yours, but its DNS records have not passed yet.No. Finish verification.
mailbox_not_found404No active mailbox matches the from address.No.
mailbox_scope_forbidden403A mailbox-scoped token tried to send as another mailbox.No.
all_recipients_suppressed422Every recipient is on your suppression list, so there is nothing to deliver.No.
invalid_local_part400The mailbox local part (before the @) has invalid characters.No. Fix the request.
project_archived409The project is archived. Restore it to send again.No.
rate_limited429A sending cap (burst, hourly, or daily). Retry-After carries the real window.Yes, after Retry-After.
plan_limit_reached429Your 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_reached409Your plan's mailbox allowance is used up.No. Upgrade.
domain_limit_reached409Your 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.

ClassStatus
ValidationError400
AuthenticationError401
PermissionError403
NotFoundError404
ConflictError409
RateLimitError429
ServerError5xx
NotImplementedError501
TimeoutErrorThe per-attempt timeout elapsed.
NetworkErrorThe request never got a response.
SignatureErrorA 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.