Skip to main content
Before you start. TikTok gates the Direct Post (video.publish) scope behind a separate App Review. Until that clears, posts go to the upload inbox with privacy=SELF_ONLY and the user manually confirms publish in the TikTok app. Public posting unlocks the moment review is approved — no code change needed; letmepost re-reads the privacy allowlist on every token refresh.

Quick reference

Limit / capabilityValue
Caption (text)≤ 2,200 characters
Hashtag count≤ 100
Media items per post1 video (no photo posts on unaudited apps)
Video formatsmp4, mov, mpeg, avi, flv, webm
Video size≤ 4,000,000,000 bytes (4 GB push_by_file)
Video duration3 s minimum, 10 min maximum (audit accounts typically capped to 60 s)
Video resolution≥ 540 px on short edge (1080 x 1920 recommended)
Aspect ratio9:16 preferred; 1:1 and 16:9 accepted with a warning
Privacypublic_to_everyone, mutual_follow_friend, self_only — forced to self_only on audit accounts
Branded contentbrandContentToggle OR brandOrganicToggle (mutually exclusive)
Post types supportedsingle video
SchedulingYes (via scheduledAt)
Reply / first commentNot supported in v1
Inbox / DMNot supported in v1
AnalyticsNot supported in v1

Connect an account

POST /v1/accounts/connect/tiktok. OAuth 2.0 with PKCE. After the callback the provider:
  1. Calls GET /v2/user/info/ to pin platformAccountId to the real TikTok open_id.
  2. Calls POST /v2/post/publish/creator_info/query/ to snapshot the privacy allowlist; accounts with only SELF_ONLY are flagged as auditState: "audit" and the publisher forces SELF_ONLY on every post until that flips.

Scopes

user.info.basic     — required to mint platform_account_id at connect
video.upload        — upload-inbox path (push_by_file)
Extended (not requested by default): video.publish (Direct Post), video.list.

Token lifecycle

24-hour access tokens; 365-day refresh tokens that roll on every use. The provider refreshes 1 hour before expiry; subscribe to token.expiring to know when a re-auth window is coming.

Post a video

tiktok-video-post.json
{
  "targets": [
    {
      "accountId": "...",
      "options": {
        "platform": "tiktok",
        "privacy": "self_only",
        "disableComment": false,
        "disableDuet": false,
        "disableStitch": false
      }
    }
  ],
  "text": "Walkthrough #demo",
  "media": [{ "kind": "video", "mediaId": "med_…" }]
}

Privacy

privacy accepts three values: public_to_everyone, mutual_follow_friend, self_only. Audit / sandbox accounts always end up as SELF_ONLY regardless of what you pass — letmepost rewrites the request and attaches a tiktok.audit.self_only warning so the caller knows we did so.

Branded content

brandContentToggle: true flags the post as a paid partnership. brandOrganicToggle: true flags it as content for your own brand. TikTok rejects requests that set both — preflight catches it with tiktok.branded_content.mutual_exclusive.

Code samples

curl https://api.letmepost.dev/v1/posts \
  -H "Authorization: Bearer lmp_live_…" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "targets": [
      {
        "accountId": "acc_…",
        "options": { "platform": "tiktok", "privacy": "self_only" }
      }
    ],
    "text": "Walkthrough #demo",
    "media": [{ "kind": "video", "mediaId": "med_…" }]
  }'
import { Letmepost } from "@letmepost/sdk";

const lmp = new Letmepost({ apiKey: process.env.LETMEPOST_API_KEY! });

await lmp.posts.create({
  targets: [
    {
      accountId: "acc_…",
      options: { platform: "tiktok", privacy: "self_only" },
    },
  ],
  text: "Walkthrough #demo",
  media: [{ kind: "video", mediaId: "med_…" }],
});
from letmepost import Letmepost

lmp = Letmepost(api_key=os.environ["LETMEPOST_API_KEY"])

lmp.posts.create(
    targets=[
        {
            "accountId": "acc_…",
            "options": {"platform": "tiktok", "privacy": "self_only"},
        }
    ],
    text="Walkthrough #demo",
    media=[{"kind": "video", "mediaId": "med_…"}],
)
client := letmepost.New(os.Getenv("LETMEPOST_API_KEY"))

client.Posts.Create(ctx, letmepost.CreatePostRequest{
    Targets: []letmepost.PostTarget{{
        AccountID: "acc_…",
        Options: &letmepost.TargetOptions{
            Platform: "tiktok",
            Privacy:  "self_only",
        },
    }},
    Text:  "Walkthrough #demo",
    Media: []letmepost.MediaInput{{Kind: "video", MediaID: "med_…"}},
})

How publishing works under the hood

TikTok’s Content Posting API is asynchronous. The publisher:
  1. Calls POST /v2/post/publish/inbox/video/init/ with source: "FILE_UPLOAD", video_size, chunk_size, total_chunk_count. Returns upload_url + publish_id.
  2. PUTs the video bytes to upload_url. Files under 64 MiB go in one request; larger files chunk at 10 MiB with explicit Content-Range headers.
  3. Returns status: "publishing" with the publish_id stamped on cid so the caller can correlate by id.
  4. A BullMQ worker polls POST /v2/post/publish/status/fetch/ on a bucketed schedule (5 s → 30 s → 2 min) until TikTok reaches a terminal state.
  5. Terminal PUBLISH_COMPLETEpost.published webhook with the public TikTok URL. Terminal FAILEDpost.failed with the upstream reason. The 30-minute deadline turns into post.failed with tiktok.publish.pending.
The SEND_TO_USER_INBOX state is also treated as terminal-published — TikTok’s upload-inbox flow IS the documented end-state for audit-mode posts. The user confirms publish in their TikTok app; we have no API signal for that final tap.

Wisdom (platform-specific things that bite)

  • Audit / sandbox accounts can only post SELF_ONLY. letmepost reads the privacy allowlist via creator_info at connect time and on every refresh; SELF_ONLY-only accounts are flagged auditState: "audit" and every post is rewritten regardless of the caller’s intent. A warning fires so you know we did so.
  • The Direct Post (video.publish) scope is a separate App Review. Without it, posts go to the user’s TikTok inbox for manual confirmation. There’s no API signal for the user tapping publish, so the worker treats SEND_TO_USER_INBOX as terminal-published.
  • ffprobe is needed for duration / resolution preflight. Without ffprobe installed locally, letmepost emits a tiktok.video.probe_unavailable warning and lets the upload proceed; TikTok’s own preflight will catch resolution issues at publish time.
  • Aspect ratio 9:16 is strongly preferred. 1:1 and 16:9 work but emit tiktok.video.aspect_non_vertical. Anything else trips tiktok.video.aspect_unusual.
  • Refresh tokens roll on every use. TikTok issues a new refresh_token on each refresh; letmepost stores it. If you bypass the refresh worker and call the upstream endpoint yourself, persist the new refresh token — discarding it silently shortens the next refresh window.
  • push_by_file only. pull_by_url requires media-domain verification on developer.tiktokapis.com which letmepost has not completed; v1 uploads always go through the chunked PUT path.

Common errors

Error ruleWhat it meansHow to fix
tiktok.media.requiredNo media on the postAttach media: [{ kind: "video", … }].
tiktok.media.single_onlyMore than one media itemSend a single video item.
tiktok.media.video_requiredNon-video media (e.g. image)TikTok unaudited apps only support video posts.
tiktok.text.max_charsCaption > 2,200 charactersShorten the caption.
tiktok.text.hashtag_maxMore than 100 hashtags in captionReduce hashtag count.
tiktok.file_format.invalidVideo mime not in TikTok’s allowlistUse mp4, mov, mpeg, avi, flv, or webm.
tiktok.video.size_maxVideo > 4 GBCompress under 4 GB.
tiktok.video.too_shortDuration < 3 s (detected via ffprobe)Use a video at least 3 s long.
tiktok.video.too_longDuration > 10 minTrim to 10 min or under.
tiktok.resolution.unsupportedShort edge < 540 pxUse 1080 x 1920 (or any 540+ short-edge resolution).
tiktok.audit.self_only (warning)Privacy rewritten to SELF_ONLY on audit accountWait for video.publish review approval to post publicly.
tiktok.privacy.not_allowedRequested privacy not in account allowlistUse one of the privacy levels surfaced in the error.
tiktok.branded_content.mutual_exclusiveBoth brand toggles setSet exactly one of brandContentToggle / brandOrganicToggle.
tiktok.publish.failedTikTok rejected the upload asyncCheck fail_reason on the webhook payload.
tiktok.publish.pendingPoll deadline exceeded (30 min)The upload may still finish in the user’s TikTok inbox; re-check via the app.

What you can’t do (yet)

  • Photo carousels (unaudited apps).
  • Direct Post (public publishing without user confirmation) until video.publish clears review.
  • pull_by_url uploads (domain verification gate).
  • Reading post engagement, comments, or analytics.
  • TikTok Live, Shop, DMs.

API reference