# REST API reference

**Early access:** the API is built and being tested, but `api.turboyap.com` isn't public yet. TikTok and Instagram open to early-access users as each platform approves Turboyap. Bluesky, Telegram and Discord are in beta: early-access users can connect them and post today, but they haven't been proven with a real production post yet. [Test mode](/docs/test-mode) needs no platform approval.

Base URL: `https://api.turboyap.com`. Authenticate with `Authorization: Bearer <key>`. Bodies are JSON unless noted.

## Keys and headers

- **Keys** are `ty_agent_…` or `ty_human_…`, and test keys are `ty_agent_test_…` or `ty_human_test_…`. Every key has scopes (`read`, `draft`, `schedule`, `publish`) and can be limited to some accounts. [The autonomy dial](/docs/autonomy-dial) lists what each scope allows, and [test mode](/docs/test-mode) covers test keys.
- **Modes don't mix.** Test keys see only test objects, and live keys only live ones. An id from the other mode returns `not_found`.
- **`X-Turboyap-Client`** (optional) names the caller, for example `my-app/1.0`. Turboyap records it on each post the call creates (`created_by.client`) and in the audit log. It can be up to 100 characters; a longer value gets `invalid_request`. The MCP server sends it for you, and the SDK sends it when you pass `client`.

## Endpoints

| Method | Path | What it does | Needs |
|---|---|---|---|
| GET | `/health` | `{ "ok": true }` while the API and its database answer; `503` when the database doesn't | No key |
| GET | `/v1/platform-rules?platform=&post_type=` | Platform limits | No key |
| GET | `/v1/me` | Workspace, kill-switch state, and the calling key | Any key |
| GET | `/v1/keys/me` | The calling key: type, mode, scopes, account allowlist and a `can` summary | Any key |
| GET | `/v1/accounts` | Connected accounts (only the allowed ones, for a key with an allowlist) | `read` |
| GET | `/v1/connect_methods` | How each platform connects on this server: `oauth` (a connect link) or `credentials` (`POST /v1/accounts`), with the steps and fields | Any key |
| POST | `/v1/connect_links` | `{ "platform" }`: a 7-day link to a page where the account's owner connects it, on the platform's own screen (TikTok, Instagram, Discord) or with a form (Bluesky, Telegram) | Live key, `draft` |
| POST | `/v1/accounts` | Connect with credentials: `{ "platform": "bluesky", "handle", "app_password", "service"? }` or `{ "platform": "telegram", "chat" }` | Human live key, `draft` |
| POST | `/v1/accounts/test` | Create a simulated account on any platform in early access or beta: `{ "platform", "handle"? }` | Test key, `draft` |
| PATCH | `/v1/accounts/:id` | `{ "autonomy_level": 1 \| 2 \| 3 }`. Level 2 behaves like level 1 until guardrails ship | Human key, `publish` |
| DELETE | `/v1/accounts/:id` | Disconnect: deletes the stored tokens, revokes them on the platform where it can (TikTok's token, Discord's webhook), and cancels the account's queued posts | Human key, `draft` |
| POST | `/v1/media` | Upload raw bytes; `Content-Type` is the file type, optional `X-Filename` | `draft` |
| GET | `/v1/media/:id` | Media details | `read` |
| POST | `/v1/posts` | Create, schedule, or preview (`dry_run: true`) | `draft`, plus `schedule` to set a time and `publish` for `"now"` |
| GET | `/v1/posts?status=&account_id=&from=&to=&limit=` | List posts | `read` |
| GET | `/v1/posts/:id` | One post with per-account status | `read` |
| PATCH | `/v1/posts/:id` | `{ "schedule_at"?, "caption"? }` | `draft`, plus `schedule` to set a time or edit a queued post, and `publish` for `"now"` |
| POST | `/v1/posts/:id/approve` | Approve pending targets; optional `{ "account_ids": […] }` | Human key, `schedule` |
| POST | `/v1/posts/:id/reject` | Reject pending targets; optional `reason` and `account_ids` | Human key, `schedule` |
| POST | `/v1/posts/:id/cancel` | Cancel targets that haven't started publishing | `draft` |
| POST | `/v1/workspace/pause` | Kill switch: stop every publish in the workspace | Human key |
| POST | `/v1/workspace/resume` | Start publishing again | Human key, `publish` |
| POST | `/v1/webhook_endpoints` | `{ "url", "enabled_events"? }`; the response includes the `whsec_` signing secret, once | Human key, `read` |
| GET | `/v1/webhook_endpoints` | List endpoints, without secrets | Human key |
| DELETE | `/v1/webhook_endpoints/:id` | Delete an endpoint | Human key |

A key with an account allowlist only sees the posts that go to its accounts, and gets `account_not_allowed` if it targets any other account. Events and signatures are in [webhooks](/docs/webhooks).

## Create post

| Field | Type | Notes |
|---|---|---|
| `type` | `"text"` \| `"image"` \| `"slideshow"` | `"text"` is for Bluesky, Telegram and Discord (beta). Video posts are coming soon |
| `media_ids` | string[] | In slide order. Text posts take none |
| `caption` | string | Default caption for all targets. For text posts, this is the post |
| `link` | object | `{ url, title?, description? }`: a link card on Bluesky text posts, the link preview on Telegram text posts, an embed on Discord. Ignored with a warning elsewhere |
| `alt_text` | object | Alt text per media id, e.g. `{ "med_…": "A pint of ice cream next to its label" }`. Sent where the platform supports it (Bluesky, Discord) |
| `targets` | array | `{ account_id, caption?, media_ids?, tiktok? }` per account |
| `schedule_at` | string | ISO 8601 with timezone, or `"now"`. Omit for a draft |
| `fit` | object | `{ overflow: "strict" \| "truncate", aspect: "strict" \| "pad" \| "crop", pad_color: "auto" \| "#rrggbb" }` |
| `dry_run` | boolean | Return the plan, errors and warnings without creating |
| `idempotency_key` | string | Retries with the same key return the original post |

- A new post returns `201` with `{ "post", "warnings", "replayed": false }`.
- Sending the same `idempotency_key` again returns `200` with the original post and `"replayed": true`, even if the body changed. Idempotency keys are unique per workspace and mode.
- A dry run returns `200` with `{ "dry_run": true, "ok", "schedule_at", "targets", "errors", "warnings" }` and saves nothing. It still checks the key's scopes and account allowlist, and each target's `status_on_create` shows whether the post would wait for approval.

## Objects

### Post

| Field | Notes |
|---|---|
| `id` | `post_…` |
| `type` | `"text"`, `"image"` or `"slideshow"` |
| `caption`, `media_ids` | The defaults each target starts from |
| `source` | `"human"` or `"agent"` |
| `created_by` | `{ "type", "key_id", "client" }`: human or agent, the key that created the post, and the `X-Turboyap-Client` value (or `null`) |
| `livemode` | `false` for test-mode posts, which publish to simulated platforms |
| `idempotency_key` | The key you sent, or `null` |
| `created_at` | ISO 8601 |
| `targets` | One per account |

### Target

| Field | Notes |
|---|---|
| `id` | `tgt_…` |
| `account_id`, `platform`, `handle` | The account it goes to |
| `post_type` | What it posts as there; a one-slide slideshow goes to Instagram as an image |
| `status`, `next_step` | See [statuses](#statuses) |
| `scheduled_at` | When it publishes, or `null` for a draft |
| `caption`, `media_ids` | What this account gets |
| `link` | The link card or preview this account shows, or `null` |
| `platform_post_id`, `platform_url`, `published_at` | Set once it's published |
| `error` | `{ "code", "message", "fix" }` when it failed |
| `approved_by` | Key id of the human who approved it, or who last edited it while it was scheduled; `null` if no human has |

### Account

| Field | Notes |
|---|---|
| `id` | `acct_…` |
| `platform`, `handle`, `display_name` | Which account it is |
| `livemode` | `false` for simulated test accounts |
| `autonomy_level`, `autonomy` | `1`, `2` or `3`, and what that level means in words |
| `status` | `active`, `needs_reauth` (a human must reconnect it) or `disabled` |
| `status_reason` | `{ "code", "message", "at" }` saying why the account isn't active, or `null` |
| `token_expires_at` | When the platform token expires. Turboyap refreshes it ahead of time. If a refresh fails or the platform rejects the token, the account becomes `needs_reauth` and [`account.reauth_required`](/docs/webhooks#events) fires |
| `next_step` | What a human has to do, when the account needs one; otherwise `null` |
| `connected_at` | ISO 8601 |

### Key

`GET /v1/keys/me` returns `{ "key": … }`, and `GET /v1/me` returns the same key next to the workspace:

```json
{
  "workspace": { "id": "ws_…", "name": "Your workspace", "paused": false },
  "key": {
    "id": "key_…",
    "name": "Claude Code",
    "prefix": "ty_agent_k3m9x",
    "type": "agent",
    "mode": "live",
    "livemode": true,
    "scopes": ["read", "draft", "schedule"],
    "account_ids": null,
    "can": { "read": true, "create_drafts": true, "schedule": true, "publish_now": false, "approve": false, "change_settings": false },
    "created_at": "2026-10-20T15:00:00.000Z"
  }
}
```

`account_ids: null` means every account in the workspace. `can` sums up what the key may do: `publish_now` needs `draft`, `schedule` and `publish`; `approve` needs a human key with `schedule`; `change_settings` is `true` for human keys (changing autonomy and resuming the workspace also need `publish`).

## Statuses

Each account a post goes to (a *target*) moves through: `draft` → `pending_approval` → `scheduled` → `publishing` → `processing` → `published`, or `sent_to_inbox` (TikTok inbox mode, waiting for you to post in-app). It can also end as `failed`, `rejected`, or `cancelled`. Every target includes a plain-language `next_step`.

A target that failed with `error.code` `unknown_outcome` was cut off mid-publish, so Turboyap can't tell whether it went out. Turboyap doesn't publish it again: check the platform before you recreate it.

## Errors

```json
{ "error": { "code": "aspect_ratio_out_of_range", "message": "…", "field": "media_ids", "fix": "Set fit.aspect to \"pad\"…", "issues": [] } }
```

`fix` is always a concrete change. Multi-problem requests list each problem in `issues`, each with its own fix.

Keys and permissions have their own errors:

| Code | HTTP | When |
|---|---|---|
| `unauthorized` | 401 | The key is missing, invalid or revoked |
| `insufficient_scope` | 403 | The key lacks a scope the call needs. `field` names the request field that needs it, such as `schedule_at`, or `scopes` when no single field does |
| `account_not_allowed` | 403 | The request touches an account outside the key's account allowlist. `field` names where, such as `targets[1].account_id` |
| `forbidden_scope` | 403 | An agent key tried something only a human key can do: approve, reject, change autonomy, pause or resume the workspace, or manage webhooks |
| `test_mode_only` | 403 | `POST /v1/accounts/test` with a live key |
| `not_found` | 404 | No such object for this key, including objects in the other mode |

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "This API key lacks the \"publish\" scope, which is needed to publish immediately. It has: read, draft, schedule.",
    "field": "schedule_at",
    "fix": "This key can schedule but not publish immediately. Set schedule_at to a future ISO 8601 time instead of \"now\", or use a key with the \"publish\" scope."
  }
}
```

Other errors use `409` (`conflict`, `invalid_status_transition`), `500` (`internal`), `502` (`platform_error`), or `422` for everything else, including validation and platform-rule errors such as `too_many_items`.

## Health check

`GET /health` needs no key. It returns `200` with `{ "ok": true }` when the API can reach its database, and `503` with `"ok": false` and an `error` when it can't. Point uptime checks at it.
