BorkerBorker Docs
Agent API & MCP

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/json with 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 429 tells you exactly how long to wait.
  • No idempotency keys yet: a retried POST after a network blip may create twice. Have agents check list_content before retrying creates.
  • Versioning: additive changes land in v1 without notice; breaking changes would ship as a /api/v2.

Scopes at a glance

EndpointMethodScopeRole
/channels, /brand, /schedule, /statsGETreadany member
/content, /content/:idGETreadany member
/contentPOSTwriteany member
/content/:idPATCH, DELETEwriteany member
/content/:id/schedulePOSTwriteany member
/keys, /keys/:idGETadminany member
/keysPOSTadminadmin/owner
/keys/:idDELETEadminadmin/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.

FieldMeaning
flagReasonsWhy flagged is true. Currently only length_violation — the post is over the channel tier's character cap.
aiIsmMatchesDetected AI-isms, as { label, match }. Reported independently of flagged.
sensitivityReasonsWhy 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, optional rejectionReason). 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", …] }201 including token, 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 callingLimit
POST /content (both modes)10 / min
Every other v1 endpoint60 / min
Unauthenticated requests, or a bearer that doesn't resolve200 / 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.

CodeStatusMeaningWhat to do
missing_authorization401No Authorization header was sentSend Authorization: Bearer <key>
invalid_api_key401Token missing, malformed, wrong environment, revoked, or its owner left the workspaceStop retrying; get a fresh key
subscription_inactive402Workspace subscription expiredSurface to the operator; retrying cannot clear it
x_url_credit_required402Scheduling an X post with a link without URL creditsRemove the link and reschedule, or surface the shortfall
forbidden403Key owner's workspace role is below the route's requirementDo not retry; the owner's role must be raised
scope_required403Key's scopes don't cover the routeUse a key with the scope named in the message
generation_limit403Monthly generation quota exhaustedStop generating; free drafts still work. Quota resets at usage.periodEnd
workspace_suspended403Workspace is suspendedStop and surface to the operator
not_found404No such resource in this workspaceRe-list to get current ids
workspace_not_found404The workspace behind this key is goneStop; the key is orphaned
published_immutable409Edit/delete attempted on a published itemCreate a new item instead
validation_error400Bad input — the message names the parameterCorrect that parameter and retry
missing_input400POST /content needs exactly one of body / promptSend exactly one
invalid_status_transition400Lifecycle move the state machine forbidsRe-read the item's actual status first
cannot_revoke_self_in_request400A token tried to revoke itselfRevoke from Settings, or use a different admin key
rate_limited429Too many requests for this key or addressBack off for Retry-After seconds
unknown_toolNo tool of that name (MCP only)Call tools/list and use a name from it
publish_failed502The publishing provider rejected the scheduleRe-read the item; it will be in the failed state
transition_failed500Accepted but could not be completedRe-read the item to establish its actual state
internal_error500Something broke on our sideRetry 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.

On this page