# Turboyap docs (full text)

> Every page of the Turboyap docs in one file. Index: https://turboyap.com/llms.txt. Each section notes the URL it comes from.

Status: Turboyap is in early access. These docs describe the API as built. Early access means it's built and being tested. Each platform opens to early-access users as it approves Turboyap. Instagram needs Meta App Review, and public TikTok posting needs TikTok's audit. The npm packages (@turboyap/sdk and @turboyap/mcp) aren't published yet.

<!-- Source: https://turboyap.com/docs/mcp-quickstart -->

# MCP quickstart: schedule social posts from Claude Code

**Early access:** the MCP server is built and being tested. `@turboyap/mcp` isn't published to npm yet, so the `npx` commands below work once it is. [Ask for early access](mailto:hello@turboyap.com?subject=Early%20access) to hear first.

The Turboyap MCP server runs on your machine, so your agent can upload files straight from disk, including a whole folder of slides. You need a Turboyap **agent key**. Start with a test key (`ty_agent_test_…`): it runs the whole flow against simulated accounts and never posts anywhere real. Switch to a live agent key (`ty_agent_…`) when you're ready.

## Claude Code

```bash
claude mcp add turboyap -e TURBOYAP_API_KEY=ty_agent_test_… -- npx -y @turboyap/mcp
```

## Cursor and other MCP clients

```json
{
  "mcpServers": {
    "turboyap": {
      "command": "npx",
      "args": ["-y", "@turboyap/mcp"],
      "env": { "TURBOYAP_API_KEY": "ty_agent_test_…" }
    }
  }
}
```

The server reads `TURBOYAP_API_KEY`, plus an optional `TURBOYAP_API_URL` that defaults to `https://api.turboyap.com`.

## With a test key, create test accounts first

A test key only sees test accounts, and the MCP server has no tool that creates them. Create a simulated TikTok and Instagram account once, with the same key:

```bash
for platform in tiktok instagram; do
  curl https://api.turboyap.com/v1/accounts/test \
    -H "Authorization: Bearer ty_agent_test_…" \
    -H "Content-Type: application/json" \
    -d "{\"platform\": \"$platform\"}"
done
```

They start on autonomy level 1, like real accounts, so your agent's posts wait for approval. Approve them with your human test key (`ty_human_test_…`), as the [test mode quickstart](/docs/test-mode) shows.

## Try it

Ask your agent something like:

> Upload the slides in ~/content/icecream and schedule them to our TikTok and Instagram for Tuesday 6pm ET. Dry-run first and fix anything Instagram can't take.

The agent works through the tools in this order:

1. `list_accounts` finds your connected accounts and their autonomy level.
2. `get_platform_rules` reads slide limits, formats and aspect ratios.
3. `upload_media` uploads the folder in natural filename order (`slide-2` before `slide-10`).
4. `create_post` with `dry_run: true` shows what each platform will get, plus every problem with a fix.
5. `create_post` applies the fixes, for example `fit: { overflow: "truncate", aspect: "pad" }`, and schedules.
6. `get_post` reports each account's status and a `next_step` to relay to you.

## Tools

| Tool | What it does |
|---|---|
| `get_workspace` | Workspace, kill-switch state, and this key's type, mode, scopes, account allowlist and `can` summary |
| `list_accounts` | Connected accounts, ids, autonomy levels |
| `get_platform_rules` | Limits per platform and post type |
| `upload_media` | Upload files, folders, or URLs; returns media ids in order |
| `create_post` | Preview (`dry_run`), save a draft, or schedule. Idempotent with `idempotency_key` |
| `list_posts` / `get_post` | Status per account, post URLs once published, errors with fixes |
| `update_post` | Reschedule or re-caption before publishing |
| `cancel_post` | Cancel before publishing |

Agents can't approve posts. On accounts set to autonomy level 1, agent posts wait for a human; see [the autonomy dial](/docs/autonomy-dial).

## Scopes

Agent keys default to `read`, `draft` and `schedule`. They can schedule a future time but not publish with `"now"`, and a key without `schedule` saves drafts (no `schedule_at`) for a human to schedule. A key can also be limited to some accounts. When a call fails with `insufficient_scope` or `account_not_allowed`, the agent can call `get_workspace` to see what its key may do.

## Every post records which agent made it

The MCP server sends an `X-Turboyap-Client` header with every call, filled in from the name and version your MCP client reports when it connects, or `turboyap-mcp` if it reports none. Turboyap stores it in each post's `created_by.client` and in the audit log, so you can tell which agent made a post.

## Errors are written for agents

Every error includes a `code`, the `field` that caused it, and a `fix`:

```json
{
  "error": {
    "code": "too_many_items",
    "message": "instagram allows 10 slides per post; got 12.",
    "field": "media_ids",
    "fix": "Set fit.overflow to \"truncate\" to post the first 10, or pass targets[1].media_ids with your best 10 slides (keep your call-to-action slide)."
  }
}
```

Agents apply the fix and retry without asking you, unless the fix needs a human (like reconnecting an account).

---

<!-- Source: https://turboyap.com/docs/test-mode -->

# Test mode: try the whole API in 5 minutes

Test mode runs the full Turboyap pipeline against simulated accounts: planning, media conversion, approvals, scheduling, publishing and webhooks. Nothing reaches a real platform, so it doesn't wait on Meta App Review or TikTok's audit. Like the rest of the API, it's in early access.

## How test mode works

- **Test keys** start with `ty_agent_test_` or `ty_human_test_`. They get the same default scopes as live keys (agent: `read`, `draft`, `schedule`; human: all four), and `GET /v1/keys/me` shows `"mode": "test"` and `"livemode": false`.
- **Test objects stay separate.** Test keys only see test objects, and live keys only see live ones. Accounts, media, posts, webhook endpoints and idempotency keys are all separate per mode, and every test object has `"livemode": false`. Using an id from the other mode returns `not_found`.
- **Simulated accounts.** `POST /v1/accounts/test` creates an account on any platform in early access or beta (TikTok, Instagram, Bluesky, Telegram or Discord) instantly, on autonomy level 1 like a newly connected real account. A live key gets `test_mode_only`.
- **Simulated publishing.** Test posts always publish through a simulated platform, never a real one. Instagram, Bluesky, Telegram and Discord targets end `published`, with a fake `platform_post_id` and a `platform_url` on `<platform>.test`, such as `instagram.test`. TikTok targets in inbox mode (the default) stop at `sent_to_inbox`, as they would for real; in direct mode they end `published`.
- **Test webhooks.** An endpoint registered with a test key gets only test events, and live endpoints never see them. See [webhooks](/docs/webhooks).

## Quickstart

You need your workspace's two test keys and an image. In early access, Turboyap creates the keys when it sets up your workspace.

```bash
export AGENT=ty_agent_test_…   # what your agent uses
export HUMAN=ty_human_test_…   # what you approve with
export API=https://api.turboyap.com
```

### 1. Create two simulated accounts

```bash
curl $API/v1/accounts/test -H "Authorization: Bearer $AGENT" \
  -H "Content-Type: application/json" -d '{"platform": "tiktok"}'
curl $API/v1/accounts/test -H "Authorization: Bearer $AGENT" \
  -H "Content-Type: application/json" -d '{"platform": "instagram", "handle": "yourbrand_test"}'
```

```json
{
  "account": {
    "id": "acct_…",
    "platform": "tiktok",
    "handle": "test_tiktok_7ad2",
    "livemode": false,
    "display_name": "Test account",
    "autonomy_level": 1,
    "autonomy": "review everything: agent posts wait for human approval",
    "status": "active",
    "status_reason": null,
    "token_expires_at": null,
    "next_step": null,
    "connected_at": "2026-10-20T15:00:00.000Z"
  }
}
```

Note both account ids. `handle` is optional: up to 30 letters, digits, dots or underscores.

### 2. Upload an image

```bash
curl $API/v1/media -H "Authorization: Bearer $AGENT" \
  -H "Content-Type: image/png" -H "X-Filename: label.png" \
  --data-binary @label.png
```

```json
{ "media": { "id": "med_…", "livemode": false, "kind": "image", "mime": "image/png", "width": 1080, "height": 1920, … }, "deduplicated": false }
```

### 3. Preview the post as the agent

Replace the ids, and set `schedule_at` to a couple of minutes from now, with a timezone (for example `2026-10-20T15:05:00Z`). The default agent key can schedule a future time but can't publish with `"now"`.

```bash
curl $API/v1/posts -H "Authorization: Bearer $AGENT" -H "Content-Type: application/json" -d '{
  "type": "image",
  "media_ids": ["med_…"],
  "caption": "Read the label.",
  "targets": [{ "account_id": "acct_…" }, { "account_id": "acct_…" }],
  "fit": { "aspect": "pad" },
  "schedule_at": "2026-10-20T15:05:00Z",
  "idempotency_key": "test-mode-quickstart-1",
  "dry_run": true
}'
```

```json
{
  "dry_run": true,
  "ok": true,
  "schedule_at": "2026-10-20T15:05:00.000Z",
  "targets": [
    { "platform": "tiktok", "post_type": "image", "slides": 1, "status_on_create": "pending_approval", "media_changes": ["convert PNG → JPEG", "fit within 1080×1920"], "tiktok_mode": "inbox", … },
    { "platform": "instagram", "post_type": "image", "slides": 1, "status_on_create": "pending_approval", "media_changes": ["convert PNG → JPEG", "pad to 0.8:1", "max width 1440px"], … }
  ],
  "errors": [],
  "warnings": [{ "code": "aspect_ratio_out_of_range", "message": "1 slide(s) will be padded to 4:5 for instagram.", … }]
}
```

Nothing was created. Both targets would start as `pending_approval`, because an agent made the post and the accounts are on level 1.

### 4. Create it

Send the same request without `"dry_run": true`. You get `201` and the post, with `"livemode": false` and `created_by.type` set to `"agent"`. Sending it again with the same `idempotency_key` returns the same post with `"replayed": true` instead of a second one.

### 5. Approve it as the human

```bash
curl -X POST $API/v1/posts/post_…/approve -H "Authorization: Bearer $HUMAN"
```

Both targets move to `scheduled`, and `approved_by` holds your human key's id. Try the same call with `$AGENT` and you get `forbidden_scope`, because agents can't approve.

### 6. Check the result

Once the scheduled time has passed:

```bash
curl $API/v1/posts/post_… -H "Authorization: Bearer $AGENT"
```

```json
{
  "post": {
    "id": "post_…",
    "livemode": false,
    "created_by": { "type": "agent", "key_id": "key_…", "client": null },
    "targets": [
      { "platform": "tiktok", "status": "sent_to_inbox", "next_step": "Delivered to the TikTok inbox. Open TikTok, add a sound, and post. Turboyap marks it published once it's live.", … },
      { "platform": "instagram", "status": "published", "platform_post_id": "instagram_post_1_f1afdb", "platform_url": "https://instagram.test/p/instagram_post_1_f1afdb", … }
    ],
    …
  }
}
```

That's the whole loop: an agent drafted, a human approved, and Turboyap published, without touching a real account. In test mode, nobody posts the TikTok draft, so that target stays at `sent_to_inbox`.

## Next steps

- **Skip review:** move a test account to full autopilot with the human key, and the agent's next post schedules directly:

  ```bash
  curl -X PATCH $API/v1/accounts/acct_… -H "Authorization: Bearer $HUMAN" \
    -H "Content-Type: application/json" -d '{"autonomy_level": 3}'
  ```

- **Hand it to your agent:** use the agent test key in the [MCP quickstart](/docs/mcp-quickstart).
- **Get events:** register a webhook endpoint with the human test key; see [webhooks](/docs/webhooks).
- **Use the SDK:** `@turboyap/sdk` has `createTestAccount({ platform, handle })` plus the same `uploadMedia`, `createPost`, `approvePost` and `getPost` calls. It isn't on npm yet.
- **Go live:** switch to live keys and connect real accounts through each platform's own sign-in, as each platform opens to early-access users.

---

<!-- Source: https://turboyap.com/docs/slideshows -->

# Post one slideshow to TikTok and Instagram

A slideshow is a first-class post type in Turboyap. Upload the slides once, and Turboyap builds a version each platform will accept.

## What changes per platform

| | TikTok photo post | Instagram carousel |
|---|---|---|
| Slides | up to 35 | 2 to 10 |
| Formats | JPEG, WebP | JPEG only |
| Aspect ratio | any (9:16 is native) | 4:5 to 1.91:1 |
| Caption | 4,000 characters, plus an optional 90-character title | 2,200 characters, 30 hashtags, 20 mentions |

A typical 12-slide vertical deck (1080×1920) fits TikTok as-is. Instagram needs two changes: at most 10 slides, and every slide inside the 4:5 to 1.91:1 range. Instagram also crops every carousel slide to the first slide's ratio, so the slides must all share one ratio.

## Let Turboyap adapt the slides

```json
{
  "type": "slideshow",
  "caption": "Ice cream or frozen dairy dessert? Read the label.",
  "media_ids": ["med_…", "…"],
  "targets": [{ "account_id": "acct_tiktok…" }, { "account_id": "acct_instagram…" }],
  "schedule_at": "2026-10-14T18:00:00-04:00",
  "fit": { "overflow": "truncate", "aspect": "pad" }
}
```

- `overflow: "truncate"` keeps the first 10 slides on Instagram. To choose the slides yourself (keeping your call-to-action slide, say), pass `media_ids` on that target instead.
- `aspect: "pad"` letterboxes slides to 4:5 without cutting any text. The bars match each slide's background color (`pad_color: "auto"`). Use `"crop"` to center-crop instead.
- PNG and WebP slides are converted to JPEG automatically and resized into each platform's limits.

Run it with `dry_run: true` first to see each platform's slide count, the media changes, and the status each account starts in.

## TikTok inbox mode

TikTok targets default to `mode: "inbox"`: the slideshow lands in the creator's TikTok inbox, where you add a trending sound and post. That's usually what you want for slideshows, since sound selection matters and the API can't pick a specific trending track. Turboyap marks the post published once it's live.

Use `mode: "direct"` with a `privacy_level` to post immediately. TikTok requires the creator to pick that privacy level, which is why inbox mode is the default and what autopilot uses unless a post says otherwise. `auto_add_music: true` lets TikTok add a recommended sound to direct posts.

---

<!-- Source: https://turboyap.com/docs/autonomy-dial -->

# The autonomy dial

Every connected account has an autonomy level. It decides what happens when an **agent** schedules a post. Posts created by humans always schedule directly.

| Level | Name | Agent posts | Status |
|---|---|---|---|
| 1 | Review everything (default) | Wait in the approval queue until a human approves | Early access |
| 2 | Autopilot with guardrails | Posting windows, volume caps, keyword filters. Until they ship, level 2 behaves like level 1 | Coming soon |
| 3 | Full autopilot | Schedule directly; every action is logged | Early access |

Early access means it's built and being tested. Each platform opens to early-access users as it approves Turboyap. A human changes a level with `PATCH /v1/accounts/:id` and a human key that has the `publish` scope.

## Rules that keep agents honest

- **Agents can't approve.** Approving, rejecting, changing autonomy levels, pausing or resuming the workspace, and managing webhooks all need a human key. No scope unlocks them for an agent key, so an agent can't approve its own drafts or raise its own level.
- **Scopes never bypass the dial.** An agent key with the `publish` scope still waits for approval on a level-1 account.
- **Edits re-open review.** If an agent changes the caption or time of an approved post on a level-1 account, the post goes back to the approval queue and [`post.pending_approval`](/docs/webhooks) fires again.
- **Everything is logged.** Every create, approval, edit, publish and failure is written to the audit log: human or agent, the key that did it, and the client name from the `X-Turboyap-Client` header.
- **One kill switch.** Any human key can pause the workspace, which stops every publish immediately. Resuming takes a human key with the `publish` scope and picks up where it left off.
- **No double posts from a restart.** If Turboyap stops partway through publishing, it marks the post failed with `unknown_outcome` instead of publishing it again.

## TikTok: a person picks the privacy level

TikTok requires the creator to choose the privacy level of every direct post themselves, from a list with no default. So TikTok targets default to inbox mode, which means autopilot on TikTok sends posts to the creator's inbox as drafts unless a post asks for direct mode. The creator picks the privacy level, adds a sound and posts them in TikTok.

Direct mode (`tiktok.mode: "direct"`) needs a `tiktok.privacy_level`, which should be the creator's own choice, not the agent's. Until Turboyap passes TikTok's audit, direct posts are private (`SELF_ONLY`).

## Keys

| Key | Prefix | Default scopes |
|---|---|---|
| Agent | `ty_agent_` | `read`, `draft`, `schedule` |
| Human | `ty_human_` | `read`, `draft`, `schedule`, `publish` |
| Test agent | `ty_agent_test_` | `read`, `draft`, `schedule` |
| Test human | `ty_human_test_` | `read`, `draft`, `schedule`, `publish` |

Test keys see only test objects and publish to simulated platforms; see [test mode](/docs/test-mode). In early access, Turboyap creates the keys for your workspace, and self-serve key management comes with the dashboard.

## Scopes

| Scope | Allows |
|---|---|
| `read` | List and read accounts, media and posts |
| `draft` | Upload media, create and edit drafts, cancel posts |
| `schedule` | Set a future `schedule_at`; for human keys, approve and reject |
| `publish` | Publish immediately (`schedule_at: "now"`); for human keys, change autonomy levels and resume the workspace |

A key can also be limited to some accounts (`account_ids`). It then lists only those accounts and the posts that go to them, and gets `account_not_allowed` if it targets any other account. `GET /v1/keys/me` (MCP: `get_workspace`) shows the calling key's type, mode, scopes, allowlist and a plain-language `can` summary.

## Who can do what

| Action | Key | Scopes |
|---|---|---|
| Read accounts, media and posts | Any | `read` |
| Upload media, create or edit a draft, cancel a post | Any | `draft` |
| Schedule a post for a future time | Any | `draft`, `schedule` |
| Edit a post that's already queued | Any | `draft`, `schedule` |
| Publish immediately (`"now"`) | Any | `draft`, `schedule`, `publish` |
| Approve or reject a post | Human | `schedule` |
| Change an account's autonomy level | Human | `publish` |
| Pause the workspace (kill switch) | Human | None |
| Resume the workspace | Human | `publish` |
| Create a webhook endpoint | Human | `read` |
| List or delete webhook endpoints | Human | None |
| Create a test account | Test key | `draft` |
| See the calling key (`GET /v1/keys/me`, `GET /v1/me`) | Any | None |

A missing scope returns `insufficient_scope`, with the `field` that needs it and a `fix`. A human-only action with an agent key returns `forbidden_scope`.

Give agents agent keys only, with the fewest scopes that do the job: `read` and `draft` for an agent that only writes drafts, or the default `read`, `draft` and `schedule` for one that schedules. Keep human keys with people, and approve from your own scripts or the [REST API](/docs/api) until the dashboard ships.

---

<!-- Source: https://turboyap.com/docs/platform-rules -->

# Platform rules

The slide limits, formats, aspect ratios, caption limits and link cards Turboyap enforces on each platform, generated from its source code.

Turboyap's planner enforces these limits before anything is scheduled. Formats other than the accepted ones are converted automatically. Set `fit` on `create_post` to truncate extra slides or pad/crop to a platform's aspect range. Agents get the same data from the `get_platform_rules` MCP tool or `GET /v1/platform-rules`.

- **Early access:** TikTok and Instagram. Early access means it's built and being tested. Each platform opens to early-access users as it approves Turboyap. Instagram needs Meta App Review, and public TikTok posting needs TikTok's audit.
- **Beta:** Bluesky, Telegram and Discord. Beta means early-access users can connect an account and post to it through the API today; it just hasn't been proven with a real production post yet.

| Platform | Post type | Items | Formats | Size | Aspect ratio | Caption | Link card |
|---|---|---|---|---|---|---|---|
| TikTok | image | 1 | JPEG, WebP | ≤ 20 MB, fits 1080×1920 | any | 4,000 chars, title 90 | no |
| TikTok | slideshow | 1–35 | JPEG, WebP | ≤ 20 MB, fits 1080×1920 | any | 4,000 chars, title 90 | no |
| Instagram | image | 1 | JPEG | ≤ 8 MB, 320–1440px wide | 4:5 to 1.91:1 | 2,200 chars, 30 hashtags, 20 mentions | no |
| Instagram | slideshow | 2–10 | JPEG | ≤ 8 MB, 320–1440px wide | 4:5 to 1.91:1 | 2,200 chars, 30 hashtags, 20 mentions | no |
| Bluesky (beta) | text | none (text only) | none | none | none | 300 graphemes, 3,000 bytes | yes, as app.bsky.embed.external (link card) |
| Bluesky (beta) | image | 1 | JPEG, PNG, WebP, GIF | ≤ 1 MB (1,000,000 bytes), fits 2000×2000 | any | 300 graphemes, 3,000 bytes | no |
| Bluesky (beta) | slideshow | 2–4 | JPEG, PNG, WebP, GIF | ≤ 1 MB (1,000,000 bytes), fits 2000×2000 | any | 300 graphemes, 3,000 bytes | no |
| Telegram (beta) | text | none (text only) | none | none | none | 4,096 chars | yes, as link_preview_options.url |
| Telegram (beta) | image | 1 | JPEG, PNG, WebP | ≤ 5 MB (5,000,000 bytes), fits 2560×2560 | 1:20 to 20:1 | 1,024 chars | no |
| Telegram (beta) | slideshow | 2–10 | JPEG, PNG, WebP | ≤ 5 MB (5,000,000 bytes), fits 2560×2560 | 1:20 to 20:1 | 1,024 chars | no |
| Discord (beta) | text | none (text only) | none | none | none | 2,000 chars | yes, as embed (title, url, description) |
| Discord (beta) | image | 1 | JPEG, PNG, WebP, GIF | ≤ 1 MB (1,000,000 bytes), fits 2560×2560 | any | 2,000 chars | yes, as embed (title, url, description) |
| Discord (beta) | slideshow | 2–10 | JPEG, PNG, WebP, GIF | ≤ 1 MB (1,000,000 bytes), fits 2560×2560 | any | 2,000 chars | yes, as embed (title, url, description) |

Text posts (`type: "text"`) take no media: the caption is the post. Caption limits count UTF-16 code units (JavaScript's string length) unless the table says graphemes, which count what a reader sees as one character, so an emoji with a skin tone is one. A byte limit applies on top, to the UTF-8 text. Where a post type has a link card, pass `link` on the post; anywhere else Turboyap ignores it with a warning.

## TikTok

- **inbox mode:** Sends the post to the creator's TikTok inbox to finish in TikTok's editor (add a trending sound, then post). Works before TikTok's app audit.
- **direct mode:** Posts straight to the account. Every direct post waits for a human to approve it and choose the privacy level, whatever the autonomy level. Until Turboyap passes TikTok's audit, direct posts are private (SELF_ONLY).
- Photos are pulled from Turboyap-hosted URLs on a domain verified with TikTok.
- auto_add_music only applies to direct posts; in inbox mode you pick the sound in-app.
- TikTok requires the creator to choose the privacy level by hand on every direct post, with no default, from the options creator_info allows. Comment settings and the commercial content disclosure are chosen the same way.
- Branded content can't be private (SELF_ONLY). Photo posts have no duet or stitch.
- Rate limit: 6 post-init requests per minute per account
- Rate limit: 30 status checks per minute per account

API guides: [TikTok photo post](https://turboyap.com/api/tiktok/photo-post), [TikTok slideshow](https://turboyap.com/api/tiktok/slideshow).

Sources: [TikTok: photo post reference](https://developers.tiktok.com/doc/content-posting-api-reference-photo-post), [TikTok: media transfer guide](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide), [developers.tiktok.com: content sharing guidelines](https://developers.tiktok.com/doc/content-sharing-guidelines).

## Instagram

- JPEG only; Turboyap converts PNG/WebP automatically.
- Instagram crops every carousel image to the first image's aspect ratio, so Turboyap normalizes all slides to one ratio.
- Vertical 9:16 slides are outside Instagram's 4:5–1.91:1 range: set fit.aspect to "pad" (keeps all content) or "crop".
- Rate limit: 100 API-published posts per account per 24 hours (a carousel counts as one)

API guides: [Instagram image post](https://turboyap.com/api/instagram/image-post), [Instagram carousel](https://turboyap.com/api/instagram/carousel).

Sources: [Meta: content publishing](https://developers.facebook.com/docs/instagram-platform/content-publishing), [Meta: IG User media reference](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media).

## Bluesky (beta)

**Beta:** you can connect a Bluesky account and post to it through the API in early access today. It hasn't been proven with a real production post yet.

- Connect with your handle and an app password (Bluesky Settings, Privacy and security, App passwords), never your main password. Turboyap stores it encrypted so it can renew the session.
- The 300-character limit counts graphemes: an emoji with a skin tone counts as one.
- Links, @mentions and #hashtags in the text become rich text. A mention that doesn't match a Bluesky account stays plain text.
- Pass `link` on a text post for a link card. Bluesky can't show a link card and images on the same post.
- Images are compressed under 1 MB: Bluesky allows 2 MB now, but self-hosted servers on older versions still enforce 1 MB. Alt text comes from `alt_text`.
- Video isn't supported yet: Turboyap's media pipeline takes images only.
- Each post's record key is derived from the post, so a retry after a lost response can't create a second post.
- Rate limit: 5,000 write points per hour and 35,000 per day per account; a post costs 3
- Rate limit: createSession: 30 per 5 minutes and 300 per day per account

API guides: [Bluesky text post](https://turboyap.com/api/bluesky/text-post), [Bluesky image post](https://turboyap.com/api/bluesky/image-post), [Bluesky multi-image post](https://turboyap.com/api/bluesky/multi-image-post).

Sources: [Bluesky: creating a post](https://bsky.network/docs/bluesky-api/creating-a-post), [Bluesky: post lexicon](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/post.json), [Bluesky: posts guide](https://docs.bsky.app/docs/advanced-guides/posts), [Bluesky: rate limits](https://docs.bsky.app/docs/advanced-guides/rate-limits), [AT Protocol: record keys (TID)](https://atproto.com/specs/tid).

## Telegram (beta)

**Beta:** you can connect a Telegram account and post to it through the API in early access today. It hasn't been proven with a real production post yet.

- Turboyap posts through its own bot. Add the bot as an administrator of your channel or group with permission to post, then connect the channel's @username or chat ID.
- Text posts take 4,096 characters; captions on photos and albums take 1,024.
- 2 to 10 images post as one album (sendMediaGroup), with the caption on the album.
- Pass `link` on a text post to choose which URL gets the link preview.
- Video and documents aren't supported yet: Turboyap's media pipeline takes images only.
- Rate limit: About 1 message per second in a chat, and 20 per minute in a group
- Rate limit: On a flood-control error Turboyap waits the retry_after Telegram sends

API guides: [Telegram text message](https://turboyap.com/api/telegram/text-message), [Telegram photo post](https://turboyap.com/api/telegram/photo), [Telegram album](https://turboyap.com/api/telegram/album).

Sources: [Telegram Bot API: sendMessage](https://core.telegram.org/bots/api#sendmessage), [Telegram Bot API: sendPhoto](https://core.telegram.org/bots/api#sendphoto), [Telegram Bot API: sendMediaGroup](https://core.telegram.org/bots/api#sendmediagroup), [Telegram Bot API: sending files](https://core.telegram.org/bots/api#sending-files), [Telegram bots FAQ: hitting limits](https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this).

## Discord (beta)

**Beta:** you can connect a Discord account and post to it through the API in early access today. It hasn't been proven with a real production post yet.

- Connect on Discord's own screen: you pick the server and channel, and Discord creates a webhook for Turboyap. No bot joins the server.
- Mentions in the text don't notify anyone: Turboyap sends an empty allowed_mentions, so an agent can't ping @everyone.
- Images go as attachments, up to 10 per message, each kept under 1 MB so the message fits Discord's 10 MiB default upload limit.
- Pass `link` to add an embed with its title and description. Discord also previews links in the text on its own.
- Forum channels aren't supported; connect a text or announcement channel.
- Rate limit: Webhook limits are set per webhook and change over time; on a 429 Turboyap waits the retry_after Discord sends

API guides: [Discord message](https://turboyap.com/api/discord/message), [Discord image message](https://turboyap.com/api/discord/image-message), [Discord multi-image message](https://turboyap.com/api/discord/multi-image-message).

Sources: [Discord: execute webhook](https://docs.discord.com/developers/resources/webhook#execute-webhook), [Discord: OAuth2 webhooks](https://docs.discord.com/developers/topics/oauth2#webhooks), [Discord: uploading files](https://docs.discord.com/developers/reference#uploading-files), [Discord: embed limits](https://docs.discord.com/developers/resources/message#embed-object-embed-limits), [Discord: rate limits](https://docs.discord.com/developers/topics/rate-limits).

Coming soon: Facebook, Threads, X, LinkedIn, YouTube and Pinterest. Their API limits are in the [social media API limits tracker](https://turboyap.com/tools/social-api-limits).

---

<!-- Source: https://turboyap.com/docs/api -->

# 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.

---

<!-- Source: https://turboyap.com/docs/webhooks -->

# Webhooks: signed post and account events

Turboyap can POST an event to your server when a post needs approval, when it finishes publishing, and when an account needs reconnecting. Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com/) spec, so any Standard Webhooks library can verify them. Webhooks are in early access, like the rest of the [API](/docs/api).

## Register an endpoint

Endpoints are managed with a **human** key; agent keys get `forbidden_scope`. Creating one also needs the `read` scope.

```bash
curl https://api.turboyap.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $TURBOYAP_HUMAN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/turboyap", "enabled_events": ["post.published", "post.partial", "post.failed"]}'
```

```json
{
  "webhook_endpoint": {
    "id": "whk_…",
    "url": "https://example.com/hooks/turboyap",
    "enabled_events": ["post.published", "post.partial", "post.failed"],
    "livemode": true,
    "created_at": "2026-10-20T15:00:00.000Z",
    "secret": "whsec_…"
  }
}
```

- The `secret` is in this response only. Store it: Turboyap never shows it again.
- Omit `enabled_events` to get all five events.
- The URL must be HTTPS on a public host. Private and internal addresses, such as `localhost` and `192.168.x.x`, are refused, so expose a local receiver through a tunnel while you develop.
- A workspace can have 16 endpoints in each mode. An endpoint created with a test key only gets test-mode events, and a live endpoint only gets live ones.
- `GET /v1/webhook_endpoints` lists endpoints without their secrets, and `DELETE /v1/webhook_endpoints/:id` removes one. In the SDK they're `createWebhookEndpoint`, `listWebhookEndpoints` and `deleteWebhookEndpoint`.

## Events

| Event | When it fires | `data` |
|---|---|---|
| `post.pending_approval` | An agent creates a post that waits for approval (level-1 accounts), or an agent edit sends an approved post back to review | `{ post }` |
| `post.published` | Every target of the post is done and none failed: each one published, or delivered to the TikTok inbox | `{ post }` |
| `post.partial` | Every target is done, and some failed while the rest published | `{ post }` |
| `post.failed` | Every target is done, and all of them failed | `{ post }` |
| `account.reauth_required` | A platform token can't be refreshed, or the platform rejected it, so a human must reconnect the account | `{ account, reason }` |

- `post` is the same object `GET /v1/posts/:id` returns, and `account` the same as in `GET /v1/accounts`. See the [API reference](/docs/api#objects).
- The three outcome events fire once per post, after its last target settles.
- Rejected and cancelled targets are human decisions, not failures. A post whose remaining targets all published sends `post.published`, and a post that was entirely rejected or cancelled sends no outcome event.
- A TikTok target counts as done once it's in the creator's inbox, so no second event fires when the creator posts it.

Every event has the same envelope. Its `id` is also the `webhook-id` header and stays the same on every retry, so use it to ignore duplicates.

```json
{ "id": "evt_…", "type": "post.published", "created_at": "2026-10-20T22:00:05.000Z", "data": { … } }
```

### post.pending_approval

```json
{
  "id": "evt_…",
  "type": "post.pending_approval",
  "created_at": "2026-10-20T15:00:00.000Z",
  "data": {
    "post": {
      "id": "post_…",
      "type": "slideshow",
      "caption": "Ice cream or frozen dairy dessert? Read the label.",
      "media_ids": ["med_…", "med_…", "med_…"],
      "source": "agent",
      "created_by": { "type": "agent", "key_id": "key_…", "client": "claude-code/2.1" },
      "livemode": true,
      "idempotency_key": "icecream-2026-10-20",
      "created_at": "2026-10-20T15:00:00.000Z",
      "targets": [
        {
          "id": "tgt_…",
          "account_id": "acct_…",
          "platform": "tiktok",
          "handle": "yourbrand",
          "post_type": "slideshow",
          "status": "pending_approval",
          "scheduled_at": "2026-10-20T22:00:00.000Z",
          "caption": "Ice cream or frozen dairy dessert? Read the label.",
          "media_ids": ["med_…", "med_…", "med_…"],
          "platform_post_id": null,
          "platform_url": null,
          "published_at": null,
          "error": null,
          "approved_by": null,
          "next_step": "Waiting for a human to approve in Turboyap (account is on autonomy level 1)."
        },
        { "id": "tgt_…", "platform": "instagram", "status": "pending_approval", … }
      ]
    }
  }
}
```

### post.published

The same post after a human approved it: Instagram published, and TikTok delivered to the creator's inbox.

```json
{
  "id": "evt_…",
  "type": "post.published",
  "created_at": "2026-10-20T22:00:05.000Z",
  "data": {
    "post": {
      "id": "post_…",
      "type": "slideshow",
      …,
      "targets": [
        {
          "id": "tgt_…",
          "platform": "tiktok",
          "status": "sent_to_inbox",
          "approved_by": "key_…",
          "next_step": "Delivered to the TikTok inbox. Open TikTok, add a sound, and post. Turboyap marks it published once it's live.",
          …
        },
        {
          "id": "tgt_…",
          "platform": "instagram",
          "status": "published",
          "platform_post_id": "1789…",
          "platform_url": "https://www.instagram.com/p/…",
          "published_at": "2026-10-20T22:00:05.000Z",
          "approved_by": "key_…",
          "next_step": "Live at https://www.instagram.com/p/…",
          …
        }
      ]
    }
  }
}
```

### post.partial

```json
{
  "id": "evt_…",
  "type": "post.partial",
  "created_at": "2026-10-20T22:00:10.000Z",
  "data": {
    "post": {
      "id": "post_…",
      …,
      "targets": [
        {
          "id": "tgt_…",
          "platform": "tiktok",
          "status": "failed",
          "error": {
            "code": "picture_size_check_failed",
            "message": "TikTok rejected the post: picture_size_check_failed.",
            "fix": "Check the media and caption against get_platform_rules, then create the post again."
          },
          "next_step": "Failed; see error.fix.",
          …
        },
        { "id": "tgt_…", "platform": "instagram", "status": "published", "platform_url": "https://www.instagram.com/p/…", … }
      ]
    }
  }
}
```

### post.failed

```json
{
  "id": "evt_…",
  "type": "post.failed",
  "created_at": "2026-10-20T22:00:10.000Z",
  "data": {
    "post": {
      "id": "post_…",
      …,
      "targets": [
        {
          "id": "tgt_…",
          "platform": "tiktok",
          "status": "failed",
          "error": { "code": "picture_size_check_failed", "message": "TikTok rejected the post: picture_size_check_failed.", "fix": "Check the media and caption against get_platform_rules, then create the post again." },
          "next_step": "Failed; see error.fix.",
          …
        }
      ]
    }
  }
}
```

### account.reauth_required

```json
{
  "id": "evt_…",
  "type": "account.reauth_required",
  "created_at": "2026-10-20T14:00:10.000Z",
  "data": {
    "account": {
      "id": "acct_…",
      "platform": "tiktok",
      "handle": "yourbrand",
      "livemode": true,
      "display_name": "Your Brand",
      "autonomy_level": 1,
      "autonomy": "review everything: agent posts wait for human approval",
      "status": "needs_reauth",
      "status_reason": { "code": "invalid_grant", "message": "Refreshing the tiktok token failed: Refresh token revoked.", "at": "2026-10-20T14:00:10.000Z" },
      "token_expires_at": "2026-10-20T14:10:10.000Z",
      "next_step": "Reconnect @yourbrand on tiktok: a human opens a new connect link (…). Posts to this account fail until then.",
      "connected_at": "2026-10-01T09:30:00.000Z"
    },
    "reason": { "code": "invalid_grant", "message": "Refreshing the tiktok token failed: Refresh token revoked." }
  }
}
```

## Headers and signature

| Header | Value |
|---|---|
| `webhook-id` | The event id (`evt_…`), the same on every retry |
| `webhook-timestamp` | Unix seconds when this attempt was sent |
| `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 signature |
| `Content-Type` | `application/json` |
| `User-Agent` | `Turboyap-Webhooks/1` |

To check a signature:

1. Take the part of the secret after `whsec_` and base64-decode it. That's the HMAC key.
2. Compute HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{body}`, where `body` is the raw request body exactly as received, not JSON you parsed and re-serialized.
3. Base64-encode the result and compare it, in constant time, with the part after `v1,`. Turboyap sends one signature, but the Standard Webhooks format allows several separated by spaces, so accept the request if any `v1` signature matches.
4. Reject timestamps more than five minutes from your clock, to stop replays.

## Retries

Your endpoint must answer with a `2xx` status within 10 seconds. Anything else counts as a failure, including a redirect, a timeout or a connection error.

- The first attempt goes out as soon as the worker picks up the event.
- After a failure, Turboyap tries again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: eight attempts over about 28 hours. After the eighth, the delivery is marked failed and isn't retried.
- Delivery is at least once. If a worker stops mid-delivery, the event is sent again about a minute later, so deduplicate on `webhook-id`.
- Order isn't guaranteed, since a retried event can arrive after newer ones. Use `created_at` and the post's current statuses rather than arrival order.

Answer quickly, and do slow work after you respond.

## Verify with the SDK

`verifyWebhook` from `@turboyap/sdk` checks the headers, signature and timestamp, then returns the parsed event. The SDK is in early access and not on npm yet; the plain Node version below has no dependencies.

```ts
import { verifyWebhook, WebhookVerificationError } from "@turboyap/sdk";

// Any framework with Fetch API requests: Next.js route handlers, Hono, Bun, Deno, Workers.
export async function POST(request: Request): Promise<Response> {
  const body = await request.text(); // the raw body, exactly as received
  try {
    const event = await verifyWebhook(process.env.TURBOYAP_WEBHOOK_SECRET!, request.headers, body);
    if (event.type === "post.failed") {
      for (const target of event.data.post?.targets ?? []) console.log(target.platform, target.error?.fix);
    }
    return new Response(null, { status: 204 });
  } catch (err) {
    if (err instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 400 });
    throw err;
  }
}
```

`verifyWebhook(secret, headers, body, { toleranceSeconds?, now? })` takes a `Headers` object or a plain header record (such as Node's `req.headers`), and a string or `Uint8Array` body. It throws `WebhookVerificationError` when a header is missing, the signature doesn't match, or the timestamp is outside the tolerance (5 minutes by default).

## Verify in plain Node

```js
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const key = Buffer.from(process.env.TURBOYAP_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

function verify(headers, body) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return null;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return null; // stale: possible replay

  const expected = Buffer.from(createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64"));
  const valid = signatures.split(" ").some((entry) => {
    const [version, signature = ""] = entry.split(",");
    const given = Buffer.from(signature);
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
  return valid ? JSON.parse(body) : null;
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const event = verify(req.headers, Buffer.concat(chunks).toString("utf8"));
    if (!event) return res.writeHead(400).end();
    // Deduplicate on event.id, then handle event.type.
    res.writeHead(204).end();
  });
}).listen(3000);
```

## Test mode

Register an endpoint with a human test key (`ty_human_test_…`) and it gets only the events from [test mode](/docs/test-mode), where posts publish to simulated platforms. It's the quickest way to build your receiver before any platform has approved Turboyap.
