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.
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": "—" }
]
}These are advisory. Nothing here rejects your draft — it lands in the pipeline either way — but they are what you need to rewrite and re-submit without a human round trip.
POST /content
Two modes — send exactly one of body or prompt:
Free draft (your text, no credit): { "body": "...", "channelId": "..." } (or platform instead of channelId; optional topic,
contentType). The draft still passes sensitivity and AI-ism holds.
Metered generation (Borker writes, one credit per channel):
{ "prompt": "...", "channelIds": ["..."], "contentType?": "..." }.
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.
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; length flags are re-derived server-side; published →409 published_immutable)status—"pending"(approve),"draft"(un-approve),"cancelled"(reject, optionalrejectionReason). Anything else, including"published", →400 invalid_status_transition.
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:
{}— auto-pick the next free slot{ "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 tick
Requires the item to have a channel. X posts with links may return
402 x_url_credit_required when the workspace is out of URL credits.
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, voice: { formality, friendliness, clarity, brevity, technicalDepth }, terms: { use, avoid }, excludedTopics, traits, principles, examples } } — or "profile": null before onboarding creates one.
Admin-scope keys additionally get industry positioning.
GET /schedule
{ "config": { timezone, approvalMode, maxPostsPerDay, dailyGenerationEnabled, newsReactiveEnabled, redistribute*, linkedinRedistribute*, sensitivityKeywords, aiIsmAllowlist, … }, "slots": [{ id, dayOfWeek, timeOfDay, platform, contentType, topicCategory, recurrenceType, weekOfMonth, enabled }] }. Read-only —
other methods answer 405.
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 | 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 |
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-07-29 — v1 launched: agent keys, content lifecycle, workspace reads, stats, and the hosted MCP endpoint.