TikTok Ads setup
Create the TikTok Marketing API app behind the TikTok Ads channel, set the API environment, get the app approved, and know what a sandbox can and cannot prove.
How to create the TikTok Marketing API app that backs the TikTok Ads channel, what to set in the API environment, and what TikTok's approval process will ask for.
TikTok approved the app (see Approval) and a real advertiser connected on 2026-10-09. That first run verified the consent round trip, the advertiser picker, the campaign / ad group / ad lists, the pixel list and the whole Change Log export against the live API. It also found that the approved scope list cannot read the integrated report, so no spend or conversion number has been read yet — see What the first real run found. Writes, report rows and pixel event counts are still as TikTok's reference describes them, not as observed. The step numbers and portal labels are TikTok's flow as documented, not a transcript.
This is not the Social Poster's TikTok app
Marketing OS talks to TikTok through two unrelated developer apps, and confusing them is the single most likely way to lose an afternoon:
| Social Poster (organic) | TikTok Ads (this document) | |
|---|---|---|
| Product | Login Kit + Content Posting API | Marketing API |
| Host | open.tiktokapis.com | business-api.tiktok.com |
| Grants | a creator's account | an advertiser in Business Center |
| Env | TIKTOK_CLIENT_KEY / TIKTOK_CLIENT_SECRET | TIKTOK_ADS_APP_ID / TIKTOK_ADS_APP_SECRET |
| Setup | docs/social-publishing-setup.md | this file |
They share no credential, no consent screen and no database row. A deployment can have either, both or neither, and revoking one leaves the other working. On the Integrations tab they are two separate cards: TikTok Ads sits with Meta and Google Ads under the ad platforms, and TikTok sits under Social publishing.
1. Create the app
-
Sign in to the TikTok for Business developer portal with an account that is an admin of the TikTok Business Center holding the advertiser you want to read. Business Center membership is what decides which advertisers the grant can reach — not the app.
-
My Apps → Create an app. Give it the product name, a description and a privacy-policy and terms URL (the public site's
/privacyand/termsserve). -
Add the Marketing API product and select the scopes:
Ad Account Management— read (the advertiser list, name, currency, timezone)Ads Management— read (campaigns, ad groups, ads)Reporting— the whole category, not one leaf. The integrated report (/report/integrated/get/) is not listed under any of its leaves, and an app holding onlyCreative fatigue insightis refused it with40001"The access token lacks the required scope" (observed 2026-10-09).Read pixels— the pixel list behind measurement healthPixel level reporting— the per-event counts behind the sameChange Log— read (what a human changed in Ads Manager; see Change Log below)Creative fatigue insight— read (/creative_fatigue/get/, behind creative fatigue below). Allowlist-only: selecting the scope is not enough, TikTok has to enable the feature for the app (the app page's Allowlist Management → Apply), and until they do every call is refused with code40118. The refusal is handled — the sync skips the fatigue pass and still completes — so the app is safe to run without it.Ads Management— write, only if you want the registry's TikTok write tools to work (MARK-I79359: budget, status and campaign/ad-group creates)
Ask for nothing beyond that. A scope you are not calling is something the reviewer will ask about — and the reverse is worse: TikTok's review can check that you call what you asked for, so a requested
Change Log,Pixel level reportingorCreative fatigue insightscope with no/changelog/,/pixel/or/creative_fatigue/call against it is a question you do not want. -
Set the Advertiser authorization URL (TikTok's name for the redirect URI) to the API callback, exactly, character for character:
http://localhost:4000/integrations/tiktok-ads/callback # local dev https://<api-host>/integrations/tiktok-ads/callback # productionTikTok reads this from the app's own configuration rather than from the authorize request, which is why a mismatch shows up as a consent screen that never completes rather than as an error message. Both entries can coexist.
-
Copy the App ID and Secret from the same page.
2. The API environment
TIKTOK_ADS_APP_ID=… # numeric; TikTok parses it as an int64
TIKTOK_ADS_APP_SECRET=…
TIKTOK_ADS_REDIRECT_URI=https://<api-host>/integrations/tiktok-ads/callback
TIKTOK_ADS_API_VERSION=v1.3 # optional; pins the API versionUntil both the id and the secret are set, the TikTok Ads card reads "not configured" and its connect route answers 503. Nothing else in the app degrades — the hourly sync simply has nothing to enqueue.
3. Approval
Status: approved. The production app "Marketing OS" was submitted on
2026-09-22 and its approval was recorded on 2026-10-08, with all 11 requested
scopes: Ad account information, Campaign / Ad group / Ad (read and
create-and-update each), Change log, Creative fatigue insight, Read pixels and
Pixel level reporting. Audience management was not requested, so audience
sync's TikTok cells stay unavailable; asking for it later is a new scope
request on the approved app, not a new app — an approved app's detail page has
an edit pencil beside Scope of permission. A Pending or denied app cannot be
edited in the portal — changing an answer means deleting it and creating it
again — and the App ID and Secret render -- until the app is approved.
The rest of this section describes the wait, for whoever creates another app.
An app can authorize an advertiser before it is approved, and then be refused
on every read. That is the failure mode to expect while you wait: the connect
succeeds, the advertiser picker fills in, and the first sync records a 40001
/ 40002 refusal as the account's sync error.
TikTok's review asks for roughly what Meta's does:
- a description of what the integration does, in product terms;
- a screen recording of the whole flow: a user connecting, choosing an
advertiser, and the resulting data appearing in the product. Record it from
the Integrations tab through to
/ads-managerwith the TikTok tab selected; - the privacy policy and terms URLs from step 1;
- confirmation that you request only the scopes you use.
Rate limits are per app and per advertiser. The hourly incremental pulls one
advertiser-info call, three entity lists and one report per account per hour,
which is far inside the default quota; a first backfill is
INITIAL_SYNC_MONTHS months in 30-day chunks, so it is a handful of extra
report calls once.
4. Sandbox
TikTok's developer portal offers a sandbox advertiser — a fake ad account with fake entities you can authorize before approval. It is genuinely useful for the connect round trip and the entity sync.
What it cannot prove: reporting. A sandbox advertiser has no delivery, so
report/integrated/get/ answers rows of zeros (or nothing at all), and
pixel/event/stats/ has no events to report health on for the same reason. A
sandbox run therefore verifies the OAuth flow, the advertiser picker, the
entity mirror and the ads manager's empty state — and says nothing about
whether spend, conversions, revenue or pixel health are read correctly.
For that, the honest test is the hermetic one: apps/api/test/tiktok-ads-sync.spec.ts
drives the real client and the real processor against a scripted Marketing API
and asserts the unit conversions, which is what would actually be wrong.
5. Verify a connection
- Settings → Integrations → Connect TikTok Ads. You should land on
TikTok's consent screen, and come back to
/settings/integrations?tiktokAds=connectedwith the advertiser picker open. - Pick an advertiser and save. The card should show the advertiser's
name, id, currency and timezone, with a
pending→syncing→syncedbadge and "First backfill in progress" underneath. - Open
/ads-managerand choose the TikTok tab. Campaigns, then ad groups, then ads, with spend and conversions over the selected window.
If the card shows a sync error instead, it carries TikTok's own message. The three worth recognizing:
| Message | What it means |
|---|---|
| "TikTok refused the saved credential — reconnect…" | Code 40105: the grant was revoked in Business Center, or the identity lost access to the advertiser. |
| "…the Marketing API app needs an approved access level…" | The app is authenticated but not approved for this call, or the advertiser is outside what it may read. |
| "No active TikTok Ads connection…" | The connection row is expired or disconnected; reconnect. |
What the first real run found
Connected on 2026-10-09 to a new advertiser with no campaigns, pixel or spend.
| Call | TikTok answered |
|---|---|
| consent → callback → advertiser picker | worked; an identity with no ad account connects with an empty picker |
advertiser/info/, the three entity lists, pixel/list/ | code 0 (empty lists) |
| Change Log create → check → download | code 0, an empty export |
report/integrated/get/ | 40001 — "The access token lacks the required scope for endpoint …" |
creative_fatigue/get/ | 40118 — "The API is not in allow list. Please apply for the allow list…" |
The whole Reporting category was requested the same day (the app row reads Approved · Scope of Permissions Change Pending; the existing scopes keep working while TikTok reviews it, and a scope change asks for a written reason).
So the card shows a sync error until that request is approved and the advertiser is reconnected — a token keeps the scopes it was issued with.
Once the scope change was approved and the advertiser reconnected (by 2026-10-15), the report was refused again, this time for a different reason:
| Call | TikTok answered |
|---|---|
report/integrated/get/ (as first built) | 40002 — "Invalid metric fields: ['total_onsite_form']" |
report/integrated/get/ (corrected) | code 0, OK (an empty list: no campaigns yet) |
The on-site form metric is onsite_form. total_onsite_form came from the
docs and had never been sent live, and one invalid metric fails the whole
report. form and user_registration (the website-pixel Submit form and
Complete registration events) are accepted too, but the lead split does not ask
for them yet. A 40002 is a parameter error, not a permission problem, so the
card now shows TikTok's sentence without the "check the scope" hint.
40001 is TikTok's catch-all (a missing scope, an advertiser the identity
cannot operate and a pixel it does not own all use it), so the card now quotes
TikTok's own sentence after the code.
Two things worth knowing if you connect a brand-new TikTok login:
- An identity with no ad account completes consent and lands on an empty picker ("That TikTok login authorized no advertisers"). Create the ad account in TikTok Ads Manager; it then appears in the picker without reconnecting.
- TikTok may put a CAPTCHA and an emailed code in front of the consent's Confirm. That is TikTok's, not ours.
6. Change Log
Since MARK-89VFAE the hourly sync also pulls TikTok's Change Log, so a
change someone made in Ads Manager appears on the same per-entity timeline as
the changes Marketing OS made itself — open a campaign, ad group or ad in
/ads-manager and it is in the drawer, badged In TikTok Ads Manager.
Two things about that API are worth knowing before you debug it:
- It is an asynchronous export, not a list endpoint. One pull is three
calls:
/changelog/task/create/starts a task,/changelog/task/check/is polled until it reportsSUCCESS, and/changelog/task/download/returns the window as a CSV. TikTok removedpage/page_sizefrom it in v1.3, so there is no paging to tune. - Its window is capped at 30 days and is asked for in a fixed UTC+00:00 zone
(
Atlantic/Reykjavik), because the file's timestamps carry no offset. The pull is watermarked on the account (changeLogSyncedThrough) and overlaps its previous window by an hour to catch a late-landing row; a re-read is harmless because a row's identity is a digest of its own content.
What you will not see on the timeline: the changes Marketing OS made through its own tools. Those come back in the export carrying our app id, and they are already on the timeline as tool invocations — with their risk tier, their guardrail verdict and their Revert button — so the mirrored copy is dropped rather than shown twice.
The file arrives as JSON wrapped around a Python bytes repr
({"file_data": "b'…'", "file_name": "….csv"}) — not the Python dict of
TikTok's documented example — and a status check sent immediately after the
create is answered 40100 "Request too frequent." Both are handled.
If the scope has not been approved yet, the change-log pull is refused with a
40001 / 40002 while everything else keeps working. That refusal is logged
and skipped on purpose: the account's sync badge stays green, the timeline
simply has nothing TikTok-sourced on it, and nothing else degrades.
A change made in TikTok can never be undone from Marketing OS. There is no snapshot of ours behind it, so the entry is context rather than a decision with a missing button.
7. Pixel health in measurement health
Since MARK-0GDBF1 the measurement health panel judges TikTok alongside Meta and GA4. A business with a linked TikTok advertiser gets three extra checks on its home dashboard:
| Check | Reads | Fails when |
|---|---|---|
| TikTok pixel installed | pixel/list/ | the advertiser has no pixel at all |
| TikTok browser vs server events (Events API) | pixel/event/stats/ | no events in the window, or none server-side |
| TikTok events attributed to ads | pixel/event/stats/ | events exist and TikTok attributed none of them |
The third is the one Meta cannot answer. Meta exposes no event match
quality at any scope through the Graph API — it exists only in Events
Manager — so attributed_count is the closest this product gets to that
signal on any channel.
Three things to know before reading a verdict:
- A business with no TikTok advertiser is unaffected. All three checks
report
unknownwith the reason, andunknownis excluded from the roll-up verdict, so such a business sees exactly what it saw before. - A dead TikTok pixel can fail the business on its own, exactly as a dead Meta pixel can. The roll-up is "worst judged check wins"; there is no per-channel weighting.
- The TikTok read fails independently. A revoked TikTok grant leaves the Meta and GA4 halves of the report intact and says why the TikTok half is blank, which is the same contract the other three gathers hold.
There is no /pixel/event/health_reporting/
Worth writing down, because the name is plausible and the endpoint is not
real: TikTok Marketing API v1.3 has no pixel/event/health_reporting/. It
is in neither TikTok's own SDK nor the published v1.3 contract. The endpoint
the Pixel level reporting scope carries is pixel/event/stats/.
Two of its limits shape the code, and both are tighter than the entity endpoints':
pixel/list/capspage_sizeat 20 (the entity endpoints take 1000) and answers its rows underdata.pixels, notdata.list.pixel/event/stats/takes at most 10 pixel ids and adate_rangeof at most 30 days, in UTC. The 7-day measurement window and the 5-pixel read cap both sit inside those.
8. Creative fatigue
TikTok's own Creative Fatigue Detection — not a metric we derive. It answers, per ad per day, whether that creative has burnt out, plus the evidence behind the verdict.
- Where it shows up:
/creatives→ the TikTok fatigue tab. Each row is one TikTok ad with TikTok's latest verdict, the fatigue index, daily new users, the DNU ratio and CPA, and a day-per-square strip for the window. Click a row for every day TikTok reported. - What is stored: one row per (ad, date) in
tiktok_ads_creative_fatigue, written by the hourly sync. The three currency fields land as integer cents like every other TikTok amount; the index and the ratios are plain floats. - The window is TikTok's:
/creative_fatigue/get/only answers for the last 60 days and itsend_dateis exclusive, so the sync asks for[today-29, today)— the freshest reading is yesterday's. A day-over-day signal has nothing to say about a partial day. - It is a per-ad call.
ad_idis required, so the pass asks only about ads that actually delivered inside the window, highest spend first, capped at 200 ads per sync. An ad with no impressions has no reading to give.
Why the tab can be empty
Three different reasons, and the tab says which:
| What you see | Why |
|---|---|
| "No TikTok advertiser linked…" | No advertiser is linked for this business — do step 5 first. |
| "No fatigue readings for this window…" | Either the app is not allowlisted for Creative Fatigue Detection, or no ad delivered enough for TikTok to judge it. |
| A row with an em dash in a column | TikTok reported the day but not that measure. An em dash is "not reported"; $0.00 is a reported zero. |
The allowlist case is silent on purpose: the fatigue pass logs its refusal once
per sync (TikTok creative fatigue unavailable for advertiser …) and the sync
still finishes as synced, because a signal we were never granted is not a
broken integration. Check the API log if you need to tell the two empties apart.
CPA is reported twice — cost_per_conversion and skan_cost_per_conversion —
and TikTok returns 0.0 for whichever one does not apply (SKAN is the iOS 14
Dedicated Campaign one). The tab shows whichever is non-zero.
What this channel does and does not do
Reads are the bulk of it, but it is no longer read-only: since MARK-I79359
budgets, delivery statuses and new campaigns / ad groups can be changed — always
through the tool registry, never an HTTP route, so the risk tiers, the money
guardrails and the audit log apply to every one of them
(ADR 0018). Those writes need the Ads Management write scope from
step 1.
TikTok spend does count against a project's monthly spend cap, so a cap set for a business running all three channels is a cap on all three.
The pixel reads in section 7 are read-only. In particular this app does
not request the Measurement scope that carries /event/track/, the
server-side Events API send: Marketing OS reports on somebody's event setup,
it does not send events on their behalf.
Something wrong or missing on this page? Tell us and we will fix it.