Developers
A REST API and an MCP server sitting on top of the same engine the Warble app uses — read a workspace's voice and brand, generate ideas and images, schedule or publish posts, and pull performance. Runs on your own API key and your own org's credits.
Every request carries a bearer key. There is no separate signup flow for machine callers — you mint the key from inside a Warble account you already have.
GET /api/v1/workspaces HTTP/1.1 Host: app.usewarble.com Authorization: Bearer wrb_live_9pT4qX2vLd8zK1rN6mF0hJ3sYw...
Keys are minted from Settings → API Keys (/settings/api-keys) inside the Warble app — a real page in the product, not part of this docs site. Each key is:
publish is opt-in and unchecked by default because it can post to live connected accounts.workspace_id in requests — Warble fills it in and rejects any request naming a different workspace. An unpinned key must pass workspace_id on every call that touches one; call GET /api/v1/workspaces first to discover which ids it can act on.The secret is shown once, at creation, and never stored in recoverable form server-side (Warble keeps only its hash). If you lose it, revoke the key and mint a new one.
Five scopes, checked per-endpoint. A request against an endpoint your key doesn't have the scope for returns 403 forbidden_scope before anything else runs.
| Scope | Unlocks |
|---|---|
| read | List/read workspaces, a workspace's brand & voice, posts, async job status, performance, and your key's own credit balance. Every GET endpoint requires this. |
| content:write | Generate ideas, per-channel copy drafts, AI images, and carousel decks; import an external image into Warble's media library. Spends credits. |
| schedule:write | Create, reschedule, cancel, and retry posts scheduled for a future time. |
| publish | Publish a post right now, no future time involved. Kept separate from schedule:write deliberately — unchecked by default in Settings. |
| connections:read | List a workspace's connected social channels (platform + display name + status only — provider account ids and tokens never cross this boundary). |
Per-endpoint scope requirements are listed with each endpoint in the reference below.
Any endpoint that creates a release — POST /api/v1/posts and POST /api/v1/posts/publish — requires an Idempotency-Key header. Omitting it on those two returns 400 invalid_request.
Idempotency-Replayed: true response header. No new post is created, no credits move twice.422 idempotency_mismatch — the key was already used for a different request and is not reusable for this one.409 idempotency_in_flight (retryable) — an identical request from the same key is still being processed. Wait and retry.done) response stays replayable for 24 hours, after which the row is cleaned up and the key can be reused.workspace_id + body + channels + scheduled_at — rather than a random value per attempt. If your process crashes or the response is lost after the write actually landed, retrying with the same derived key replays the original result instead of creating a second post. A fresh random key on every retry defeats the entire protection.Two burst backstops sit in front of every request, plus an optional per-key spending cap.
429 rate_limited.429 rate_limited.daily_credit_cap in Settings → API Keys (null = uncapped). A credit-spending call that would push the key's rolling 24h spend over the cap is rejected before it runs — 402 daily_cap_exceeded, with cap, spent_24h, and requested in error.details.Both burst limits are retryable (back off and retry after a short delay); the daily cap is not — it clears at the 24-hour rolling window, not at midnight. Check current spend anytime with GET /api/v1/credits.
Every non-2xx response has the same shape, designed for a model to parse and decide retry-vs-stop without guessing:
{
"error": {
"code": "insufficient_credits",
"message": "insufficient credits: need 4, have 1",
"retryable": false,
"details": { "needed": 4, "balance": 1 }
}
}
details is present only on error codes that carry structured context; treat it as optional.
| Code | HTTP status | Retryable |
|---|---|---|
| unauthorized | 401 | no |
| forbidden_scope | 403 | no |
| not_found | 404 | no |
| invalid_request | 400 | no |
| insufficient_credits | 402 | no |
| rate_limited | 429 | yes |
| daily_cap_exceeded | 402 | no |
| channel_not_connected | 422 | no |
| idempotency_in_flight | 409 | yes |
| idempotency_mismatch | 422 | no |
| upstream_failed | 502 | yes |
| internal | 500 | yes |
16 endpoints, base URL https://app.usewarble.com/api/v1. All request/response bodies are JSON; all timestamps are ISO 8601 UTC.
List the workspaces (brands) this key can act on. Usually the first call an agent makes. A pinned key sees only its own workspace.
Response — 200{
"workspaces": [
{ "id": "5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0", "name": "Wildflower Co.", "type": "business" }
]
}
Voice profile, business summary, and content pillars — the grounding context an agent needs to write on-brand before calling /ideas or /drafts.
{
"workspace_id": "5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0",
"voice": "Punchy. Confident. Short sentences. Occasional rhetorical question.",
"business_profile": {
"summary": "A neighborhood flower shop known for same-day arrangements.",
"industry": "retail_florist",
"audience": "Local shoppers who want same-day, no-fuss flowers.",
"offer": "Same-day local delivery, custom arrangements.",
"pillars": [
{ "title": "Behind the counter", "angle": "What actually goes into an arrangement." }
]
},
"compliance": { "universal": true, "industryEnabled": false, "industryId": null, "brandRisk": false }
}
Connected social channels for a workspace. Platform, display name, and connection status only — provider account ids and tokens never cross this boundary.
Response — 200{
"channels": [
{ "platform": "instagram", "display_name": "@wildflowerco", "status": "connected" },
{ "platform": "facebook", "display_name": "Wildflower Co.", "status": "connected" }
]
}
List posts for a workspace, newest-scheduled-first. Filters: status, release_id, ids (comma-separated), since, until, limit (max 200, default 50), before (cursor — a post id from the previous page's next_cursor).
GET /api/v1/posts?workspace_id=5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0&status=scheduled&limit=2Response — 200
{
"posts": [
{
"id": "0a1d6f2e-9c3b-4a71-8e6a-2f9b1c4d7a58",
"release_id": "c9a2e6f1-2b44-4d9a-8e11-7a5c9b0d3f21",
"platform": "instagram",
"status": "scheduled",
"body": "Fresh peonies just arrived — come see them before they're gone.",
"scheduled_at": "2026-08-29T15:00:00.000Z",
"published_at": null,
"post_url": null,
"publish_error": null,
"image_url": "https://app.usewarble.com/api/media/b6d3a1f4-8c2e-4b7a-9f0d-3e6c1a4b8d59",
"media_urls": null,
"video_url": null,
"link_key": null
}
],
"next_cursor": null
}
Valid status values: draft, scheduled, submitting, submitted, published, partial, failed.
Schedule a post for a future time. Creates one post per channel under a shared release_id; the worker publishes each on its own at scheduled_at.
Idempotency-Key: sched-5b8f19a2-peonies-2026-08-29T15:00:00Z
{
"workspace_id": "5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0",
"body": "Fresh peonies just arrived — come see them before they're gone.",
"channels": ["instagram", "facebook"],
"scheduled_at": "2026-08-29T15:00:00Z",
"image_url": "https://app.usewarble.com/api/media/b6d3a1f4-8c2e-4b7a-9f0d-3e6c1a4b8d59"
}
Response — 202
{
"release_id": "c9a2e6f1-2b44-4d9a-8e11-7a5c9b0d3f21",
"posts": [
{ "id": "0a1d6f2e-9c3b-4a71-8e6a-2f9b1c4d7a58", "platform": "instagram", "status": "scheduled" },
{ "id": "7f3c2b1a-4e5d-4a9b-8c1f-6d0a3e2b9f47", "platform": "facebook", "status": "scheduled" }
]
}
Optional fields: body_by_channel (per-channel copy overrides, still clamped to each platform's character limit), video_url, media_urls (carousel), destination_url. Image/video/media URLs must point at Warble's own hosted media — run an external URL through POST /api/v1/media first, or generate one with POST /api/v1/images.
Reschedule (pass scheduled_at) or cancel (pass cancel: true) a post that hasn't gone out yet. Refuses posts already submitting, submitted, published, or partial; a failed post must go through /retry instead.
{ "scheduled_at": "2026-08-30T15:00:00Z" }
Response — 200
{
"id": "0a1d6f2e-9c3b-4a71-8e6a-2f9b1c4d7a58",
"status": "scheduled",
"scheduled_at": "2026-08-30T15:00:00.000Z"
}
{ "cancel": true }
Response — 200
{
"id": "0a1d6f2e-9c3b-4a71-8e6a-2f9b1c4d7a58",
"status": "draft",
"cancelled": true
}
Cancel maps to the same lifecycle a browser-cancelled post uses — there's no separate cancelled status; it goes back to draft and the response carries cancelled: true so a caller isn't misled.
Retry a failed post via the same provider-aware retry path the dashboard's retry button uses (prefers retrying at the provider directly over a fresh submission, to dodge same-content dedup). Naturally idempotent — no Idempotency-Key needed.
{ "id": "0a1d6f2e-9c3b-4a71-8e6a-2f9b1c4d7a58", "mode": "provider-retry" }
mode is provider-retry or requeue depending on which path was available. Returns 400 invalid_request if the post isn't currently failed.
Publish now. Same request shape and idempotency contract as POST /api/v1/posts, minus scheduled_at — it's scheduled at the current time and goes out on the worker's next tick (typically within ~30 seconds), not synchronously. Requires the publish scope specifically, separate from schedule:write.
Idempotency-Key: pub-5b8f19a2-peonies-now
{
"workspace_id": "5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0",
"body": "Fresh peonies just arrived — come see them before they're gone.",
"channels": ["instagram"]
}
Response — 202
{
"release_id": "c9a2e6f1-2b44-4d9a-8e11-7a5c9b0d3f21",
"posts": [
{ "id": "0a1d6f2e-9c3b-4a71-8e6a-2f9b1c4d7a58", "platform": "instagram", "status": "scheduled" }
]
}
Generate on-brand post ideas, grounded in the workspace's voice and business profile (or a topic you supply). 1 credit per idea, debited before generation and refunded automatically if generation fails.
{
"workspace_id": "5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0",
"topic": "our new peony arrivals",
"count": 3
}
Response — 200
{
"ideas": [
"Fresh peonies just landed — grab a bunch before the weekend crowd does.",
"Behind the counter: how we pick which peonies make the cut.",
"Peony season is short. Here's how to make the vase last."
],
"compliance": [ [], [], [] ]
}
count defaults to 5, max 8. topic is optional if the workspace has a confirmed business profile to ground on; otherwise required. compliance is one flag array per idea (empty = no issues) — see the brand endpoint's compliance config for what can trigger a flag.
Turn one master post body into native per-channel copy — the adapt step, run server-side and fanned out. Returns copy only; nothing is persisted or scheduled. 1 credit per requested channel.
Request{
"workspace_id": "5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0",
"body": "Fresh peonies just arrived — come see them before they're gone.",
"channels": ["instagram", "x"]
}
Response — 200
{
"body_by_channel": {
"instagram": "Fresh peonies just landed 🌸 Come see them before the weekend crowd does.",
"x": "Fresh peonies just landed. Come see them before they're gone."
},
"compliance": { "instagram": [], "x": [] }
}
Every returned body is already clamped to that channel's character limit — an over-limit post is not possible from this endpoint.
Generate an AI image. Synchronous — the request blocks until the image is rendered and hosted, typically ~10–30s.
{
"workspace_id": "5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0",
"idea": "peonies on a marble counter, morning light"
}
Response — 200
{
"url": "https://app.usewarble.com/api/media/b6d3a1f4-8c2e-4b7a-9f0d-3e6c1a4b8d59",
"model": "gemini-3.1-flash-lite-image",
"prompt": "A symbolic, conceptual image capturing the idea behind this social post..."
}
Pass prompt instead of idea to skip the idea→prompt step and control the image prompt directly. Pass "model": "openai" to use the OpenAI lane instead of the Gemini default. Credits are debited up front using the real cost of whichever model is currently configured — typically 1–2 credits at the $0.07/credit peg — and refunded automatically if generation fails.
Import an external image URL into Warble's hosted media library, so it can be used as image_url on POST /api/v1/posts. Fetches the bytes server-side (agents hold URLs, not multipart form data) behind an SSRF guard — https-only, blocks localhost/private/loopback addresses including ones reached via redirect.
{
"workspace_id": "5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0",
"url": "https://example.com/photos/peonies.jpg"
}
Response — 200
{
"id": "b6d3a1f4-8c2e-4b7a-9f0d-3e6c1a4b8d59",
"url": "https://app.usewarble.com/api/media/b6d3a1f4-8c2e-4b7a-9f0d-3e6c1a4b8d59"
}
15MB max source file. Requires the workspace to have attested media rights in Settings first (same consent gate as a manual upload) — otherwise 400 invalid_request.
Start an async multi-slide carousel generation job from a topic. Debits 2 credits up front (a fixed pre-flight cost for this endpoint's template), refunded automatically if setup or the job itself fails.
{
"workspace_id": "5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0",
"topic": "how we source our flowers"
}
Response — 202
{ "job_id": "cmp_2d4a6f81-3b9c-4e12-9a7d-5c8e1f0b6a23", "poll_after_ms": 3000 }
Poll GET /api/v1/jobs/{job_id} until status is done or failed.
Poll any async job's status — a single facade over every job type Warble runs (carousel, campaign, onboarding), routed by the id's prefix.
Response — 200, still running{
"id": "cmp_2d4a6f81-3b9c-4e12-9a7d-5c8e1f0b6a23",
"status": "running",
"result": null,
"error": null,
"poll_after_ms": 3000
}
Response — 200, done
{
"id": "cmp_2d4a6f81-3b9c-4e12-9a7d-5c8e1f0b6a23",
"status": "done",
"result": { "slides": ["..."] },
"error": null,
"poll_after_ms": null
}
status is one of running, done, failed. poll_after_ms is a suggested delay before the next poll, or null once the job is finished.
Post-level metrics for a workspace — impressions, likes, clicks, engagement rate, per post and totaled. limit caps how many posts come back (max 200, default 50), newest-sent-first.
GET /api/v1/performance?workspace_id=5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0&limit=1Response — 200
{
"workspace": { "id": "5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0", "name": "Wildflower Co." },
"totals": { "postsLive": 42, "impressions": 18400, "likes": 612, "engagementRate": 0.0333, "clicks": 89, "ctr": 0.0048 },
"posts": [
{
"postId": "0a1d6f2e-9c3b-4a71-8e6a-2f9b1c4d7a58",
"platform": "instagram",
"sentAt": "2026-08-24T15:00:00.000Z",
"impressions": 1204,
"likes": 58,
"engagementRate": 0.0482,
"clicks": 6
}
]
}
A post's impressions/likes/engagementRate are null, not zero, until provider results have synced — check for null before treating a post as "zero engagement".
Remaining credit balance for the org, plus this key's rolling 24-hour spend, its daily cap (if any), and the scopes it carries. The MCP server calls this once at startup to decide which tools to advertise.
Response — 200{
"balance": 214,
"key_spent_24h": 12,
"daily_cap": 100,
"scopes": ["read", "content:write", "schedule:write", "connections:read"]
}
Warble ships a small MCP server that wraps this REST API as tools — one per capability above, plus job polling. It filters what it advertises to the scopes your key actually has, so a read-only key never even sees a tool like warble_schedule_post.
The MCP server currently talks over stdio only — it runs as a local process next to your agent, not a hosted endpoint. Inside a checkout of the Warble repo:
cd mcp && npm installnpm run build, compiles to dist/stdio.js) or run it straight from source during development with npm start (runs tsx src/stdio.ts, no build step).WARBLE_API_KEY and register the command in your MCP client's config, as below.wrb_live_... key from Settings → API Keys. The server exits immediately if this is unset.https://app.usewarble.com.{
"mcpServers": {
"warble": {
"command": "node",
"args": ["/absolute/path/to/warble-db/mcp/dist/stdio.js"],
"env": {
"WARBLE_API_KEY": "wrb_live_9pT4qX2vLd8zK1rN6mF0hJ3sYw...",
"WARBLE_API_URL": "https://app.usewarble.com"
}
}
}
}
For local development against source instead of a build, swap the command for "command": "npx", "args": ["tsx", "/absolute/path/to/warble-db/mcp/src/stdio.ts"].