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
| Property | Value |
|---|---|
| Identifier | tiktok-business |
| Display name | TikTok (the ProviderInfo name is literally “TikTok”; use the identifier to tell it apart) |
| Connection mode | oauth (authorization code; no PKCE) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first post is published |
| Pending / async publishing | Yes. supports_pending = true; post_pending → check_post_status; no finalize_post |
| Analytics | Yes. supports_analytics = true; profile counts plus the last 20 videos |
| Post analytics | Yes. supports_post_analytics = true; one video’s views/likes/comments/shares |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | true |
max_concurrent_job | 10000 |
Character limit (max_length) | 2200 characters (Business video caption; photo captions allow 4000, but the port uses 2200 for both) |
| API | https://business-api.tiktok.com/open_api/v1.3 |
| OAuth scopes | user.info.basic, user.info.username, user.info.stats, video.list, video.upload, video.publish |
| Token lifetime | 24 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"]
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
TIKTOK_BUSINESS_CLIENT_ID | Yes | none | Business developer app Client ID. |
TIKTOK_BUSINESS_CLIENT_SECRET | Yes | none | Business developer app Client secret. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds 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_URL | No | https://business-api.tiktok.com/open_api/v1.3 | API origin including the /open_api/v1.3 prefix. The provider appends /business/..., /tt_user/oauth2/..., /discovery/.... |
TIKTOK_BUSINESS_WEB_BASE_URL | No | https://www.tiktok.com | Authorize 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
- Create a TikTok for Business account at https://business.tiktok.com/ (the account whose organic posts you will publish).
- 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.
- 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
- My Apps → Create app / Connect an app.
- Fill in app name, description, website, privacy policy and Terms of Service URLs (HTTPS, public, verified).
- 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.
- Add the six scopes from the next section.
video.publishandvideo.uploadare the posting scopes;video.listanduser.info.*power analytics and the account profile. - 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:
| Rule | Value |
|---|---|
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 port | https://dev.example.com:3000/... is invalid |
| Length 10–512 characters | |
| Up to 10 URLs per app, one active at a time | activating 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_URLwith a port. The Business redirect rules forbid ports, sohttps://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:
- get the app and the six scopes approved;
- connect your own Business Account;
- post with
content_posting_method: "UPLOAD"first (a private draft in the account’s inbox) to validate the pipeline without publishing; - 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_levelyou 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()
| Field | Type | Required | Business behavior |
|---|---|---|---|
title | string (≤ 90) | No | Photos only: post_info.title, truncated to 90. Videos have no title; the caption is post_info.caption. |
privacy_level | enum PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY | Yes | Photos 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. |
duet | boolean | Yes | Videos, DIRECT_POST only → disable_duet = !duet. |
stitch | boolean | Yes | Videos, DIRECT_POST only → disable_stitch = !stitch. |
comment | boolean | Yes | Both media types, DIRECT_POST only → disable_comment = !comment. |
autoAddMusic | "yes" / "no" | Yes | Photos, 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_toggle | boolean | Yes | Sent as is_branded_content for both media types. |
brand_organic_toggle | boolean | Yes | Sent as is_brand_organic for both media types. |
video_made_with_ai | boolean | No | Videos, DIRECT_POST only → is_ai_generated. Not supported for photos. |
music | object {id, title?, artist?, image?, audio_volume?, video_volume?} | No | music.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". |
location | object {id, name, address?} | No | location_id + location_name from locationSearch. Photos and videos, DIRECT_POST only. |
content_posting_method | DIRECT_POST / UPLOAD | Yes | UPLOAD 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_levelrequired and one of the four values;duet,stitch,comment,brand_content_toggle,brand_organic_togglerequired booleans;video_made_with_aiboolean when present;autoAddMusicrequired, exactly"yes"/"no";content_posting_methodrequired,DIRECT_POST/UPLOAD;music.idnon-empty; music strings typed; volumes numeric or numeric strings in0..=100;location.idandlocation.namenon-empty;addressa 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_urlis a URL, not a file upload. There is noFILE_UPLOADpath in the Business API: a local filesystem path will not work. Serve the video from a public HTTPS URL.- Thumbnail precedence:
custom_thumbnail_url(frommedia.thumbnail) wins; otherwisethumbnail_offset(frommedia.thumbnail_timestamp, only when non-zero). upload_to_draft: truereplaces all the interaction fields forUPLOAD.- 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_musicis only a fallback: if a random track was found, the flag is false andmusic_sound_infocarries the chosen track.is_draft: truereplaces the interaction fields forUPLOAD.
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’sconvert_to_jpeg = truerewrites 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 != 0or a missingdata.share_id. The provider classifies it itself:RefreshToken/Disconnectbecome provider errors; everything else becomesBadBodywith TikTok’s message. post_pendingstores{ "publishId": <share_id> }.check_post_statuscallsGET /business/publish/status/?business_id=<open_id>&publish_id=<share_id>:SEND_TO_USER_INBOX→Completedwithpost_id = "missing"andhttps://www.tiktok.com/messages?lang=en#<share_id>;PUBLISH_COMPLETE→ numericpost_ids[0]from the raw body (regex, to avoid int64 precision loss) →https://www.tiktok.com/@<username>/video|photo/<id>; if absent → share id andhttps://www.tiktok.com/@<username>;FAILED→BadBodyusingreason+ the envelope;- anything else →
Pending.
resolve_release_idonly handles ids containing_pub_/_inbox_; for non-drafts whose publish id starts withp_pub_and privacy isSELF_ONLYorMUTUAL_FOLLOW_FRIENDS, it returnsUnavailablewithout a call.
Provider functions (triggerTool)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
musicSearch | genre? (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. |
locationSearch | q (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/withitem_id,likes,comments,shares,video_views→ Views, Recent Likes, Recent Comments, Recent Shares (summed).
post_analytics()reuses/business/video/list/with afilters={"video_ids":[post_id]}and returns Views, Likes, Comments, Shares.missing()lists the last 20 videos withitem_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_URLwith an HTTPS port is invalid for Business. The redirect rules forbid ports.https://host:8443/...is rejected when registering the URL. Use 443.redirectmeto.comstill applies for HTTPFRONTEND_URLs. The computed URL ishttps://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_errorsis driven by the body, and each call site checksbody["code"] == 0. Do not rely on HTTP status codes in logs; readcode/message. business_idis theopen_id, verbatim.details()deliberately does not strip dashes (unliketiktok, which removes them) because the value is reused asbusiness_idon every call. Changing the app changes theopen_idand creates a new channel.- Token refresh is JSON, not form-encoded.
POST /tt_user/oauth2/refresh_token/withclient_id,client_secret,grant_type=refresh_token,refresh_token. Refresh tokens last a year but rotate; the response’sopen_idmay be used to keep the identity.expires_inis hard-coded to 23 h;refresh_cronis false, so refresh is reactive on the first expired call. TIKTOK_BUSINESS_BASE_URLmust include/open_api/v1.3. The provider appends paths beginning with/business/...and/tt_user/...; setting it to justhttps://business-api.tiktok.comproduces 404s.TIKTOK_BUSINESS_WEB_BASE_URLis the authorize host, not the API host. Defaulthttps://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 withprivacy_level_option_mismatch(or the Business equivalent) rather than being corrected up front. - No video privacy control.
privacy_levelis 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_lengthis shared with the video-caption limit, so longer photo captions are rejected by Rustiz before TikTok sees them. Thetitleis truncated to 90; the caption is not truncated, just length-checked. autoAddMusic = "yes"overridesmusic. 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/falsebecomes 0, and an unparsable value or an array/object becomes JSONnull(JSON has no NaN). musicSearchis trending-only. No free-text search;genreis the only filter.durationis multiplied by 1000 and may be 0 for a clip with no duration.locationSearchtruncates the query to 100 characters and returns[]for an empty query or a non-token API error.video_url/photo_imagesmust be publicly fetchable. Unlike the legacy provider’sFILE_UPLOAD, the Business API pulls media server-side. Local paths,localhostURLs and private bucket URLs fail withvideo_pull_failed/photo_pull_failedorurl_ownership_unverified.- Draft field names differ by media type. Video uses
upload_to_draft; photo usesis_draft. Both discard the interaction/privacy/music/location fields. thumbnail_offsetis omitted when 0.custom_thumbnail_urlwins 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_COMPLETEcan arrive beforepost_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_idcan 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
[], butRefreshToken/Disconnectpropagate 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_exceededorToo many requests; the provider maps them to aBadBody“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
- TikTok API for Business — authorization and redirect-URL formatting: https://business-api.tiktok.com/portal/docs/marketing-api-authentication/v1.3
- TikTok API for Business — documentation index (authorization, data security review, business account endpoints): https://business-api.tiktok.com/portal/docs/about-the-guide/v1.3
- TikTok API for Business — rate limits: https://business-api.tiktok.com/portal/docs/rate-limits/v1.3
- TikTok API for Business — sandbox accounts (Marketing API, deprecation): https://business-api.tiktok.com/portal/docs/sandbox-accounts/v1.3
- Business developer portal: https://business-api.tiktok.com/portal
- Postiz provider overview (shares the
TikToksettings schema): https://docs.postiz.com/self-host/providers/overview - Postiz posting rules: https://docs.postiz.com/general/platforms/overview