API Reference
Every /api/v1 endpoint — auth, conventions, request and response shapes, scopes, and error codes.
Conventions
- Base URL:
https://borker.xyz/api/v1. Tokens are environment-bound:bork_live_…for production,bork_test_…elsewhere. - Auth: every request sends
Authorization: Bearer <agent key>. A logged-in browser session also works on v1 routes (handy for poking around), but agents should always use a key. - JSON in, JSON out. Send
Content-Type: application/jsonwith bodies. Responses use camelCase fields and ISO-8601 UTC timestamps. - Errors share one shape:
{ "error": { "code": "...", "message": "..." } }— see the error codes table. - Rate limits: see Rate limits. A
429tells you exactly how long to wait. - Idempotency:
POST /contentwithbodytakes an optionalIdempotency-Keyheader (1 to 255 characters). A retry with the same key within 24 hours answers200with the draft the first call created, same id, and creates nothing; a different key creates a new draft, and keys never match across workspaces. Apromptgeneration takes no key and is refused with400 validation_errorif it sends one. - Versioning: additive changes land in v1 without notice; breaking
changes would ship as a
/api/v2. One exception, dated and described in the changelog: the v3 release changedGET /brandandGET /schedulein place, madePOST /contentname its channel in both modes (apromptgeneration, and abodydraft that used to acceptplatforminstead), and made approving need a connected channel (PATCH /content/:idwithstatus: "pending", andPOST /content/:id/schedule).
Scopes at a glance
| Endpoint | Method | Scope | Role |
|---|---|---|---|
/channels, /brand, /schedule, /stats | GET | read | any member |
/content, /content/:id | GET | read | any member |
/content | POST | write | editor+ |
/content/:id | PATCH, DELETE | write | editor+ |
/content/:id/schedule | POST | write | editor+ |
/media | GET | read | any member |
/media, /media/uploads | POST | write | editor+ |
/sources/supply | POST | write | editor+ |
/keys, /keys/:id | GET | admin | any member |
/keys | POST | admin | admin/owner |
/keys/:id | DELETE | admin | admin/owner |
Scopes cascade (admin ⊃ write ⊃ read). The key owner's workspace role
applies on top — both checks must pass. A viewer's key can read but never
write: every content write answers 403 forbidden for it, whatever the
key's scopes say.
Content
GET /content
Cursor-paginated list, newest first.
Query: status (draft | pending | scheduled | publishing |
published | failed | cancelled | all, default all),
channelId, source (api, manual, news_reactive, redistribute,
daily_generation, build_log for posts written from build-log entries),
cursor, limit (1–100, default 20). Any other
source value, the retired campaign included, is 400 validation_error.
curl "https://borker.xyz/api/v1/content?status=draft&limit=10" \
-H "Authorization: Bearer $BORKER_KEY"{
"items": [
{
"id": "…", "status": "draft", "platform": "x",
"channelId": "…", "channelName": "My Startup", "voice": "brand",
"content": "…", "topic": "release notes", "source": "api",
"createdAt": "2026-07-29T01:00:00Z",
"scheduledAt": "2026-07-30T09:00:00Z",
"flagged": false, "sensitive": false,
"flagReasons": [], "aiIsmMatches": [], "sensitivityReasons": [],
"createdBy": { "userId": "…", "fullName": "Sven" }
}
],
"nextCursor": "eyJjcmVhdGVkQXQi…"
}Pass nextCursor back as cursor for the next page (null = end).
createdBy.email appears only for admin-scope keys.
Every endpoint that returns a content item returns this same shape.
sourceUrl, scheduledAt, publishedAt, publishedUrl and title are
omitted rather than sent as null when they do not apply, so check for
the key's presence. voice is the voice of the item's channel (personal
or brand, see GET /channels) and null while the item has no channel;
topic is what the post is about, the one you set on create or the first
line of the text when you set none; title is the Paragraph title when one
was set. media lists the images attached to the item, each as
{ id, url, format, size }, in the order they publish; it is [] when the
item carries none.
Once an item publishes it carries publishedAt (when it went live) and, where
the provider gives one, publishedUrl (the live post). An item that was
attempted and failed has status: "failed" and no publishedAt — that is how
you tell a failed publish from one that simply has not run yet.
Why a draft was held
Three fields explain the flagged and sensitive booleans. Unlike the fields
above they are always present, and empty when there is nothing to report, so
you can iterate them without checking for the key first.
| Field | Meaning |
|---|---|
flagReasons | Why flagged is true. Currently only length_violation — the post is over the channel tier's character cap. |
aiIsmMatches | Detected AI-isms, as { label, match }. Reported independently of flagged. |
sensitivityReasons | Why sensitive is true — either keyword: <word> or principle: <your rule>. |
flagged means length only. A draft can be flagged: false and still carry
several aiIsmMatches, so read the array rather than the boolean when you want
to know whether the writing needs another pass:
{
"flagged": false,
"aiIsmMatches": [
{ "label": "delve", "match": "delve" },
{ "label": "em dash", "match": "—" }
]
}At creation time these are advisory — the draft lands in the pipeline either
way. They stop being advisory when the item leaves draft: approving or
scheduling an item whose current text still matches AI-ism patterns
requires an explicit acknowledgment (see PATCH /content/:id below). The scan
runs fresh against the text at transition time, so the cheapest path is to
rewrite until aiIsmMatches is empty, then approve. Editing re-runs the
deterministic scans, so the annotations on the returned item are always
current.
POST /content
Two modes — send exactly one of body or prompt:
Free draft (your text, no credit): { "body": "...", "channelId": "..." } (optional topic, title, scheduledAt, media). The draft
still passes sensitivity and AI-ism holds.
Autopilot never covers it. Autopilot approves and publishes only the posts Borker plans itself. A post an agent creates stays a draft until a person approves it or the agent schedules it through the API, within the workspace's checks: the sensitivity hold, the length limit and URL credits.
A draft gets a time. Send scheduledAt (ISO-8601, in the future) to say
when the draft should go out once approved, or leave it out and the draft
takes its channel's next open time under the workspace's plan, the same rule
the app's composer and reactive placement use. Either way the item stays a
draft and waits for review, now at a known instant on the calendar, which
the returned item reports as scheduledAt. A scheduledAt that has gone by
answers 400 validation_error with "Pick a time that has not gone by yet.",
and one that is not a timestamp answers "Pick a date and a time."; no draft
is created in either case.
Images. media is an array in publish order, each entry { "url": "…" }
(a public https image URL Borker fetches and validates) or { "id": "…" }
(an upload id from POST /media or GET /media). See
Media for what is accepted and how a refusal reads.
Metered generation (Borker writes, one credit per channel):
{ "prompt": "...", "channelIds": ["..."] }.
contentType is retired on both modes: a request that still sends it gets
no error — the field is ignored and not stored, and it no longer appears in
responses. What a post is shaped like is decided by the target channel; where
it came from is the source field on every returned item.
Paragraph essays need a title. On Paragraph a post's title is a field
of its own, not the first line of the body. A body draft with no title
publishes as "Untitled", so send one whenever you target a Paragraph
channel. It is accepted and ignored on platforms that have no title, so a
call posting the same draft to several channels does not have to branch.
A prompt generation supplies its own title and needs nothing here.
Naming the channel. Both modes accept either channelId (a string) or
channelIds (an array), so you do not have to remember which spelling goes
with which mode. A body draft is a single item: pass one id, and a
channelIds array with more than one entry is refused rather than silently
trimmed. A prompt generation fans out, one post per id. Sending both
channelId and channelIds in one request is refused — a precedence rule
would quietly target a channel you did not ask for.
Both modes must name their channel, with channelIds or channelId.
Borker never picks a channel for you. A prompt generation that names none is
refused with 400 validation_error, and nothing is generated or billed. A
body draft that names none is refused the same way and no draft is created:
platform is no longer read, because a draft with no channel could never be
approved or scheduled. The message names every connected channel with the id
to pass, and the same list comes back machine-readable inside the error:
{
"error": {
"code": "validation_error",
"message": "Name a channel: X Borker @borkerhq (8c1e...), LinkedIn Sam Rivera @sam-rivera (41d2...). Pass one of these ids in `channelIds`; nothing was generated.",
"channels": [
{ "id": "8c1e...", "platform": "x", "name": "Borker @borkerhq" },
{ "id": "41d2...", "platform": "linkedin", "name": "Sam Rivera @sam-rivera" }
]
}
}A body draft's refusal ends "Pass its id in channelId; no draft was
created." instead. A workspace with no connected channel is refused the same
way, with an empty channels list. The MCP catalog's generate_content makes
channelIds required, and create_content_draft no longer offers platform,
so schema-driven callers see the rule without reading this page.
Duplicate channel ids are collapsed, so a channel is generated for at most
once per request. Every channel is validated before any generation runs: if
one is unusable, the whole request is refused with 400 validation_error
naming it, and nothing is created or billed.
Over quota before anything is created → 403 generation_limit. If the quota
runs out partway through a multi-channel request, the response is still
201 and carries a warning alongside the items that were created:
{
"items": [{ "id": "...", "status": "draft" }],
"warning": {
"code": "generation_limit",
"message": "Monthly generation limit reached: 100/100 on starter.",
"unfulfilledChannelIds": ["..."]
}
}You are never billed for an item the response does not list, so treat items
as the authoritative record of what a request created. Retrying after a 400
is safe; retrying after a 201 with a warning would duplicate the items you
already received. When the stop was not the quota, warning.code is
generation_failed and warning.message is one plain sentence naming the
cause (no Anthropic key, the key refused, no credit on the Anthropic
account, the writer unavailable) and what to do; the provider's own error
text is never forwarded.
Both modes return 201 { "items": [...] } with source: "api".
GET /content/:id · PATCH /content/:id · DELETE /content/:id
PATCH accepts any of:
content— replace the text (draft/pending only; published →409 published_immutable). The deterministic scans re-run against the saved text server-side: AI-ism matches, keyword sensitivity reasons, and length flags all clear or set to match what you saved, and the returned item carries the refreshed annotations.channelId— move the item to another channel (draft/pending only, the same ascontent). It must be a connected channel of this workspace on the item's own platform; otherwise400 validation_errornames which ("Target channel not found in this workspace", which is also the answer to an empty or malformed id, "Cannot reassign to a disconnected channel", "Target channel must match the content's platform"), and nothing is written. The item's plan slot moves with it, and the scans re-run for the new channel. This is how a draft created withplatformbefore v3, which has no channel, gets one. When the slot had no channel yet, giving the item one places it the way connecting an account does: it approves a draft an auto-approval voice held only for want of a channel (the text passes auto's checks again first, and the approval is recorded as your key), and it queues an approved item whose plan slot had no channel at the slot's planned time. The returned item says where it landed (status,scheduledAt); when the planned time has already passed the item stays approved with nothing queued, and the response carriesplannedTimePassed: true. Sent withstatus: "draft", the un-approve happens first, so the item moves as a draft and nothing is queued.status—"pending"(approve),"draft"(un-approve),"cancelled"(reject, optionalrejectionReason). Anything else, including"published", →400 invalid_status_transition. On ascheduleditem,"pending"unschedules it: the time is cleared, a held URL credit is released, and nothing is queued again; to move it, usePOST /content/:id/schedule.scheduledAt— withstatus: "pending"on a draft, the ISO-8601 instant (in the future) to queue the approved post at. A time that has gone by answers400 validation_errorwith "Pick a time that has not gone by yet." before anything moves, the text edit in the same call included.acknowledgeAiIsms—trueto approve an item whose current text still matches AI-ism patterns. Without it, astatus: "pending"on such an item returns 409 withacknowledgmentRequired: trueand the exactaiIsmMatchesin the error envelope. Rewrite the text until the matches clear (the better fix), retry withacknowledgeAiIsms: true, or add the label to the workspace allowlist in Settings. The acknowledgment binds to the exact text — editing after approval re-asks on the next transition.
Approving needs a connected channel. A draft with no channel answers
400 validation_error with "This post has no channel yet, so give it one
before approving it.", and a draft on a disconnected channel answers the same
code with "This post's channel is disconnected, so move it to a connected
channel first." Nothing is written, a text edit sent in the same
call included; the item stays a draft. Send channelId with a connected
channel, in the same call or before, and the approval goes through: the
channel check reads the channel the item will have once the call is done.
Approving queues the post. It goes out at the scheduledAt you passed,
else at the instant the plan gave it, else at the plan's next open time
(the rule the app's composer and reactive placement use), and the response
carries that instant as scheduledAt beside the item, whose status is
then scheduled. When the item's planned time has already passed and no
scheduledAt was sent, the approval stands but nothing is queued: the
response carries plannedTimePassed: true beside the item, and the fix is
POST /content/:id/schedule with a new scheduledAt or publishNow.
DELETE removes any non-published item (published →
409 published_immutable) and best-effort cancels a scheduled
external post first.
POST /content/:id/schedule
Approves (if needed) and schedules in one call:
{}— the time the item is already planned for (scheduledAton the item). A draft you created through this API has one: the time it was given at creation. An item with no planned time answers400 validation_error, and the fix is to sendscheduledAtorpublishNow.{ "scheduledAt": "2026-08-01T15:00:00Z" }— specific time, snapped to 5 minutes, must be in the future{ "publishNow": true }— goes out on the next publish tickacknowledgeAiIsms: true— required when the item's current text matches AI-ism patterns, same 409 contract asPATCH /content/:id.
Requires the item to have a connected channel: no channel ("This post isn't
on one of your channels yet, so pick a channel and try again."), or a
disconnected one ("This post's channel is disconnected, so move it to a
connected channel first."), answers 400 validation_error. Give it a
connected channel with PATCH /content/:id and channelId, then schedule. X posts with
links may return
402 x_url_credit_required when the workspace is out of URL credits.
Approving an item you created (here or via PATCH /content/:id with
status: "pending") adds it to the workspace's plan for that week, so it
counts toward the weekly posting target and the next planning run writes
one fewer post of its own.
Returns { "item": … } in the same shape as every other content endpoint.
Media
An agent attaches images to a draft through media on POST /content and
PATCH /content/:id. Each entry is { "url": "…" } or { "id": "…" },
in publish order; on PATCH the list replaces the item's images and []
clears them (draft and pending items only). Every image, whichever way it
arrives, is checked the way an upload from the app is: the bytes decide the
format (JPEG, PNG, GIF or WebP; AVIF is converted, HEIC is refused), 4.5MB at
most. The item's media field lists what is attached, as
{ id, url, format, size }.
All or nothing. When any image fails, the whole call fails with
400 validation_error, the message names the image and the reason, nothing
is created, and any upload the call had already made is removed again:
{ "error": { "code": "validation_error",
"message": "Image 2 (http://169.254.169.254/x.png) could not be used: that address is not a public web address, so Borker does not read it. No draft was created." } }Per-platform caps are checked at attach, before anything is fetched: X takes 4 images, LinkedIn 9, a LinkedIn page 1, Farcaster 2. A longer list is refused in one sentence ("Farcaster posts carry at most 2 images and 3 were given. No draft was created.").
By URL. { "url": "https://…/photo.png" } must be a public https or
http address. Borker fetches it server-side under its address guard:
private, loopback, link-local and cloud-metadata ranges are refused before
any connection (also when a hostname resolves there, and on every redirect),
the read has a 10-second deadline, and an answer that is not a 2xx image is
refused by status.
By id. { "id": "…" } names an upload Borker already holds and no post
has taken, from any of the three routes below. An id that is not this
workspace's unattached upload answers "no upload with that id is waiting in
this workspace; list_media shows the ones that are".
GET /media
{ "media": [{ id, filename, format, size, createdAt, expiresAt }] }: the
uploads no post has taken yet, newest first (the MCP tool list_media). An
upload nobody attaches within 24 hours of createdAt is removed, object and
row, by the daily housekeeping; expiresAt says when.
POST /media
Multipart, one field named file. Answers 201 { "media": { id, filename, format, size, createdAt, expiresAt } }, a ready upload whose id goes into
media. For scripts and n8n:
curl -X POST https://borker.xyz/api/v1/media \
-H "Authorization: Bearer $BORKER_KEY" \
-F "file=@photo.png"POST /media/uploads
The MCP tool request_media_upload's route. { "filename": "photo.png" }
answers 201 { "mediaId", "uploadUrl", "expiresAt" }: a signed, single-use,
workspace-bound link, good for 15 minutes, that a shell-capable agent sends
the file to with an HTTP PUT and no header:
curl -T photo.png "$UPLOAD_URL"The bytes are validated when they arrive; the answer is 200 { "media" } and
mediaId is attachable from then on. The same link a second time answers
409 validation_error ("This upload link was already used or has expired.
Request a new one with request_media_upload."); a link Borker did not issue
answers 401; bytes that are not an accepted image answer 400 with the
format sentence; a body over 4.5MB answers 413.
{ "filename": "photo.png", "data": "<base64>" } skips the link for a small
picture: the bytes (1MB at most, decoded) are validated and stored now, and
the answer is 201 { "media" }. Larger inline data answers
400 validation_error ("Inline data is capped at 1MB; for a larger image
request an upload link (leave data out) and send the file to it.").
Attaching and removing images are provenance events on the item
(media_attached, media_removed), recorded as the key that did it.
Build log
POST /sources/supply
The MCP tool supply_entry's route. Sends one build-log entry: a piece of
work the person did and confirmed is worth sharing. A skill or agent sends
it; the person plans entries in Borker, on the Sources page.
{
"source": "build-log",
"title": "Day 12: one voice screen",
"body": "Tone sliders, terminology lists and traits all went. ...",
"happenedAt": "2026-08-23",
"externalId": "session:7c1d#12",
"kind": "reversal",
"context": "Last month the settings were split across three screens.",
"skillVersion": "0.3.0",
"destinations": ["4b0c2e5a-9d1f-4c3e-8a7b-2f6d1e0c9b8a"]
}| Field | Required | What it is |
|---|---|---|
source | yes | The handle of the source, up to 80 characters. The first entry for a handle creates the source, named from the handle (build-log shows as "Build log"), with the voice You. |
title | yes | The entry's title, up to 200 characters. |
body | yes | What happened, in full: at least 40 characters. |
happenedAt | yes | The date the work happened (2026-08-23) or a full ISO timestamp. Orders the series. |
externalId | no | Your own stable id for the entry, up to 255 characters. Makes a retry safe. |
kind | no | What kind of beat the entry is: finding (something found out), decision (a decision made), ship (something shipped) or reversal (something taken back or changed). Borker shapes the post by it. |
context | no | Up to 600 characters: what was going on, why it mattered, who it is for. Background for Borker's writer only, never posted verbatim. |
skillVersion | no | Up to 20 characters: the version of the skill or agent sending the entry, e.g. 0.3.0. Borker keeps the last one on the source, with when the last entry arrived. |
media | no | At most one image, [{url}] or [{id}], the shape POST /content takes. Borker keeps it until the entry's day and puts it on every post written from the entry. An image that cannot be used fails the call with the same 400 validation_error sentence POST /content answers (field: "media"), and nothing is supplied. |
destinations | no | The channel ids from GET /channels the entry goes to, each channel once. Name one by default, several only when the entry belongs on more than one. Borker writes one post per named channel, each on that channel's next free posting day, in that channel's voice. With none named, the entry goes to the channel with the next free day. |
It answers 201 { "id", "position", "state", "kind", "context", "planned", "notPlanned", "paces", "destinations" },
with kind and context as stored (null when not sent) and destinations as
stored ([] when none was named). position is the entry's
place in its series, counted by happenedAt and then by arrival, so an entry
sent late for an earlier day takes its place by date.
A supply places nothing: a new entry arrives with state not_yet_planned, and
planned and notPlanned are both null. A source paces its new entries by
default (the person can switch that off in Settings, Sources), and then paces
is true: Borker's night run places each new entry, oldest day first, on the
next free day of the workspace's channels, and the channel decides the voice.
With pacing off, paces is false and the entry waits on the Sources page for
the person to plan it.
An externalId the source already holds answers 200 with that entry,
unchanged, its first destinations included, and creates nothing. Once that entry is planned, state is planned
and planned is { "date": "2026-10-15", "time": "09:30" }, the workspace's
own day and time.
No AI runs and no generation credit is used at supply: Borker writes a post
from an entry on the day it is planned for. A handle that a
feed of the workspace already uses is refused with 400 validation_error and
"field": "source"; a missing title, a short body or a happenedAt that
is not a date, a kind outside the four, a context over 600 characters or a
skillVersion over 20 characters each answer 400 validation_error naming the field in the message and in
error.field. So does a destinations that is not a list of ids, names one
channel twice or names an id that is not one of the workspace's channels
("field": "destinations"), and nothing is supplied.
Workspace reads
GET /channels
{ "channels": [{ id, platform, provider, displayName, handle, accountType, voice, subscriptionTier, isDefault, isActive, connectedAt }] }.
Optional ?platform= filter.
voice is which of the workspace's two voices the channel writes in:
personal speaks as the founder, brand as the company. It is resolved
the way generation resolves it (a LinkedIn page is always brand, and a
legacy value in accountType does not change the answer), so read voice
and treat accountType as the raw stored value. The per-voice settings in
GET /schedule apply by it: voices.you governs the personal channels,
voices.brand the brand ones.
GET /brand
{ "profile": { name, tagline, motto, mission, whatWeAre, primaryUrl, aboutYou: { facts, inYourWords, you: { facts, inYourWords }, brand: { facts, inYourWords } }, standingRules: [{ name, brief, scope }], voiceConfidence: { pieces, target, thin }, excludedTopics } }
— or "profile": null before onboarding creates one.
aboutYou.facts is the canonical fact block: the only claims Borker
makes about the workspace, and the only ones an agent should make. A
workspace has two voices, so each fact belongs to one of them: facts
and inYourWords are everything and the paragraph written for both,
while aboutYou.you and aboutYou.brand carry the half that belongs to
the person and the half that belongs to the brand. A claim listed only
under brand is not a claim to make from the founder's own account. The
tagline and mission are brand claims too: Borker stores each as a
brand fact as well as a profile field, and serves it once, as the field,
rather than repeating it inside aboutYou. The other prose fields are the
workspace's own words about itself; treat them as voice, not as licence
to assert anything the facts, the tagline and the mission do not say.
standingRules are the confirmed rules for how the writing sounds,
scoped global, you (the founder) or brand; suggestions awaiting
confirmation are not served — a rule is suggested, confirmed, or removed,
nothing else. voiceConfidence.thin means at least one
of the workspace's two voices — its own and its brand's — has less
writing on file than Borker wants before it claims to write in that
voice; drafting continues either way.
excludedTopics is the workspace's live avoid list — the subjects its
content strategy tells Borker to stay out of, the same list drafting and
source matching obey.
GET /schedule
{
"config": {
"timezone": "Europe/Berlin", "approvalMode": "manual", "defaultModel": null,
"sensitivityKeywords": [], "aiIsmAllowlist": [], "matchThreshold": 70,
"voices": {
"you": { "approvalMode": "manual", "sensitivityKeywords": [], "aiIsmAllowlist": [] },
"brand": { "approvalMode": "auto", "sensitivityKeywords": [], "aiIsmAllowlist": [] }
}
},
"cadence": [
{ "channelId": "…", "platform": "x", "voice": "brand",
"cadence": "most_days", "cadenceLabel": "Most days", "weeklyPosts": 5, "enabled": true }
],
"planned": [
{ "id": "…", "channelId": "…", "platform": "x", "targetDate": "2026-10-03",
"targetTime": "09:00", "state": "written", "contentId": "…" }
]
}Read-only — other methods answer 405. config is null until onboarding
writes one.
cadence has one row per connected channel: how often Borker writes for it
(cadence is one of off, weekly, twice_weekly, most_days, daily,
with cadenceLabel as the app spells it and weeklyPosts the number it
plans to) and whether that pulse is on. A channel the strategy has not given
a pulse yet reads as off. voice is the channel's voice, as in
GET /channels.
planned is the plan for the coming 14 days, in the workspace's timezone:
each row is one post Borker intends to write or has written, with its
channel, target date and time, state (planned until written, then
written, skipped when the person took it out) and contentId once the
post exists, so you can fetch it with GET /content/:id. An item you
create and approve enters this window as a plan item of its own.
matchThreshold is the relevance a source item has to clear, out of 100,
before Borker reacts to it the same day.
The three gate settings follow the voice. approvalMode,
sensitivityKeywords and aiIsmAllowlist at the top level are the
answers that apply to both voices; voices.you and voices.brand carry
each voice's own, with approvalMode there resolved already (a voice
with no answer of its own reports the workspace one). A draft is judged
by the shared settings plus those of the voice on its channel (voices.you
for a channel whose voice is personal, voices.brand for brand), so
the brand account can publish on its own while the founder's posts wait for
review.
Gone with the v3 release, under the one-release exception in
Conventions: the slots array (v3 writes no calendar slots;
the plan above replaces them) and the knobs maxPostsPerDay,
minHoursBetween, queueBufferDays, reactiveMinRelevance and
autoApproveTypes, which nothing reads any more. An agent that sized its
week from maxPostsPerDay should read cadence instead.
GET /stats
Workspace analytics over ?days= (1–365, default 30):
{
"windowDays": 30,
"channelBalance": [
{ "channelId": "…", "platform": "x", "displayName": "My Startup",
"counts": { "draft": 2, "published": 8 }, "total": 10 }
],
"pipeline": { "byStatus": { "draft": 3 }, "sourceMix": { "api": 2 }, "total": 12 },
"aiIsm": { "flagged": 1, "total": 12, "rate": 0.083 },
"usage": { "current": 42, "limit": 500, "plan": "pro", "periodEnd": "…" }
}Keys
All /keys routes require admin scope on the calling token.
GET /keys—{ "keys": [...] }, active and revoked, never a secret.GET /keys/:id— one key.POST /keys(admin/owner) —{ "name": "...", "scopes": ["read"|"write"|"admin", …] }→201includingtoken, the only response that ever contains it.DELETE /keys/:id(admin/owner) — revoke, idempotent. A token revoking itself →400 cannot_revoke_self_in_request.
Rate limits
Limits are per agent key, counted in a rolling 60-second window.
| What you're calling | Limit |
|---|---|
POST /content (both modes) | 10 / min |
| Every other v1 endpoint | 60 / min |
| Unauthenticated requests, or a bearer that doesn't resolve | 200 / min per IP address |
POST /content is tighter than the rest because both of its modes can make
Borker's AI run — the prompt mode by generating, and the body mode via the
sensitivity check when your workspace has principles configured. It is 10/min
even when a given call happens not to invoke a model, so the limit is
predictable rather than depending on your workspace's configuration.
Over MCP the same limits apply, and a refusal is reported before the tool runs.
Discovery calls (initialize, tools/list, ping) get their own 60/min per
address so a client can connect without a key.
What a 429 tells you
HTTP/1.1 429
Retry-After: 42
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785445800
{ "error": { "code": "rate_limited",
"message": "Rate limit exceeded. Retry after 42 seconds." } }Wait Retry-After seconds and retry. X-RateLimit-Reset is a Unix timestamp
for when the window rolls over. Over MCP the same figure arrives as
error.data.retryAfterSeconds.
These headers are sent only on a 429, not on successful responses — so
you cannot currently watch X-RateLimit-Remaining to pace yourself in
advance. Space bursts out, and treat the 429 as the signal to back off.
Error codes
Every failure — from a REST route or from an MCP tool — uses one envelope:
{ "error": { "code": "validation_error", "message": "…" } }code is stable and safe to branch on; message tells you what to do about
it. Some errors add machine-readable detail alongside them inside error,
never as a second top-level key.
Over MCP the same envelope arrives as the JSON-RPC error.data.code when the
failure is about your credentials, and as the tool result text otherwise —
see MCP.
| Code | Status | Meaning | What to do |
|---|---|---|---|
missing_authorization | 401 | No Authorization header was sent, and no browser session either | Send Authorization: Bearer <key> |
invalid_api_key | 401 | Token missing, malformed, wrong environment, revoked, or its owner left the workspace | Stop retrying; get a fresh key |
subscription_inactive | 402 | Workspace subscription expired | Surface to the operator; retrying cannot clear it |
x_url_credit_required | 402 | Scheduling an X post with a link without URL credits | Remove the link and reschedule, or surface the shortfall |
forbidden | 403 | Key owner's workspace role is below the route's requirement | Do not retry; the owner's role must be raised |
scope_required | 403 | Key's scopes don't cover the route | Use a key with the scope named in the message |
generation_limit | 403 | Monthly generation quota exhausted | Stop generating; free drafts still work. Quota resets at usage.periodEnd |
workspace_suspended | 403 | Workspace is suspended | Stop and surface to the operator |
not_found | 404 | No such resource in this workspace | Re-list to get current ids |
workspace_not_found | 404 | The workspace behind this key is gone | Stop; the key is orphaned |
published_immutable | 409 | Edit/delete attempted on a published item | Create a new item instead |
validation_error (with acknowledgmentRequired: true) | 409 | Approve/schedule on text that matches AI-ism patterns, without acknowledgeAiIsms | Rewrite until aiIsmMatches clears, or retry with acknowledgeAiIsms: true |
validation_error | 400 | Bad input — the message names the parameter | Correct that parameter and retry |
missing_input | 400 | POST /content needs exactly one of body / prompt | Send exactly one |
invalid_status_transition | 400 | Lifecycle move the state machine forbids | Re-read the item's actual status first |
cannot_revoke_self_in_request | 400 | A token tried to revoke itself | Revoke from Settings, or use a different admin key |
rate_limited | 429 | Too many requests for this key or address | Back off for Retry-After seconds |
rate_limited_key | 429 | Too many tool calls on this agent key (MCP only, in error.data.code) | Back off for error.data.retryAfterSeconds; the address is not the problem |
rate_limited_ip | 429 | Too many discovery calls, or calls with no usable key, from this address (MCP only) | Back off for error.data.retryAfterSeconds; a valid key moves you to the per-key limit |
unknown_tool | — | No tool of that name (MCP only) | Call tools/list and use a name from it |
publish_failed | 502 | The publishing provider rejected the schedule | Re-read the item; it will be in the failed state |
transition_failed | 500 | Accepted but could not be completed | Re-read the item to establish its actual state |
internal_error | 500 | Something broke on our side. Every unexpected failure on a v1 route, a thrown exception included, arrives in this envelope with this code | Retry once, then contact support |
capacity_unknown | 503 | The cap this call is measured against could not be read, so no verdict was reached | Retry after a short pause; this is not a quota refusal and credits would not clear it |
Cross-workspace access doesn't get a special error: another
workspace's resources are simply 404 not_found, and lists only
ever contain the key's own workspace.
Changelog
- 2026-10-21 —
POST /sources/supplyandsupply_entrytake an optionaldestinations: the channel ids an entry goes to. The night run writes one post per named channel, each on that channel's next free day, and the answer carriesdestinationsas stored. An id that is not one of the workspace's channels answers400 validation_errornamingdestinations. Additive. - 2026-10-13 —
POST /sources/supplyplaces nothing: Borker's night run places new entries, and the answer carriespaces(whether it will).plannedis set only on a retry of an entry already planned, andnotPlannedis alwaysnull. Additive. - 2026-10-13 —
POST /sources/supplyandsupply_entrytake an optionalskillVersion, kept on the source. A source paces new entries by default, and the answer carriesplanned(the day and time) ornotPlanned(why not). Additive. - 2026-10-13 —
POST /sources/supplyandsupply_entrytake two optional fields,kindandcontext, and answer them back. Additive. - 2026-10-13 —
POST /sources/supplyand the MCP toolsupply_entry: a skill or agent sends build-log entries, which arrive unplanned on the workspace's Sources page, each with at most one image inmedia, kept for the entry's day.GET /contentandlist_contentacceptsource=build_log. Additive. - 2026-10-02 —
GET /schedulereturns the v3 shape. This is a breaking change to a v1 payload, under the same one-release v3 exception as theGET /brandchange below:slots,maxPostsPerDay,minHoursBetween,queueBufferDays,reactiveMinRelevanceandautoApproveTypesare gone;cadence,plannedandconfig.matchThresholdare new. Additive in the same release:voiceon channels and items;topicandtitleon items;scheduledAton abodydraft, which otherwise takes its channel's next open time;mediaonPOST /contentandPATCH /content/:id, plusGET /media,POST /mediaandPOST /media/uploads; a v1 request with noAuthorizationheader and no session answers401 missing_authorization, and an unexpected failure inside a v1 route answers500 internal_error, both in the v1 envelope;GET /branddropsexamplesand stops repeating the tagline and mission insideaboutYou; thecampaignsource filter is refused;rate_limited_keyandrate_limited_ipjoin the catalogue. See the release note. - 2026-09-25 — A
bodydraft must name its channel, and a draft can be given one. This is a breaking change to a v1 request, shipping with the v3 release under the same one-release exception as theGET /brandchange below. Before,POST /contentwithbodyandplatformbut nochannelIdwrote a draft with no channel. Now it is refused with400 validation_error, naming the connected channels and their ids in the message and inerror.channels, and no draft is created;platformis no longer read. The MCP toolcreate_content_draftno longer offersplatform. Additive in the same release:PATCH /content/:id(and MCPupdate_content) takeschannelId, so a draft created the old way is given a connected channel and then approved. - 2026-09-25 — Approving needs a connected channel. This is a breaking
change to a v1 request, under the same v3 exception.
PATCH /content/:idwithstatus: "pending"on a draft with no channel, or on a disconnected one, answers400 validation_errorwith a sentence naming the cause, andPOST /content/:id/schedulerefuses a disconnected channel the way it already refused a missing one. Before, such a draft was approved and waited with nowhere to go out. The MCP toolsupdate_contentandschedule_contentanswer the same. The fix is a connected channel throughPATCH /content/:idwithchannelId. - 2026-09-25 —
POST /contentwith apromptmust name its channels. This is a breaking change to a v1 request, shipping with the v3 release under the same one-release exception as theGET /brandchange below. Before, apromptwith nochannelIdsorchannelIdwas written for the workspace's single active channel per platform. Now it is refused with400 validation_error, naming the connected channels and their ids in the message and inerror.channels, and nothing is generated or billed. The MCP toolgenerate_contentnow listschannelIdsas required. An agent that sent a prompt alone should calllist_channelsand pass the ids. - 2026-09-01 —
GET /brandreturns the v3 voice profile. This is a breaking change to a v1 payload, and the versioning rule above says breaking changes ship as/api/v2. The v3 release is a single hard cutover of code and data together, so there is no version of the product in which both shapes are true and no/api/v2to move to; the rule is suspended for this one release and resumes after it. What changed:voice(the five 1-10 attributes),terms,traits,principlesandindustryare gone, because the settings that produced them are gone from the product.aboutYou(the canonical fact block),standingRulesandvoiceConfidenceare new and carry what those fields were for.name,tagline,motto,mission,whatWeAre,primaryUrl,excludedTopicsandexamplesare unchanged. An agent readingprofile.voice.formalityshould readprofile.standingRulesinstead. - 2026-08-16 — AI-ism acknowledgment gate (BORK-496): approving or
scheduling an item whose current text matches AI-ism patterns returns
409 with
acknowledgmentRequired: true+ the match list unless the request carriesacknowledgeAiIsms: true. Edits viaPATCH /content/:idandupdate_contentre-run the deterministic scans and return refreshed annotations. - 2026-07-29 — v1 launched: agent keys, content lifecycle, workspace reads, stats, and the hosted MCP endpoint.