# Chaviar Public API (v1)

Server-to-server JSON API for briefing content into a brand's pipeline. Everything an API request
creates is a normal draft: it appears in the app, obeys the brand's voice/rules, draws the same
token wallet, and (in `review` mode) waits in the same review deck.

- **Base URL** — `https://chaviar.app/api/v1`
- **Content type** — `application/json` on every request with a body. Anything else is a `415`.
- **Plans** — Pro and Studio. Other tiers get `403 plan_required`.
- **Not for browsers.** There are no CORS headers and no cookie auth; a key in front-end code is a
  leaked key.

---

## Authentication

Every call carries a brand-scoped API key as a bearer token:

```
Authorization: Bearer cvk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

Keys are created in **Settings → API keys** (workspace owner only). The full key is displayed
**exactly once** at creation — only a SHA-256 digest is stored, so it cannot be recovered. If you
lose it, revoke and mint a new one.

A key belongs to **one brand** and carries its own grants:

| Grant | Meaning |
| --- | --- |
| `allowed_kinds` | Which `kind` values it may request (`post`, `story`). Anything else → `403 kind_not_allowed`. |
| `allow_auto_publish` | Whether it may use `mode: "auto"`. Off by default → `403 auto_not_allowed`. |

| Plan | Live keys | Content requests / 24h per key |
| --- | --- | --- |
| Pro | 1 | 100 |
| Studio | 5 | 500 |

Revoking a key takes effect immediately (`403 key_revoked`). Deleting the brand kills its keys.

---

## Rate limits

| Bucket | Limit | Applies to |
| --- | --- | --- |
| Burst | 10 requests / minute per key | `POST /content` |
| Daily | 100 (Pro) / 500 (Studio) per 24h per key | `POST /content` |
| Reads | 60 requests / minute per key | `GET /requests/{id}`, `GET /wallet` |

Burst is checked **before** daily, so a burst rejection never consumes daily quota. Over the limit
returns `429 rate_limited` with a `Retry-After` header.

---

## POST /content

Brief one post or one story. Returns immediately — generation runs in the background.

### Body

| Field | Type | Notes |
| --- | --- | --- |
| `kind` | `"post"` \| `"story"` | **Required.** Must be in the key's `allowed_kinds`. |
| `brief` | string, 1–2000 chars | **Required.** Free-text steer for the planner. Up to 2 http(s) links in the brief are fetched and summarised into the generation context. |
| `images` | array | Reference images. Max **1** for a post, **4** for a story. See below. |
| `platforms` | string[] | **`post` only.** Narrows the fan-out to these channels: `in`, `ig`, `fb`, `th`, `x`. Omit to fan out to every connected channel. Unknown keys are a `400`. Only ever narrows — an unconnected channel is skipped. |
| `mode` | `"review"` (default) \| `"auto"` | `review` → the draft waits for a human. `auto` → approve + schedule on success. Requires `allow_auto_publish`. |
| `publish_at` | `"now"` \| ISO-8601 with offset | **`mode: "auto"` only.** Must be in the future and within 30 days. **An offset is mandatory** (`…Z` or `…+02:00`) — a bare local time is rejected. Omit to use the brand's next peak slot. |
| `expire_after` | `"30m"` \| `"6h"` \| `"2d"` | Measured **from now**, not from `publish_at`. If the request has not published by then, it is cancelled and its draft withdrawn. Max `30d`. Must land *after* `publish_at`, otherwise `400` — e.g. `publish_at` six days out with `expire_after: "2d"` would cancel the post before it ever went live. |
| `idempotency_key` | string ≤128 chars | Safe retries — see [Idempotency](#idempotency). |

Unknown top-level fields are rejected with `400 invalid_request` rather than ignored — a typo'd
`publishAt` would otherwise publish immediately.

### Images

Each entry sets **exactly one** of:

- `{"url": "https://…"}` — fetched server-side over http(s). Must return one of the raster
  `Content-Type`s below. URLs resolving to private/internal addresses (directly or via redirect) are
  refused, and the download is aborted the moment it crosses the size cap.
- `{"data_url": "data:image/jpeg;base64,…"}` — inline bytes.

Both paths accept exactly the same formats: `png`, `jpeg`, `webp`, `heic`, `heif`, `avif`, `gif`,
`tiff`, `bmp`. SVG is not accepted on either path.

Both paths are size-capped (15 MB by default) and re-encoded to JPEG/PNG on storage. The first image
is the primary reference; a story may build a frame around each of up to four.

### Cost

A `post` costs **1 token**, a `story` costs **2**, drawn from the brand's creative wallet at request
time. If the wallet is short the request is refused with `402` and nothing is charged. A generation
that fails is refunded automatically.

### Responses

`202 Accepted` — accepted and queued:

```json
{
  "id": "0d5c2b6e-3b7a-4e1e-9c22-7f0a1b2c3d4e",
  "status": "composing",
  "kind": "post",
  "mode": "review",
  "created_at": "2026-07-26T12:00:00.000Z"
}
```

`200 OK` — idempotent replay (see below); the body is the request's *current* state, same shape as
`GET /requests/{id}`.

### Examples

**Minimal post, human review (the default):**

```bash
curl -sS -X POST https://chaviar.app/api/v1/content \
  -H "Authorization: Bearer $CHAVIAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "post",
    "brief": "We are open late on Thursdays through August — 8am to 9pm."
  }'
```

**Sportily — a matchday story, auto-published immediately, from four photos:**

```bash
curl -sS -X POST https://chaviar.app/api/v1/content \
  -H "Authorization: Bearer $CHAVIAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "story",
    "brief": "Race day recap: our tipsters called 3 of 4 winners at Cheltenham today. Punchy, confident, no odds claims. Push the free-trial CTA on the last frame.",
    "images": [
      { "url": "https://cdn.sportily.app/matchday/2026-07-26/paddock.jpg" },
      { "url": "https://cdn.sportily.app/matchday/2026-07-26/finish.jpg" },
      { "url": "https://cdn.sportily.app/matchday/2026-07-26/board.jpg" },
      { "url": "https://cdn.sportily.app/matchday/2026-07-26/celebration.jpg" }
    ],
    "mode": "auto",
    "publish_at": "now",
    "expire_after": "6h",
    "idempotency_key": "sportily-recap-2026-07-26"
  }'
```

**A post scheduled for a specific instant, narrowed to two channels:**

```bash
curl -sS -X POST https://chaviar.app/api/v1/content \
  -H "Authorization: Bearer $CHAVIAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "post",
    "brief": "Tease Saturday'\''s accumulator drop. Link: https://sportily.app/acca",
    "platforms": ["ig", "fb"],
    "mode": "auto",
    "publish_at": "2026-08-01T09:00:00+01:00",
    "expire_after": "10d",
    "idempotency_key": "acca-teaser-2026-08-01"
  }'
```

Verified response:

```json
{
  "id": "cfaa9f6e-f608-4c7a-a68f-e15e002a0cb2",
  "status": "composing",
  "kind": "post",
  "mode": "auto",
  "created_at": "2026-07-26T16:14:38.726Z"
}
```

`09:00+01:00` is stored as the instant `08:00Z` — the offset is honoured, never reinterpreted as
server-local time. `expire_after: "10d"` is measured from *now*, so it comfortably outlasts a
publish six days out.

---

## Idempotency

Send `idempotency_key` (any string ≤128 chars, unique per key per logical request) and a retry is
free:

- **First call** → `202` and the work starts.
- **Any later call with the same key** → `200` and the *stored* request's current state. Nothing is
  generated twice, nothing is charged twice.
- The guarantee is enforced by a unique database constraint, so two **simultaneous** identical POSTs
  also produce exactly one request — the loser replays the winner.
- The key is bound to the first request. Changing the body does **not** create a new request; use a
  new `idempotency_key` for new work.
- A request that ended `failed` replays as `failed`. After fixing the cause (e.g. topping up
  tokens), retry with a **fresh** `idempotency_key`.

Without `idempotency_key`, every call is an independent request — and an independent charge.

---

## GET /requests/{id}

```bash
curl -sS https://chaviar.app/api/v1/requests/0d5c2b6e-3b7a-4e1e-9c22-7f0a1b2c3d4e \
  -H "Authorization: Bearer $CHAVIAR_API_KEY"
```

```json
{
  "id": "0d5c2b6e-3b7a-4e1e-9c22-7f0a1b2c3d4e",
  "status": "scheduled",
  "kind": "post",
  "mode": "auto",
  "created_at": "2026-07-26T12:00:00.000Z",
  "content_id": "5f31c0a2-7a1c-4b02-8f0d-2b9e4a6c1d33",
  "scheduled_for": "2026-08-01T08:00:00.000Z"
}
```

Requests belonging to another brand return `404` — ids are never confirmed across tenants.

### Status lifecycle

| `status` | Meaning |
| --- | --- |
| `composing` | Queued; the draft does not exist yet. |
| `awaiting_review` | The draft is ready and waiting for a human in the review deck. Terminal for `mode: "review"` until someone decides. |
| `scheduled` | Approved and armed for delivery. `scheduled_for` is set. |
| `published` | Live on the channel. `published_at` is set. |
| `failed` | Generation or delivery failed. `error` carries a code; any token spend was refunded. |
| `expired` | `expire_after` elapsed before publication; the draft was withdrawn. |
| `cancelled` | A human skipped the draft in the review deck. |

`error` is one of `insufficient_tokens`, `enqueue_failed`, `generation_failed`, `content_blocked`
(the AI provider's content rules refused the imagery/wording — a different angle usually works), or
`publish_failed`. Provider messages are never returned.

A channel whose connection breaks leaves the request `scheduled`; it delivers once the owner
reconnects.

**Polling:** poll `GET /requests/{id}` every few seconds. A post typically reaches
`awaiting_review`/`scheduled` in under two minutes; a story takes longer.

---

## GET /wallet

```bash
curl -sS https://chaviar.app/api/v1/wallet -H "Authorization: Bearer $CHAVIAR_API_KEY"
```

```json
{ "tokens": 214, "plan": "pro" }
```

`tokens` is the brand's remaining creative allowance (posts cost 1, stories 2).

---

## Auto-publish rules

`mode: "auto"` approves and schedules the generated cards without a human. Two rules always apply and
cannot be overridden by the API:

1. **LinkedIn is never auto-published.** LinkedIn's API terms require a human in the loop, so a
   LinkedIn card stays in the review deck even under `mode: "auto"`. The request is still accepted;
   its other channels arm normally. If LinkedIn is the *only* target, the request settles at
   `awaiting_review`.
2. **Paused channels stay paused.** A card bound to a channel the owner paused (or whose connection
   has lapsed) is left for review.

Without `publish_at`, an auto request lands in the brand's next peak slot for its own timezone.

**`mode: "auto"` does not run the in-app guardrail classifier.** The earned-trust auto-publish path in
the app screens drafts for regulated topics, unverifiable claims and first-time content pillars, and
routes anything flagged to a human. An API request does not: `mode: "auto"` is an explicit instruction
from a key you created and granted `allow_auto_publish`, and it is honoured as one. Use
`mode: "review"` (the default) for anything that needs a second pair of eyes.

---

## Errors

Every non-2xx response is:

```json
{ "error": { "code": "invalid_request", "message": "\"brief\" must not be empty." } }
```

Branch on `code`; `message` is for humans and may change.

| HTTP | `code` | When |
| --- | --- | --- |
| 400 | `invalid_request` | Malformed JSON, unknown field, bad value, unusable image. |
| 401 | `unauthorized` | Missing, malformed or unknown bearer key. |
| 403 | `key_revoked` | The key was revoked. |
| 403 | `plan_required` | The brand is not on Pro or Studio. |
| 403 | `kind_not_allowed` | `kind` is outside the key's `allowed_kinds`. |
| 403 | `auto_not_allowed` | `mode: "auto"` on a key without `allow_auto_publish`. |
| 404 | `not_found` | No such request for this brand. |
| 415 | `unsupported_media_type` | `Content-Type` is not `application/json`. |
| 402 | `insufficient_tokens` | Wallet short. The error object carries `balance` (tokens available). |
| 429 | `rate_limited` | Burst or daily limit. See `Retry-After`. |
| 500 | `internal` | Our side. Nothing was charged; retry (ideally with an `idempotency_key`). |

A `402` looks like:

```json
{ "error": { "code": "insufficient_tokens", "message": "Not enough tokens — a story costs 2.", "balance": 1 } }
```

---

## Operational notes

- Responses are `Cache-Control: no-store`. Never cache a request's status.
- API requests do **not** consume the auto-pilot allowance and do **not** affect the brand's cadence
  — they ride the same path as a manual "Create" in the app.
- API spend appears in the brand's token ledger as **API generation**.
