Errors and fixes
The machine catalog — HTTP statuses, reason slugs, per-channel validation codes, the five fix kinds with their parameters, and the upgrade markers — everything an agent branches on.
Inklate’s errors are written to be acted on, and every machine-readable part of them is stable. This page is the full catalog: what a failed request looks like on each door, the reason slugs you can branch on, the validation codes that appear inside a successful post response, the five fix kinds and their parameters, and the upgrade markers on plan refusals.
Two places errors appear
- Request errors — the call itself was refused (bad input, missing auth, rate limit). REST returns a non-2xx status with the one error envelope; MCP returns the same facts as a prefixed text line.
- Validation errors — the call succeeded (a draft with problems saves fine) and the response’s per-channel
errors[]tell you what would stop publishing. These never use HTTP status; read them off the 2xx response.
The bridge between the two: arming (setting a scheduled_at or publishing) a post that isn’t ready is refused as a request error — 400 BAD_REQUEST with reason: "not_ready" — and the whole write rolls back.
The refusal answers with the post it refused. You do not need a second call to find out why: the error carries the same post object a successful call would have returned, per-channel errors[] and machine fixes[] included. On a refused create its id is null, because the rollback means nothing was stored; on a refused update or publish the id is real and the post still stands at its previous state.
Request errors: one code table, every door
Every refusal maps through one kind→status table, so the same failure reads the same over REST, MCP, and the CLI:
| Code | HTTP status | Meaning |
|---|---|---|
BAD_REQUEST | 400 | Corrigible input — the message says what to change. |
UNAUTHORIZED | 401 | Missing or invalid credential. |
FORBIDDEN | 403 | Not allowed — permissions, or a plan limit (see upgrade markers below). |
NOT_FOUND | 404 | The resource doesn’t exist in this organization. |
CONFLICT | 409 | A concurrent write won (e.g. a stale expected_updated_at). |
TOO_MANY_REQUESTS | 429 | Request budget exhausted — back off and retry. |
INTERNAL_SERVER_ERROR | 500 | Nothing you can correct; the message is deliberately opaque. |
REST envelope:
{
"error": {
"code": "BAD_REQUEST",
"message": "This post has 2 channels that are not ready — fix the errors first",
"reason": "not_ready",
"request_id": "req_…",
"post": {
"id": null,
"ready": false,
"channels": [
{
"channel_id": "ch_…",
"provider": "x",
"ready": false,
"errors": [
{
"code": "validation",
"field": "content",
"message": "319 characters is over X's 280 limit",
"fixes": [{ "kind": "trim_to_fold", "label": "Trim to 280", "max_length": 280 }]
}
]
}
]
}
}
}error.post appears only on refusals that were decided by inspecting a post — today, arming. Every other refusal omits it.
Over MCP the same facts ride inline, so an agent parses a slug, not prose. The post follows the marker line as plain JSON:
BAD_REQUEST: This post has 2 channels that are not ready — fix the errors first [reason:not_ready]
{
"id": null,
"ready": false,
"channels": [ … ]
}The bracket marker carries reason:<slug>, index:<n> (which entry of a batch input failed), and retry_after_ms:<n> when present. The CLI exits 4 on any typed refusal, prints the same per-channel ✓/✗ summary a successful write prints, and puts the full refusal — error.post included — on stdout.
Reason slugs
reason is the finer machine slug on a request error, present where one exists:
| Reason | On code | When | What to do |
|---|---|---|---|
not_ready | BAD_REQUEST | You tried to schedule/publish a post whose channels, media, or content aren’t ready. | Read error.post — it carries the per-channel errors[] and fixes[]. |
unknown_channel | BAD_REQUEST | A channels entry matched no channel id and no connected provider. | Call channels_list / GET /channels; the message lists valid providers. |
ambiguous_channel | BAD_REQUEST | A provider alias (e.g. "linkedin") matched more than one channel. | Use the exact channel id; the message lists the candidates. |
invalid_cursor | BAD_REQUEST | A pagination cursor was malformed or hand-built. | Cursors are opaque — re-read from the first page. |
rate_limited | TOO_MANY_REQUESTS | The per-organization request budget is spent. Carries retry_after_ms (REST: Retry-After). | Wait that long, then retry. |
slow_down | TOO_MANY_REQUESTS | Device-flow polling faster than the interval. | Increase the polling interval. |
Media failure reasons
An uploaded or URL-ingested file that fails carries error.reason on the file object (and failed: true in a post response’s media[] entry). The reasons are a closed set:
| Reason | Meaning |
|---|---|
too_large | Over the size limit for its type. |
unsupported_type | A type Inklate doesn’t accept. |
content_mismatch | The bytes don’t match the declared type (spoofed magic bytes). |
empty | Zero bytes arrived. |
upload_incomplete | The upload never finished. |
fetch_failed | A URL reference couldn’t be fetched. |
decode_failed | The file couldn’t be decoded/processed. |
infected | Flagged by the virus scan. |
All of them are terminal: remove or replace the file. A file that is merely still processing is not an error — wait, then re-check.
Validation codes on post responses
Every post response (create, update, get, validate) carries channels[], each with ready and errors[]. An error is:
{
"code": "validation",
"field": "content",
"message": "Text is 312 characters — X allows 280",
"fixes": [{ "kind": "trim_to_fold", "label": "Trim to 280 characters", "max_length": 280 }]
}code is the verdict category, field names the input to change, message carries the real numbers, and fixes the machine repairs where any exist.
| Code | Field | Meaning |
|---|---|---|
auth | channel | The channel is disconnected, its grant expired/revoked, or a scope is missing — reconnect it. |
validation | content | The content breaks the platform’s rules (length, media count, aspect, options). Often fixable. |
unsupported | channel | The provider doesn’t support this operation or format. |
platform_unavailable | channel | The provider is down — retry later. |
content_policy | content | The platform refused the content itself. |
unknown | content | An indeterminate provider failure. |
rate_limit | scheduled_at | The provider’s publish window/quota for this channel is exhausted — schedule later. |
quiet_hours | scheduled_at | The schedule time falls inside the organization’s quiet hours. |
banned_term | content | The copy contains a term the organization blocks. |
blocked_link | content | The copy links to a domain the organization blocks. |
empty | content | No content for this channel — add text or media. |
not_found | channel | The channel vanished from the organization mid-request. |
Only a validation refusal carries fixes — a policy refusal (quiet_hours, banned_term, blocked_link) names the rule but has no automatic repair. Soft issues arrive separately as warnings[] (codes: truncated, dropped_media, missing_media, dropped_mark, flattened_link) and never block publishing.
The five fix kinds
A fix is a machine repair, discriminated on kind. Applied server-side when autofix is on (the default); a caller preferring manual control writes the fixed content via per_channel.
| Kind | Parameters | Repairs | Auto-applied? |
|---|---|---|---|
trim_to_fold | max_length | Over-length text — trims to the limit. | Yes |
rebalance_thread | item_max, item_max_length (nullable) | Too many / too-long thread items — caps and trims. | Yes |
set_title | max_length | A video format that requires a title — adds one within the limit. | Yes |
split_to_thread | item_max_length, item_max | Over-length post on a thread-capable provider — reshapes losslessly. | No — changes the format; opt in via per_channel. |
crop_to | aspect_ratio | An image outside the required ratio — crops a derivative. | No — needs an explicit media decision. |
Every kind also carries a human label (what the composer puts on the button). Fixes that were applied by autofix are reported in the response’s fixes_applied (channel_id, provider, kind). An applied fix marks that channel copy authored, so a later story edit never silently discards it.
Upgrade markers: [upgrade:<kind>]
A plan-limit refusal is always FORBIDDEN (403), and its message ends with a structured suffix an agent can parse — "… [upgrade:seats]":
| Kind | Refused because… | What a client does |
|---|---|---|
tier | A feature or free-org limit of the plan. | Offer a plan upgrade (a human decision — link the dashboard). |
seats | Inviting past the included seat count. | Add seats, or remove a member. |
credits | The prepaid Ink balance is exhausted. | A human tops up in the dashboard; agents cannot move money. |
channels | The connected-channel cap. | Upgrade, or disconnect a channel. |
brands | The organizations-per-subscription cap. | Upgrade, or consolidate organizations. |
topup | A top-up path failure (reserved). | Send the user to billing in the dashboard. |
Billing is read-only for agents: billing_plan and billing_ink report state over MCP, but checkout and top-ups happen in the dashboard, by a person.
REST API
Base URL, bearer auth, and the one composed post call over HTTP — with the operation-by-operation reference in the API tab.
Files & media
One upload API — reserve a presigned PUT, send the bytes, poll readiness. URLs are references on posts; files are a first-class resource with get, list, and delete.