SendlySendly
API Reference

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.

SurfaceContent typeShape
/api/v1/*application/problem+jsonRFC 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

FieldTypeAlways presentDescription
typestringyesA URI naming the error class. It dereferences to the matching section on this page
titlestringyesShort, stable summary for the type. Identical across every occurrence of the same code — never occurrence-specific
statusnumberyesThe HTTP status, repeated in the body
detailstringnoWhat went wrong this time. Omitted when it would only repeat title
instancestringnoThe request path the failure occurred on
codestringyesThe machine-readable code. This is the field to branch on
request_idstringnoCorrelation id for the request. Quote it when reporting a problem
errorsarraynoField-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.

HeaderExampleMeaning
RateLimit-Policy"per-key";q=100;w=60IETF draft-11 structured field. q = quota, w = window in seconds
RateLimit"per-key";r=42;t=17IETF draft-11 structured field. r = requests remaining, t = seconds until reset (a delta)
X-RateLimit-Limit100Max requests in the window
X-RateLimit-Remaining42Requests remaining
X-RateLimit-Reset1764499200Absolute UNIX epoch seconds when the window resets
Retry-After17On 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 wrong
  • error.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

FieldTypeDescription
successbooleanPresent (false) on migrated routes; absent on unmigrated ones
error.messagestringHuman-readable error description
error.codestringMachine-readable error code (see below)
error.details.errorsarrayPer-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:

HeaderDescription
Retry-AfterSeconds to wait before retrying
X-RateLimit-LimitMax requests allowed in the current window
X-RateLimit-RemainingRequests remaining (0 on a 429)
X-RateLimit-ResetUnix 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

CodeStatusDescription
NOT_AUTHENTICATED401Authentication is required and was not provided
INVALID_SESSION401Session is missing or expired
INVALID_API_KEY401API key is invalid or not found
KEY_REVOKED400The API key has been revoked
FORBIDDEN403Not allowed to perform this action
INSUFFICIENT_PERMISSIONS403The API key lacks permission for this endpoint (e.g. a sending-only key)
SCOPE_MISSING403The credential was not granted the scope this endpoint requires
PROJECT_ACCESS_DENIED403No access to this project
PROJECT_DISABLED403The project has been disabled
SSE_SESSION_ONLY403This stream endpoint requires session auth, not an API key

Validation & input

CodeStatusDescription
VALIDATION_ERROR400 / 422Request failed validation (missing/invalid fields). Migrated routes use 422 with error.details.errors
NO_PROJECT / PROJECT_REQUIRED400A project context is required but was not resolved
MISSING_FROM400The email from address is missing
INVALID_IDEMPOTENCY_KEY400The supplied idempotency key is malformed
IDEMPOTENCY_KEY_REUSED422This idempotency key was already used with a different request body
RESERVED_EVENT400The event name is reserved for system use and cannot be tracked manually
RECIPIENT_SUPPRESSED400The recipient is on the suppression list
UNPROCESSABLE422The request was understood but cannot be processed
INVALID_ASSIGNEE422The assignee is not a valid project member

Resources & conflicts

CodeStatusDescription
NOT_FOUND404The requested resource does not exist
DOMAIN_NOT_FOUND404The domain does not exist
CONFLICT409The resource already exists or conflicts with current state
RESUBSCRIBE_CONFIRMATION_REQUIRED409The contact previously unsubscribed from this list. Retry POST /api/lists/{id}/subscribe with allowResubscribe: true once they have consented
ALREADY_SUBSCRIBED400The contact is already subscribed
DOMAIN_OWNED / DOMAIN_NOT_ALLOWED / DOMAIN_BLOCKED403The domain cannot be used (owned elsewhere, not allowed, or blocked)

Billing

CodeStatusDescription
BILLING_DISABLED404Billing is not enabled for this project
NO_SUBSCRIPTION400No active subscription
NO_CUSTOMER400No Stripe customer on file

Rate limiting & server

CodeStatusDescription
RATE_LIMIT_EXCEEDED429Too many requests — see the Retry-After / X-RateLimit-* headers
ENQUEUE_FAILED503The background job for this request could not be queued
CONFIG_ERROR500The server is misconfigured for this operation
INTERNAL_ERROR500An unexpected server error occurred
ERRORvariesGeneric 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_KEYinvalid_api_key
INVALID_SESSION, UNAUTHORIZEDinvalid_session
SCOPE_MISSING, INSUFFICIENT_PERMISSIONSscope_missing
PROJECT_ACCESS_DENIEDproject_access_denied
PROJECT_DISABLEDproject_disabled
VALIDATION_ERROR, INVALID_IDEMPOTENCY_KEYvalidation_error
NOT_FOUND, RESOURCE_NOT_FOUNDresource_not_found
CONFLICTconflict
RATE_LIMIT_EXCEEDEDrate_limited
BILLING_LIMIT_EXCEEDED, QUOTA_EXHAUSTEDquota_exhausted
IDEMPOTENCY_KEY_REUSEDidempotency_key_reused
ENQUEUE_FAILEDenqueue_failed
INTERNAL_ERROR, anything unmappedinternal_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"
  }
}
FieldTypeDescription
successbooleantrue for successful requests
dataobjectResponse 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 Authorization header uses Bearer <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 connection
  • project_access_denied — check the project id; the same code is returned for a project you cannot see and one that does not exist
  • project_disabled — this needs human action, not a retry

Validation issues (400 / 422)

Problem: validation_error / VALIDATION_ERROR

Solutions:

  • Read detail (v1) or error.message (legacy) — it names what failed
  • On v1, walk errors[]: each pointer is a JSON Pointer at the offending field
  • Verify all required fields are present and typed correctly
  • If the message mentions the after cursor, 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 the RateLimit header 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_exhausted note

Server errors (500 / 503)

Problem: internal_error, enqueue_failed, INTERNAL_ERROR

Solutions:

  • Retry after a short delay; enqueue_failed in particular is transient
  • Send an Idempotency-Key so retries cannot double-execute
  • Report persistent failures with the request_id from the response body

Best practices

  1. Check the HTTP status (response.ok) before processing responses
  2. Switch on the code (code on v1, error.code on legacy), not just the status
  3. Distinguish rate_limited from quota_exhausted — only one is worth retrying
  4. Honor Retry-After on 429s with exponential backoff
  5. Send an Idempotency-Key on writes so retries are safe
  6. Log request_id from v1 problem documents — it is what we need to investigate
  7. Monitor error rates to detect issues early

Getting help

If you continue experiencing issues:

  1. Read detail / error.message — it usually names the exact problem
  2. Check the API Reference for correct usage
  3. Report persistent issues via the GitHub repository, quoting the request_id

On this page