loudly API — developer guide
loudly exposes a public REST API at /api/v1 so you can drive the daily loop programmatically —
read the stack, propose and generate brand-grounded cards, run the pre-publish check, schedule,
publish, and measure. It's the third thin adapter over the one service layer (lib/services/),
alongside the app and the MCP server, so a key is bound by exactly the
same authorization, plan gating, pre-publish severity, and credit rules as a person clicking in
the UI (Rule 11 / PRD §12).
- Base URL:
https://<your-domain>/api/v1. Local dev:http://localhost:3000/api/v1. - Auth: a workspace API key (
sk_live_…) as a Bearer token — mint one in Settings → Developers. - Reference: the full OpenAPI 3.1 spec at
/api/v1/openapi— import it into your API client (Postman, Insomnia, Bruno) or generate a client from it. - Source:
app/api/v1/— every route is a thin wrapper over an existing use-case.
Authenticate
- Open Settings → Developers and generate a key. It's shown once (
sk_live_…) — copy it and store it outside your repo. Each key carriesreadand/orwritescope. - Send it on every request as a Bearer token:
KEY=sk_live_YOURKEY
curl -s https://<your-domain>/api/v1/me \
-H "Authorization: Bearer $KEY"
GET /api/v1/me echoes the workspace, plan tier, and active brand the key resolves to — call it
first to confirm the connection. A key carries no user identity, so owner-only actions (team,
billing, settings-as-owner) aren't reachable through the API by construction.
Quickstart — publish your first card
The daily loop, end to end. A brand-new workspace already arrives with proposals, so you can start at step 1; or propose your own from a one-line hook.
1. Read today's stack (or propose a card from a brief):
# the proposals + radar + queued/live items
curl -s https://<your-domain>/api/v1/today -H "Authorization: Bearer $KEY"
# or draft a brand-grounded proposal from a hook (the API writes the copy, not you)
curl -s -X POST https://<your-domain>/api/v1/cards \
-H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"hook":"Weekend flat-white offer","channels":["instagram"]}'
2. Render the hero (1 credit). No body means "ensure the hero is rendered" — it's idempotent, so a repeat call returns the cached asset and charges nothing:
curl -s -X POST https://<your-domain>/api/v1/cards/$CARD_ID/asset \
-H "Authorization: Bearer $KEY"
3. Pick where it lands (optional) — on Facebook and Instagram a card can publish as a
feed post, a Story, or a Reel. The set_format action binds the placement per channel
(missing = the feed default; Reels need a video creative — an image-only card is blocked
by the pre-publish gate). The card echoes the choice in its formats map:
curl -s -X PATCH https://<your-domain>/api/v1/cards/$CARD_ID \
-H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"action":"set_format","platform":"facebook","formatId":"fb-story"}'
4. Schedule it — PATCH /api/v1/cards/{id} with the schedule action runs the pre-publish
severity gate first (a block is a 422, an unacknowledged warning a 409):
curl -s -X PATCH https://<your-domain>/api/v1/cards/$CARD_ID \
-H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"action":"schedule","at":{"date":"2026-07-05","time":"17:30"}}'
5. Or publish now — POST /api/v1/cards/{id}/publish publishes every connected channel.
Exactly-once: a re-publish returns alreadyPublished: true per channel rather than double-posting.
curl -s -X POST https://<your-domain>/api/v1/cards/$CARD_ID/publish \
-H "Authorization: Bearer $KEY" -H "content-type: application/json" -d '{}'
Endpoints
Every write needs a write-scoped key; reads need read. Publish and asset render are the
sensitive tier (see rate limits).
| Method + path | Scope | What it does |
|---|---|---|
GET /api/v1/me |
read | Who the key is — workspace, plan, active brand |
GET /api/v1/today |
read | Today's proposal stack (proposals + radar + queued/live) |
GET /api/v1/cards |
read | List cards; ?status=proposed|scheduled|posted|dismissed, cursor-paginated |
POST /api/v1/cards |
write | Propose a card from a one-line hook (AI-drafts the copy) |
GET /api/v1/cards/{id} |
read | One card in full |
PATCH /api/v1/cards/{id} |
write | Act on a card: editCopy · schedule · dismiss · backToStack · reschedule · repropose · set_format (what a channel publishes as — feed/Story/Reel on FB/IG) |
POST /api/v1/cards/{id}/asset |
write | Render or vary the hero (no steer/style = idempotent ensure-rendered, 0 credits on repeat; a steer and/or style override is a deliberate paid regen, 1 credit per call) |
POST /api/v1/cards/{id}/publish |
write | Publish now (runs the pre-publish gate; exactly-once per channel) |
GET /api/v1/channels |
read | Connected channels + health |
GET /api/v1/performance |
read | Top posts by uplift |
GET /api/v1/posts/{id}/metrics |
read | A post's engagement snapshot |
GET /api/v1/credits |
read | Credit meter (used / cap / balance) |
GET /api/v1/brand-kits |
read | List brand kits |
GET /api/v1/brand-kits/{id} |
read | A brand kit + its reference library (includes visualStyle) |
PATCH /api/v1/brand-kits/{id} |
write | Update curated kit fields, including visualStyle (preset is one of the eight registry styles — editorial-photo, flat-typographic-poster, flat-illustration, minimal-product, warm-lifestyle, matte-3d, bold-poster, paper-collage — or custom, the kit's derived contract) |
The full request and response shape for every endpoint is in the OpenAPI spec at
/api/v1/openapi — import it into Postman, Insomnia, or Bruno to explore it interactively.
Concepts
- Workspace + brand. A key belongs to a workspace, which holds one or more brand kits.
Requests resolve to the workspace's default brand. (Per-request brand selection — an
X-Brand-Idheader — is coming; see what's not here yet.) - Card lifecycle. A card moves
proposed → scheduled → posted, or isdismissed(the reason trains the next stack). The card carries per-channel copy, a hero asset, and per-channel schedule. - Credits. Rendering a hero spends one credit; at the cap the render returns
402(insufficient_credits).GET /api/v1/creditsreports used / cap / balance. - Channels.
GET /api/v1/channelslists what's connected and its health; publish fans out to them.
Pagination
List endpoints return { "data": [...], "nextCursor": "..." | null }. Pass ?cursor= (from the
previous nextCursor) and an optional ?limit= (max 100, default 50). A null nextCursor
means the last page.
Errors
Failures return a JSON { "error": "...", "report"?: {...} } body — the error is one of the
codes below; report carries the pre-publish severity detail on blocked / needs_confirmation.
The same catalog is in the spec at components/schemas/Error.
| Status | Codes |
|---|---|
| 400 | invalid_body, invalid_status |
| 401 | unauthorized |
| 402 | insufficient_credits |
| 403 | insufficient_scope, plan_limit, forbidden |
| 404 | not_found |
| 409 | no_active_brand, invalid_transition, needs_confirmation |
| 413 | payload_too_large |
| 422 | blocked |
| 429 | rate_limited |
Rate limits
Per-key, per-minute ceilings by tier — reads 120, writes 30, sensitive (publish +
generate) 10, scaled by plan (Growth ×2). Every response carries X-RateLimit-Limit,
X-RateLimit-Remaining, and X-RateLimit-Reset; a 429 adds Retry-After. A key driving the
MCP server too draws down this same window — one blended
budget per key. The credit ledger and idempotency are the real cost guards — this limiter is
the secondary abuse guard.
Idempotency
- Publish is exactly-once at the service layer (a
publishingguard + a unique(card, channel)constraint); a re-publish returnsalreadyPublished: trueper channel. - Render with no
steeris an idempotent "ensure rendered" — a repeat call charges nothing and returns the cached asset. Asteer(scene chip or custom prompt) is a deliberate paid regen. - Create (
POST /api/v1/cards) is the one non-idempotent write today; anIdempotency-Keyheader is coming (see below). Until then, don't blindly retry a create on a network timeout.
What's not here yet
These are tracked and on the way — don't build against them until they ship:
- Outbound webhooks (moment-fired, post-published, post-failed, connection-broke).
- A generated, typed SDK (today: hand-written calls against the OpenAPI spec).
- Per-request brand selection (
X-Brand-Id) and create idempotency (Idempotency-Key).
Owner-gated capabilities (team, billing, settings-as-owner) are never exposed to a key — an agent or script is bound by exactly the same rules as a person in the UI.
Security
Keys carry your workspace's full read/write access — treat them like passwords, store them outside
the repo or in a secret manager, and revoke a leaked key in Settings → Developers (revocation
is immediate). Prefer a read-only key for anything that only queries.