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. - No idempotency keys yet: a retried
POSTafter a network blip may create twice. Have agents checklist_contentbefore retrying creates. - 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 /brandin 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 | any member |
/content/:id | PATCH, DELETE | write | any member |
/content/:id/schedule | POST | write | any member |
/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.
Content
GET /content
Cursor-paginated list, newest first.
Query: status (draft | pending | scheduled | publishing |
published | failed | cancelled | all, default all),
channelId, source (api, manual, campaign, news_reactive,
redistribute, daily_generation), cursor, limit (1–100,
default 20).
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",
"content": "…", "source": "api",
"createdAt": "2026-07-29T01: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 and publishedUrl are omitted
rather than sent as null when they do not apply, so check for the key's
presence.
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). The draft still passes sensitivity
and AI-ism holds.
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.
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.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 before approving it." 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 a plan-placed item whose planned time is still ahead also
queues it to publish at that time. When the planned time has already
passed, 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). Nothing picks a time for you: an item with no planned time answers400 validation_error, and the fix is to sendscheduledAtorpublishNow. A draft you created through this API has no planned time, so it always needs one of the two.{ "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 and try again."), 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.
Workspace reads
GET /channels
{ "channels": [{ id, platform, provider, displayName, handle, accountType, subscriptionTier, isDefault, isActive, connectedAt }] }.
Optional ?platform= filter.
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, examples } }
— 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
prose fields above it are the workspace's own words about itself; treat
them as voice, not as licence to assert anything the facts 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, approvalMode, maxPostsPerDay, minHoursBetween, queueBufferDays, sensitivityKeywords, aiIsmAllowlist, voices: { you: { approvalMode, sensitivityKeywords, aiIsmAllowlist }, brand: { approvalMode, sensitivityKeywords, aiIsmAllowlist } }, … }, "slots": [{ id, dayOfWeek, timeOfDay, platform, contentType, topicCategory, recurrenceType, weekOfMonth, enabled }] }. Read-only —
other methods answer 405.
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, so the
brand account can publish on its own while the founder's posts wait for
review.
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 | 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 |
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 | 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-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.