Flaghand

Guides · Updated

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, its Direct Post reference, creator info reference, media transfer guide, status reference, sharing guidelines and token guide.

What you need first

  1. A registered app on TikTok for Developers, 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 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:

    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.

curl

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

{
  "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

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.

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:

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
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 explains the connection and what's kept, and How to schedule TikTok posts covers TikTok's own scheduler. Sign-up takes no card (current terms). Get started with Flaghand.

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.