Error Codes
API error codes and troubleshooting
Two error dialects
Sendly serves two API surfaces, and each one serializes errors its own way. Both are produced from the same underlying errors — only the wire format differs.
| Surface | Content type | Shape |
|---|---|---|
/api/v1/* | application/problem+json | RFC 9457 problem document with lowercase code |
/api/* (legacy) | application/json | { error: { message, code } } envelope with UPPER_SNAKE codes |
Which one am I getting?
The URL decides. A request to /api/v1/emails always answers with a problem
document; a request to /api/emails always answers with the legacy envelope.
Read the status code for the category in both cases, then switch on code.
New integrations should target /api/v1. The legacy surface is still supported
and is what the endpoints outside the /v1 prefix emit.
/api/v1 — RFC 9457 problem+json
Every /api/v1 error is an RFC 9457
problem document served as application/problem+json:
{
"type": "https://docs.sendly.now/api-reference/errors#validation_error",
"title": "Validation failed",
"status": 422,
"detail": "The request body did not match the schema.",
"instance": "/api/v1/contacts",
"code": "validation_error",
"request_id": "req_01JD8Z2K9M4Q",
"errors": [
{ "pointer": "/email", "code": "invalid_string", "message": "Invalid email" }
]
}Fields
| Field | Type | Always present | Description |
|---|---|---|---|
type | string | yes | A URI naming the error class. It dereferences to the matching section on this page |
title | string | yes | Short, stable summary for the type. Identical across every occurrence of the same code — never occurrence-specific |
status | number | yes | The HTTP status, repeated in the body |
detail | string | no | What went wrong this time. Omitted when it would only repeat title |
instance | string | no | The request path the failure occurred on |
code | string | yes | The machine-readable code. This is the field to branch on |
request_id | string | no | Correlation id for the request. Quote it when reporting a problem |
errors | array | no | Field-level failures; present on validation errors |
Each entry in errors carries a pointer (a JSON
Pointer into the request body, e.g.
/recipients/0/email), a code, and a human-readable message.
Branch on `code`, not `title` or `type`
title is prose and type is a URL; both are stable today but neither is the
contract. code is the documented, machine-readable value — it is the one
field guaranteed to keep its meaning.
/api/v1 problem codes
The complete registry. Anything not listed here cannot reach you from /api/v1:
an unmapped internal failure is reported as internal_error rather than leaking
a code that was never designed as a public contract.
invalid_api_key — Invalid API key
Status: 401
The Authorization: Bearer credential was missing, malformed, unknown, or
revoked.
What to do: verify the key was copied whole and has not been rolled. Secret
endpoints require an sk_ key. This is not retryable — a retry with the same
key fails identically.
invalid_session — Invalid session
Status: 401
A session-authenticated call arrived with no session, or with an expired one. Also the fallback for an unauthenticated request that carried no credential of any kind.
What to do: sign in again. If you are calling from a server, use an API key rather than a session.
scope_missing — Missing required scope
Status: 403
The credential is valid but was not granted the scope this endpoint requires —
for example a sending-only key calling a contacts endpoint, or an OAuth
connection whose grant does not include contacts:read.
What to do: issue a key with the needed scope, or ask the user to re-approve the connection. Retrying changes nothing until the grant does.
project_access_denied — Project access denied
Status: 403
The authenticated principal is not a member of the project the request targets, or no project could be resolved for the call.
What to do: check the project id you are sending. Note that a project you have no access to is reported the same way as one that does not exist — that is deliberate, so the API does not confirm the existence of other tenants' projects.
project_disabled — Project disabled
Status: 403
The project has been disabled. Sending and most write operations are refused while it stays that way.
What to do: open the dashboard for the reason, or email support@sendly.now. Do not retry — this clears only by human action.
validation_error — Validation failed
Status: 400 or 422
The request did not match the endpoint's schema. Body and query validation
failures answer 422 with an errors array pointing at each offending field;
malformed input the parser could not even structure answers 400.
Pagination cursors also land here. detail names the exact problem — one of
"The after cursor is malformed.", "The after cursor was issued for a
different sort order. Restart from the first page.", or "The after cursor was
issued for a different set of filters. Restart from the first page."
A cursor encodes the sort order and filter set it was minted under, so changing
either mid-pagination invalidates it. There is no errors array for a cursor
failure — a cursor is not a body field a caller can fix piecewise.
What to do: fix the request. For cursor failures, restart from the first page with the new sort/filters.
resource_not_found — Resource not found
Status: 404
The addressed resource does not exist, or does not belong to the project the credential resolves to.
What to do: verify the id and the project. Not retryable.
conflict — Conflict
Status: 409
The request conflicts with current state. The most common cause is an
Idempotency-Key whose first request is still in flight: the key is claimed
before the work begins, so a concurrent replay is refused rather than allowed to
execute a second time.
What to do: this one is retryable. Wait briefly and retry with the same key — once the original completes, the same key replays its stored response.
rate_limited — Rate limit exceeded
Status: 429
You exceeded the per-API-key request rate. This is a short-window burst limit, not a billing quota.
What to do: honor Retry-After (seconds; never less than 1) and back off.
The response also carries the rate-limit headers, so a
well-behaved client can pace itself and avoid the 429 entirely.
quota_exhausted — Quota exhausted
Status: 429
Reserved for a billing or plan allowance being reached — as opposed to
rate_limited, which is a short request-rate burst limit.
Where sending limits are actually enforced today
quota_exhausted is part of the published code registry, but no /api/v1
endpoint currently returns it. Sending allowances (the monthly cap, per-category
billing limits, and daily sending ceilings) are enforced asynchronously in the
send pipeline, after the API has already accepted the request — so a send that
exceeds your cap succeeds at the API and is refused later, on delivery. You learn
about it through the billing notification email, the in-app bell, and the usage
view, not through this response. See Billing.
What to do: handle the code defensively — if you ever receive it, retrying
will not help. Raise the limit, upgrade, or wait for the billing period to roll.
Never treat it like rate_limited: backing off and retrying in a loop would fail
for the rest of the period.
idempotency_key_reused — Idempotency key reused
Status: 422
This Idempotency-Key was already used for a request with a different body.
Serving the first response would be wrong (you asked for something else) and
executing the new one would break the guarantee the key exists to provide.
What to do: this is terminal — do not retry with that key. Generate a new
Idempotency-Key for the new payload. Reusing a key is only safe for a byte-identical
retry of the same request.
enqueue_failed — Service temporarily unavailable
Status: 503
The request was accepted and valid, but the background job that carries out the work could not be queued.
What to do: retry with backoff. Send an Idempotency-Key so a retry cannot
double-execute if the first attempt actually landed.
internal_error — Internal server error
Status: 500
An unexpected server-side failure. Also the deliberate fallback for any internal condition with no designed public code — the API fails closed rather than inventing a contract.
What to do: retry once after a short delay. If it persists, report it with
the request_id from the response body.
Rate-limit headers
/api/v1 responses carry two header families describing the same bucket.
They are both emitted on every rate-limited route, on success as well as on a
429.
| Header | Example | Meaning |
|---|---|---|
RateLimit-Policy | "per-key";q=100;w=60 | IETF draft-11 structured field. q = quota, w = window in seconds |
RateLimit | "per-key";r=42;t=17 | IETF draft-11 structured field. r = requests remaining, t = seconds until reset (a delta) |
X-RateLimit-Limit | 100 | Max requests in the window |
X-RateLimit-Remaining | 42 | Requests remaining |
X-RateLimit-Reset | 1764499200 | Absolute UNIX epoch seconds when the window resets |
Retry-After | 17 | On a 429 only. Seconds to wait; floored at 1 |
The two families define “reset” differently
RateLimit's t= is a delta in seconds. X-RateLimit-Reset is an
absolute epoch timestamp. This is deliberate, not a bug: the X- trio
predates the IETF draft and existing clients parse it as epoch seconds. Do not
feed one into a parser written for the other.
The policy name is per-key because the bucket is keyed on the API key. Session
callers run no per-key bucket and receive no rate-limit headers.
Legacy /api/* envelope
Everything in this section describes the legacy /api/* surface only. None
of it applies to /api/v1.
The legacy surface returns a compact envelope. The HTTP status line carries the
category; the JSON body always carries a nested error object with a
human-readable message and a machine-readable code:
error.message— a human-readable description of what went wrongerror.code— a machine-readable code for programmatic handling
Envelope rollout
A newer envelope was rolled out route-by-route across the legacy surface.
Routes already migrated add a top-level success: false flag alongside
error, and report request-validation failures as 422 with a nested
error.details.errors list of per-field issues (see below). Routes not
yet migrated still emit the bare { error } shape and use 400 for
validation. Write clients defensively: always read error.code and the HTTP
status, and treat success and error.details as optional.
Legacy HTTP status codes
200 OK
Request successful.
201 Created
Resource created successfully.
400 Bad Request
Invalid request format, malformed body, or failed input validation. Most
validation failures use error.code: "VALIDATION_ERROR".
401 Unauthorized
Authentication failed or missing. Verify your API key or session.
403 Forbidden
Not authorized to access this resource, or the project has been disabled.
404 Not Found
The requested resource does not exist. Verify the resource ID.
409 Conflict
The resource already exists or the request conflicts with current state.
422 Unprocessable Entity
Request-body or query-parameter validation failed on a migrated route (for
example the Contacts and Templates endpoints). The body carries
error.code: "VALIDATION_ERROR" and a nested error.details.errors array of
per-field issues. Routes not yet migrated report the same class of failure as a
400 instead.
429 Too Many Requests
Rate limit exceeded. The response includes a Retry-After header (seconds to
wait) and X-RateLimit-* headers — honor them and back off.
500 Internal Server Error
An unexpected server error occurred. Retry after a short delay; if it persists, report it via the GitHub repository.
Legacy error response format
Every legacy error — regardless of status — carries a nested error object with
a message and a code:
{
"error": {
"message": "Project ID required",
"code": "VALIDATION_ERROR"
}
}Routes migrated to the newer envelope add a top-level success: false flag:
{
"success": false,
"error": {
"message": "Validation failed",
"code": "VALIDATION_ERROR"
}
}On a 422, those routes also include error.details.errors — a list of the
individual fields that failed validation:
{
"success": false,
"error": {
"message": "Validation failed",
"code": "VALIDATION_ERROR",
"details": {
"errors": [
{ "field": "email", "message": "Invalid email", "code": "invalid_string" }
]
}
}
}Legacy response fields
| Field | Type | Description |
|---|---|---|
success | boolean | Present (false) on migrated routes; absent on unmigrated ones |
error.message | string | Human-readable error description |
error.code | string | Machine-readable error code (see below) |
error.details.errors | array | Per-field validation issues; present only on 422 responses |
The legacy envelope has no statusCode, requestId, top-level errors[],
suggestion, or timestamp field, and legacy responses carry no X-Request-ID
header. (The /api/v1 surface does carry a request_id — see the
problem fields table.) Treat success and error.details as
optional: they appear only on routes already migrated to the newer envelope. Use
the HTTP status code for the category and error.code for specifics.
Legacy rate-limit headers (429 only)
On a 429 Too Many Requests, a legacy response carries these additional headers:
| Header | Description |
|---|---|
Retry-After | Seconds to wait before retrying |
X-RateLimit-Limit | Max requests allowed in the current window |
X-RateLimit-Remaining | Requests remaining (0 on a 429) |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets |
The legacy surface emits only this X- trio — the IETF RateLimit structured
fields are /api/v1 only.
Legacy error codes reference
error.code is a stable string for programmatic handling. The most common
values are below. Codes are grouped by the HTTP status they typically accompany;
unexpected server errors fall back to INTERNAL_ERROR (500), and errors raised
without a specific code surface as the generic ERROR.
Authentication & authorization
| Code | Status | Description |
|---|---|---|
NOT_AUTHENTICATED | 401 | Authentication is required and was not provided |
INVALID_SESSION | 401 | Session is missing or expired |
INVALID_API_KEY | 401 | API key is invalid or not found |
KEY_REVOKED | 400 | The API key has been revoked |
FORBIDDEN | 403 | Not allowed to perform this action |
INSUFFICIENT_PERMISSIONS | 403 | The API key lacks permission for this endpoint (e.g. a sending-only key) |
SCOPE_MISSING | 403 | The credential was not granted the scope this endpoint requires |
PROJECT_ACCESS_DENIED | 403 | No access to this project |
PROJECT_DISABLED | 403 | The project has been disabled |
SSE_SESSION_ONLY | 403 | This stream endpoint requires session auth, not an API key |
Validation & input
| Code | Status | Description |
|---|---|---|
VALIDATION_ERROR | 400 / 422 | Request failed validation (missing/invalid fields). Migrated routes use 422 with error.details.errors |
NO_PROJECT / PROJECT_REQUIRED | 400 | A project context is required but was not resolved |
MISSING_FROM | 400 | The email from address is missing |
INVALID_IDEMPOTENCY_KEY | 400 | The supplied idempotency key is malformed |
IDEMPOTENCY_KEY_REUSED | 422 | This idempotency key was already used with a different request body |
RESERVED_EVENT | 400 | The event name is reserved for system use and cannot be tracked manually |
RECIPIENT_SUPPRESSED | 400 | The recipient is on the suppression list |
UNPROCESSABLE | 422 | The request was understood but cannot be processed |
INVALID_ASSIGNEE | 422 | The assignee is not a valid project member |
Resources & conflicts
| Code | Status | Description |
|---|---|---|
NOT_FOUND | 404 | The requested resource does not exist |
DOMAIN_NOT_FOUND | 404 | The domain does not exist |
CONFLICT | 409 | The resource already exists or conflicts with current state |
RESUBSCRIBE_CONFIRMATION_REQUIRED | 409 | The contact previously unsubscribed from this list. Retry POST /api/lists/{id}/subscribe with allowResubscribe: true once they have consented |
ALREADY_SUBSCRIBED | 400 | The contact is already subscribed |
DOMAIN_OWNED / DOMAIN_NOT_ALLOWED / DOMAIN_BLOCKED | 403 | The domain cannot be used (owned elsewhere, not allowed, or blocked) |
Billing
| Code | Status | Description |
|---|---|---|
BILLING_DISABLED | 404 | Billing is not enabled for this project |
NO_SUBSCRIPTION | 400 | No active subscription |
NO_CUSTOMER | 400 | No Stripe customer on file |
Rate limiting & server
| Code | Status | Description |
|---|---|---|
RATE_LIMIT_EXCEEDED | 429 | Too many requests — see the Retry-After / X-RateLimit-* headers |
ENQUEUE_FAILED | 503 | The background job for this request could not be queued |
CONFIG_ERROR | 500 | The server is misconfigured for this operation |
INTERNAL_ERROR | 500 | An unexpected server error occurred |
ERROR | varies | Generic error code when a more specific one was not set |
How legacy codes map onto /api/v1
Both surfaces are produced from the same thrown errors, so a legacy code has a
deterministic /api/v1 counterpart. Useful when migrating a client:
| Legacy code | /api/v1 code |
|---|---|
INVALID_API_KEY | invalid_api_key |
INVALID_SESSION, UNAUTHORIZED | invalid_session |
SCOPE_MISSING, INSUFFICIENT_PERMISSIONS | scope_missing |
PROJECT_ACCESS_DENIED | project_access_denied |
PROJECT_DISABLED | project_disabled |
VALIDATION_ERROR, INVALID_IDEMPOTENCY_KEY | validation_error |
NOT_FOUND, RESOURCE_NOT_FOUND | resource_not_found |
CONFLICT | conflict |
RATE_LIMIT_EXCEEDED | rate_limited |
BILLING_LIMIT_EXCEEDED, QUOTA_EXHAUSTED | quota_exhausted |
IDEMPOTENCY_KEY_REUSED | idempotency_key_reused |
ENQUEUE_FAILED | enqueue_failed |
INTERNAL_ERROR, anything unmapped | internal_error |
Success response format
Most legacy endpoints return a { success, data } envelope:
{
"success": true,
"data": {
"contact": "cnt_abc123",
"event": "evt_xyz789",
"timestamp": "2025-11-30T10:30:00.000Z"
}
}| Field | Type | Description |
|---|---|---|
success | boolean | true for successful requests |
data | object | Response payload specific to the endpoint |
/api/v1 list endpoints return { data, next_cursor } instead — see the
API overview.
Handling errors in your code
Check the HTTP status (response.ok / response.status) to decide whether the
request succeeded, then read the machine-readable code for specific handling.
/api/v1 (problem+json)
const response = await fetch('https://api.sendly.now/api/v1/contacts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${secretKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({ email: 'user@example.com' }),
});
if (!response.ok) {
const problem = await response.json();
console.error(`[${problem.code}] ${problem.title}: ${problem.detail ?? ''}`);
switch (problem.code) {
case 'validation_error':
// Fix the request. `problem.errors` names each offending field.
for (const issue of problem.errors ?? []) {
console.error(` ${issue.pointer}: ${issue.message}`);
}
break;
case 'rate_limited': {
// Short burst limit — back off and retry.
const retryAfter = Number(response.headers.get('Retry-After') ?? '1');
await new Promise((r) => setTimeout(r, retryAfter * 1000));
break;
}
case 'quota_exhausted':
// Plan allowance — retrying will not help. Alert a human.
break;
case 'idempotency_key_reused':
// Terminal. Never retry with this key.
break;
case 'conflict':
// The first request with this key is still running. Retry shortly.
break;
}
throw new Error(`${problem.title} (request_id: ${problem.request_id})`);
}
const { data } = await response.json();Legacy /api/*
const response = await fetch('https://api.sendly.now/api/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${publicKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ event: 'purchase', email: 'user@example.com' }),
});
const body = await response.json();
if (!response.ok) {
console.error(`Error [${body.error.code}]: ${body.error.message}`);
switch (body.error.code) {
case 'VALIDATION_ERROR':
// Fix the request and retry.
break;
case 'INVALID_API_KEY':
// Prompt the user to check their API key.
break;
case 'RATE_LIMIT_EXCEEDED': {
const retryAfter = Number(response.headers.get('Retry-After') ?? '1');
await new Promise((r) => setTimeout(r, retryAfter * 1000));
break;
}
}
throw new Error(body.error.message);
}
console.log('Event tracked:', body.data);Python
import requests
response = requests.post(
'https://api.sendly.now/api/v1/contacts',
headers={
'Authorization': f'Bearer {secret_key}',
'Content-Type': 'application/json',
},
json={'email': 'user@example.com'},
)
if not response.ok:
problem = response.json()
print(f"[{problem['code']}] {problem['title']}")
if problem['code'] == 'validation_error':
for issue in problem.get('errors', []):
print(f" {issue['pointer']}: {issue['message']}")
elif problem['code'] == 'rate_limited':
retry_after = int(response.headers.get('Retry-After', '1'))
# Wait retry_after seconds, then retry with backoff.
elif problem['code'] == 'quota_exhausted':
# Plan allowance reached — retrying will not help.
pass
else:
print(f"Created: {response.json()['data']}")Troubleshooting guide
Authentication issues (401)
Problem: invalid_api_key / invalid_session (v1), or NOT_AUTHENTICATED,
INVALID_SESSION, INVALID_API_KEY (legacy)
Solutions:
- Verify your API key is copied correctly (no extra spaces)
- Check you're using the right key type (
sk_for secret,pk_for tracking) - Ensure the
Authorizationheader usesBearer <token>format - Verify the key hasn't been revoked or regenerated
Permission issues (403)
Problem: scope_missing, project_access_denied, project_disabled
Solutions:
scope_missing— issue a key carrying the scope the endpoint requires, or have the user re-approve the OAuth connectionproject_access_denied— check the project id; the same code is returned for a project you cannot see and one that does not existproject_disabled— this needs human action, not a retry
Validation issues (400 / 422)
Problem: validation_error / VALIDATION_ERROR
Solutions:
- Read
detail(v1) orerror.message(legacy) — it names what failed - On v1, walk
errors[]: eachpointeris a JSON Pointer at the offending field - Verify all required fields are present and typed correctly
- If the message mentions the
aftercursor, restart pagination from the first page
Not found issues (404)
Problem: resource_not_found / NOT_FOUND, DOMAIN_NOT_FOUND
Solutions:
- Verify the resource ID is correct
- Check the resource belongs to your project
- Ensure it hasn't been deleted
Throttling issues (429)
Problem: rate_limited (v1) or RATE_LIMIT_EXCEEDED (legacy)
Solutions:
- Honor
Retry-After, implement exponential backoff (1s, 2s, 4s, 8s), and pace off theRateLimitheader so you never hit the limit in the first place - Reduce request frequency; batch operations when the endpoint supports it
- If you are hitting a sending allowance rather than a request-rate limit, the
API will not tell you — see Billing and the
quota_exhaustednote
Server errors (500 / 503)
Problem: internal_error, enqueue_failed, INTERNAL_ERROR
Solutions:
- Retry after a short delay;
enqueue_failedin particular is transient - Send an
Idempotency-Keyso retries cannot double-execute - Report persistent failures with the
request_idfrom the response body
Best practices
- Check the HTTP status (
response.ok) before processing responses - Switch on the code (
codeon v1,error.codeon legacy), not just the status - Distinguish
rate_limitedfromquota_exhausted— only one is worth retrying - Honor
Retry-Afteron 429s with exponential backoff - Send an
Idempotency-Keyon writes so retries are safe - Log
request_idfrom v1 problem documents — it is what we need to investigate - Monitor error rates to detect issues early
Getting help
If you continue experiencing issues:
- Read
detail/error.message— it usually names the exact problem - Check the API Reference for correct usage
- Report persistent issues via the GitHub repository, quoting the
request_id