# TikTok photo post API

Publish a single-image TikTok photo post through an API or MCP server: limits, inbox vs direct mode, curl, SDK and Claude Code examples, and errors with fixes.

**Early access:** publishing photo posts to TikTok is built and being tested in the REST API, `@turboyap/sdk` and the `@turboyap/mcp` server. It opens to early-access users as TikTok approves Turboyap; public posts need TikTok's audit. Meanwhile, [test mode](https://turboyap.com/docs/test-mode) runs the same requests against a simulated TikTok account.

## What a TikTok photo post is

A TikTok photo post is a post built from a still image instead of a video. TikTok's Content Posting API handles it the same way as a slideshow, with one image. In Turboyap it's `type: "image"` with one media id.

Like slideshows, photo posts go to the creator's TikTok inbox by default so they can add a sound, or post directly with `mode: "direct"`.

## Limits

What TikTok accepts for this post type. Turboyap's planner checks every one of these before a post is scheduled (the same data as [platform rules](https://turboyap.com/docs/platform-rules)), and each value links to the TikTok documentation it comes from.

| Limit | Value | Source |
|---|---|---|
| Images per post | 1 | [TikTok: photo post reference](https://developers.tiktok.com/doc/content-posting-api-reference-photo-post) |
| Formats | JPEG, WebP (Turboyap converts anything else to JPEG) | [TikTok: media transfer guide](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) |
| File size | 20 MB per image | [TikTok: media transfer guide](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) |
| Dimensions | Fits 1080×1920 (Turboyap resizes to fit) | [TikTok: media transfer guide](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) |
| Aspect ratio | Any | [TikTok: photo post reference](https://developers.tiktok.com/doc/content-posting-api-reference-photo-post) |
| Caption | 4,000 characters (sent as `description`) | [TikTok: photo post reference](https://developers.tiktok.com/doc/content-posting-api-reference-photo-post) |
| Title | 90 characters | [TikTok: photo post reference](https://developers.tiktok.com/doc/content-posting-api-reference-photo-post) |
| Rate limit | 6 post-init requests per minute per account | [TikTok: photo post reference](https://developers.tiktok.com/doc/content-posting-api-reference-photo-post) |
| Rate limit | 30 status checks per minute per account | [TikTok: get post status](https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status) |

Compare with other platforms in the [social media API limits tracker](https://turboyap.com/tools/social-api-limits#tiktok).

## Example requests

All three do the same thing: upload the media, preview the post with a dry run, then schedule it. The idempotency key makes retries safe: send the same request again with the same key and you get the original post back, not a second one.

**Early access:** `api.turboyap.com` isn't public yet, and `@turboyap/sdk` and `@turboyap/mcp` aren't on npm yet. These examples show the API as built.

### curl

```bash
# 1. Upload each image. The body is the raw file; Content-Type is its type.
curl https://api.turboyap.com/v1/media \
  -H "Authorization: Bearer $TURBOYAP_API_KEY" \
  -H "Content-Type: image/png" \
  -H "X-Filename: slide-1.png" \
  --data-binary @slide-1.png
# → { "media": { "id": "med_…", "width": 1080, "height": 1920, … }, "deduplicated": false }

# 2. Preview. Remove "dry_run" to create and schedule the post.
curl https://api.turboyap.com/v1/posts \
  -H "Authorization: Bearer $TURBOYAP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "image",
    "media_ids": ["med_…"],
    "caption": "New flavor drops Friday.",
    "targets": [{ "account_id": "acct_tiktok…", "tiktok": { "mode": "inbox" } }],
    "schedule_at": "2026-10-20T18:00:00-04:00",
    "idempotency_key": "tiktok-photo-post-2026-10-20",
    "dry_run": true
  }'
```

### TypeScript with @turboyap/sdk

```ts
import { readFile } from "node:fs/promises";
import { TurboyapClient } from "@turboyap/sdk";

const ty = new TurboyapClient({ apiKey: process.env.TURBOYAP_API_KEY! });
const account = (await ty.listAccounts()).find((a) => a.platform === "tiktok")!;

const media_ids: string[] = [];
for (const file of ["image.png"]) {
  const { media } = await ty.uploadMedia(await readFile(file), { filename: file, contentType: "image/png" });
  media_ids.push(media.id);
}

const post = {
  type: "image" as const,
  media_ids,
  caption: "New flavor drops Friday.",
  targets: [{ account_id: account.id, tiktok: { mode: "inbox" as const } }],
  schedule_at: "2026-10-20T18:00:00-04:00",
  idempotency_key: "tiktok-photo-post-2026-10-20",
};

// Preview first: every problem comes back with a fix.
const preview = await ty.createPost({ ...post, dry_run: true });
if (!preview.ok) throw new Error(preview.errors.map((e) => `${e.code}: ${e.fix}`).join("\n"));

const { post: created } = await ty.createPost(post);
console.log(created.targets[0]?.status, created.targets[0]?.next_step);
```

### Claude Code (MCP)

Add the Turboyap MCP server once, with an agent key from your workspace:

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

Then ask:

> Post ~/content/drop/teaser.png to our TikTok as a photo post for tomorrow at 9am ET with the caption "New flavor drops Friday." Send it to my inbox so I can add a sound. Dry-run first.

The agent calls `list_accounts`, `get_platform_rules`, `upload_media`, `create_post` with `dry_run: true`, applies any fixes, calls `create_post` again to schedule, and reports each account's `next_step` from `get_post`. The same server works in Cursor; see the [MCP quickstart](https://turboyap.com/docs/mcp-quickstart).

## Inbox and direct mode

| Mode | What happens | Target status |
|---|---|---|
| `inbox` (default) | 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. | `sent_to_inbox`, then `published` once you post it in TikTok |
| `direct` | 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). | `publishing` → `processing` → `published` |

Direct mode needs a `privacy_level` the creator allows. `auto_add_music` only applies to direct posts; in inbox mode you pick the sound in the app.

A direct-mode target:

```json
{
  "account_id": "acct_tiktok…",
  "tiktok": {
    "mode": "direct",
    "privacy_level": "PUBLIC_TO_EVERYONE",
    "auto_add_music": true,
    "title": "Read the label"
  }
}
```

TikTok options on each target (`targets[i].tiktok`)

| Option | Values | Notes |
|---|---|---|
| `mode` | `"inbox"` or `"direct"` | Defaults to inbox. |
| `title` | Up to 90 characters | Shown above the description. |
| `privacy_level` | `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY` | Required for direct posts; must be one the creator allows. |
| `auto_add_music` | `true` or `false` | Direct posts only. |
| `disable_comment` | `true` or `false` |  |
| `brand_content_toggle`, `brand_organic_toggle` | `true` or `false` | Branded content disclosure. |

Photos are pulled from Turboyap-hosted URLs on a domain verified with TikTok.

## Common errors and fixes

Every error has a `code`, the `field` that caused it, and a `fix` an agent can apply without asking you. A request with several problems returns all of them in `issues`; a dry run returns them without creating anything.

| Code | When | Fix |
|---|---|---|
| `too_many_items` | More than one media id on an image post. | Send one media id, or set `fit.overflow` to `"truncate"` to post only the first. For several images, use `type: "slideshow"`. |
| `caption_too_long` | Caption over 4,000 characters. | Shorten it, or pass `targets[i].caption` with a version under 4,000 characters. |
| `title_too_long` | `tiktok.title` over 90 characters. | Shorten the title to 90 characters. |
| `invalid_request` | Direct mode without `tiktok.privacy_level`. | Set `tiktok.privacy_level` (e.g. `"PUBLIC_TO_EVERYONE"`), or use mode `"inbox"` to finish the post in TikTok. |
| `media_format_unsupported` | A media id points to a video, or the upload isn't an image Turboyap can read. | Upload PNG, JPEG or WebP images. Video posts are coming soon. |
| `schedule_in_past` | `schedule_at` is more than a minute in the past. | Use `"now"` or a future time with a timezone offset. |
| `account_unavailable` | The TikTok account needs to be reconnected. | Reconnect the account, then retry. |
| `unsupported_platform` | A target is on a platform Turboyap doesn't support yet. | Remove that target. Supported platforms: tiktok, instagram, bluesky (beta), telegram (beta), discord (beta). |
| `insufficient_scope` | The key lacks a scope the request needs, such as `publish` for `schedule_at: "now"` (agent keys don't have it by default). | Schedule a future time instead of `"now"`, omit `schedule_at` to save a draft, or use a key with the missing scope. `GET /v1/keys/me` lists the key's scopes. |
| `account_not_allowed` | The key is limited to some accounts, and a target isn't one of them. | Remove that target, or use a key whose `account_ids` include the account. |
| `forbidden_scope` | An agent key tries to approve or reject a post. | A human approves agent posts. To skip review for an account, a human sets its autonomy level to 3. |
| `unknown_outcome` | Turboyap stopped partway through publishing (for example, a restart) and can't confirm whether the post went out. It doesn't publish it again. | Check the account on the platform. If the post isn't there, create it again. |

Errors from TikTok itself appear on the target's `error` with TikTok's own code. Turboyap retries rate limits and temporary failures with backoff, and marks the account for reconnection when TikTok rejects its token.

## Approval and audit

- **Level 1, review everything (default):** a post an agent schedules starts as `pending_approval` and waits until a human approves it with a human key (`POST /v1/posts/:id/approve`). Agent keys can't approve.
- **Level 3, full autopilot:** agent posts schedule directly. Every action is still logged. Level 2, guardrails, is coming soon.
- **On TikTok, autopilot goes to the inbox:** TikTok requires a person to pick the privacy level of every direct post, so TikTok targets default to inbox mode. Autopilot posts land in the creator's inbox as drafts unless the post sets direct mode with a `privacy_level` the creator chose.
- **Posts created by humans** schedule directly at any level.
- **Edits re-open review:** if an agent changes the caption or time of an approved post on a level-1 account, it goes back to `pending_approval`.
- **Dry runs show the outcome:** each target's `status_on_create` tells the agent whether the post will wait for approval.
- **Audit log:** every create, approval, edit, publish and failure is recorded with whether a human or an agent did it, the key id, and the MCP client name. The kill switch (`POST /v1/workspace/pause`) stops every publish to TikTok and every other platform at once.

Read more in [the autonomy dial](https://turboyap.com/docs/autonomy-dial).

## FAQ

### Can I post a single photo to TikTok with the API?

Yes. TikTok's Content Posting API accepts photo posts with one image. Send `type: "image"` with one media id to Turboyap's `POST /v1/posts`.

### What's the difference between a TikTok photo post and a slideshow?

Only the number of images. Both use TikTok's photo post endpoint: one image is a photo post, two to 35 images is a slideshow. See the [TikTok slideshow guide](https://turboyap.com/api/tiktok/slideshow).

### Why did my TikTok post through the API come out private?

TikTok makes every post from an unaudited app private (`SELF_ONLY`). Until Turboyap passes TikTok's audit, direct posts are private. Inbox mode isn't affected: the post lands in your TikTok inbox and you publish it yourself in the app.

### How long can a TikTok photo post title and caption be?

The title can be 90 characters and the description (caption) 4,000 characters, both counted in UTF-16 units.

## Related

- [TikTok slideshow API](https://turboyap.com/api/tiktok/slideshow)
- [Instagram image post API](https://turboyap.com/api/instagram/image-post)
- [Bluesky image post API](https://turboyap.com/api/bluesky/image-post) (beta)
- [Telegram photo post API](https://turboyap.com/api/telegram/photo) (beta)
- [Discord image message API](https://turboyap.com/api/discord/image-message) (beta)
- [Post one slideshow to TikTok and Instagram](https://turboyap.com/docs/slideshows)
- [REST API reference](https://turboyap.com/docs/api)
- [Platform rules](https://turboyap.com/docs/platform-rules)
- [Social media API limits tracker](https://turboyap.com/tools/social-api-limits)
