> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fanfeed.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Conventions

> Base URLs, headers, errors, timestamps, and pagination.

| Environment    | Base URL                        | `environment` value |
| -------------- | ------------------------------- | ------------------- |
| **Production** | `https://api.fanfeed.ai/v1`     | `production`        |
| **Sandbox**    | `https://api-dev.fanfeed.ai/v1` | `sandbox`           |

Both hosts serve the same contract. The `environment` column is the name your key is bound
to, which is what `GET /v1/health` reports back. Integrate against the sandbox, then change
one config value to go live: read the whole base URL from config, version prefix included,
rather than compiling it in.

One caveat: **the sandbox runs against the production dataset.** Users created with a sandbox key are real profiles in the same database,
and the isolation is the key's environment rather than a separate copy of the data. Use
throwaway addresses on a domain you own for sandbox users, and ask us when you want them
cleaned up.

### Headers

```http theme={null}
X-PARTNER-API-KEY: <your FanFeed partner key>
Content-Type: application/json
```

### Errors

```json theme={null}
{
  "error": {
    "code": "invalid_batch_size",
    "message": "Send at most 200 media items per request.",
    "details": { "received": 412 }
  }
}
```

`error.code` is stable and machine-readable, so branch on it rather than on `message`.
`message` is for your logs and should not be shown to end users. `details` is present only
where it adds something, and its shape varies by code.

| `error.code`         | HTTP | Where                           | Meaning                                                                                   | Retry?                                   |
| -------------------- | ---- | ------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------- |
| `invalid_api_key`    | 401  | Everywhere                      | Key missing, malformed, or not authorized.                                                | No. Fix the key.                         |
| `user_not_found`     | 404  | Every `/users/{user_id}/…` call | No FanFeed user with that id.                                                             | No. The id is wrong or was never stored. |
| `invalid_email`      | 400  | `POST /users`                   | Email absent or not parseable.                                                            | No.                                      |
| `empty_batch`        | 400  | `POST /users/{user_id}/media`   | `media` was an empty array.                                                               | No.                                      |
| `invalid_batch_size` | 413  | `POST /users/{user_id}/media`   | More than 200 items. `details.received` carries the count.                                | No. Split and resend.                    |
| `invalid_media_item` | 400  | `POST /users/{user_id}/media`   | One or more items failed validation. `details.items` lists the offending index and field. | No. Fix the payload.                     |
| `invalid_cursor`     | 400  | `GET /users/{user_id}/events`   | Cursor not recognized or expired.                                                         | No. Restart from the first page.         |
| `rate_limited`       | 429  | Everywhere                      | Too many requests. Honor `Retry-After`.                                                   | Yes, after the delay.                    |
| `internal_error`     | 500  | Everywhere                      | Unexpected failure on our side.                                                           | Yes, with backoff.                       |

Those nine are the published application errors. Four more are framework-level codes that a
malformed or unsupported request can trigger, and a strict `switch` on `error.code` will hit
them during integration:

| `error.code`             | HTTP | Where             | Meaning                                                           | Retry?              |
| ------------------------ | ---- | ----------------- | ----------------------------------------------------------------- | ------------------- |
| `unsupported_media_type` | 415  | All three `POST`s | Body sent without `Content-Type: application/json`.               | No. Fix the header. |
| `invalid_request`        | 400  | Everywhere        | Malformed path parameter, or a body that fails schema validation. | No.                 |
| `not_found`              | 404  | Everywhere        | Unknown path. Distinct from `user_not_found`.                     | No.                 |
| `method_not_allowed`     | 405  | Everywhere        | Wrong verb on a real path.                                        | No.                 |

**Per-item rejections are not errors.** A batch containing unusable items still returns `200`,
and the items come back in `rejected[]` with a `reason`; see
[The response](/guides/ingest-media#the-response). `error.code` means the whole request failed.

### Retries and idempotency

Every endpoint is safe to retry, so a timeout never needs special handling:

* **`POST /users`** is idempotent on email. A retry returns the same `user_id` rather than
  creating a second profile.
* **`POST /users/{user_id}/media`** is safe to re-send. Photos that matched upsert on your
  asset id; photos that matched nothing are re-evaluated rather than duplicated. See
  [Incremental syncs](/guides/ingest-media#incremental-syncs).
* **`POST /users/{user_id}/sync-complete`** is idempotent; see
  [Telling FanFeed a sync finished](/guides/ingest-media#telling-fanfeed-a-sync-finished).
* **The `GET` endpoints** are reads.

Retry `429` after `Retry-After` and `5xx` with exponential backoff. Do not retry a `4xx` other
than `429`; the request will fail the same way again.

### Timestamps

ISO 8601, UTC, on everything you send. One exception on the way back: an event's
`starts_at_local` is the local date and time at the venue with no offset. **Do not convert
it.** It is already the time the user experienced, and shifting it will show someone a
7:30 p.m. concert at 2:30 a.m.

### Pagination

Cursor-based. Pass `next_cursor` from the previous response as `cursor`. `has_more` tells you
when to stop. Cursors are opaque; do not parse them.

### Operational endpoints

Two more endpoints sit outside the five that carry the integration. Neither is part of the
API reference, and neither touches user data.

**`GET /v1/ping`** needs no key and is never rate-limited. Returns:

```json theme={null}
{ "status": "ok", "version": "v1" }
```

Use it to prove the base URL and TLS before a key has been issued.

**`GET /v1/health`** requires a valid key, but is deliberately **not** rate-limited, so it
still answers while you are being throttled. It echoes back the key's `partner_id` and
`environment` along with the rate-limit policy in force for that key:

```json theme={null}
{
  "status": "ok",
  "version": "v1",
  "partner_id": "your-partner-id",
  "environment": "sandbox",
  "rate_limit": {
    "enabled": false,
    "window_seconds": 60,
    "ingest_per_window": null,
    "read_per_window": null,
    "write_per_window": null
  }
}
```

This is how you confirm which environment your key is bound to, and it is the endpoint to call
after a `429` to see what the limit actually is; see
[Rate limiting](/authentication#rate-limiting).
