# Astrology API error codes, what each one means and what to fix

Every failed RoxyAPI request answers with the same three fields. Find the `code` on this page and you have the fix.

```json
{
  "error": "Card not found: the-fool-of-cups",
  "code": "not_found",
  "doc_url": "https://roxyapi.com/docs/errors#not_found"
}
```

| Field | Use it for |
|---|---|
| `error` | Reading. Plain English, and the wording can change, so never branch on it |
| `code` | Branching. A stable identifier, safe to switch on forever |
| `doc_url` | Looking it up. An absolute link to the entry on this page for that exact code |

`doc_url` is the same URL in every environment, so log it, print it in your CLI, or paste it into a bug report and whoever reads it lands on the explanation.

A `400` adds one more field, `issues`, listing every field that failed at once so you can correct the whole request in a single retry instead of one round trip per mistake. See [validation_error](#validation_error).

**Tip: Every error the API answers is JSON, on every endpoint, including `404`. Parse the body before you decide what went wrong, and branch on `code`, never on the status alone.**

## Keys and access

### api_key_required

**401.** The request arrived with no key at all.

Send your key in the `X-API-Key` header, or as `Authorization: Bearer ...` from a browser. [Authentication](/docs/authentication) has a working example in curl and JavaScript. No key yet? Pick a plan on [pricing](/pricing) and yours is created the moment payment clears.

### invalid_api_key

**401.** A key was sent, but it is not one we can verify.

Almost always a copy and paste problem: a trailing space, a truncated value, or a placeholder like `your_api_key_here` left in the code. Copy the key again from the **API Keys** tab of your [account](/account?tab=keys). If you lost it, create a new one there.

### api_key_revoked

**401.** The key was valid once and has since been revoked.

Someone on your team deleted it from the account page, usually after a leak. Create a fresh key and roll it out to every deployment that still carries the old one.

### subscription_not_found

**401.** The key verifies, but the subscription behind it no longer exists.

This is what a key from a deleted account looks like. Start a new subscription on [pricing](/pricing), then swap in the key it gives you.

### subscription_inactive

**401.** The subscription behind this key is not active.

A cancelled or suspended plan keeps working until the paid period runs out, and this is the response after that. Reactivate from your [account](/account) and the same key starts working again.

### unauthorized

**401.** A generic authentication failure with no more specific code to give.

Treat it exactly like [api_key_required](#api_key_required): check that the key is present, complete, and sent on every request rather than only the first.

### forbidden

**403.** The key is valid but this particular request is not permitted.

Read the `error` field for the specific reason, then check the two publishable key entries below, which are the common causes.

### origin_required

**403.** A publishable key with an origin allowlist was used from somewhere that sent no `Origin` header.

Browsers set that header for you, so this means the call came from a server, a script, or a native mobile app. Those are server side callers: use a secret key there and keep the publishable key for the browser.

### origin_not_allowed

**403.** The page that made the call is not on the allowlist for this publishable key.

Add the site to the key on the **API Keys** tab of your [account](/account?tab=keys). Entries are plain hostnames such as `example.com`, protocol and port are ignored, and there are no wildcards, so staging and preview domains each need their own entry.

### publishable_key_not_allowed_on_mcp

**401.** A publishable key was used against an MCP server.

MCP is server side traffic, so a publishable key carries no browser origin to check there. Use a secret key in your MCP client config. [MCP setup](/docs/mcp) shows where it goes for each client.

### invalid_client_ip

**401.** A keyless request could not be attributed to a caller.

Send your API key in `X-API-Key` and the call runs against your own plan.

## Request problems

### validation_error

**400.** One or more fields in the request did not pass validation.

The body carries an `issues` array with every failure, not just the first, so one read tells you everything to change:

```json
{
  "error": "date: Invalid date",
  "code": "validation_error",
  "doc_url": "https://roxyapi.com/docs/errors#validation_error",
  "issues": [
    {
      "path": "date",
      "message": "Invalid date",
      "code": "invalid_format",
      "format": "date"
    }
  ]
}
```

`path` is the dot separated field path, or `(root)` for the body itself. `expected`, `minimum`, `maximum`, `format` and `pattern` appear when the failure has them, which is what lets an AI agent rebuild a valid payload without reading English. Dates are `YYYY-MM-DD` and times are 24 hour `HH:MM:SS`; [field formats](/docs/agent-field-formats) lists the shapes per domain.

### bad_request

**400.** The request was rejected before field validation ran.

Read the `error` field: it names the problem. A malformed JSON body is the usual cause, so check that the payload actually parses.

### unprocessable_entity

**422.** The request is well formed and every field is valid, but the combination cannot be computed.

The `error` field says which values conflict. Change one of them and retry.

### unsupported_media_type

**415.** A `POST`, `PUT` or `PATCH` arrived without `Content-Type: application/json`.

Every endpoint that takes a body takes JSON. Add the header. Most HTTP clients set it for you when you pass a JSON body rather than a string, so this usually means the body was sent as raw text.

### payload_too_large

**413.** The request body is bigger than we accept.

Our endpoints are built around one subject per request, and the largest legitimate body is a compatibility call with two people, so a body near the cap almost always means a batch was assembled by mistake. Split it into one call per subject. A body far over the cap is refused before it reaches the API and comes back as a bare `413` with no JSON body, which is deliberate: nothing oversized is read.

### method_not_allowed

**405.** The path exists, but not for this verb.

The body carries an `allow` array and the response carries an `Allow` header, both listing what the path does accept. Charts and readings are `POST` because they take a body; lookups and reference data are `GET`.

### not_found

**404.** Nothing is served at this path, or the identifier in it does not exist.

On a path miss the body adds a `suggestions` array of the closest real endpoints, plus a `docs` link to the reference for that domain, so a typo is usually one field away from the fix. On an identifier miss the `error` field names the accepted values. Identifiers are forgiving about casing and separators, so `Third Eye`, `third-eye` and `THIRD_EYE` all resolve.

### conflict

**409.** The request cannot be applied in the current state.

The `error` field says what already exists or what is already in progress. Read the current state, then retry with values that fit it.

### gone

**410.** The path was removed permanently and is not coming back.

Delete the call from your code. Nothing here needs a retry.

## Limits

### rate_limit_exceeded

**429.** You are through your monthly request allowance.

The response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Used` and `X-RateLimit-Reset`, plus `Retry-After` in seconds, so a client that honours `Retry-After` stops instead of hammering. Cache the calls that never change (a natal chart for a fixed birth input is the same forever) or move up a plan on [pricing](/pricing). [Caching and cost](/docs/guides/caching) covers what is safe to keep.

### rate_limited

**429.** The same outcome as [rate_limit_exceeded](#rate_limit_exceeded), reported without the plan specific detail.

Handle both the same way: back off for `Retry-After` seconds, then retry.

### free_tier_exhausted

**429.** A keyless request has used up its trial allowance.

Send your API key in `X-API-Key` to run against your own plan. Get one on [pricing](/pricing).

## Something went wrong on our side

### internal_error

**500.** The request failed inside our service, not in your code.

Retry with exponential backoff. If it keeps happening, send us the `error` string, the endpoint, and the time in UTC from our [contact page](/contact) and we will trace it.

### compute_saturated

**503.** We are briefly at capacity for heavy calculations.

The response carries `Retry-After` in seconds. Wait that long and send the same request again, unchanged: nothing about it is wrong.

### error

**Any status.** A fallback code for a status that has no more specific name.

The HTTP status and the `error` field are the useful signals here. Retry `5xx`, do not retry `4xx`.

## Handling this in code

Branch on `code`, retry only what is worth retrying, and print `doc_url` in your logs so the next person does not have to guess.


### JavaScript

```javascript
const res = await fetch('https://roxyapi.com/api/v2/tarot/cards/fool', {
  headers: { 'X-API-Key': process.env.ROXY_API_KEY }
});

if (!res.ok) {
  const err = await res.json();
  console.error(`[${err.code}] ${err.error} ${err.doc_url}`);
  if (err.code === 'validation_error') console.error(err.issues);
  if (res.status === 429 || res.status >= 500) {
    // Safe to retry after Retry-After seconds
  }
  throw new Error(err.code);
}
```

### Python

```python
import os, requests

res = requests.get(
    "https://roxyapi.com/api/v2/tarot/cards/fool",
    headers={"X-API-Key": os.environ["ROXY_API_KEY"]},
)

if not res.ok:
    err = res.json()
    print(f"[{err['code']}] {err['error']} {err['doc_url']}")
    if err["code"] == "validation_error":
        print(err["issues"])
    res.raise_for_status()
```

### curl

```bash
curl -s -o body.json -w '%{http_code}\n' \
  https://roxyapi.com/api/v2/tarot/cards/fool \
  -H "X-API-Key: $ROXY_API_KEY"

jq -r '"\(.code) \(.doc_url)"' body.json
```

Retry `429` and `5xx` with exponential backoff. Never retry a `4xx` other than `429`: the request will fail the same way until you change it.

## Frequently asked questions


### Is the code stable enough to switch on?

Yes. `code` is the machine readable half of the contract and is safe to branch on. The `error` sentence beside it is written for people and the wording can change at any time, so never parse it.

### Why does my error response have a doc_url field?

It is a direct link to the entry for that exact code on this page, so a failing response explains itself without a search. The URL is identical in every environment, which makes it safe to log and to paste into a bug report.

### Which errors should my client retry?

Retry `429` and any `5xx`, with exponential backoff, and honour the `Retry-After` header on a `429`. Every other `4xx` describes something about the request itself and will keep failing until the request changes.

### How do I see every validation problem at once?

Read the `issues` array on a `400`. It lists every field that failed in that request, each with its path and the reason, so one retry can fix all of them.
