# TikTok

Publish videos and photo posts to TikTok with the Content Posting API, directly or as inbox drafts.


## Overview

The `tiktok` platform publishes with TikTok's Content Posting API. You can post:

- One video (Direct Post)
- A photo post with 1–10 images (requires pull-from-URL mode; TikTok allows up to 35, but a Relaycast post carries at most 10 media items)
- Drafts sent to the creator's TikTok inbox, when your app has no Direct Post access (inbox mode)

TikTok posts always need media; text-only posts are not supported. The post `content` becomes the video caption or the photo description.

## Connect the account

In **[Integrations](https://social-publish-kappa.vercel.app/dashboard/integrations)**, choose **TikTok** and approve access.

### App setup (self-hosted)

1. At developers.tiktok.com, add the **Login Kit** and **Content Posting API** products to your app and enable **Direct Post**.
2. Register the redirect URI `https://social-publish-kappa.vercel.app/api/integrations/tiktok/callback`. TikTok requires an absolute `https` URL with no query string; for local development, point a tunnel at the dashboard and set `TIKTOK_REDIRECT_URI` to the tunnel URL.
3. Set `TIKTOK_CLIENT_KEY` and `TIKTOK_CLIENT_SECRET`.

Scopes requested: `user.info.basic` and `video.publish` (or `video.upload` in inbox mode), plus `video.list` when analytics are enabled. `TIKTOK_SCOPES` (comma-separated) replaces the list entirely.

| Setting | Effect |
| --- | --- |
| `TIKTOK_POST_MODE=inbox` | For apps without Direct Post. Login asks for `video.upload`, and posts arrive in the creator's TikTok inbox as drafts to finish in the app. Default: `direct`. |
| `TIKTOK_PULL_FROM_URL=true` | TikTok fetches media from its URL instead of Relaycast uploading the file. Requires the media domain or URL prefix to be verified in the TikTok developer portal. **Required for photo posts.** |
| `TIKTOK_ENABLE_VIDEO_LIST=true` | Requests `video.list` for post analytics. Needs the Display API product on your app. |
| `TIKTOK_REDIRECT_URI` | Overrides the redirect URI (TikTok only). |

> [!WARNING]
> Until your app passes TikTok's audit, TikTok only allows posting with **Only me** visibility (`SELF_ONLY`) to private accounts. Other visibility levels are rejected by TikTok.

TikTok rejects the whole login with `invalid_scope` if a requested scope hasn't been granted to the app, so only enable `TIKTOK_ENABLE_VIDEO_LIST` once the Display API is added.

## Post types & media rules

| Rule | Limit |
| --- | --- |
| Media | Exactly 1 video, **or** 1–10 photos (the post limit of 10 media items); no mixing |
| Video caption | 2,200 characters |
| Photo description | 4,000 characters |
| Photo title | `photoTitle`, 90 characters |
| Video size | Up to 1 GB when uploaded by Relaycast (in pull-from-URL mode TikTok fetches the file itself) |
| Video duration | Must not exceed the creator's maximum, which TikTok reports per account |
| Visibility | `privacyLevel` is required and must be one the creator allows |
| Branded content | `brandContent` (paid partnership) cannot be used with `SELF_ONLY` |

Right before posting, Relaycast reads the creator's settings from TikTok. If the creator has turned off comments, Duet or Stitch in TikTok, those are turned off for the post too.

## Options

| Option | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `privacyLevel` | string (per account) | Yes |  | Who can view this post. Options depend on the TikTok account. Unaudited apps can only post as 'Only me'. |
| `disableComment` | boolean | No | `false` | Turn off comments |
| `disableDuet` | boolean | No | `false` | Turn off Duet. Videos only. |
| `disableStitch` | boolean | No | `false` | Turn off Stitch. Videos only. |
| `coverTimestampMs` | number | No |  | Cover frame (ms). Videos only. Timestamp of the frame used as the cover. |
| `brandContent` | boolean | No | `false` | Paid partnership. Promoting another brand or a third party. Cannot be used with 'Only me'. |
| `brandOrganic` | boolean | No | `false` | Your brand. Promoting yourself or your own business. |
| `isAigc` | boolean | No | `false` | AI-generated content |
| `photoTitle` | string | No |  | Photo post title. Photo posts only (max 90 characters). The caption is used as the description. |
| `autoAddMusic` | boolean | No | `false` | Add recommended music. Photo posts only. |
`privacyLevel` has no default: TikTok's posting guidelines require it to be chosen explicitly. The dashboard Playground lists the values allowed for the selected account. Values are `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR` and `SELF_ONLY`. In inbox mode, send `SELF_ONLY`; the creator picks the real visibility in TikTok.

`disableDuet`, `disableStitch` and `coverTimestampMs` apply to videos; `photoTitle` and `autoAddMusic` apply to photo posts.

## Examples

### Video

```bash tab="cURL"
curl -X POST https://social-publish-api.onrender.com/v1/posts \
  -H "x-access-key: $RELAYCAST_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "content": "3 tips for faster mornings #productivity",
    "media": [{ "url": "https://cdn.example.com/tips.mp4", "type": "video" }],
    "targets": [{
      "accountId": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382716",
      "options": {
        "privacyLevel": "PUBLIC_TO_EVERYONE",
        "disableDuet": true,
        "coverTimestampMs": 1500,
        "isAigc": false
      }
    }],
    "scheduledFor": "2026-11-06T19:00:00Z"
  }'
```

```ts tab="Node.js"
const res = await fetch("https://social-publish-api.onrender.com/v1/posts", {
  method: "POST",
  headers: {
    "x-access-key": process.env.RELAYCAST_API_KEY!,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    content: "3 tips for faster mornings #productivity",
    media: [{ url: "https://cdn.example.com/tips.mp4", type: "video" }],
    targets: [
      {
        accountId: "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382716",
        options: { privacyLevel: "PUBLIC_TO_EVERYONE", disableDuet: true, coverTimestampMs: 1500, isAigc: false },
      },
    ],
    scheduledFor: "2026-11-06T19:00:00Z",
  }),
});
const { data } = await res.json();
```

```python tab="Python"
import os, requests

res = requests.post(
    "https://social-publish-api.onrender.com/v1/posts",
    headers={"x-access-key": os.environ["RELAYCAST_API_KEY"]},
    json={
        "content": "3 tips for faster mornings #productivity",
        "media": [{"url": "https://cdn.example.com/tips.mp4", "type": "video"}],
        "targets": [{
            "accountId": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382716",
            "options": {
                "privacyLevel": "PUBLIC_TO_EVERYONE",
                "disableDuet": True,
                "coverTimestampMs": 1500,
                "isAigc": False,
            },
        }],
        "scheduledFor": "2026-11-06T19:00:00Z",
    },
)
data = res.json()["data"]
```

### Photo post (requires `TIKTOK_PULL_FROM_URL=true`)

```json title="Request body"
{
  "content": "Weekend hike in four photos",
  "media": [
    { "url": "https://media.example.com/hike-1.jpg", "type": "image" },
    { "url": "https://media.example.com/hike-2.jpg", "type": "image" },
    { "url": "https://media.example.com/hike-3.jpg", "type": "image" },
    { "url": "https://media.example.com/hike-4.jpg", "type": "image" }
  ],
  "targets": [{
    "accountId": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382716",
    "options": { "privacyLevel": "FOLLOWER_OF_CREATOR", "photoTitle": "Weekend hike", "autoAddMusic": true }
  }],
  "publishNow": true
}
```

The photo URLs must be on a domain verified in your TikTok developer portal.

## Analytics & comments

With `TIKTOK_ENABLE_VIDEO_LIST=true` (and the Display API product), analytics show views, likes, comments and shares. Accounts connected before the flag was turned on must reconnect.

Comments and messaging are not supported for TikTok.

> [!NOTE]
> TikTok publishes asynchronously and may hold a post for moderation. If TikTok is still processing when Relaycast stops waiting, the target is marked `published` without an `externalUrl` (it is never re-sent, to avoid duplicates). Inbox drafts never have a public URL until the creator posts them in TikTok.

## Limitations & common errors

| Error | Cause |
| --- | --- |
| `TikTok needs one video or 1–10 photos` | No media attached. |
| `TikTok posts cannot mix photos and videos` | Mixed media. |
| `TikTok posts take exactly one video` | More than one video. |
| `TikTok photo posts require PULL_FROM_URL …` | Photo post without `TIKTOK_PULL_FROM_URL=true`. |
| `Choose who can view this TikTok post` | `privacyLevel` is missing. |
| `Branded content (paid partnership) cannot be posted with 'Only me' visibility` | `brandContent: true` with `SELF_ONLY`. |
| Target fails: visibility not available for this account | The creator (or an unaudited app) doesn't allow that `privacyLevel`. |
| Target fails: video longer than the account allows | Trim the video to the creator's maximum duration. |
| Target fails: `url_ownership_unverified` | Pull-from-URL mode with a media domain that isn't verified in the TikTok portal. |
| Target retried: daily posting cap reached | TikTok's per-creator posting cap; retried later. |
| Login fails with `invalid_scope` | A requested scope (for example `video.list`) isn't granted to the app. |
