Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TikTok (tiktok)

tiktok is the TikTok for Developers (Login Kit + Content Posting API) provider. It publishes videos from a local file (FILE_UPLOAD) and photo carousels from public URLs (PULL_FROM_URL), either directly to the profile (DIRECT_POST) or as a draft in the TikTok app inbox (UPLOAD). Publishing is two-phase: post_pending registers the upload and returns pending; the engine polls the read-only status endpoint. There is no finalize step, because the publish call is irreversible and a retried mutation could post twice.

Do not confuse this with tiktok-business: a different developer app, a different API host, different OAuth endpoints, a trailing-slash redirect URI, and Access-Token headers instead of Bearer.

  • Rust source: crates/rustiz-providers/src/tiktok.rs
  • Upstream port: tiktok.provider.ts
  • Editor: Normal
  • Connection mode: oauth

Read the gotchas first. Until the app passes TikTok’s Content Posting API audit, every post is forced to SELF_ONLY and only private accounts can post, with a 5-users-per-24 h cap. Photo posts only work with a verified URL property (domain or URL prefix). When FRONTEND_URL is not HTTPS the login goes through redirectmeto.com, and that relayed URL must be registered byte-for-byte.

Overview

PropertyValue
Identifiertiktok
Display nameTiktok (the display string in ProviderInfo)
Connection modeoauth (authorization code; no PKCE challenge)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first post is published
Pending / async publishingYes. supports_pending = true; post_pending → check_post_status; no finalize_post
AnalyticsYes. supports_analytics = true; profile counts plus the last 20 videos
Post analyticsYes. supports_post_analytics = true; views/likes/comments/shares for one video
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegtrue — PNG uploads are converted to JPEG by the engine before publishing
max_concurrent_job10000 (effectively unlimited; TikTok’s own per-user limits are the real constraint)
Character limit (max_length)2000 characters (stricter than TikTok’s 2200 video / 4000 photo-description limits)
APIhttps://open.tiktokapis.com (v2)
OAuth scopesvideo.list, user.info.basic, video.publish, video.upload, user.info.profile, user.info.stats
Token lifetime24 h; the port records 23 h (23 * 3600), but refresh is reactive (on the first expired call), not scheduled

Server Environment Variables

required_env() returns exactly two variables:

#![allow(unused)]
fn main() {
&["TIKTOK_CLIENT_ID", "TIKTOK_CLIENT_SECRET"]
}
VariableRequiredDefaultPurpose
TIKTOK_CLIENT_IDYesnoneDeveloper app Client key (16 characters in Postiz’s docs).
TIKTOK_CLIENT_SECRETYesnoneDeveloper app Client secret (32 characters in Postiz’s docs).
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the redirect URI; also decides whether redirectmeto.com is prefixed. Trailing / stripped.
TIKTOK_BASE_URLNohttps://open.tiktokapis.comAPI origin. Paths appended include /v2/oauth/token/, /v2/user/info/, /v2/post/publish/..., /v2/video/list/, /v2/video/query/, /v2/post/publish/status/fetch/, /v2/post/publish/creator_info/query/.
TIKTOK_WEB_BASE_URLNohttps://www.tiktok.comAuthorize origin /v2/auth/authorize/ and the base of release URLs.

required_env_alternatives() is not overridden.

rustiz providers --status | jq '.[] | select(.identifier=="tiktok")'

Step-by-Step Developer App Setup

1. Create the app

  1. Sign up / log in at https://developers.tiktok.com/.
  2. Create or join an organization (recommended; an individual-owned app is allowed but not recommended for production).
  3. Open https://developers.tiktok.com/apps → Connect an app, choose the owner organization.
  4. Fill in App details:
    • app icon 1024×1024, JPEG/JPG/PNG, ≤ 5 MB;
    • app name, category, description (shown on the consent screen);
    • Platforms → Web and the official website URL.
  5. Add public Terms of Service and Privacy Policy URLs on HTTPS. Apps created after 9 Sep 2024 must verify these URL properties before review.

2. Add products

  • Login Kit (Web): add the redirect URI (next section). This grants user.info.basic by default.
  • Content Posting API: add it and enable the Direct Post configuration. This is what makes /video/init/ and /content/init/ usable.
  • Optionally Display API for video.list (analytics/video queries).

3. Add the scopes

Under Scopes, add all six:

user.info.basic
user.info.profile
user.info.stats
video.list
video.publish
video.upload

video.publish is the Direct Post scope and requires approval. video.upload is the inbox/draft scope. video.list powers analytics and the “missing posts” lookup. user.info.stats is needed for follower/like counts.

The Postiz TikTok page lists video.create. That is a legacy name; TikTok’s current scope reference has video.list (read public videos), which is what the Rust code requests and checks. Request exactly the six scopes above.

4. Register the redirect URI

TikTok Login Kit requires absolute HTTPS, ≤ 512 characters, static (no query parameters), no fragment, at most 10 URIs. The Rust code builds:

{FRONTEND_URL}/integrations/social/tiktok

with one twist:

#![allow(unused)]
fn main() {
let prefix = if frontend.contains("https") { "" } else { "https://redirectmeto.com/" };
format!("{prefix}{frontend}/integrations/social/tiktok")
}
  • FRONTEND_URL=https://social.example.com → https://social.example.com/integrations/social/tiktok.
  • FRONTEND_URL=http://localhost:5000 → https://redirectmeto.com/http://localhost:5000/integrations/social/tiktok. redirectmeto.com is a public HTTPS redirector: TikTok sees an HTTPS URL, the human’s browser is then 302’d to localhost. The exact redirectmeto URL must be registered in Login Kit, and it is also what is sent to the token endpoint, so do not edit it.
  • The check is frontend.contains("https"), a substring test on the whole URL, not a scheme check. A hostname containing https would suppress the relay.
  • Localhost is not accepted by TikTok directly; use the relay or a public HTTPS tunnel/domain. Media, separately, must always be public HTTPS.

5. Verify URL properties (required for Content Posting)

In the app page → URL properties → Verify properties (Production mode):

  • Verify the website, Terms and Privacy URLs.
  • For photos and any PULL_FROM_URL video, verify the domain or URL prefix that serves your media. Domain verification uses a DNS TXT signature; URL-prefix verification uses a signature file uploaded under the prefix. Subdomains are covered by a domain verification, but a different path is not covered by a URL prefix.
  • The media URL must be https, must not redirect (3xx is invalid), and must stay reachable for up to one hour while TikTok downloads it.

If you skip this, the post fails with url_ownership_unverified or photo_pull_failed.

6. Copy credentials

App page → Credentials:

TIKTOK_CLIENT_ID="your-client-key"
TIKTOK_CLIENT_SECRET="your-client-secret"
FRONTEND_URL="https://social.example.com"

Restart Rustiz and check rustiz providers --status.

Sandbox / testing mode

TikTok for Developers has a real sandbox:

  1. On the app page, toggle Production → Sandbox.
  2. Create Sandbox (up to 5 sandboxes), optionally cloning the production configuration. Edit app details/products in the sandbox and Apply changes.
  3. Under Target users, click Add account and log in with each TikTok account you want to test with (up to 10 accounts). The account owner agrees to the Developer Terms.
  4. Authorize each target user through Login Kit in the sandbox.
  5. URL verification in a sandbox is only required for the Content Posting API; the ToS/privacy/website checks are production-review requirements.

Sandbox limitations:

  • TikTok’s sandbox is a restricted environment: expect Content Posting results to remain private (SELF_ONLY) rather than public, even after sandbox setup. It is for validating the integration/UX, not for public posting.
  • Sandbox configuration is separate; changes do not affect the live app.
  • When satisfied, import the sandbox configuration into a production draft and submit it for review.

Production app review and the Content Posting audit

These are two separate gates:

  1. App review (Products/Scopes). Submit under App review with:
    • a written explanation of how each product and scope is used;
    • at least one demo video (up to 5, ≤ 50 MB each) showing the complete end-to-end flow, including the consent screen and the posting UX. Statuses: Draft → In review → Live (or Not approved with comments). Any later change must be submitted as a revision.
  2. Content Posting API audit. This is separate from app review and is what lifts the unaudited restrictions:
    • unaudited clients may only publish SELF_ONLY;
    • every posting account must be private at posting time;
    • at most 5 users may post through the client in any 24 h window;
    • attempting a public level fails with unaudited_client_can_only_post_to_private_accounts. Apply at https://developers.tiktok.com/application/content-posting-api. The audit checks the Content Sharing Guidelines: show which account receives the post, let the creator choose privacy/interaction settings, obtain consent before upload, and never add promotional watermarks.

Other limits that surface as errors: ~15–25 posts/account/day, at most 5 pending shares in any 24 h (spam_risk_too_many_pending_share), and a daily active-user cap (reached_active_user_cap). The Rust provider maps all of these to actionable BadBody messages.

Exact OAuth scopes and checks

#![allow(unused)]
fn main() {
info.scopes = vec![
    "video.list",
    "user.info.basic",
    "video.publish",
    "video.upload",
    "user.info.profile",
    "user.info.stats",
];
}

The authorization URL joins them with commas (scope=a,b,c), URL-encoded. At token exchange check_scopes(&self.info.scopes, token["scope"]) requires all six; commas or spaces are accepted in the response. If the user declines one scope, the connection fails with not_enough_scopes even though TikTok may have issued a usable token for the others.

The token request is form-encoded and includes:

client_key, client_secret, code, grant_type=authorization_code,
code_verifier=<state>, redirect_uri=<computed redirect>

There is no PKCE challenge in the authorization URL; code_verifier is sent but is not tied to a challenge. TikTok ignores it today.

Connecting a Channel (CLI & MCP)

CLI

# Opens the TikTok consent page in the default browser.
rustiz channels connect tiktok --open

# Prints the URL to stderr and waits for the callback in-process.
rustiz channels connect tiktok

# Remote server: open the URL yourself, then poll.
rustiz channels connect-status <connectionId>

Do not pass --fields (it switches to the custom-credentials branch) or --external-url (unsupported).

In local/in-process mode the CLI serves the callback on the FRONTEND_URL port when that is http://localhost/http://127.0.0.1. With the default http://localhost:5000 the login URL is the redirectmeto URL, but the browser still ends up on localhost, so the CLI can finish the flow. A remote server must serve the callback itself.

MCP

Start:

{ "provider": "tiktok" }

Result (or a URL elicitation):

{
  "connected": false,
  "pending": true,
  "connectionId": "abc123",
  "url": "https://www.tiktok.com/v2/auth/authorize/?client_key=...&redirect_uri=...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll:

{ "connectionId": "abc123" }

is_between_steps is false: there is no channelSetupTool step. Once connected, integrationList returns the channel id. The channel’s profile field is the TikTok username, which is used to build post URLs.

Post Settings & Media Validation

settings_schema()

FieldTypeRequiredBehavior
titlestring (≤ 90)NoPhoto DIRECT_POST: sent as post_info.title, truncated to 90 chars. Photo UPLOAD: sent as the title, not truncated. Video: ignored — the post message becomes the title. Validation still enforces ≤ 90 even when it will be ignored.
privacy_levelenum PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLYYesOnly when DIRECT_POST. Defaults to PUBLIC_TO_EVERYONE at publish time if missing, but validation requires a value.
duetbooleanYesVideos only, DIRECT_POST only. Sent as disable_duet = !duet. Ignored for photos.
stitchbooleanYesVideos only, DIRECT_POST only. Sent as disable_stitch = !stitch. Ignored for photos.
commentbooleanYesBoth media types, DIRECT_POST only. Sent as disable_comment = !comment.
autoAddMusicenum "yes", "no"YesPhotos only, DIRECT_POST only. "yes" sends auto_add_music: true (TikTok picks recommended music). Ignored for videos.
brand_content_togglebooleanYesPaid-partnership disclosure; DIRECT_POST only.
brand_organic_togglebooleanYesOwn-business promotion disclosure; DIRECT_POST only.
video_made_with_aibooleanNoVideos only, DIRECT_POST only. Sent as is_aigc. Not sent for photos (TikTok’s photo endpoint has a top-level is_aigc, which this port does not use).
musicobject {id, title?, artist?, image?, audio_volume?, video_volume?}NoDeclared for TikTok Business only. Legacy tiktok.rs never reads it. music.id must be non-empty if the object is present.
locationobject {id, name, address?}NoDeclared for TikTok Business only. Legacy tiktok.rs never reads it. id and name must be non-empty.
content_posting_methodenum DIRECT_POST, UPLOADYesDIRECT_POST publishes; UPLOAD sends the media to the user’s TikTok inbox and TikTok ignores every other setting. The draft expires after 24 h if the user does not finish it.

The schema also lists privacy_level, duet, stitch, comment, autoAddMusic, brand_content_toggle, brand_organic_toggle, content_posting_method in required, and has additionalProperties: true.

The schema is duplicated in tiktok-business, so music and location appear here even though only the Business provider implements them. Passing them to tiktok is silently ignored.

validate_settings() rules

  • The whole settings value must be a JSON object.
  • title, if non-null, must be a string of at most 90 characters.
  • privacy_level is required and must be one of the four enum values.
  • duet, stitch, comment, brand_content_toggle, brand_organic_toggle are required booleans (missing or wrong type → error).
  • video_made_with_ai, if present and non-null, must be a boolean.
  • autoAddMusic is required and must be exactly "yes" or "no".
  • content_posting_method is required and must be "DIRECT_POST" or "UPLOAD".
  • music, if present and non-null, must be an object with a non-empty string id; title/artist/image must be strings when present; audio_volume/video_volume must be numbers (or numeric strings) in 0..=100.
  • location, if present and non-null, must be an object with non-empty string id and name; address must be a string when present.

The DTO requires the interaction booleans even for UPLOAD, where they are discarded. Agents must send a complete settings object.

check_validity() rules

  • No media → "No video / images selected".
  • More than one media item and any video → "Only pictures are supported when selecting multiple items".
  • More than one media item where the first is a video → "You need one media".
  • All images: every image is measured; the first whose shorter side > 1080 produces "Image N is WxH, TikTok allows a maximum of 1080px on the shorter side". This catches the all-or-nothing picture_size_check_failed before publish.

There is no upper bound on the number of images for tiktok (the Business provider checks 35). TikTok’s photo endpoint allows up to 35, so a larger carousel fails at the API with invalid_params.

Media rules and publish behavior

Videos

  • Detected by has_extension(path, "mp4") — a case-insensitive substring check. A URL without .mp4 is treated as a photo.
  • Local files and public URLs both work; the bytes are streamed from storage.
  • source_info is FILE_UPLOAD with video_size, chunk_size, total_chunk_count:
    • ≤ 64 MiB → one chunk (chunk_size = video_size, count 1);
    • 64 MiB → 10 MiB chunks and count = size / 10 MiB (floor). The final chunk absorbs the remainder and may exceed chunk_size (TikTok allows up to 128 MiB for the last part).

  • Upload PUT requests send Content-Type: video/mp4, Content-Length and Content-Range: bytes start-end/size; 200/201/206 are accepted.
  • A TikTok rejection (BadBody) fails the post. A network error during upload is treated as ambiguous: the provider returns pending and lets the status endpoint decide, because TikTok may already have all the bytes.
  • video_cover_timestamp_ms is sent when the media carries a thumbnailTimestamp.
  • TikTok’s documented limits are a maximum duration of 3, 5 or 10 minutes depending on the creator, 23–60 fps, 360–4096 px per side and ≤ 4 GB; the Rust port does not validate these.

Photos

  • Photo posts only support PULL_FROM_URL. source_info.photo_images is the list of media paths as stored (typically your public upload/CDN URLs), photo_cover_index: 0, source: "PULL_FROM_URL".
  • post_mode is DIRECT_POST or MEDIA_UPLOAD (note: not "UPLOAD").
  • The endpoint is always /v2/post/publish/content/init/.
  • Requirements: WebP or JPEG, ≤ 20 MB per image, shorter side ≤ 1080 px, up to 35 images, public HTTPS and ownership-verified. The engine’s convert_to_jpeg = true rewrites PNG uploads to JPEG first.
  • is_aigc is not sent for photos.

Endpoint selection

content_posting_methodMediaEndpoint
DIRECT_POSTvideoPOST /v2/post/publish/video/init/
DIRECT_POSTphotoPOST /v2/post/publish/content/init/
UPLOADvideoPOST /v2/post/publish/inbox/video/init/
UPLOADphotoPOST /v2/post/publish/content/init/ (post_mode: MEDIA_UPLOAD)

What the body actually sends

DIRECT_POST video:

{
  "post_info": {
    "title": "<the whole post message>",
    "privacy_level": "PUBLIC_TO_EVERYONE",
    "disable_duet": false,
    "disable_comment": false,
    "disable_stitch": true,
    "is_aigc": false,
    "brand_content_toggle": false,
    "brand_organic_toggle": true,
    "video_cover_timestamp_ms": 1234
  },
  "source_info": { "source": "FILE_UPLOAD", "video_size": 123, "chunk_size": 123, "total_chunk_count": 1 }
}

DIRECT_POST photo:

{
  "post_info": {
    "title": "<settings.title, 90 chars>",
    "description": "<the whole post message>",
    "privacy_level": "PUBLIC_TO_EVERYONE",
    "disable_comment": false,
    "auto_add_music": true,
    "brand_content_toggle": false,
    "brand_organic_toggle": true
  },
  "source_info": {
    "source": "PULL_FROM_URL",
    "photo_cover_index": 0,
    "photo_images": ["https://cdn.example/a.jpg", "https://cdn.example/b.jpg"]
  },
  "post_mode": "DIRECT_POST",
  "media_type": "PHOTO"
}

UPLOAD sends only title (video) or title + description (photo), plus the source info. Every privacy/interaction/brand/music setting is discarded by TikTok.

Status, release resolution and analytics

check_post_status makes one read-only call to POST /v2/post/publish/status/fetch/ with { "publish_id": ... }:

StatusResult
SEND_TO_USER_INBOXCompleted with post_id = "missing" and release_url = https://www.tiktok.com/messages?lang=en#<publish_id>. The draft has no public id until the user publishes it.
PUBLISH_COMPLETEIf the raw body contains a numeric publicaly_available_post_id (TikTok’s typo), that id and the profile URL with /video/<id> or /photo/<id>; otherwise the publish id and https://www.tiktok.com/@<username>. The public id only appears after moderation.
FAILEDBadBody, with handle_errors mapping known reasons.
anything else (including PROCESSING_*)Pending — the engine polls again.

resolve_release_id handles a channel whose release id was "missing":

  • the publish id is read from the URL fragment (.../messages#<publish_id>);
  • only ids containing _pub_ or _inbox_ are considered;
  • for non-drafts, SELF_ONLY and MUTUAL_FOLLOW_FRIENDS immediately return Unavailable (TikTok never exposes a public id for non-public posts);
  • if the status has a public id → Resolved; draft with none → None; non-draft with none → Pending.

release_url() queries /v2/video/query/?fields=id,share_url and strips the query string from share_url — it carries TikTok tracking parameters, including the app’s client key.

analytics():

  • /v2/user/info/?fields=follower_count,following_count,likes_count,video_count → Followers, Following, Total Likes, Videos;
  • the last 20 videos from /v2/video/list/ are queried with id,like_count,comment_count,share_count,view_count and summed into Views, Recent Likes, Recent Comments, Recent Shares.

post_analytics() queries the same fields for one video and returns Views, Likes, Comments, Shares. Analytics errors are swallowed (logged, empty array), so a missing scope looks like “no data”, not an error.

missing() returns the last 20 videos as (id, cover_image_url) so a "missing" release can be matched manually.

Plugs

Neither run_plug() nor run_post_plug() is overridden. Both use the trait defaults and return Unsupported for every method. TikTok has no auto-repost or auto-comment plug in this port (the upstream provider does not define them either).

Implementation Notes & Gotchas

  • The redirect URI is a third-party dependency when FRONTEND_URL is not HTTPS. https://redirectmeto.com/ is in the live login path. It must be reachable by the human’s browser, and the exact relayed URL must be registered in Login Kit. If redirectmeto.com is down, local/HTTP connects fail. Set an HTTPS FRONTEND_URL to remove the dependency.
  • Redirect URI substring check. frontend.contains("https") decides the relay; it is not a URL parse. A FRONTEND_URL such as http://https.localhost:5000 would skip the relay and TikTok would reject the URI.
  • expires_in is hard-coded to 23 h. TikTok access tokens live 24 h. Since refresh_cron is false, there is no proactive refresh; the first API call after expiry fails with access_token_invalid, the provider maps it to RefreshToken, the engine refreshes and retries. Refresh tokens rotate; the new one is stored.
  • open_id is normalized by removing dashes. TikTok open ids are app-scoped: the same TikTok account connected through a different client key gets a different open_id, so it becomes a separate Rustiz channel. Reusing the same app is required for continuity.
  • The open id is also the channel identity for analytics. Changing apps means reconnecting; the old channel’s analytics will not match.
  • video.list is requested even for posting. If TikTok grants video.publish/video.upload but not video.list, check_scopes refuses the connection. This is deliberate (analytics and missing need it), but it makes scope approval stricter than a publish-only integration.
  • settings.title is mostly a photo field. For videos the title is the post message, so a >90-char title setting fails validation for nothing. For photos it is truncated to 90, while the description (the message) is capped by max_length = 2000, below TikTok’s 4000.
  • music and location are dead settings on this provider. They exist in the shared schema but tiktok.rs never reads them. Use tiktok-business for commercial music and location tags.
  • autoAddMusic only applies to photos. For videos the field is validated but never sent.
  • video_made_with_ai only applies to videos. The photo endpoint accepts a top-level is_aigc, which this port does not send.
  • UPLOAD is not publishing. It creates an inbox draft that the user must finish in the TikTok app within 24 h. The status becomes SEND_TO_USER_INBOX, the release id is "missing", and settings are ignored. Use it only when the user explicitly asks to review in the app.
  • Photo posts are URL pulls, not uploads. A local UPLOAD_DIRECTORY file path is not readable by TikTok. Your media must be served over public HTTPS from a verified domain/prefix. This is the most common cause of photo_pull_failed / url_ownership_unverified.
  • Validation is shallow. No file size, duration, fps, resolution (video) or MIME check beyond the .mp4 substring and the photo shorter-side rule. TikTok rejects the publish later with file_format_check_failed, duration_check_failed, frame_rate_check_failed, etc.; handle_errors maps those to readable messages.
  • spam_risk_too_many_pending_share means 5 pending drafts in 24 h. The provider’s message tells the user to open the TikTok inbox and try again after 24 h. reached_active_user_cap (unaudited daily user cap) is mapped to Disconnect, which cannot actually help; reconnecting does not reset the cap.
  • post_url depends on integration.profile. It must contain the TikTok username (set from user/info at connect time). If it is empty, release URLs become https://www.tiktok.com/@/video/....
  • The public-id regex relies on a TikTok typo. It matches "publicaly_available_post_id" (missing the second l) in the raw response, exactly as TikTok emits it, because JSON numbers in that array can exceed 2^53 and lose precision if parsed normally.
  • Error identifier typo. The FAILED status branch uses ProviderError::bad_body("titok-error-upload", ...) (missing a t), while the upload path uses "tiktok-error-upload". Cosmetic, but it changes the error identifier in logs.
  • Status polling is single-shot by design. No loops or sleeps in check_post_status; the engine’s durable timer re-runs it. A stuck or retried check can never re-run the publish, which prevents duplicates. The blocking post() path loops 27 times / 8 minutes with 20 s sleeps.
  • Analytics swallows every non-token error. Missing scopes, rate limits and transient failures all become []. Check the logs if the analytics panel is empty.
  • Creator info is never queried before publishing. post_pending sends the configured privacy_level directly; it does not call /v2/post/publish/creator_info/query/ and does not check the account’s privacy_level_options, comment_disabled, duet_disabled or max_video_post_duration_sec. TikTok’s Direct Post UX guidelines require that query, and the API returns privacy_level_option_mismatch when the chosen level is not offered. The only caller of creator_info/query is the unexposed max_video_length(). This is also a common reason a Content Posting audit is rejected.
  • max_video_length() is not exposed. It calls /v2/post/publish/creator_info/query/ and returns max_video_post_duration_sec, but TikTok does not override functions(), so triggerTool cannot invoke it. It is only reachable from Rust code.
  • Length checks count Unicode scalar values, not UTF-16 code units. The engine’s max_length check and take_chars count chars, while TikTok’s photo title limit is 90 UTF-16 runes. A title made of emoji (2 code units each) can pass validate_settings at 90 characters and still exceed TikTok’s 90-rune limit. The same applies to the 2000-character message check versus TikTok’s 2200/4000 UTF-16 limits.
  • Rate limits. The Content Posting API allows 6 requests per minute per user access token; status checks are a separate, higher per-endpoint limit (commonly 30/min). The blocking path polls every 20 s (3/min) and stays under both. TikTok also caps daily posts per account (~15–25) and pending drafts (5 per 24 h).
  • convert_to_jpeg only rewrites PNGs. prepare_post_media converts PNG files to JPEG and re-stores them when UPLOAD_DIRECTORY is set; other formats are passed through. TikTok’s accepted photo formats are WebP and JPEG, so a GIF photo post will fail at TikTok even though validation accepts it.

References