SendpositoryDocs

Errors

Every error type with the HTTP status it arrives with, what causes it, and whether retrying the request will help.

Updated

Every failure returns the same shape:

{
  "error": {
    "type": "domain_not_verified",
    "message": "The domain `mail.yourdomain.com` is not verified yet. Finish its DNS setup before sending."
  }
}

Branch on type, never on message. Types are stable; messages are written for humans and may be reworded.

Every type

TypeHTTPCauseRetry?
validation_error422Malformed payload - bad address, missing subject, no body.No - fix the request.
authentication_error401Key missing, wrong, or revoked.No.
permission_denied403Account suspended, subscription cancelled, or a sending key on a management endpoint.No.
domain_not_verified403The from domain isn't on your account, or hasn't passed DNS checks.After verifying.
email_not_verified403Your account's own email address hasn't been confirmed yet.After clicking the link in your confirmation email.
suppressed_recipient422A recipient is on your suppression list.No - that's the point.
quota_exceeded402Month's allowance used.Next period, or after an upgrade.
trial_expired402Free trial ended.After a plan is active.
rate_limit_exceeded429Too many requests per second.Yes, after Retry-After.
not_found404No such message, domain or key on this account.No.
internal_error500Something failed on our side.Yes, with backoff.

Handling them

Most types mean stop and fix something. Only two are worth retrying automatically:

const data = await res.json();

if (!res.ok) {
  switch (data.error.type) {
    // The address is dead or complained. Skip it - this isn't a failure.
    case "suppressed_recipient":
      return;

    // Wait exactly as long as we're told, then try again.
    case "rate_limit_exceeded":
      await new Promise((r) =>
        setTimeout(r, Number(res.headers.get("retry-after") ?? 1) * 1000),
      );
      return retry();

    // Transient on our side.
    case "internal_error":
      return retryWithBackoff();

    // Out of allowance - alert someone rather than looping.
    case "quota_exceeded":
    case "trial_expired":
      await notifyOps(data.error.message);
      return;

    default:
      throw new Error(`${data.error.type}: ${data.error.message}`);
  }
}

Retrying safely

Any retry risks sending twice if the first request actually succeeded but the response was lost. Send an Idempotency-Key and that can't happen - see Idempotency.

Seeing failures

Every authenticated request is logged, with its status and error type:

The Logs page

Filter by 4xx or 5xx to find what's failing.

On this page