# How to post a video with the TikTok API

To post a video with TikTok's Content Posting API, get a user access token with the **`video.publish`** scope. Call
`POST /v2/post/publish/creator_info/query/`, then `POST /v2/post/publish/video/init/` with the post's settings and the
file's size, and `PUT` the file to the `upload_url` TikTok returns. `POST /v2/post/publish/status/fetch/` tells you when
it's done. Each user token gets **6 init requests a minute**, and a creator can make about **15 posts a day** through
the API. Until TikTok audits your app, every post is **Only me** (`SELF_ONLY`), on private accounts only, for at most
**5 users a day**.

Checked on November 3, 2026 against TikTok's [Direct Post guide](https://developers.tiktok.com/doc/content-posting-api-get-started),
its [Direct Post reference](https://developers.tiktok.com/doc/content-posting-api-reference-direct-post),
[creator info reference](https://developers.tiktok.com/doc/content-posting-api-reference-query-creator-info),
[media transfer guide](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide),
[status reference](https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status),
[sharing guidelines](https://developers.tiktok.com/doc/content-sharing-guidelines) and
[token guide](https://developers.tiktok.com/doc/oauth-user-access-token-management).

## What you need first

1. **A registered app** on [TikTok for Developers](https://developers.tiktok.com), with the Content Posting API
   product added and its Direct Post setting turned on.
2. **Approval for the `video.publish` scope.** The account that posts must grant it to your app too. To send a draft to
   the creator's TikTok inbox instead, you need `video.upload`.
   [TikTok's Content Posting API, explained](/docs/guides/tiktok-content-posting-api) compares the two routes and the audit.
3. **A user access token.** Send the person to `https://www.tiktok.com/v2/auth/authorize/` with your `client_key`,
   `scope=video.publish`, `response_type=code`, your `redirect_uri` and a random `state`. Then swap the code for
   tokens from your server:

   ```bash
   curl -X POST https://open.tiktokapis.com/v2/oauth/token/ \
     -H "Content-Type: application/x-www-form-urlencoded" \
     --data-urlencode "client_key=$TIKTOK_CLIENT_KEY" \
     --data-urlencode "client_secret=$TIKTOK_CLIENT_SECRET" \
     --data-urlencode "code=$CODE" \
     --data-urlencode "grant_type=authorization_code" \
     --data-urlencode "redirect_uri=$REDIRECT_URI"
   ```

   The access token lasts 2 hours and the refresh token 180 days. Each refresh returns a new refresh token and ends
   the old one, so store the new one every time.
4. **A private test account.** An unaudited app can only post to accounts set to private.
5. **A video file.** MP4 with H.264 is the safe choice. The code below names it `video.mp4`.

The examples read the token from `TIKTOK_ACCESS_TOKEN`. Keep it, and your client secret, out of your code and your logs.

## Post it

Every answer has an `error` object. Only `"code": "ok"` means it worked, even when the HTTP status is 200. The code
below sends the whole file in one chunk, which works for files up to 64 MB. Bigger files need
[chunks](#files-over-64-mb-chunks-and-content-range).

### curl

```bash
# 1. Ask TikTok what this account can post right now.
curl -X POST https://open.tiktokapis.com/v2/post/publish/creator_info/query/ \
  -H "Authorization: Bearer $TIKTOK_ACCESS_TOKEN" \
  -H "Content-Type: application/json; charset=UTF-8"

# 2. Start the post. One chunk: chunk_size is the whole file.
SIZE=$(wc -c < video.mp4 | tr -d ' ')
INIT=$(curl -s -X POST https://open.tiktokapis.com/v2/post/publish/video/init/ \
  -H "Authorization: Bearer $TIKTOK_ACCESS_TOKEN" \
  -H "Content-Type: application/json; charset=UTF-8" \
  -d "{\"post_info\": {\"title\": \"Hello from the TikTok API #test\", \"privacy_level\": \"SELF_ONLY\",
                     \"disable_comment\": true, \"disable_duet\": true, \"disable_stitch\": true},
       \"source_info\": {\"source\": \"FILE_UPLOAD\", \"video_size\": $SIZE, \"chunk_size\": $SIZE,
                       \"total_chunk_count\": 1}}")
UPLOAD_URL=$(echo "$INIT" | jq -r .data.upload_url)
PUBLISH_ID=$(echo "$INIT" | jq -r .data.publish_id)

# 3. Send the file. --data-binary keeps every byte; plain --data would strip line breaks.
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: video/mp4" \
  -H "Content-Range: bytes 0-$((SIZE - 1))/$SIZE" \
  --data-binary @video.mp4

# 4. Check on it.
curl -X POST https://open.tiktokapis.com/v2/post/publish/status/fetch/ \
  -H "Authorization: Bearer $TIKTOK_ACCESS_TOKEN" \
  -H "Content-Type: application/json; charset=UTF-8" \
  -d "{\"publish_id\": \"$PUBLISH_ID\"}"
```

For a private account, the creator info answer looks like this:

```json
{
  "data": {
    "creator_username": "yourname",
    "creator_nickname": "Your Name",
    "creator_avatar_url": "https://…",
    "privacy_level_options": ["FOLLOWER_OF_CREATOR", "MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY"],
    "comment_disabled": false,
    "duet_disabled": true,
    "stitch_disabled": true,
    "max_video_post_duration_sec": 600
  },
  "error": { "code": "ok", "message": "", "log_id": "202611031200000000000000000000" }
}
```

`privacy_level` must be one of `privacy_level_options`. A private account gets `FOLLOWER_OF_CREATOR`; a public one
gets `PUBLIC_TO_EVERYONE`. If `comment_disabled`, `duet_disabled` or `stitch_disabled` is true, send that `disable_`
flag as true. Don't send a video longer than `max_video_post_duration_sec`.

The init answer holds the post's `publish_id` and the `upload_url`, which works for one hour. TikTok answers the `PUT`
with `201 Created` once it has every byte. The status then moves from `PROCESSING_UPLOAD` to `PUBLISH_COMPLETE`, or to
`FAILED` with a `fail_reason`.

### Python

```python
import os
import time

import requests

API = "https://open.tiktokapis.com"
HEADERS = {
    "Authorization": f"Bearer {os.environ['TIKTOK_ACCESS_TOKEN']}",
    "Content-Type": "application/json; charset=UTF-8",
}


def call(path, body=None):
    resp = requests.post(f"{API}{path}", headers=HEADERS, json=body, timeout=30)
    error = resp.json().get("error", {})
    if resp.status_code != 200 or error.get("code") != "ok":
        raise RuntimeError(f"TikTok answered {resp.status_code}: {error}")
    return resp.json()["data"]


creator = call("/v2/post/publish/creator_info/query/")
if "SELF_ONLY" not in creator["privacy_level_options"]:
    raise RuntimeError("This account can't post Only me right now.")

with open("video.mp4", "rb") as f:
    video = f.read()
size = len(video)

init = call(
    "/v2/post/publish/video/init/",
    {
        "post_info": {
            "title": "Hello from the TikTok API #test",
            "privacy_level": "SELF_ONLY",
            "disable_comment": True,
            "disable_duet": True,
            "disable_stitch": True,
        },
        "source_info": {"source": "FILE_UPLOAD", "video_size": size, "chunk_size": size, "total_chunk_count": 1},
    },
)

put = requests.put(
    init["upload_url"],
    headers={"Content-Type": "video/mp4", "Content-Range": f"bytes 0-{size - 1}/{size}"},
    data=video,
    timeout=300,
)
put.raise_for_status()

while True:
    status = call("/v2/post/publish/status/fetch/", {"publish_id": init["publish_id"]})
    if status["status"] in ("PUBLISH_COMPLETE", "FAILED"):
        break
    time.sleep(5)
print(status)
```

### Node.js (18 or later)

Save it as `post.mjs`, so top-level `await` works.

```js
import { readFile } from 'node:fs/promises';

const API = 'https://open.tiktokapis.com';
const headers = {
  Authorization: `Bearer ${process.env.TIKTOK_ACCESS_TOKEN}`,
  'Content-Type': 'application/json; charset=UTF-8',
};

async function call(path, body) {
  const resp = await fetch(`${API}${path}`, { method: 'POST', headers, body: body && JSON.stringify(body) });
  const json = await resp.json();
  if (!resp.ok || json.error?.code !== 'ok') throw new Error(`TikTok answered ${resp.status}: ${JSON.stringify(json.error)}`);
  return json.data;
}

const creator = await call('/v2/post/publish/creator_info/query/');
if (!creator.privacy_level_options.includes('SELF_ONLY')) throw new Error("This account can't post Only me right now.");

const video = await readFile('video.mp4');
const size = video.length;

const init = await call('/v2/post/publish/video/init/', {
  post_info: {
    title: 'Hello from the TikTok API #test',
    privacy_level: 'SELF_ONLY',
    disable_comment: true,
    disable_duet: true,
    disable_stitch: true,
  },
  source_info: { source: 'FILE_UPLOAD', video_size: size, chunk_size: size, total_chunk_count: 1 },
});

const put = await fetch(init.upload_url, {
  method: 'PUT',
  headers: { 'Content-Type': 'video/mp4', 'Content-Range': `bytes 0-${size - 1}/${size}` },
  body: video,
});
if (!put.ok) throw new Error(`TikTok answered the upload with ${put.status}: ${await put.text()}`);

let status;
do {
  await new Promise((r) => setTimeout(r, 5000));
  status = await call('/v2/post/publish/status/fetch/', { publish_id: init.publish_id });
} while (!['PUBLISH_COMPLETE', 'FAILED'].includes(status.status));
console.log(status);
```

Status reads are limited to 30 a minute per token, so don't poll faster than every 2 seconds.

In a real app, the creator picks the title, who can view it, and whether to allow comments, Duet and Stitch. TikTok's
guidelines require that screen, with no privacy level chosen for them and every interaction off until they turn it on.
The examples fix those choices only to stay short.

## Files over 64 MB: chunks and Content-Range

TikTok's chunk rules are strict, and a wrong number fails the upload:

- A chunk is 5 MB to 64 MB. The last chunk takes the leftover bytes and may be up to 128 MB.
- `total_chunk_count` is `video_size` divided by `chunk_size`, rounded down.
- A file under 5 MB goes in one chunk, with `chunk_size` equal to the file's size. A file over 64 MB must be chunked.
- At most 1,000 chunks, sent in order, all to the same `upload_url`.
- Each `PUT` carries `Content-Range: bytes FIRST-LAST/TOTAL`, with zero-based, inclusive byte positions.

TikTok answers `206` for each chunk and `201` after the last one. A `416` means the `Content-Range` doesn't match what
TikTok has received so far. This plans and sends the chunks:

```python
CHUNK = 10_000_000  # TikTok's own example size


def chunk_plan(size):
    if size < CHUNK:
        return size, 1  # one chunk: the whole file
    return CHUNK, size // CHUNK  # the last chunk takes the leftover bytes


def upload_chunks(upload_url, path, size, chunk_size, count):
    with open(path, "rb") as f:
        for i in range(count):
            first = i * chunk_size
            last = size - 1 if i == count - 1 else first + chunk_size - 1
            part = f.read(last - first + 1)
            r = requests.put(
                upload_url,
                headers={"Content-Type": "video/mp4", "Content-Range": f"bytes {first}-{last}/{size}"},
                data=part,
                timeout=300,
            )
            if r.status_code not in (201, 206):
                raise RuntimeError(f"Chunk {i + 1} of {count}: TikTok answered {r.status_code}")
```

Send `chunk_size` and `total_chunk_count` from `chunk_plan` in the init request's `source_info`. A 50,000,123-byte
file becomes 5 chunks: four of 10,000,000 bytes, then one of 10,000,123.

## Hosted videos, inbox drafts, photos

| To do | Do this |
|---|---|
| Post a video already on your server | Send `"source_info": {"source": "PULL_FROM_URL", "video_url": "https://…"}` and skip the `PUT`. The URL must be on a domain or URL prefix you've verified in TikTok's developer portal, use HTTPS and not redirect. TikTok's guidelines say to use this, not `FILE_UPLOAD`, for files already on your server |
| Send a draft to the creator's inbox | Call `POST /v2/post/publish/inbox/video/init/` with only `source_info`, using a `video.upload` token. The creator finishes and posts it in TikTok. The status stops at `SEND_TO_USER_INBOX` until they do |
| Post photos | Call `POST /v2/post/publish/content/init/` with `"media_type": "PHOTO"` and up to 35 image URLs, pulled from your verified domain. See [the explainer](/docs/guides/tiktok-content-posting-api) |
| Stop a slow pull | `POST /v2/post/publish/cancel/` with the `publish_id`. TikTok tries, but can't stop one that's almost done |

## Limits

| | Limit |
|---|---|
| Video init (`/video/init/` and `/inbox/video/init/`) | 6 requests a minute per user token |
| Creator info (`/creator_info/query/`) | 20 requests a minute per user token |
| Status fetch (`/status/fetch/`) | 30 requests a minute per user token |
| Posts per creator | Usually about 15 Direct Posts in 24 hours, shared by every app that posts for that creator |
| Unaudited app | Up to 5 users posting in 24 hours, private accounts only, `SELF_ONLY` only |
| Inbox drafts | At most 5 pending in any 24 hours |
| Caption (`title`) | 2,200, counted in UTF-16 runes |
| Video file | Up to 4 GB. MP4 (recommended), WebM or MOV; H.264 (recommended), H.265, VP8 or VP9 |
| Video picture and frame rate | 360 to 4,096 pixels on each side, 23 to 60 frames a second |
| Video length | 3 minutes for every creator, 5 or 10 for some; check `max_video_post_duration_sec` |
| Chunks | 5 MB to 64 MB each, the last up to 128 MB, 1 to 1,000 chunks |
| `upload_url` | Valid for one hour |
| Pull from URL | HTTPS, no redirects, verified domain or prefix; TikTok gives up after one hour |
| Access token, refresh token | 2 hours, 180 days |

Past a rate limit, TikTok answers `429` with `rate_limit_exceeded`. The daily post cap is a different error:
`spam_risk_too_many_posts`. If your app hits its daily user cap, you get `reached_active_user_cap`.

## Do it yourself, or let an agent draft it through Flaghand

Call the API yourself when you're building TikTok posting into a product for many creators. TikTok's guidelines
expect that: they don't allow an app that only posts to accounts you or your team manage, and they require a posting
screen where the creator makes every choice.

If an AI agent writes the posts, Flaghand is a publishing desk for that case. You connect TikTok with TikTok's own
sign-in. Your agent connects to Flaghand's MCP server (`https://app.flaghand.com/mcp`) or its API and drafts a video (one MP4 or MOV
of up to 64 MB, which Flaghand sends in one chunk) or a photo post of up to 35 photos or slides, then asks you to
approve it. Flaghand reads the video's length and compares it with what TikTok allows the account. To post directly,
you choose on Flaghand's TikTok posting screen: who can view it, with nothing chosen for you; comments, Duet and
Stitch, off unless you turn them on; disclosure; and TikTok's consent statement. A draft for your TikTok inbox takes
those choices in TikTok instead. Your passkey approves that exact post. Right before it goes out, Flaghand asks TikTok
for the account's settings again, and sends nothing if they no longer fit. Flaghand sends it at its time, never twice, and
follows TikTok's status until it's posted, in your inbox or failed. No token Flaghand issues can approve, schedule or
post.

Flaghand's TikTok app isn't audited yet. Until it is, posting directly works only to a private account, seen by
Only me. To reach your audience now, have Flaghand send it to your TikTok inbox as a draft, then publish it in the
TikTok app. [Flaghand and TikTok](/tiktok) explains the connection and what's kept, and
[How to schedule TikTok posts](/docs/guides/how-to-schedule-tiktok-posts) covers TikTok's own scheduler.
Sign-up takes no card ([current terms](/pricing)).
[Get started with Flaghand](/schedule).

## Questions

**Why can only I see the videos my app posts to TikTok?** TikTok hasn't audited your app yet. Until it does, every
Direct Post must be `SELF_ONLY`, to a private account, for up to 5 users a day. After the audit, the creator must make
the account public and set each old post to Everyone themselves.

**What's the difference between `video.publish` and `video.upload`?** `video.publish` posts straight to the profile
through `/v2/post/publish/video/init/`. `video.upload` sends a draft to the creator's TikTok inbox through
`/v2/post/publish/inbox/video/init/`, and they post it in the app.

**How many videos can I post a day through the TikTok API?** TikTok says usually about 15 per creator in 24 hours,
shared across every app posting for them. The 6 init requests a minute are a rate limit, not a post allowance.

**What's the biggest video the TikTok API takes?** 4 GB. Send files over 64 MB in chunks of 5 to 64 MB, or let TikTok
pull the file from your verified domain.

**Is there a TikTok API for Python?** The Content Posting API is plain HTTPS and JSON, so `requests` is enough. The
Python example above posts a video with three endpoints and a `PUT`.

**Why don't I get a TikTok post ID back?** TikTok returns `publicaly_available_post_id` (its spelling) only once a
post is public and has passed moderation. An Only me post never gets one through the API; use the `publish_id` to
track it.

## Sources

Checked November 3, 2026:

- TikTok: [Get Started - Direct Post](https://developers.tiktok.com/doc/content-posting-api-get-started)
- TikTok: [Direct Post reference](https://developers.tiktok.com/doc/content-posting-api-reference-direct-post)
- TikTok: [Upload (inbox) reference](https://developers.tiktok.com/doc/content-posting-api-reference-upload-video)
- TikTok: [Query Creator Info](https://developers.tiktok.com/doc/content-posting-api-reference-query-creator-info)
- TikTok: [Media Transfer Guide](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide)
- TikTok: [Get Post Status](https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status)
- TikTok: [Content Sharing Guidelines](https://developers.tiktok.com/doc/content-sharing-guidelines)
- TikTok: [Photo post reference](https://developers.tiktok.com/doc/content-posting-api-reference-photo-post)
- TikTok: [Login Kit for web](https://developers.tiktok.com/doc/login-kit-web),
  [Get a user access token](https://developers.tiktok.com/doc/oauth-get-a-user-access-token) and
  [Manage access tokens](https://developers.tiktok.com/doc/oauth-user-access-token-management)

[All guides](/docs/guides.md)
