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),paramtells you which one. - requestId — unique id for this request (also returned as the
X-Request-IDresponse 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
}