TikTok API Errors: Every Content Posting Code and Fix
Every TikTok Content Posting API error code in one place: what it means, where it appears, whether to retry, and how to fix it. Find your error code here.
TLDR: TikTok Content Posting API errors surface in three places: error.code on the init call, HTTP statuses on the upload PUT, and fail_reason on a status check returning 200. Check the code, not the status. unaudited_client_can_only_post_to_private_accounts means your Direct Post audit has not passed. Retry rate limits, TikTok-side internal errors, and pull failures; wait out posting caps; fix or reconnect the rest.
We built PostZen’s TikTok API integration and hit several of these errors in production: a PNG photo rejected with file_format_check_failed, photo downloads that stalled on TikTok’s side, and a privacy picker we pinned to SELF_ONLY while unaudited. TikTok spreads the Content Posting API’s codes across six reference pages and a general error page, which spells one of them differently, and some failures never appear on the request that caused them.
This is the reference we wanted: every code, where it appears, whether a retry helps, and the fix, checked against TikTok’s documentation on October 6, 2026. Our TikTok API guide covers the publishing flow and the audit; this post covers what goes wrong.
Where do TikTok Content Posting API errors come from?
Every Content Posting response carries an error object with code, message, and log_id. The endpoint references say any code other than ok means the request failed, and TikTok’s API v2 error handling page says support needs the code, message, and log_id. Errors surface in three places, plus two endpoints with habits of their own:
| Where | What you get | HTTP |
|---|---|---|
The init call: POST /v2/post/publish/video/init/ (Direct Post, scope video.publish), POST /v2/post/publish/inbox/video/init/ (drafts, scope video.upload), or POST /v2/post/publish/content/init/ (photos, either scope by post_mode), all on https://open.tiktokapis.com |
error.code in the JSON envelope |
400, 401, 403, 429, or 5xx |
The upload PUT to upload_url, for source: "FILE_UPLOAD" |
Documented only as HTTP statuses; tabled under invalid_param below |
201, 206, 400, 403, 404, 416, or 5xx |
The status check, POST /v2/post/publish/status/fetch/, or the post.publish.failed webhook, after a successful init |
status: "FAILED" with a fail_reason. The other statuses are PROCESSING_UPLOAD, PROCESSING_DOWNLOAD, SEND_TO_USER_INBOX (drafts), and PUBLISH_COMPLETE |
200, with error.code: "ok", so a check on the HTTP status alone sees success |
The creator info call, POST /v2/post/publish/creator_info/query/ |
spam_risk_too_many_posts, spam_risk_user_banned_from_posting, or reached_active_user_cap in error.code; the reference labels that row “200 (intentional)” |
200 |
The OAuth token endpoint, POST /v2/oauth/token/ |
A flat shape instead of the envelope: top-level error (for example invalid_request), error_description, and log_id |
Not documented per code |
Our dashboard composer’s creator-info call reads error.code and blocks the post on any of those three cap codes, telling the user that TikTok says the creator cannot make more posts right now. One more quirk: the posting references spell the parameter error invalid_param and the general v2 list invalid_params; match both. So branch on error.code or fail_reason, never on the HTTP status alone. Every code, as of October 6, 2026:
| Code | Where it appears | HTTP | Retry? | Fix |
|---|---|---|---|---|
access_token_invalid |
Any call | 401 | No | Refresh the token; reconnect the creator if the refresh fails |
scope_not_authorized |
Any call | 401 | No | Re-authorize with video.publish or video.upload |
scope_permission_missed |
Any call | 400 | No | Read error.message; re-authorize with the extra scope |
invalid_param / invalid_params |
Init | 400 | No | Read error.message: caption length, photo count, chunk plan |
invalid_file_upload |
Upload | 400 | No | Re-encode within the media spec |
app_version_check_failed |
Photo init with MEDIA_UPLOAD |
400 | No | The creator’s TikTok app must be version 31.8 or later |
unaudited_client_can_only_post_to_private_accounts |
Init | 403 | No | Pass the Direct Post audit; test with a private account and SELF_ONLY |
spam_risk_too_many_pending_share |
Draft init, video and photo | 403 | After the 24-hour window | The creator finishes the drafts waiting in their inbox |
spam_risk_too_many_posts |
Init; creator info (200); fail_reason |
403 | After the 24-hour window | Creator’s daily cap, shared across apps; post from the TikTok app |
spam_risk_user_banned_from_posting |
Init; creator info (200); fail_reason |
403 | No | Nothing in your code fixes a ban |
reached_active_user_cap |
Init; creator info (200) | 403 | After the 24-hour window | Your app’s daily creator cap; ask TikTok support to raise it |
url_ownership_unverified |
Init with pull from URL; cancel | 403 | No | Verify the domain or URL prefix in the developer portal |
privacy_level_option_mismatch |
Init | 403 | No | Offer only the creator’s privacy_level_options; require a choice |
rate_limit_exceeded |
Any call | 429 | Yes, with backoff | Per token, per minute: 6 init, 20 creator info, 30 status |
internal_error (blank in some tables) |
Any call | 5xx | Yes, with backoff | TikTok-side; retry later |
invalid_publish_id |
Status; cancel | 400 | No | The publish_id does not exist; check what you stored |
token_not_authorized_for_specified_publish_id |
Status; cancel | 400 | No | Use the token of the creator who owns that publish |
publish_not_cancellable |
Cancel | 400 | No | The task already finished |
file_format_check_failed |
fail_reason |
200, FAILED |
No | Video: MP4, WebM, MOV. Photo: JPEG or WebP; convert PNG |
picture_size_check_failed |
fail_reason |
200, FAILED |
No | Video sides 360 to 4096 px; photos up to 1080p |
frame_rate_check_failed |
fail_reason |
200, FAILED |
No | Re-encode at 23 to 60 fps |
duration_check_failed |
fail_reason |
200, FAILED |
No | Trim to the creator’s max_video_post_duration_sec |
video_pull_failed / photo_pull_failed |
fail_reason |
200, FAILED |
Yes, with a new init | Fix the URL (https, no redirects, reachable for an hour), then re-init |
publish_cancelled |
fail_reason |
200, FAILED |
n/a | You cancelled it |
auth_removed |
fail_reason |
200, FAILED |
No | The creator revoked access mid-process; reconnect |
internal |
fail_reason |
200, FAILED |
Yes, with a new init | TikTok calls it retryable |
spam_risk_text |
fail_reason |
200, FAILED |
No | TikTok flagged the caption; do not retry it |
spam_risk |
fail_reason |
200, FAILED |
No | TikTok judged the request risky; do not retry |
Why does the TikTok API return unaudited_client_can_only_post_to_private_accounts?
The Direct Post reference describes it in a line: unaudited clients can only post to a private account, and the attempt is blocked at init with HTTP 403. There are two gates, and most developers only know the first.
App review moves your app from Sandbox to production; it is where the Invalid Website URL rejections happen, and our guide to the Invalid Website URL rejection covers them. The Direct Post audit is a separate application in the developer portal under Products, then Content Posting API, then Direct Post. Until it passes, the Content Sharing Guidelines impose three restrictions: up to 5 users can post through your app in a 24-hour window, every posting account must be private at the time of posting, and only SELF_ONLY viewership is allowed. An unaudited app posting to a public creator account gets this error.
To test before the audit, fix both halves: make the test account private in the TikTok app (Profile, then Menu, then Settings and privacy, then Privacy, then Private account, per TikTok’s Help Center) and send privacy_level: "SELF_ONLY". creator_info will not warn you: it describes the creator’s account, not your audit status. A public account lists PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, and SELF_ONLY; a private one FOLLOWER_OF_CREATOR, MUTUAL_FOLLOW_FRIENDS, and SELF_ONLY; neither reflects the audit. While our app was unaudited, PostZen ran a server flag that limited the privacy picker to SELF_ONLY whatever creator_info returned.
The other route is the Upload flow, which sends a draft to the creator’s inbox: its reference does not list this error, the guidelines’ unaudited restrictions are written for Direct Post, and the creator sets visibility in TikTok’s editor. The trade-offs: every post needs a human, and drafts have a cap of their own, covered next.
Posts published while unaudited stay private; the guidelines’ one way to open them up is for the owner to switch the account to public, then change each post’s privacy to Everyone. We read that as the audit changing only future posts, since the guidelines never say existing posts are touched. The audit reviews your compose screen against the required UX:
| Audit requirement | What TikTok’s guidelines ask for |
|---|---|
| Creator identity | Show the creator’s nickname from creator_info, fetched when the compose screen renders |
| Duration | Stop videos longer than max_video_post_duration_sec |
| Privacy | A dropdown of the returned privacy_level_options with no default; the creator must choose |
| Comment, Duet, Stitch | Off by default; disabled and greyed out when the creator has turned them off |
| Commercial content | A disclosure toggle, off by default, with a choice between the creator’s own brand and branded content; branded content cannot be private |
| Consent text | The Music Usage Confirmation declaration before the publish button |
| Content | A preview of the post, no watermarks or added branding, and a note that processing can take a few minutes |
A self-hoster of the open-source scheduler Postiz documented a Direct Post audit rejection in issue #1563 (May 26, 2026); the reason TikTok cited was a privacy dropdown that defaulted to public. Our own approvals took six weeks and more than a dozen submissions.
What does spam_risk_too_many_pending_share mean in the TikTok API?
The draft flow has a cap of its own. The Upload Video reference and the Photo Post reference both list spam_risk_too_many_pending_share at HTTP 403 on init: the creator has hit the daily cap for API uploads still waiting to be finished and posted, which TikTok sets at “at most 5 pending shares within any 24-hour period.” A pending share is a draft in the creator’s TikTok inbox: status SEND_TO_USER_INBOX, webhook post.publish.inbox_delivered.
The fix is on the creator’s side: stop sending drafts for that account, have the creator open their TikTok inbox notifications and finish what is waiting, and try again after the window. Whether finishing or deleting a draft frees a slot before 24 hours pass is undocumented; retrying immediately returns the same error. If your users rarely finish drafts, or your product schedules posts, that is a sign to move to Direct Post once your audit passes; our guide to scheduling TikTok posts compares the routes.
How do you fix access_token_invalid and scope errors?
access_token_invalid (401) means the token is invalid, expired, or missing from the request, per the v2 error list. The last case matters: a dropped or malformed Authorization: Bearer header produces the same code as an expired token, so check the request before blaming the token store.
The lifetimes and the rotation rule come from TikTok’s token management page:
| Token | Lifetime | Rule |
|---|---|---|
| Access token | 24 hours (expires_in: 86400) |
Can be refreshed without user consent, so a background job can keep it current |
| Refresh token | 365 days (refresh_expires_in: 31536000) |
The refresh response may return a different one; use the new value whenever it differs |
Rotation is the catch: a system that keeps the old refresh token works until that token stops working, then every refresh fails. When a refresh fails because the token expired or the creator revoked access, the only fix is reconnecting through OAuth.
scope_not_authorized (401) means the token lacks video.publish (Direct Post and creator info) or video.upload (drafts); check the comma-separated scope string in the token response and re-authorize with the missing scope. scope_permission_missed (400) means some requested fields need additional scopes; read the message. auth_removed is a fail_reason, not an init error: the creator removed your app’s access mid-download or mid-processing, and TikTok says not to retry.
Our publisher refreshes before calling TikTok whenever the access token is about to expire, saves the returned refresh token every time, and treats all three auth codes as a reconnect prompt, not a retry. The refresh is a form-encoded POST; its errors use the flat shape from the first section:
// Node.js 18 or later. Persist BOTH returned tokens: TikTok may rotate
// the refresh token, and the old one stops working when it does.
export async function refreshTikTokToken(refreshToken) {
const res = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_key: process.env.TIKTOK_CLIENT_KEY,
client_secret: process.env.TIKTOK_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
});
const json = await res.json().catch(() => ({}));
// Flat error shape here: error, error_description, log_id.
if (!res.ok || json.error) {
throw new Error(`refresh failed: ${json.error} ${json.error_description}`);
}
return {
accessToken: json.access_token,
expiresAt: Date.now() + json.expires_in * 1000,
refreshToken: json.refresh_token, // may differ from the one you sent
scopes: json.scope.split(','),
};
}
The same in Python:
import os
import time
import requests
def refresh_tiktok_token(refresh_token):
res = requests.post(
"https://open.tiktokapis.com/v2/oauth/token/",
data={
"client_key": os.environ["TIKTOK_CLIENT_KEY"],
"client_secret": os.environ["TIKTOK_CLIENT_SECRET"],
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
timeout=30,
)
try:
body = res.json()
except ValueError:
body = {}
# Flat error shape here: error, error_description, log_id.
if not res.ok or "error" in body:
raise RuntimeError(
f"refresh failed: {body.get('error')} "
f"{body.get('error_description')}"
)
return {
"access_token": body["access_token"],
"expires_at": time.time() + body["expires_in"],
# Persist this too: TikTok may rotate it.
"refresh_token": body["refresh_token"],
"scopes": body["scope"].split(","),
}
Why does the TikTok API fail a post with file_format_check_failed?
file_format_check_failed is a fail reason, not an init error: the init accepts the request and returns a publish_id, and seconds later the status check reports FAILED with that fail_reason. We learned this in production on August 15, 2026, when a PNG photo post failed seconds after being accepted. TikTok’s photo format list is exact, JPEG and WebP; GIF is not on it either.
Our fix keeps accepting PNG and converts it at publish time: the stored upload is never modified, and TikTok receives a derived JPEG on the same verified domain. JPEG has no alpha channel, and our image library composites transparent pixels onto black by default, so without a flatten step transparent areas would come out black; we flatten onto white at quality 90. In Node with sharp:
import sharp from 'sharp';
// JPEG has no alpha channel. Flatten first, or transparent pixels can
// come out black. We use white at quality 90.
const jpegBuffer = await sharp(pngBuffer)
.flatten({ background: '#ffffff' })
.jpeg({ quality: 90 })
.toBuffer();
In Python with Pillow:
from PIL import Image
png = Image.open("photo.png").convert("RGBA")
flat = Image.new("RGB", png.size, (255, 255, 255))
flat.paste(png, mask=png.getchannel("A")) # alpha as the mask
flat.save("photo.jpg", "JPEG", quality=90)
The full spec, from the Media Transfer Guide as of October 6, 2026, with the fail reason each rule produces; the general v2 list adds invalid_file_upload (400) for an uploaded file that misses it:
| Rule | Video | Photo | Error to expect |
|---|---|---|---|
| Format | MP4 (recommended), WebM, MOV | JPEG, WebP | file_format_check_failed |
| Codec | H.264 (recommended), H.265, VP8, VP9 | Not specified | |
| Frame rate | 23 to 60 fps | frame_rate_check_failed |
|
| Dimensions | 360 to 4096 pixels on each side | Maximum 1080p; TikTok does not say which side it measures | picture_size_check_failed |
| Duration | Up to the creator’s max_video_post_duration_sec: every creator gets 3 minutes, some 5 or 10, and 10 minutes is the API maximum |
duration_check_failed |
|
| Size | Up to 4 GB | Up to 20 MB per image | Not specified |
| Count | One | Up to 35 per post | Not specified; likely invalid_param at init |
| Transfer | PULL_FROM_URL, or FILE_UPLOAD with a Content-Type of video/mp4, video/quicktime, or video/webm |
PULL_FROM_URL only |
url_ownership_unverified at init |
Just as useful is what the guide does not list, as of the same date: no bitrate cap, no audio codec rule, no required aspect ratio, and no exact 1080 by 1920 size, though some guides claim all four. Treat 9:16 at 1080 by 1920 as a default, not a requirement, and check a file’s actual bytes (magic numbers or a decoder) rather than its extension: a PNG renamed to .jpg is still a PNG.
What do url_ownership_unverified and video_pull_failed mean?
Pull from URL (source: "PULL_FROM_URL") only works when the media URL sits under a domain or URL prefix your app has verified in the developer portal under Manage URL properties; otherwise init returns url_ownership_unverified with HTTP 403. Our Invalid Website URL guide, linked in the audit section, covers that portal screen. The rules that catch people out, from the Media Transfer Guide and the guidelines:
| Rule | Detail |
|---|---|
| Domain verification | TikTok recommends a DNS signature. It reaches down, not up: verifying static.example.com covers video.static.example.com but not example.com |
| URL prefix verification | https:// plus host plus path plus a trailing slash, matched literally from the start; a path segment inserted before the end of the prefix breaks the match. The host cannot be an IP address |
| Protocol | https only |
| Redirects | None; a 3xx response is treated as invalid |
| Availability | Reachable for the whole download, which times out one hour after the task starts, so signed URLs need a lifetime longer than an hour. TikTok’s download bandwidth can reach 100 Mbps |
| Photos | PULL_FROM_URL only, so photo posts always need a verified domain |
| Videos | FILE_UPLOAD is the alternative, though the guidelines say to pull when the file is already on your server |
A download that breaks fails later as video_pull_failed or photo_pull_failed: a connection error, or the one-hour timeout. The status reference recommends a retry if you are certain the URL is valid, and a retry means a new init with a new publish_id.
We hit the slow version on August 14, 2026: photo posts were accepted, then sat at PROCESSING_DOWNLOAD until our poller gave up after 20 minutes, and nothing reached the creator’s inbox. It was TikTok’s side; the next morning the same unchanged JPEG went from PROCESSING_DOWNLOAD to SEND_TO_USER_INBOX in 6 seconds. Three lessons. A format rejection fails in seconds; a stall looks different. Log downloaded_bytes from every status response; it is how we tell slow from stalled. And since TikTok’s own timeout is an hour, a poller that gives up sooner never sees the real fail_reason: keep the publish_id and check later, or subscribe to the post.publish.failed webhook. POST /v2/post/publish/cancel/ abandons a slow download on a best-effort basis; a cancelled task fails with publish_cancelled, a finished one returns publish_not_cancellable.
Which TikTok API errors mean you hit a posting cap or rate limit?
Five TikTok API codes mean you are over a limit, each a different one:
| Code | What is capped | The limit | What to do |
|---|---|---|---|
spam_risk_too_many_posts |
Direct Post posts per creator per 24 hours, shared across every app | Varies by creator, typically around 15 a day | Wait for the window; TikTok suggests the creator posts from the TikTok app meanwhile |
spam_risk_too_many_pending_share |
Unfinished API drafts per creator | 5 in any 24-hour period | The creator finishes the drafts |
reached_active_user_cap |
Distinct publishing creators per app per 24 hours | 5 while unaudited; after the audit, a cap set from your audit estimates | Wait; ask developer support for a raise |
spam_risk_user_banned_from_posting |
The creator | A trust and safety ban | Do not retry |
rate_limit_exceeded |
Requests per user access token per minute | 6 per init endpoint, 20 creator info, 30 status fetch | Back off and retry |
spam_risk_too_many_posts also arrives as a fail_reason and from creator info with HTTP 200; the cap is shared across all apps using Direct Post, so switching tools does not reset it, and TikTok’s guidance for the fail reason is to post from the TikTok mobile app. The audited creator cap comes from the usage estimates in your application form, so estimate honestly; one developer in a 2025 r/automation thread reported a cap of roughly 100 creators, raised by TikTok developer support after three to four weeks (one report, not a rule). spam_risk_text and spam_risk are the ban’s siblings, fail reasons for a caption or a request TikTok judged risky; do not retry any of the three.
For rate_limit_exceeded (429), back off, honor a Retry-After header if one is present (TikTok does not document one), and poll status no faster than you need (we poll every 5 seconds, well under 30 a minute). TikTok meters per token and caps creators per day, and documents no unit budget like YouTube’s quota, which our YouTube API quota guide covers. internal_error or a bare 5xx at init is a TikTok server or network error to retry later, and internal as a fail_reason is one TikTok itself calls retryable.
What causes privacy_level_option_mismatch and invalid_param?
privacy_level_option_mismatch (403 at init) means privacy_level is missing or not one of the creator’s privacy_level_options. TikTok adds a warning: every client must display the creator’s options and honor the user’s choice, and occurrences of this error from product apps suggest a breach of its product-use guidance. The fix is structural: call creator_info when rendering the compose screen (account settings change between sessions), offer only the returned options, and require an explicit choice. Two related rules: branded content (brand_content_toggle: true) cannot be SELF_ONLY and must be public or friends, and brand_content_toggle is required in Direct Post post_info.
invalid_param (400) says to read error.message; the documented limits predict most causes, with caption and photo limits from the Direct Post and Photo Post references and chunk rules from the Media Transfer Guide:
| Documented limit | Value |
|---|---|
Video caption (title) |
2,200 UTF-16 runes |
Photo title |
90 runes |
Photo description |
4,000 runes |
| Photo URLs per post | 35 |
| Chunk size | 5 MB to 64 MB; the final chunk may reach 128 MB to absorb trailing bytes |
| Files under 5 MB | One chunk, with chunk_size equal to the file size |
| Files over 64 MB | Must be chunked |
| Chunk count | 1 to 1,000; total_chunk_count is video_size divided by chunk_size, rounded down |
| Chunk order | Sequential |
One caption tip from our publisher. TikTok counts length in UTF-16 code units (runes), and truncating to the limit can cut between the two halves of a surrogate pair (most emoji), leaving a lone surrogate that becomes a replacement character on the wire. We drop a trailing high surrogate after slicing.
The upload PUT is documented only as HTTP statuses, from the same guide:
| Status | Meaning | What to do |
|---|---|---|
| 206 | Chunk accepted, more to come | Send the next chunk |
| 201 | All parts uploaded; TikTok starts processing | Poll status |
| 400 | Malformed headers, or Content-Length does not match the bytes sent |
Fix the request |
| 403 | upload_url expired; it lasts one hour |
Start over with a new init |
| 404 | No upload task for that URL | Use the whole upload_url, query string included |
| 416 | Content-Range does not match upload progress |
Resend from the progress TikTok reports in its Content-Range response header |
| 5xx | Gateway or TikTok error | Retry that chunk |
Each chunk carries Content-Type, Content-Length, and Content-Range: bytes FIRST-LAST/TOTAL. The status endpoint adds two 400s of its own: invalid_publish_id when the ID does not exist, and token_not_authorized_for_specified_publish_id when the token is not the owning creator’s.
How should your code handle TikTok API errors?
Every TikTok API error above reduces to a classifier and a poller: four actions, error.code checked even on HTTP 200, and no post declared failed just because a poll budget ran out. Node.js 18 or later, as an ES module:
// Node.js 18 or later, ES module. Codes as of October 6, 2026.
const REAUTH = new Set([
'access_token_invalid', 'scope_not_authorized',
'scope_permission_missed', 'auth_removed',
]);
const RETRY = new Set([
'rate_limit_exceeded', 'internal_error', 'internal',
'video_pull_failed', 'photo_pull_failed',
]);
const WAIT = new Set([
'spam_risk_too_many_posts', 'spam_risk_too_many_pending_share',
'reached_active_user_cap',
]);
// Returns 'reauth' | 'retry' | 'wait' | 'fail'. 429 and 5xx count as retry.
export function classify(code, httpStatus = 200) {
if (REAUTH.has(code)) return 'reauth';
if (RETRY.has(code) || httpStatus === 429 || httpStatus >= 500) {
return 'retry';
}
if (WAIT.has(code)) return 'wait';
return 'fail';
}
function tiktokError(code, message, extra = {}) {
return Object.assign(new Error(`TikTok ${code}: ${message}`), {
code, ...extra,
});
}
export async function tiktok(path, token, body = {}) {
const res = await fetch(`https://open.tiktokapis.com${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json; charset=UTF-8',
},
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
const code = json.error?.code ?? `http_${res.status}`;
// Check the code even on HTTP 200: creator_info reports caps that way.
if (code !== 'ok') {
throw tiktokError(code, json.error?.message ?? res.statusText, {
logId: json.error?.log_id,
action: classify(code, res.status),
retryAfter: res.headers.get('retry-after'),
});
}
return json.data ?? {};
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Polls every 5 s; the limit is 30 a minute. 120 polls is 10 minutes.
export async function waitForPublish(token, publishId, maxPolls = 120) {
for (let i = 0; i < maxPolls; i++) {
const data = await tiktok('/v2/post/publish/status/fetch/', token, {
publish_id: publishId,
});
console.log(publishId, data.status, data.downloaded_bytes ?? '');
if (['PUBLISH_COMPLETE', 'SEND_TO_USER_INBOX'].includes(data.status)) {
return data;
}
if (data.status === 'FAILED') {
// 'retry' here means a NEW init with a new publish_id. Polling this
// one again returns the same failure.
throw tiktokError(data.fail_reason, 'publish failed', {
action: classify(data.fail_reason),
});
}
await sleep(5000);
}
// Not failed, just unfinished. TikTok's own download timeout is an hour,
// so keep the publish_id and check later, or wait for the
// post.publish.failed webhook.
throw tiktokError('still_processing', 'keep the publish_id', {
action: 'wait',
});
}
The same in Python with requests:
import time
import requests
BASE = "https://open.tiktokapis.com"
REAUTH = {"access_token_invalid", "scope_not_authorized",
"scope_permission_missed", "auth_removed"}
RETRY = {"rate_limit_exceeded", "internal_error", "internal",
"video_pull_failed", "photo_pull_failed"}
WAIT = {"spam_risk_too_many_posts", "spam_risk_too_many_pending_share",
"reached_active_user_cap"}
class TikTokError(Exception):
def __init__(self, code, message, action, log_id=None):
super().__init__(f"TikTok {code}: {message}")
self.code, self.action, self.log_id = code, action, log_id
def classify(code, http_status=200):
"""Returns 'reauth', 'retry', 'wait', or 'fail'. 429 and 5xx retry."""
if code in REAUTH:
return "reauth"
if code in RETRY or http_status == 429 or http_status >= 500:
return "retry"
if code in WAIT:
return "wait"
return "fail"
def tiktok(path, token, body=None):
res = requests.post(
BASE + path,
json=body or {},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
try:
payload = res.json()
except ValueError:
payload = {}
error = payload.get("error") or {}
code = error.get("code") or f"http_{res.status_code}"
# Check the code even on HTTP 200: creator_info reports caps that way.
if code != "ok":
raise TikTokError(code, error.get("message", res.reason),
classify(code, res.status_code), error.get("log_id"))
return payload.get("data") or {}
def wait_for_publish(token, publish_id, max_polls=120):
"""Polls every 5 s; the limit is 30 a minute. 120 polls is 10 minutes."""
for _ in range(max_polls):
data = tiktok("/v2/post/publish/status/fetch/", token,
{"publish_id": publish_id})
print(publish_id, data.get("status"), data.get("downloaded_bytes"))
if data.get("status") in ("PUBLISH_COMPLETE", "SEND_TO_USER_INBOX"):
return data
if data.get("status") == "FAILED":
# "retry" here means a NEW init with a new publish_id. Polling
# this one again returns the same failure.
reason = data.get("fail_reason", "unknown")
raise TikTokError(reason, "publish failed", classify(reason))
time.sleep(5)
# Not failed, just unfinished. TikTok's own download timeout is an hour,
# so keep the publish_id and check later, or wait for the
# post.publish.failed webhook.
raise TikTokError("still_processing", "keep the publish_id", "wait")
Why it is shaped this way:
- Four actions, not two.
reauthopens a reconnect prompt,retrybacks off,waitpauses that creator or your app until the window passes, andfailgoes back to the user with TikTok’s message. Our publisher runs a three-way version of this split: auth codes prompt a reconnect, transient codes (rate_limit_exceeded,internal_error, any 429 or 5xx) retry with backoff, and everything else, cap codes included, is a validation failure, not retried. - Code over status.
tiktok()throws on anyerror.codeother thanokwhatever the HTTP status, which is how the three creator-info cap codes get caught. - A retry after
FAILEDis a new init.video_pull_failedandinternalare retryable, but thatpublish_idis spent. - Unfinished is not failed. After its poll budget the poller throws
still_processingand keeps thepublish_id, because TikTok’s download timeout is an hour and the realfail_reasonmay not exist yet. We log every status transition withdownloaded_bytesandfail_reason.
The shorter path: one request to every platform
If you would rather not own any of this, here is one vertical video going to TikTok, Instagram Reels, and YouTube in one PostZen request, a week out:
curl -X POST https://api.postzen.dev/v1/posts \
-H "Authorization: Bearer pzn_live_..." \
-H "Content-Type: application/json" \
-H "x-request-id: tiktok-errors-teaser-2026-10-13" \
-d '{
"content": "Every TikTok API error we hit in production, in 60 seconds.",
"scheduledFor": "2026-10-13T16:00:00Z",
"mediaItems": [{ "url": "https://cdn.example.com/teaser.mp4" }],
"platforms": [
{
"platform": "tiktok",
"accountId": "acc_tiktok",
"settings": {
"privacyLevel": "publicToEveryone",
"allowComments": true
}
},
{
"platform": "instagram",
"accountId": "acc_instagram",
"settings": { "postType": "reel" }
},
{
"platform": "youtube",
"accountId": "acc_youtube",
"settings": {
"title": "Every TikTok API error we hit in production",
"privacyStatus": "public"
}
}
]
}'
Line by line:
platformsholds one target per network with its ownaccountIdandsettings. TikTok takesprivacyLevel(publicToEveryone,mutualFollowFriends,followerOfCreator, orselfOnly, whatever the creator’s account allows) plusallowComments,allowDuet,allowStitch,uploadAsDraft,brandContentToggle,brandOrganicToggle, andvideoCoverTimestampMs; Instagram gets a reel; YouTube needstitleandprivacyStatus.mediaItemsis a URL PostZen downloads and re-hosts, up to 100 MB;POST /v1/media/presigntakes larger files, up to 5 GB, though TikTok videos stay capped at 4 GB. Photo posts take up to 10mediaItemsand a top-leveltitle.scheduledFormust be at least 60 seconds ahead, or sendpublishNow: true; our social media scheduling API guide covers the scheduler.x-request-idmakes the create idempotent: a retry after a timeout returns the original post, not a duplicate.
PostZen absorbs most of this guide: our TikTok app is audited, so posts can be public; media sits on our verified domain, so url_ownership_unverified never reaches you; tokens are refreshed; PNG photos become JPEG; and requests are validated first (a privacy level unless uploadAsDraft: true, public or friends visibility for branded content, photo titles up to 90 characters, no GIFs, photos up to 20 MB). A failed target shows up in GET /v1/posts/{id} as platforms[].status: "failed" with an error message, and the post.published, post.failed, post.partially_failed, account.needs_reauth, and account.disconnected webhooks report it without polling; the TikTok integration docs list every setting. What still applies: creator-level caps such as spam_risk_too_many_posts count posts sent through PostZen too, bans are bans, and the creator’s account settings still decide which privacy levels exist. API rate limits are per minute by plan, on the pricing page.
The same request reaches all 10 platforms (Instagram, X, LinkedIn, Facebook, Threads, YouTube, TikTok, Pinterest, Bluesky, Telegram), the @postzen/node and postzen-sdk Python SDKs wrap it, and AI agents publish and schedule through our MCP server; see the TikTok integration page, the TikTok MCP page, and the best social media APIs for developers comparison. Build directly if TikTok is your only network and the audit is on your roadmap anyway; keep the classifier above in your worker. Otherwise this is what we built our social media API for: one request, one error shape, and the auth, hosting, and format errors above handled before they reach you.
Frequently asked questions
What does unaudited_client_can_only_post_to_private_accounts mean?
Your app has not passed TikTok's Direct Post audit, and the creator's account is public. Until the audit passes, TikTok allows only SELF_ONLY posts, only to accounts set to private at posting time, and only five users per 24 hours. The error is returned by the init call with HTTP 403. To test, make the account private and send SELF_ONLY, or send drafts through the Upload flow.
Why are my TikTok API posts private?
Usually because the app that published them was unaudited: TikTok restricts every Direct Post from an unaudited client to SELF_ONLY viewing. To open them up later, the account owner switches the account to public, then changes each post's privacy to Everyone; nothing in the guidelines says passing the audit changes existing posts. If your app is audited, check the privacy_level you sent and whether the account itself is private.
What is spam_risk_too_many_pending_share in the TikTok API?
It is the HTTP 403 error TikTok's Upload endpoints return when a creator has five API uploads waiting in their TikTok inbox within a 24-hour period. A pending share is a draft the creator has not finished. Stop sending drafts for that creator, ask them to open their inbox notifications and finish what is waiting, and try again after the window. Retrying immediately returns the same error.
How do I fix access_token_invalid in the TikTok API?
Refresh the token. TikTok access tokens last 24 hours and refresh tokens 365 days, so call POST /v2/oauth/token/ with grant_type=refresh_token, then store both returned tokens, because the refresh token may be rotated. The same code appears when the Authorization header is missing or malformed, so check the request first. If the refresh fails, the refresh token has expired or been revoked, and the creator must reconnect through OAuth.
Why does TikTok reject PNG images with file_format_check_failed?
Because TikTok's photo format list is exact: JPEG and WebP only, as of October 6, 2026. GIF is not on the list either. The init call accepts the request and returns a publish_id, then the status endpoint reports FAILED with fail_reason file_format_check_failed within seconds. Convert PNG to JPEG before sending, flattening transparency onto a background, since JPEG has no alpha channel. PostZen does this conversion at publish time.
How many posts per day can you publish through the TikTok API?
TikTok does not publish a fixed number. Its Content Sharing Guidelines say the Direct Post cap varies by creator, is typically around 15 posts a day per creator account, and is shared across every app using Direct Post, so switching tools does not reset it. Hitting it returns spam_risk_too_many_posts. Drafts have a separate limit of 5 pending shares per creator in 24 hours.



