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 Business (tiktok-business)

tiktok-business uses the TikTok API for Business — TikTok Account / Business Account surface, not the TikTok for Developers Login Kit surface. It posts to a TikTok Business Account through https://business-api.tiktok.com/open_api/v1.3.

Everything is different from tiktok: a separate developer app and credentials, a separate OAuth token endpoint that accepts JSON, Access-Token headers instead of Authorization: Bearer, response envelopes that report failures as HTTP 200 with a non-zero code, and a redirect URI that must end with a slash.

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

Critical deployment note. TikTok Business requires the registered redirect URL to end in /. The Rust code therefore builds .../integrations/social/tiktok-business/. The Rust server registers only /integrations/social/{provider} (no trailing slash), and axum/matchit match paths exactly — so the callback to .../tiktok-business/?auth_code=... has no matching route and returns 404. Put a reverse-proxy rewrite in front (strip the trailing slash before forwarding) or add a route alias before relying on this provider. See Implementation Notes & Gotchas.

Overview

PropertyValue
Identifiertiktok-business
Display nameTikTok (the ProviderInfo name is literally “TikTok”; use the identifier to tell it apart)
Connection modeoauth (authorization code; no PKCE)
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; one video’s views/likes/comments/shares
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegtrue
max_concurrent_job10000
Character limit (max_length)2200 characters (Business video caption; photo captions allow 4000, but the port uses 2200 for both)
APIhttps://business-api.tiktok.com/open_api/v1.3
OAuth scopesuser.info.basic, user.info.username, user.info.stats, video.list, video.upload, video.publish
Token lifetime24 h access token, 1 year refresh token; the port records 23 h

Server Environment Variables

required_env() returns exactly two variables:

#![allow(unused)]
fn main() {
&["TIKTOK_BUSINESS_CLIENT_ID", "TIKTOK_BUSINESS_CLIENT_SECRET"]
}
VariableRequiredDefaultPurpose
TIKTOK_BUSINESS_CLIENT_IDYesnoneBusiness developer app Client ID.
TIKTOK_BUSINESS_CLIENT_SECRETYesnoneBusiness developer app Client secret.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the trailing-slash redirect URI; decides whether redirectmeto.com is prefixed. Trailing / stripped before the provider path and final slash are appended.
TIKTOK_BUSINESS_BASE_URLNohttps://business-api.tiktok.com/open_api/v1.3API origin including the /open_api/v1.3 prefix. The provider appends /business/..., /tt_user/oauth2/..., /discovery/....
TIKTOK_BUSINESS_WEB_BASE_URLNohttps://www.tiktok.comAuthorize origin (/v2/auth/authorize/) and release-URL base.

These are deliberately not shared with tiktok: a Business app cannot be used with the Login Kit provider, and vice versa.

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

Step-by-Step Developer App Setup

1. Business account and developer registration

  1. Create a TikTok for Business account at https://business.tiktok.com/ (the account whose organic posts you will publish).
  2. Register as a developer in the TikTok API for Business portal at https://business-api.tiktok.com/portal. The Business API’s own guide gives the order: create a TikTok for Business account → register as a developer → create a developer app → authorization. Use a Business app here; do not reuse the consumer Login Kit app’s credentials.
  3. Create an organization/company profile if the portal asks for one. App ownership should be the organization, not a personal developer account.

2. Create the developer app

  1. My Apps → Create app / Connect an app.
  2. Fill in app name, description, website, privacy policy and Terms of Service URLs (HTTPS, public, verified).
  3. Add the TikTok Account / Business Account capability (the organic posting product; portal labels vary between “TikTok Account”, “Business Account” and “Accounts API”). The endpoints used by this provider are:
    • /business/get/ — profile information;
    • /business/video/list/ — video list and per-video metrics;
    • /business/video/publish/ — video publish;
    • /business/photo/publish/ — photo publish;
    • /business/publish/status/ — publish status;
    • /business/publish/location/ — location search;
    • /discovery/cml/trending_list/ — commercial music library.
  4. Add the six scopes from the next section. video.publish and video.upload are the posting scopes; video.list and user.info.* power analytics and the account profile.
  5. Submit the app for review. Depending on the data you access, TikTok may also require its Data security and privacy review (and, for US data, the US data security review). Approval is not instant.

3. Configure the TikTok account holder redirect URL

This is the step that differs most from Login Kit. In My Apps → App Detail → Basic Information, the portal shows a TikTok account holder authorization URL derived from the redirect URL you register. TikTok’s documented rules:

RuleValue
Absolute and ends with /https://dev.example.com/auth/callback/
Static; query parameters denied.../callback/?id=1 is invalid
No fragment/#.../callback/#1 is invalid
Must start with https://http://... is invalid
No porthttps://dev.example.com:3000/... is invalid
Length 10–512 characters
Up to 10 URLs per app, one active at a timeactivating a URL regenerates the authorization URL

Rustiz builds exactly:

{prefix}{FRONTEND_URL}/integrations/social/tiktok-business/

where prefix is empty for an HTTPS FRONTEND_URL, or https://redirectmeto.com/ otherwise. Examples:

https://social.example.com/integrations/social/tiktok-business/
https://redirectmeto.com/http://localhost:5000/integrations/social/tiktok-business/

Register the exact string, including the final /. The same string is sent to /tt_user/oauth2/token/ as redirect_uri, so a mismatch produces TikTok’s redirect_uri error.

Do not use an HTTPS FRONTEND_URL with a port. The Business redirect rules forbid ports, so https://social.example.com:8443/... is rejected by the portal. Terminate TLS on 443 in front of Rustiz.

4. Copy credentials and configure Rustiz

From the app’s Basic Information / Credentials:

TIKTOK_BUSINESS_CLIENT_ID="your-client-id"
TIKTOK_BUSINESS_CLIENT_SECRET="your-client-secret"
FRONTEND_URL="https://social.example.com"

Restart, then check rustiz providers --status.

Sandbox / testing mode

  • The TikTok API for Business sandbox accounts you may find in the portal are ad-account sandboxes for the Marketing API, not an organic posting sandbox. TikTok has announced they will be deprecated by 1 December 2026 and recommends creating your own test account in production.
  • There is no separate sandbox for the TikTok Account/Business Account endpoints used here. The practical test path is:
    1. get the app and the six scopes approved;
    2. connect your own Business Account;
    3. post with content_posting_method: "UPLOAD" first (a private draft in the account’s inbox) to validate the pipeline without publishing;
    4. then test DIRECT_POST.
  • Use a secondary Business Account if you do not want test content on the main profile. The API has no “test post” flag.

Production app review

  • The app must be approved for the TikTok Account capability and all six scopes. Unapproved scopes fail at authorize time or at the first API call.
  • Expect the Business API’s data security and privacy review for production access; the portal’s guide lists it as a distinct step after app creation.
  • Unlike the Login Kit Content Posting API, this provider has no separate “audit to lift SELF_ONLY” gate in the code. Visibility comes from the Business Account and the privacy_level you send for photos; videos have no privacy field at all and use the account default.
  • Re-review is required for changes to a live app, as with any TikTok app.

Exact OAuth scopes and checks

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

The authorization URL joins them with commas. At token exchange the provider passes response["data"]["scope"] to check_scopes; all six are required, and the helper accepts either comma- or space-separated values. user.info.username is what populates the channel’s username/profile, which is used in release URLs.

The authorization URL is built on TIKTOK_BUSINESS_WEB_BASE_URL (default https://www.tiktok.com):

{web}/v2/auth/authorize/?client_key=<id>&redirect_uri=<encoded>&state=<16 chars>
  &response_type=code&disable_auto_auth=1&scope=a,b,c

Note disable_auto_auth=1: the consent page is always shown, even for a member with an existing grant. The token exchange posts JSON to {base}/tt_user/oauth2/token/:

{
  "client_id": "...",
  "client_secret": "...",
  "grant_type": "authorization_code",
  "auth_code": "...",
  "redirect_uri": ".../integrations/social/tiktok-business/"
}

authenticate() rejects an empty auth_code before calling TikTok, because a declined consent otherwise surfaces as TikTok’s unhelpful Please provide the auth_code (code 40002).

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect tiktok-business --open
rustiz channels connect tiktok-business
rustiz channels connect-status <connectionId>

Do not pass --fields or --external-url.

Because of the trailing-slash route mismatch described at the top, a direct rustiz serve / rustiz mcp deployment will receive /integrations/social/tiktok-business/?auth_code=... as a 404. Before testing:

  • put a rewrite in the reverse proxy, for example Caddy:

    rewrite /integrations/social/tiktok-business/ /integrations/social/tiktok-business
    

    (or equivalent nginx: rewrite ^/integrations/social/tiktok-business/$ /integrations/social/tiktok-business break;), while leaving the outward-facing redirect URI with the slash; or

  • add a server route alias for the trailing-slash path.

The token exchange still uses the trailing-slash redirect_uri, which is what TikTok validates, so the proxy rewrite only affects the local route.

MCP

Start:

{ "provider": "tiktok-business" }

Poll:

{ "connectionId": "abc123" }

There is no channelSetupTool step (is_between_steps = false). Once connected, integrationList returns the channel; its profile is the TikTok username and its internal id is the Business open_id (also the business_id for every API call).

Post Settings & Media Validation

settings_schema() and validate_settings() are duplicated from the TikTok provider and are identical (see tiktok.md), including the music and location fields, which this provider actually implements.

settings_schema()

FieldTypeRequiredBusiness behavior
titlestring (≤ 90)NoPhotos only: post_info.title, truncated to 90. Videos have no title; the caption is post_info.caption.
privacy_levelenum PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLYYesPhotos only. Videos have no privacy field and publish with the account default. Defaults to PUBLIC_TO_EVERYONE if omitted at publish time, but validation requires it.
duetbooleanYesVideos, DIRECT_POST only → disable_duet = !duet.
stitchbooleanYesVideos, DIRECT_POST only → disable_stitch = !stitch.
commentbooleanYesBoth media types, DIRECT_POST only → disable_comment = !comment.
autoAddMusic"yes" / "no"YesPhotos, DIRECT_POST only. "yes" attaches a random commercial music library track (Rustiz picks it, not TikTok) and overrides an explicit music selection. If no track can be found it falls back to auto_add_music: true.
brand_content_togglebooleanYesSent as is_branded_content for both media types.
brand_organic_togglebooleanYesSent as is_brand_organic for both media types.
video_made_with_aibooleanNoVideos, DIRECT_POST only → is_ai_generated. Not supported for photos.
musicobject {id, title?, artist?, image?, audio_volume?, video_volume?}Nomusic.id is a commercial-library track from musicSearch. Videos: music_sound_info with music_sound_id, music_sound_volume, video_original_sound_volume (defaults 50, not the API’s 0/muted). Photos: music_sound_info with only music_sound_id; ignored when autoAddMusic is "yes".
locationobject {id, name, address?}Nolocation_id + location_name from locationSearch. Photos and videos, DIRECT_POST only.
content_posting_methodDIRECT_POST / UPLOADYesUPLOAD saves a draft (upload_to_draft for video, is_draft for photo) and TikTok ignores every other setting.

validate_settings() rules

Same as tiktok:

  • object required;
  • title ≤ 90 characters when present;
  • privacy_level required and one of the four values;
  • duet, stitch, comment, brand_content_toggle, brand_organic_toggle required booleans;
  • video_made_with_ai boolean when present;
  • autoAddMusic required, exactly "yes"/"no";
  • content_posting_method required, DIRECT_POST/UPLOAD;
  • music.id non-empty; music strings typed; volumes numeric or numeric strings in 0..=100;
  • location.id and location.name non-empty; address a string when present.

check_validity() rules

  • No media → "No video / images selected".
  • More than one media with any video → "Only pictures are supported when selecting multiple items".
  • More than one media whose first item is a video → "You need one media".
  • More than 35 images → "You can select up to 35 pictures" (Business-only check; the legacy TikTok provider does not enforce it).
  • All-image posts: each image is measured; the first with a shorter side > 1080 px → "Image N is WxH, TikTok allows a maximum of 1080px on the shorter side".

Media and publish behavior

Videos — POST /business/video/publish/:

{
  "business_id": "<open_id>",
  "video_url": "https://cdn.example/video.mp4",
  "custom_thumbnail_url": "https://cdn.example/thumb.jpg",
  "post_info": {
    "caption": "the post message",
    "is_brand_organic": true,
    "is_branded_content": false,
    "disable_comment": false,
    "disable_duet": false,
    "disable_stitch": true,
    "is_ai_generated": true,
    "location_id": "...",
    "location_name": "...",
    "music_sound_info": {
      "music_sound_id": "...",
      "music_sound_volume": 50,
      "video_original_sound_volume": 50
    }
  }
}
  • video_url is a URL, not a file upload. There is no FILE_UPLOAD path in the Business API: a local filesystem path will not work. Serve the video from a public HTTPS URL.
  • Thumbnail precedence: custom_thumbnail_url (from media.thumbnail) wins; otherwise thumbnail_offset (from media.thumbnail_timestamp, only when non-zero).
  • upload_to_draft: true replaces all the interaction fields for UPLOAD.
  • No privacy_level. Do not expect the photo privacy setting to affect videos; the account default applies.

Photos — POST /business/photo/publish/:

{
  "business_id": "<open_id>",
  "photo_images": ["https://cdn.example/a.jpg", "https://cdn.example/b.jpg"],
  "photo_cover_index": 0,
  "post_info": {
    "title": "a title",
    "caption": "the post message",
    "is_brand_organic": true,
    "is_branded_content": false,
    "privacy_level": "PUBLIC_TO_EVERYONE",
    "disable_comment": false,
    "auto_add_music": false,
    "location_id": "...",
    "location_name": "...",
    "music_sound_info": { "music_sound_id": "..." }
  }
}
  • Cover is always the first image (photo_cover_index: 0); there is no setting to choose another.
  • auto_add_music is only a fallback: if a random track was found, the flag is false and music_sound_info carries the chosen track.
  • is_draft: true replaces the interaction fields for UPLOAD.

Media requirements

  • Videos: MP4/H.264 recommended; 23–60 fps; 360–4096 px per side; maximum duration depends on the account (3/5/10 min). The file must be a public HTTPS URL; the Business API downloads it (video_url), there is no byte upload.
  • Photos: JPEG or WebP; ≤ 20 MB each; shorter side ≤ 1080 px; up to 35 images; public HTTPS URLs in photo_images. The engine’s convert_to_jpeg = true rewrites PNG uploads to JPEG.
  • The media host must be reachable by TikTok’s servers without redirects. A signed URL is fine if it stays valid for the download window.

Publish response and status

  • A publish failure is an HTTP 200 body with code != 0 or a missing data.share_id. The provider classifies it itself: RefreshToken/Disconnect become provider errors; everything else becomes BadBody with TikTok’s message.
  • post_pending stores { "publishId": <share_id> }.
  • check_post_status calls GET /business/publish/status/?business_id=<open_id>&publish_id=<share_id>:
    • SEND_TO_USER_INBOX → Completed with post_id = "missing" and https://www.tiktok.com/messages?lang=en#<share_id>;
    • PUBLISH_COMPLETE → numeric post_ids[0] from the raw body (regex, to avoid int64 precision loss) → https://www.tiktok.com/@<username>/video|photo/<id>; if absent → share id and https://www.tiktok.com/@<username>;
    • FAILED → BadBody using reason + the envelope;
    • anything else → Pending.
  • resolve_release_id only handles ids containing _pub_/_inbox_; for non-drafts whose publish id starts with p_pub_ and privacy is SELF_ONLY or MUTUAL_FOLLOW_FRIENDS, it returns Unavailable without a call.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
musicSearchgenre? (e.g. POP, ROCK, ELECTRONIC)[{id, title, artist, image, duration, previewUrl}]Calls /discovery/cml/trending_list/?business_id=...&genre=.... There is no text query; filter locally. duration is returned in milliseconds (TikTok’s seconds × 1000). Non-token errors return []; token errors surface as RefreshToken.
locationSearchq (required, truncated to 100 chars)[{id, name, address}]Calls /business/publish/location/?business_id=...&search_query=.... Empty q returns []. Non-token errors return [].

MCP:

{
  "integrationId": "int_...",
  "methodName": "locationSearch",
  "dataSchema": [{ "key": "q", "value": "Milan" }]
}

Use the returned id/name in the location setting and the returned track id in music.

Analytics

  • analytics():
    • /business/get/?fields=["followers_count","following_count","total_likes","videos_count"] → Followers, Following, Total Likes, Videos;
    • the last 20 videos from /business/video/list/ with item_id,likes,comments,shares,video_views → Views, Recent Likes, Recent Comments, Recent Shares (summed).
  • post_analytics() reuses /business/video/list/ with a filters={"video_ids":[post_id]} and returns Views, Likes, Comments, Shares.
  • missing() lists the last 20 videos with item_id,thumbnail_url.
  • Token errors (RefreshToken/Disconnect) surface from analytics, so the caller can refresh and retry. All other errors are logged and returned as [].

Plugs

Neither run_plug() nor run_post_plug() is overridden. Both use the trait defaults and return Unsupported for every method. There is no auto-repost or auto-comment plug for TikTok Business in this port.

Implementation Notes & Gotchas

  • Trailing-slash redirect vs. the Rust route (deployment blocker). The code builds .../tiktok-business/ because TikTok Business requires it. The server route is /integrations/social/{provider} with no alias. Matchit 0.8 is exact (/path/foo/{arg} does not match /path/foo/myarg/), so the callback 404s. Fix it at the proxy or add a route. This also affects the CLI’s in-process callback server, which uses the same router.
  • FRONTEND_URL with an HTTPS port is invalid for Business. The redirect rules forbid ports. https://host:8443/... is rejected when registering the URL. Use 443.
  • redirectmeto.com still applies for HTTP FRONTEND_URLs. The computed URL is https://redirectmeto.com/http://localhost:5000/integrations/social/tiktok-business/. The port is inside the relayed path, so the redirect URI itself has no port and satisfies TikTok’s rule. Register it exactly.
  • Every API failure is HTTP 200. handle_errors is driven by the body, and each call site checks body["code"] == 0. Do not rely on HTTP status codes in logs; read code/message.
  • business_id is the open_id, verbatim. details() deliberately does not strip dashes (unlike tiktok, which removes them) because the value is reused as business_id on every call. Changing the app changes the open_id and creates a new channel.
  • Token refresh is JSON, not form-encoded. POST /tt_user/oauth2/refresh_token/ with client_id, client_secret, grant_type=refresh_token, refresh_token. Refresh tokens last a year but rotate; the response’s open_id may be used to keep the identity. expires_in is hard-coded to 23 h; refresh_cron is false, so refresh is reactive on the first expired call.
  • TIKTOK_BUSINESS_BASE_URL must include /open_api/v1.3. The provider appends paths beginning with /business/... and /tt_user/...; setting it to just https://business-api.tiktok.com produces 404s.
  • TIKTOK_BUSINESS_WEB_BASE_URL is the authorize host, not the API host. Default https://www.tiktok.com. Overriding it also changes release-URL bases and the messages URL.
  • No creator-info query. The provider sends the chosen privacy_level (photos only) and interaction flags without asking TikTok which options the account supports. If the account does not offer the level, the publish fails with privacy_level_option_mismatch (or the Business equivalent) rather than being corrected up front.
  • No video privacy control. privacy_level is a photo-only field on this API. A “private” video setting in the UI does nothing; the account default applies. Plan tests accordingly.
  • Photo captions allow 4000, the port allows 2200. max_length is shared with the video-caption limit, so longer photo captions are rejected by Rustiz before TikTok sees them. The title is truncated to 90; the caption is not truncated, just length-checked.
  • autoAddMusic = "yes" overrides music. This is intentional: the UI hides the track picker when random music is on, so a stale selection must not win. For photos, a random commercial track is attached; for videos the setting is ignored entirely.
  • Music volumes default to 50. The Business API defaults both volumes to 0 (muted); the provider follows the TikTok app’s default of 50 instead, so selecting a track without volumes does not produce a silent video. Precisely: a missing key becomes 50, explicit null/false becomes 0, and an unparsable value or an array/object becomes JSON null (JSON has no NaN).
  • musicSearch is trending-only. No free-text search; genre is the only filter. duration is multiplied by 1000 and may be 0 for a clip with no duration.
  • locationSearch truncates the query to 100 characters and returns [] for an empty query or a non-token API error.
  • video_url / photo_images must be publicly fetchable. Unlike the legacy provider’s FILE_UPLOAD, the Business API pulls media server-side. Local paths, localhost URLs and private bucket URLs fail with video_pull_failed/photo_pull_failed or url_ownership_unverified.
  • Draft field names differ by media type. Video uses upload_to_draft; photo uses is_draft. Both discard the interaction/privacy/music/location fields.
  • thumbnail_offset is omitted when 0. custom_thumbnail_url wins over the timestamp. If neither exists, TikTok picks a frame.
  • The 35-image limit is Business-only. The legacy provider’s validation skips it, so a 36-image legacy photo post is rejected later by TikTok.
  • Status PUBLISH_COMPLETE can arrive before post_ids. The public id may lag up to ~3 minutes, and non-public posts never get one. The provider falls back to the profile URL and keeps the share id. resolve_release_id can fill the id in later.
  • Error identifier. Publish failures use "tiktok-business-error-upload"; token/API errors elsewhere use "tiktok-business".
  • Analytics token errors are not swallowed. Non-token errors become [], but RefreshToken/Disconnect propagate so the engine refreshes once and retries. The legacy provider swallows everything instead.
  • Length checks count Unicode scalar values, not UTF-16 code units. The title/caption checks count chars; TikTok’s photo title is 90 UTF-16 runes and the photo description is 4000. An emoji-heavy title/caption can exceed TikTok’s count while passing Rustiz.
  • Rate limits are per app and per endpoint. TikTok returns rate_limit_exceeded or Too many requests; the provider maps them to a BadBody “TikTok API rate limit exceeded” message. The Content Posting API’s 6/min-per-user rule does not apply to this Business surface; the Business portal’s global/endpoint limits do.
  • Business sandbox accounts are for ads. They do not create a sandbox for organic posting, and TikTok plans to deprecate them on 1 Dec 2026.

References