Errors and limits

The error envelope, every error code the API returns and how to fix it, rate limits and quotas, and retrying safely with Idempotency-Key.

Embed
Preview of the card
<a href="https://referalli.com/docs/errors-and-limits"><img src="https://referalli.com/docs/_api/public/orgs/07f432f4-34b9-46a7-aaad-c0e395d2e4f9/spaces/2bf4f614-3630-4fbf-85f9-5ecafa6b7d97/pages/dc50fa39-79a0-4bab-85e5-67ace5e965b7/social-card.svg?v=3e7ae33eec8b" alt="Errors and limits" width="600" height="315"></a>

Every API error returns a JSON body with a single error object. Branch on error.code, which is stable; error.message is for people and may change.

Error envelope

{
  "error": {
    "code": "not_found",
    "message": "program not found",
    "docs_url": "https://referalli.com/docs/errors-and-limits#not_found"
  }
}

Field

Type

Present

Meaning

code

string

always

Machine-readable error code from the table below.

message

string

always

Human-readable description.

details

any

sometimes

Extra context. For validation errors, the list of failing fields. For plan limits, { "limit": 20, "resource": "programs" }.

requestId

string

internal_error only

The id to quote when reporting a problem.

docs_url

string

always

Link to this code's entry below: what it means and how to fix it.

Every response, success or failure, carries an x-request-id header (req_…) that matches the server logs.

Error codes

Each code below is stable; branch on it. The status is the usual one for the code.

validation_error

400 (or 422 on the signed auth-connection endpoints). The body or query failed validation, the body isn't valid JSON, an unknown field was sent, or a session call is missing x-org-id. Fix: read message and details, correct the named field, and resend. Retrying unchanged fails the same way.

unauthorized

401. No credentials, an unknown or revoked API key, an expired session, or an inbound webhook or signed call whose signature didn't verify. Fix: send Authorization: Bearer rk_…, sign in again, or check the signing secret and clock.

step_up_required

401. A partner with two-factor sign-in on changed their payout profile without a valid x-totp-code header. Fix: prompt for the current code and resend with it. See Authentication.

payment_required

402. The plan's limit on programs or partners is reached. details is { "limit": 20, "resource": "programs" }. Fix: archive something or upgrade the plan.

forbidden

403. Authenticated, but not allowed: the wrong role, not a member of the org, a suspended org, a cross-origin cookie request, an API key without the needed scope (this API key lacks the payouts:write scope, or this API key is read-only), or a publishable key used from an origin it doesn't allow. Fix: use a member with the right role, or a key with the scope the message names.

not_found

404. The resource doesn't exist in this org (ids from other orgs look the same), a tracking token or link code is unknown, or a referenced id in the body doesn't exist (programId not found). Fix: check the id and the org.

conflict

409. A unique value is taken (a link or coupon code, a program slug, a partner email, an enrollment, a team member), or the action would break a rule: cancelling a payout run whose money is in flight, removing an org's last owner, deleting a partner who has history. Fix: use another value, or follow the message (for a partner with history, archive instead).

idempotency_in_progress

409. A request with the same Idempotency-Key is still running. Fix: wait a moment and retry with the same key; you'll then get its stored response.

replay

409. A signed auth-connection call reused an x-referally-id (or a signature) already received. Fix: send each call with a new id; retries of a failed call need a new id and a fresh signature.

gone

410. The endpoint was retired, for example POST /t/{trackingToken}/mobile/event (purchases are now reported server-side). Fix: use the replacement named in message.

idempotency_key_reused

422. The Idempotency-Key was already used for a different request (another method, path or body). Fix: use a new key for each distinct request.

connection_paused

423. The merchant paused this auth connection, so its calls aren't processed. Fix: nothing to retry until the merchant resumes it in Settings.

rate_limited

429. An endpoint limit or the per-key API quota was exceeded. The response carries Retry-After (seconds). Fix: wait that long, then retry. See Rate limits and quotas.

bad_request

413, 415 and other 4xx. Rejected by the HTTP layer before reaching a handler, for example a body over the size limit (413) or an unsupported content-type (415). Fix: send JSON under the size limit.

webhooks_disabled

503. The payout-provider settlement webhook isn't configured on this deployment. Fix: the operator sets the webhook secret; providers retry on their own.

sso_required

403. This organization requires single sign-on, your email is on its SSO domains, and this session didn't come through SSO. details.signInUrl starts an SSO sign-in. Fix: sign in through your company's identity provider (the web app follows signInUrl for you).

live_mode_only

409. The team, members and single sign-on are shared by the live and test orgs, so they can only be changed in live mode. Fix: switch the dashboard back to live mode, or use a live (rk_live_) key.

test_mode_only

409. This endpoint (for example the test clock or the captured test inbox) only exists in test mode. Fix: call it with an rk_test_ key or from a test-mode session.

test_mode_unsupported

409. Test mode pays every payout through the test rail, so real payout connections can't be added there. Fix: connect payout providers in live mode; in test mode, payouts complete on the test rail automatically.

mode_mismatch

400. The request sent referally-mode with an API key whose mode is different (for example referally-mode: test with a live key). A key's mode is fixed, so the header is refused rather than ignored. details.keyMode and details.requestedMode say which is which. Fix: use an rk_test_ key for test mode (or an rk_live_ key for live), or drop the header.

access_suspended

403. Your company's identity provider removed or suspended you, and you are the organization's last owner, so Referalli suspended your access instead of removing you. Fix: ask your IT admin to restore your access in the identity provider, or have another owner added.

sso_not_configured

503. Single sign-on isn't set up on this Referalli deployment (the operator hasn't connected it to Atlas). Fix: the operator configures it; until then, SSO settings are unavailable.

sso_provider_error

502. The SSO provider (the identity broker that runs single sign-on: Atlas today) returned an error. details.provider names it and details.providerCode is its own code. Fix: retry; if it persists, check the SSO connection in your settings or contact support.

atlas_error

502. Deprecated: SSO provider errors are now sso_provider_error, with details.provider: "atlas". No longer returned.

internal_error

500. An unexpected server error. The body includes requestId. Fix: retry with backoff; if it persists, report the requestId.

Validation in detail

Validation errors

Request bodies are validated strictly. A failing request returns 400 with details listing each problem:

{
  "error": {
    "code": "validation_error",
    "message": "invalid request",
    "details": [
      {
        "code": "invalid_format",
        "path": ["destinationUrl"],
        "message": "Invalid URL"
      }
    ]
  }
}

Each entry's path names the field. Fix the field and resend; retrying unchanged will fail the same way.

Unknown fields

/v1 request bodies reject fields the endpoint doesn't define, so a misspelled option fails loudly instead of being ignored. The message names the fields:

{
  "error": {
    "code": "validation_error",
    "message": "unknown field(s): referalToken",
    "details": [
      { "code": "unrecognized_keys", "keys": ["referalToken"], "path": [], "message": "Unrecognized key: \"referalToken\"" }
    ]
  }
}

Nested objects are checked too, and the message gives the field's path. A misspelt bps in a commission rule returns unknown field(s): config.base.bpss; inside an array the index is part of the path (config.tiers.0.colour). Free-form maps stay open: payout details, tax form data, org settings, branding theme and utmTemplate accept any keys.

Query strings stay lenient: unknown query parameters are ignored. Inbound provider webhooks (/webhooks/…) and the public tracking endpoints (/t/…) accept extra fields, because providers add fields over time. Every endpoint's accepted body is listed in the OpenAPI document.

Some checks happen after the schema and return validation_error with a message but no details, for example a commission rule with an unsupported bounty event.

Malformed JSON

A body sent as application/json that does not parse returns 400 validation_error. The message starts with malformed JSON body: followed by the parser's description of the problem, and there is no details list:

{
  "error": {
    "code": "validation_error",
    "message": "malformed JSON body: Unexpected end of JSON input"
  }
}

An empty body is treated as no body, not as malformed JSON.

Resources in other orgs

Tenant data is isolated per org. Fetching an id that belongs to another org returns 404, exactly as if it did not exist.

Rate limits and quotas

Per-key quota

Every authenticated call (an API key, a signed-in user in an org, or a partner) draws from a token bucket sized by the org's plan. The bucket refills continuously; a burst can use it all at once.

Plan

Sustained

Burst

Starter

10 requests/second

20

Free

25 requests/second

50

Growth

100 requests/second

200

Scale

250 requests/second

500

Each API key has its own bucket. A signed-in person has one per org. Every authenticated response carries the bucket's state:

Header

Meaning

RateLimit-Limit

The bucket's size (the burst).

RateLimit-Remaining

Requests you can make right now.

RateLimit-Reset

Seconds until the bucket is full again.

Retry-After

On a 429 only: seconds until the next request can succeed.

Public endpoints (tracking, inbound webhooks, signed auth-connection calls) don't count against the quota; they have their own limits below.

Endpoint limits

Endpoint

Limit

Behavior when exceeded

GET /v1/auth/atlas/callback

30 per 15 minutes per IP

Redirects to the login page with error=rate_limited.

POST /v1/orgs

20 per hour per user

429 rate_limited with Retry-After.

Partner identity verification (/v1/partner/kyc/…)

5 per hour per IP

429 rate_limited with Retry-After.

GET /t/{trackingToken}/codes/{code}

Bursts of 30 per IP (refills 1 per second)

429 rate_limited with Retry-After.

Signed auth-connection calls

Bursts of 300 per connection (refills 20 per second)

429 rate_limited with Retry-After and RateLimit-*.

GET /go/{trackingToken}/{code}

Bursts of 60 per link (refills 2 per second) and 30 per IP (refills 1 per second)

The visitor is still redirected. The click is recorded but marked rate-limited, which makes it ineligible for attribution.

Limits are kept in the database, so they hold across every API instance.

Retrying safely

  • Retry 429 and 5xx responses with backoff, waiting at least Retry-After when it's present. Do not retry other 4xx responses unchanged.

  • Send an Idempotency-Key header on writes (below), so a retry after a timeout can't create something twice.

  • POST /v1/referrals/associate accepts an idempotencyKey. A repeated key returns the existing referral instead of recording a second association.

  • Billing webhooks are idempotent by provider event id. A redelivered event returns "duplicate": true and has no further effect.

Idempotency-Key

Every POST, PUT, PATCH and DELETE made with an API key or a session accepts an Idempotency-Key header: any unique string of up to 255 printable ASCII characters, such as a UUID.

curl -X POST $BASE/v1/partners \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -H "Idempotency-Key: 6f1c2a90-1b7e-4f5d-9c3e-2a8b0d4e7f11" \
  -d '{"email": "dana@example.com", "displayName": "Dana"}'
  • The first response (status and body) is stored for 24 hours. A retry with the same key gets that response again, with an idempotent-replayed: true header, and nothing runs twice.

  • The same key with a different method, path or body is 422 idempotency_key_reused.

  • A retry while the first request is still running is 409 idempotency_in_progress.

  • 5xx and 429 responses aren't stored, so you can retry those with the same key.

  • Keys are scoped to the caller: two API keys can use the same string without colliding.

Inbound webhooks and the public tracking endpoints don't use the header; they deduplicate on the provider's own event ids.

Updated