Errors

Every non-2xx response uses the same envelope so a single handler covers your whole integration. Includes a stable error name, a human-readable message, and a requestId you can quote to support.

The error envelope

Every non-2xx response returns the same JSON shape, regardless of which endpoint or which kind of failure:

{
  "statusCode": 400,
  "name": "validation_error",
  "message": "Invalid email address in \"to\": foo",
  "param": "to",
  "requestId": "req_KYFR3xMMlFHR5Ee9",
  "documentation": "https://docs.posthawk.dev/errors"
}

Field-by-field:

  • statusCode — the HTTP status. Always matches the response status line.
  • name — stable machine-readable category. Branch on this in code; the value is part of our API contract and won't change for a given class of error.
  • message — human-readable detail. Safe to surface in your own UI ("Domain not verified") but expect the wording to evolve over time.
  • param — optional. When the failure is tied to a specific input field (to, from, templateId), param tells you which one.
  • requestId — unique id for this request (also returned as the X-Request-ID response header). Quote this when contacting support and we can find every log line for the failing call.
  • documentation — link to this page.

Some error types include extra fields. For example, rate_limit_error adds retryAfter (seconds) and resetAt (ISO timestamp). validation_error with multiple constraint failures adds details (string array). Always check for these on the relevant name.

Error name reference

| name | HTTP | When you'll see it |
|---|---|---|
| authentication_error | 401 | Missing API key, invalid API key, or revoked key. |
| permission_error | 403 | Authenticated but blocked — plan quota exceeded, insufficient scope, or workspace gate. |
| validation_error | 400, 422 | Invalid input — bad email format, missing required field, malformed body, unverified from domain. |
| not_found_error | 404 | Resource doesn't exist (or you can't see it because of workspace scoping). |
| conflict_error | 409 | State collision — usually a duplicate resource or a stale write. |
| rate_limit_error | 429 | API key rate limit exceeded. Response includes retryAfter (seconds) and X-RateLimit-Reset header. See Rate Limits. |
| service_unavailable_error | 503 | A downstream (SES, Redis, Supabase) is temporarily unhealthy. Safe to retry with exponential backoff. |
| timeout_error | 504 | We didn't get a response from a downstream in time. Retry with backoff. |
| internal_error | 500 | Unexpected server-side bug. The full stack trace is logged against your requestId — open a support ticket with that id and we can investigate. |
| request_error | other 4xx | Fallback for client-side failures that don't fit a more specific category. |

The X-Request-ID header

Every response (success or failure) includes X-Request-ID: req_<random>. If your client sends an X-Request-ID header on the request, we honor it (must match req_[A-Za-z0-9_-]{8,64} — anything malformed gets a fresh id). This is how you correlate logs across your own systems and ours.

Pattern for support requests:

> "POST /v1/send returned 500 with requestId req_KYFR3xMMlFHR5Ee9"

That single string lets us find every log line, every BullMQ job, and every downstream call associated with the failing request.

When (and how) to retry

| name | Safe to auto-retry? | How |
|---|---|---|
| authentication_error | No. | Fix the key, don't loop. |
| permission_error | No. | Plan / scope issue — surface to the user. |
| validation_error | No. | The same input produces the same failure. Fix the request. |
| not_found_error | No. | Resource doesn't exist; check the id. |
| conflict_error | Maybe. | If you control the second writer, back off and reconcile. |
| rate_limit_error | Yes. | Sleep retryAfter seconds (or wait until X-RateLimit-Reset) then retry. |
| service_unavailable_error | Yes. | Exponential backoff: 1s, 2s, 4s, 8s, max 5 attempts. |
| timeout_error | Yes. | Same as 503. Important: pair retries with an Idempotency-Key so a partial success doesn't duplicate. |
| internal_error | Once. | One retry after ~1s; if it fails again, surface to the user and open a ticket with the requestId. |

For idempotent retries on POST endpoints, see Authentication → Idempotency.

Handling errors in code

TypeScript / JavaScript:

const res = await fetch('https://api.posthawk.dev/v1/send', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ' + process.env.POSTHAWK_API_KEY,
  },
  body: JSON.stringify({ from, to, subject, html }),
});

if (!res.ok) {
  const err = await res.json();
  // err.name is the stable category, err.message is human-readable,
  // err.requestId is what you quote in a support ticket.
  if (err.name === 'rate_limit_error') {
    await new Promise(r => setTimeout(r, err.retryAfter * 1000));
    // ...retry...
  } else if (err.name === 'validation_error') {
    throw new Error(`Bad request on ${err.param || 'body'}: ${err.message}`);
  } else {
    throw new Error(`Posthawk ${err.name}: ${err.message} (requestId: ${err.requestId})`);
  }
}

Python:

import requests, time

res = requests.post(
    'https://api.posthawk.dev/v1/send',
    headers={
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {api_key}',
    },
    json={'from': sender, 'to': [to], 'subject': subject, 'html': html},
)

if not res.ok:
    err = res.json()
    if err['name'] == 'rate_limit_error':
        time.sleep(err['retryAfter'])
        # ...retry...
    else:
        raise RuntimeError(f"Posthawk {err['name']}: {err['message']} (requestId: {err['requestId']})")

The official SDKs handle this for you

The TypeScript and Python SDKs unwrap the envelope into a { data, error } tuple. You never write the if/else above manually:

const { data, error } = await posthawk.emails.send({ from, to, subject, html });
if (error) {
  // error.name, error.message, error.requestId, error.statusCode
}