Overview
The Chaviar API takes a brief and returns a draft. You send a sentence about what happened — a new arrival, a fixture result, a delay, a launch — and Chaviar writes it in your brand's voice, renders the creative, and routes it to your connected channels.
| 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. |
| Endpoints | POST /content, GET /requests/{id}, GET /wallet |
Not for browsers. There are no CORS headers and no cookie auth. A key in front-end code is a leaked key — call the API from your server.
Authentication
Every call carries a brand-scoped API key as a bearer token:
Authorization: Bearer cvk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXKeys 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 it and mint a new one.
Grants
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. |
How many keys
| 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.
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."
}'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 Images. |
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 and 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. |
idempotency_key | string ≤ 128 chars | Safe retries — see Idempotency. |
Unknown top-level fields are rejected with 400 invalid_request rather than ignored —
a typo'd publishAt would otherwise publish immediately.
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:
{
"id": "0d5c2b6e-3b7a-4e1e-9c22-7f0a1b2c3d4e",
"status": "composing",
"kind": "post",
"mode": "review",
"created_at": "2026-07-26T12:00:00.000Z"
}200 OK — idempotent replay; the body is the request's current state, the
same shape as GET /requests/{id}.
More examples
A matchday story, auto-published immediately, built from four photos:
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 today. Punchy, confident, no odds claims. Push the free-trial CTA on the last frame.",
"images": [
{ "url": "https://cdn.example.com/matchday/paddock.jpg" },
{ "url": "https://cdn.example.com/matchday/finish.jpg" },
{ "url": "https://cdn.example.com/matchday/board.jpg" },
{ "url": "https://cdn.example.com/matchday/celebration.jpg" }
],
"mode": "auto",
"publish_at": "now",
"expire_after": "6h",
"idempotency_key": "recap-2026-07-26"
}'A post scheduled for a specific instant, narrowed to two channels:
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 drop. Link: https://example.com/acca",
"platforms": ["ig", "fb"],
"mode": "auto",
"publish_at": "2026-08-01T09:00:00+01:00",
"expire_after": "10d",
"idempotency_key": "teaser-2026-08-01"
}'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.
Images
Each entry in images sets exactly one of:
{ "url": "https://cdn.example.com/photo.jpg" }Fetched server-side over http(s). Must return one of the raster content types below. URLs resolving to private or 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,/9j/4AAQSkZJRg..." }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 4.
Idempotency
Send idempotency_key (any string ≤ 128 chars, unique per key
per logical request) and a retry is free:
- First call →
202and the work starts. - Any later call with the same key →
200and 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_keyfor new work. - A request that ended
failedreplays asfailed. After fixing the cause (e.g. topping up tokens), retry with a freshidempotency_key.
Without idempotency_key, every call is an independent request — and an independent charge.
GET /requests/{id}
curl -sS https://chaviar.app/api/v1/requests/0d5c2b6e-3b7a-4e1e-9c22-7f0a1b2c3d4e \
-H "Authorization: Bearer $CHAVIAR_API_KEY"{
"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 or 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 or scheduled in under two
minutes; a story takes longer.
GET /wallet
curl -sS https://chaviar.app/api/v1/wallet \
-H "Authorization: Bearer $CHAVIAR_API_KEY"{ "tokens": 214, "plan": "pro" }tokens is the brand's remaining creative allowance (posts cost 1, stories
2). Check it before a batch so you never burn a request on a 402.
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:
- 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 atawaiting_review. - 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:
{ "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. |
402 | insufficient_tokens | Wallet short. The error object carries balance (tokens available). |
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. |
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:
{
"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.
Need something the API doesn't cover yet? Tell us at [email protected].