Errors
Every error the API returns has the same JSON shape, a stable code to branch on, and a request id to quote to support.
Error response format#
A failed request answers with a 4xx or 5xx status and a JSON body holding a readable error and a machine-readable code. Branch on code and the status: the wording of error may improve over time, the codes will not change.
HTTP/1.1 422 Unprocessable Entity
X-Request-Id: 03f82111-6c1e-4a53-9a1f-5d0c8e7b2a44
{
"error": "Domain 'acme.com' is not verified",
"code": "unprocessable_entity"
}errorstring- What went wrong, in words you can show a developer. Validation errors name each failing field as
path: message, joined with commas —to.0: must be a valid email address, subject: Subject is required when not using a template. codestring- One of the codes below. Derived from the status unless the endpoint sets a more specific one.
required, missingstring[]- Only on
403 insufficient_scope: the scopes the endpoint needs and the ones your credentials lack. missingFieldsstring[]- Only on the
400for an empty send body: which offrom,to,subjectandhtml|text|template_idare absent.
Every response from the API — success or failure — carries an X-Request-Id header. Include it when you contact support; it finds the exact request in our logs.
Error codes#
| Code | Status | When |
|---|---|---|
invalid_request | 400 | The body is not valid JSON or failed validation. Also the code of any other 4xx that has no code of its own. |
unauthorized | 401 | No credentials, or the API key or access token is invalid, revoked or expired. |
forbidden | 403 | Not allowed: missing scope, team suspended or paused, key restricted to another domain, account not verified — or a POST/PATCH/PUT/DELETE without a Bearer header. |
not_found | 404 | The resource does not exist, belongs to another team, or was deleted by log retention. |
conflict | 409 | A resource with the same unique value already exists (a contact email, a property name). |
attachment_file_missing | 410 | An inbound attachment’s stored file no longer exists. Retrying will not help. |
payload_too_large | 413 | The API refused a body that is too large to process (e.g. an email preview). Bodies over 36 MB are stopped earlier by the proxy with an HTML 413 and no code. |
unprocessable_entity | 422 | The request is well-formed but cannot be carried out: unverified domain, unpublished template, suppressed recipients, spam block, reused idempotency key. |
rate_limit_exceeded | 429 | Too many requests for this key and endpoint. Wait Retry-After seconds, then retry. |
monthly_limit_exceeded | 429 | Your plan’s monthly email allowance is used up. Do not retry: upgrade or wait for the month to roll over. |
daily_limit_exceeded | 429 | The rolling 24-hour cap on sends to external recipients (new and Free accounts) was reached. Do not retry; it clears as the window rolls. |
send_limit_exceeded | 429 | Sending from new accounts is paused platform-wide during a safety review. Do not retry; it clears automatically. |
internal_error | 500 | Something failed on our side (also returned for 502 from an upstream integration). |
service_unavailable | 503 | Maintenance mode, or a feature whose backing service is not available. |
HTTP status codes#
| Status | Name | Meaning |
|---|---|---|
200 | OK | The request succeeded; the body holds the result. |
201 | Created | A resource was created (API keys, contacts, domains, webhooks…). |
202 | Accepted | POST /emails and POST /emails/batch: accepted and queued; delivery happens afterwards. |
400 | Bad Request | Malformed JSON or failed validation. |
401 | Unauthorized | Missing or invalid credentials. |
403 | Forbidden | Credentials are valid but may not do this. |
404 | Not Found | No such resource — or no such route (a plain-text 404). |
409 | Conflict | Duplicate of an existing resource. |
410 | Gone | The resource existed but is permanently unavailable. |
413 | Payload Too Large | The body is over the size limit. |
422 | Unprocessable Entity | Valid request that cannot be carried out in the current state. |
429 | Too Many Requests | Rate limit or quota reached. |
500 | Internal Server Error | Unexpected failure on our side. |
502 | Bad Gateway | A third-party service we called on your behalf failed (e.g. a DNS provider). |
503 | Service Unavailable | Maintenance, or a dependency is unavailable. Retry later. |
Each endpoint on the Emails page lists the exact statuses it returns and what triggers each one.
Common errors#
| Error | Status | What to do |
|---|---|---|
Unauthorized | 401 | A GET was sent without an Authorization header (other methods get Invalid request origin instead). Add Authorization: Bearer sk_live_…. |
Invalid API key | 401 | The key is mistyped, revoked, rotated, or was restricted to a domain that has since been deleted. Create a new key. |
Invalid request origin | 403 | A POST, PATCH, PUT or DELETE was sent without an Authorization: Bearer header. Add the header — without it the request is treated as a browser request. |
insufficient_scope | 403 | The key or token lacks a permission this endpoint needs; the missing field lists it. Use a full-access key or grant the scope. |
Verify your email address before sending email. | 403 | The team owner has not confirmed their email address. Click the link in the verification email. |
API key is restricted to a different domain | 403 | The key was created for another domain than the one in from. Send from the key’s domain or use an unrestricted key. |
Sending is paused for this team | 403 | Sending was paused (for example during a safety review). Contact support. |
Domain 'example.com' not found for this team | 422 | The from domain has not been added to this team. Add it under Domains — or check you are using the right team’s key. |
Domain 'example.com' is not verified | 422 | Finish the domain’s DNS records and verify it before sending. |
Template is not published | 422 | Publish the template (POST /templates/:id/publish) before sending with it. |
All recipients are suppressed | 422 | Every recipient (or every to recipient) is on your suppression list. Remove the address from Suppressions if it should receive mail again. |
Email rejected by spam filter (score 11.40) | 422 | The content scored at or above the block threshold. Check it with POST /emails/spam-preview and remove what the symbols point at. |
This idempotency key was already used for a different request. Use a new key to send a different email. | 422 | Each distinct email needs its own idempotency_key. |
Contact with this email already exists | 409 | Update the existing contact instead of creating it again. |
Rate limit exceeded. Please try again later. | 429 | Wait for the number of seconds in Retry-After, then retry. |
Monthly email limit reached. Upgrade your plan. | 429 | Code monthly_limit_exceeded. Your plan’s monthly allowance is used up. Retrying will not help until you upgrade or the month rolls over. |
Request body is missing required fields: from, to, subject, html|text|template_id. See https://poststack.dev/docs/emails for a working example. | 400 | The send body was empty or had neither recipients nor content. Check that you send JSON with Content-Type: application/json. |
to.0: must be a valid email address | 400 | Recipients must be bare addresses. Only from accepts Name <address>. |
emails: Too big: expected array to have <=100 items | 400 | Split the batch into requests of 100 emails or fewer. |
Handling errors in code#
The SDKs raise a typed error for any non-2xx response, carrying the status, the error message, the code and the request id.
import { PostStack, PostStackError } from '@poststack.dev/sdk';
try {
await poststack.emails.send({ /* … */ });
} catch (err) {
if (err instanceof PostStackError) {
err.statusCode; // 422
err.code; // "unprocessable_entity"
err.message; // "Domain 'acme.com' is not verified"
err.requestId; // "03f82111-6c1e-…" — quote it to support
}
throw err;
}from poststack import PostStack, PostStackError
try:
ps.emails.send({...})
except PostStackError as err:
print(err.status_code, err.code, err.error, err.request_id)
raiseres, err := client.Emails.Send(ctx, input)
if err != nil {
var apiErr *poststack.Error
if errors.As(err, &apiErr) {
log.Printf("status=%d code=%s msg=%s request_id=%s",
apiErr.StatusCode, apiErr.Code, apiErr.Message, apiErr.RequestID)
}
return err
}Retries#
Retry network errors, 408, 429 and 5xx with exponential backoff. Do not retry other 4xx responses: the same request will fail the same way until you change it.
All three SDKs do this for you — up to 3 retries by default, with full-jitter exponential backoff starting at 250 ms — and send an Idempotency-Key with every POST, so a retried email send can never deliver twice. Change the count with maxRetries (TypeScript), max_retries (Python) or WithMaxRetries (Go).
Rate limits#
This is a summary; Rate limits covers every limit and quota in detail.
Limits are counted per API key (or OAuth token, or dashboard user) and per endpoint, over fixed windows — one minute for most endpoints. Free-plan teams get half of each limit. The send endpoints allow 60 requests per minute for POST /emails and 10 for POST /emails/batch; see Rate limits → Limits by endpoint for the rest. Every rate-limited endpoint returns these headers, so you can slow down before you hit the limit:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Unix time (seconds) when the window resets. |
Retry-After | On a 429 only: seconds to wait before retrying. |
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790686842
Retry-After: 47
Content-Type: application/json
{
"error": "Rate limit exceeded. Please try again later.",
"code": "rate_limit_exceeded"
}In front of the API, the proxy also limits each source IP address to 30 requests per second (10 per second on /emails/batch), with a short burst allowance. Its 429 has no JSON body and no Retry-After; back off for a second and retry. Spreading one integration over many API keys does not raise this limit.
Troubleshooting#
401 Invalid API key with a key that used to work
The key was revoked or rotated (rotation deletes the old key immediately), or it was restricted to a domain that has since been deleted — deleting a domain expires the keys restricted to it. Create a new key under API keys in the dashboard.
403 insufficient_scope
A sending-only key called an endpoint that manages resources, or an OAuth token lacks the scope. The missing field names what is needed. See Permissions.
403 Invalid request origin
A POST, PATCH, PUT or DELETE arrived without an Authorization: Bearer … header, so it was treated as a browser request and failed the cross-site check before authentication ran. Add the header (the scheme is case-sensitive: Bearer). Calls from a browser should go through your own server — an API key must never reach the browser.
500 on every retry
Note the X-Request-Id of one failed request and contact support with it. The SDKs already retried three times before surfacing the error.