# Errors

The error response format, every API error code, and the codes recorded on failed targets.


Relaycast uses conventional HTTP status codes and returns a JSON error body with a machine-readable `code`. There are two kinds of errors to handle:

- **Request errors** are returned immediately by the API, before anything is created.
- **Publish errors** happen later, in the background, and are recorded on individual targets of an accepted post.

## Error format

```json
{
  "error": {
    "code": "validation_error",
    "message": "Content is not valid for some platforms",
    "details": {
      "2b7d9e1f-3a4c-4d5e-8f6a-7b8c9d0e1f2a": [
        { "field": "content", "message": "Bluesky allows 300 characters (this post has 342)" }
      ]
    }
  }
}
```

| Field | Description |
| --- | --- |
| `code` | Stable identifier. Branch on this. |
| `message` | Human-readable explanation. It may change, so don't parse it. |
| `details` | Optional extra data. Its shape depends on the code. |

Every response also has an `X-Request-Id` header. Log it with the error to find the call in [Request Logs](/docs/logs).

## HTTP status codes

| Status | Meaning |
| --- | --- |
| `200` | OK. Also returned when an `Idempotency-Key` replays an existing post. |
| `201` | Created: a post was accepted, or a Bluesky account connected. |
| `400` | The request is invalid. Fix it before retrying. |
| `401` | Missing or invalid access key. |
| `402` | Not enough credits. |
| `404` | The post or route doesn't exist (or belongs to another project). |
| `413` | The request body is larger than 1 MB. |
| `429` | Rate limit exceeded. |
| `500` | Unexpected server error. Safe to retry with backoff. |

## Request error codes

| Code | Status | When | `details` |
| --- | --- | --- | --- |
| `invalid_json` | 400 | The body isn't valid JSON. | |
| `validation_error` | 400 | The body doesn't match the schema, or content, media or options aren't valid for a platform, or `scheduledFor` is in the past. | Schema problems: an array of issues with `path` and `message`. Platform problems: an object keyed by account ID, each with `{ field, message }` items. |
| `unknown_account` | 400 | An `accountId` isn't in your project. | `{ "missing": [ids] }` |
| `account_inactive` | 400 | An account needs reconnecting. | `{ "accountIds": [ids] }` |
| `unknown_media` | 400 | A `media://` reference isn't in your media library, in `media` or in options. | `{ "missing": ["media://..."] }` |
| `invalid_credentials` | 400 | Bluesky rejected the identifier or app password. | |
| `unauthorized` | 401 | No access key, or the key is unknown or revoked. | |
| `insufficient_credits` | 402 | The project doesn't have enough credits for every target. | |
| `not_found` | 404 | Unknown post ID or route. | |
| `rate_limited` | 429 | More requests than the per-minute limit for this key. | |
| `internal_error` | 500 | Something went wrong on our side. | |

Request bodies over 1 MB are rejected with `413` and the code `payload_too_large`, in the same JSON format.

### Schema validation details

When the body itself is malformed, `details` lists the problems, each with a `path` into the body:

```json
{
  "error": {
    "code": "validation_error",
    "message": "Invalid request body",
    "details": [
      { "code": "invalid_type", "path": ["targets", 0, "accountId"], "message": "Invalid UUID" },
      { "code": "custom", "path": ["scheduledFor"], "message": "Either publishNow or scheduledFor is required" }
    ]
  }
}
```

### Platform validation details

When the body is well-formed but doesn't fit a platform, `details` is keyed by account ID, so you can show problems next to the right account:

```ts title="show-errors.ts"
const res = await fetch("https://social-publish-api.onrender.com/v1/posts", { method: "POST", headers, body });
if (res.status === 400) {
  const { error } = await res.json();
  if (error.code === "validation_error" && error.details && !Array.isArray(error.details)) {
    for (const [accountId, issues] of Object.entries(error.details as Record<string, { field: string; message: string }[]>)) {
      for (const issue of issues) console.warn(`${accountId} ${issue.field}: ${issue.message}`);
    }
  }
}
```

## Publish errors

Once a post is accepted, each target publishes on its own. A target that fails has `status: "failed"` with `errorCode` and `errorMessage` set. Its credit is refunded.

| `errorCode` | Meaning | Retried automatically |
| --- | --- | --- |
| `media_error` | The media couldn't be downloaded, was too large, or was rejected by the platform. Also used when a `media://` file was deleted before publishing. | Only for temporary download failures (`5xx` or network) |
| `invalid_content` | The platform rejected the content, for example a duplicate post or a format it won't accept. | No |
| `auth_revoked` | The platform no longer accepts the account's credentials. The account is marked `reconnect_required`. | No |
| `rate_limited` | The platform throttled the request. | Yes |
| `platform_error` | Any other platform failure, such as an outage or unexpected response. | Usually |

Retries use exponential backoff, up to 5 attempts in total. While a target waits for a retry, its status is back to `queued` and `errorCode` / `errorMessage` show the last error. See [Idempotency & Retries](/docs/idempotency#publish-retries).

Read publish errors with `GET /v1/posts/{id}`, or receive them in `post.partial` and `post.failed` [webhook notifications](/docs/notifications), where each target includes its `error` message.

## Handling errors

| Status | What to do |
| --- | --- |
| `400` | Don't retry unchanged. Show `details` to the user, or fix the request. |
| `401` | Check the key is set and hasn't been revoked. |
| `402` | Add credits or reduce the number of targets. |
| `404` | Check the ID and that you're using the right project's key. |
| `429` | Wait for the next minute, then retry. See [Rate Limits](/docs/limits). |
| `500`, network errors, timeouts | Retry with backoff, reusing the same `Idempotency-Key`. |
