Developers

Drive Warble
from your own agent.

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.

Authentication

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:

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.

Scopes

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.

ScopeUnlocks
readList/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:writeGenerate ideas, per-channel copy drafts, AI images, and carousel decks; import an external image into Warble's media library. Spends credits.
schedule:writeCreate, reschedule, cancel, and retry posts scheduled for a future time.
publishPublish a post right now, no future time involved. Kept separate from schedule:write deliberately — unchecked by default in Settings.
connections:readList 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.

Idempotency

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.

Same key, same body
Replays the original response verbatim (status + JSON), with an Idempotency-Replayed: true response header. No new post is created, no credits move twice.
Same key, different body
422 idempotency_mismatch — the key was already used for a different request and is not reusable for this one.
Same key, concurrent
409 idempotency_in_flight (retryable) — an identical request from the same key is still being processed. Wait and retry.
Lease window
An in-flight row is trusted for about 5 minutes before Warble's background sweep reconciles it on its own (checks whether the release actually got created).
Replay window
A completed (done) response stays replayable for 24 hours, after which the row is cleaned up and the key can be reused.
Practical guidance: derive the key deterministically from the request's own content — e.g. a hash of 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.

Rate limits & credit caps

Two burst backstops sit in front of every request, plus an optional per-key spending cap.

Per IP
120 requests / 60s, checked before authentication even runs. Over the limit → 429 rate_limited.
Per key (or session)
60 requests / 60s, checked after authentication. Over the limit → 429 rate_limited.
Daily credit cap
Optional, set per key as 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.

Errors

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.

CodeHTTP statusRetryable
unauthorized401no
forbidden_scope403no
not_found404no
invalid_request400no
insufficient_credits402no
rate_limited429yes
daily_cap_exceeded402no
channel_not_connected422no
idempotency_in_flight409yes
idempotency_mismatch422no
upstream_failed502yes
internal500yes

Endpoints

16 endpoints, base URL https://app.usewarble.com/api/v1. All request/response bodies are JSON; all timestamps are ISO 8601 UTC.

Workspaces

GET /api/v1/workspaces read

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" }
  ]
}
GET /api/v1/workspaces/{id}/brand read

Voice profile, business summary, and content pillars — the grounding context an agent needs to write on-brand before calling /ideas or /drafts.

Response — 200
{
  "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 }
}
GET /api/v1/workspaces/{id}/channels connections:read

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" }
  ]
}

Posts & publishing

GET /api/v1/posts read

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).

Request
GET /api/v1/posts?workspace_id=5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0&status=scheduled&limit=2
Response — 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.

POST /api/v1/posts schedule:write

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.

Requires Idempotency-Key202 Accepted
Request
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.

PATCH /api/v1/posts/{id} schedule:write

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.

Request — reschedule
{ "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"
}
Request — cancel
{ "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.

POST /api/v1/posts/{id}/retry schedule:write

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.

Response — 200
{ "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.

POST /api/v1/posts/publish publish

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.

Requires Idempotency-Key202 Accepted
Request
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" }
  ]
}

Content generation

POST /api/v1/ideas content:write

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.

Request
{
  "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.

POST /api/v1/drafts content:write

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.

POST /api/v1/images content:write

Generate an AI image. Synchronous — the request blocks until the image is rendered and hosted, typically ~10–30s.

Synchronous
Request
{
  "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.

POST /api/v1/media content:write

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.

Request
{
  "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.

POST /api/v1/carousels content:write

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.

Async — poll GET /jobs/{id}202 Accepted
Request
{
  "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.

Async jobs

GET /api/v1/jobs/{id} read

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.

Analytics & billing

GET /api/v1/performance read

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.

Request
GET /api/v1/performance?workspace_id=5b8f19a2-6e2d-4d7b-9d0a-1f7c9e2a44f0&limit=1
Response — 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".

GET /api/v1/credits read

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"]
}

MCP server

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.

Run it locally (stdio)

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:

  1. Install its dependencies: cd mcp && npm install
  2. Either build it once (npm 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).
  3. Set WARBLE_API_KEY and register the command in your MCP client's config, as below.
Environment variables
WARBLE_API_KEY
Required. A wrb_live_... key from Settings → API Keys. The server exits immediately if this is unset.
WARBLE_API_URL
Optional. Defaults to https://app.usewarble.com.
Claude Desktop / Claude Code MCP config
{
  "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"].

Remote (hosted) MCP transport is coming soon. For now, stdio is the only supported transport — the server runs as a child process wherever you run it, authenticated with your own API key. Nothing at a public URL to connect to yet.