# Changelog of TikTok Ads Transcript Scraper — Creative Center TikTok Top Ads (`steadyfetch/tiktok-ads-transcript-scraper`) Actor

- **URL**: https://apify.com/steadyfetch/tiktok-ads-transcript-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/steadyfetch/tiktok-ads-transcript-scraper.md

## Changelog

### 1.0.88 — 2026-09-19

- **The whole "Maximum cost per run" you set is now spent on transcripts.** A capped run used to hold back about a tenth of your cap as a cushion against platform usage — but your cap pays for the transcripts and nothing else, so that tenth was cap you had asked to spend and did not get: a cap worth ten transcripts delivered nine. A run now transcribes every ad its cap can pay for, and still ends the way it always has — on its own line naming your cap and the exact count of what was left, never on a run the platform cuts short. Long videos are unaffected: the extra minutes they bill have always been held against the live room at the moment they are charged, and still are. Nothing about the price, the charged events, the input form or any output column changed.

### 1.0.87 — 2026-09-18

- **A video link on an Akamai host is no longer downloaded, transcribed and charged to you as a TikTok ad — it now gets pointed at the actor that does read it.** The last build narrowed the check that decides whether a link is a TikTok ad video, but kept one host under Akamai's shared customer domain on the strength of a recollection rather than a measurement. We went back to the two harvests this actor's host rules are built from — a signed Top Ads list replay and 20 public Creative Center ad pages, 98 ad video links between them — and not one ad on either was served from an Akamai host. So that host is gone from both of this actor's lists: a link there is refused before anything is downloaded or charged, and instead of a dead-end refusal you now get a link to our media transcriber, which reads a direct audio or video file from any host. Every real ad link keeps working exactly as it did — the hosts your ads actually come from are the ones both harvests measured — and no input field, output column, charged event or price changed.

### 1.0.86 — 2026-09-14

- **A video link on a host that only looked like TikTok's could be downloaded, transcribed and charged to you as a TikTok ad.** The check that decides whether a link is a TikTok ad video accepted anything under `akamaized.net`. That is Akamai's shared customer domain — thousands of unrelated companies publish under it — not TikTok's own, so a link on any of their hosts passed the check. It reached that check two ways: pasted into "Ad video URLs or ad page links" yourself, or simply sitting somewhere in a row of a Creative Center scraper run you chained in, since this actor scans a chained row for anything that looks like a TikTok video link. Either way the run downloaded it, transcribed it and charged you for it as an ad transcript. Before narrowing anything we re-read 20 public TikTok Creative Center ad pages: all 46 video links sat on TikTok's own `tiktokcdn.com`, and no ad anywhere carried an `akamaized.net` link. The check now accepts TikTok's own CDN, plus TikTok's own label under Akamai, and nothing else — every real ad link keeps working exactly as it did, and a link this actor will not fetch is refused before anything is downloaded or charged.
- **The fresh-link lookup now runs the same check, and a refused link no longer reads as "this ad has no video".** When you pass a material ID or an ad page link, this actor opens the ad's own Creative Center page to mint a fresh video link — and that link went straight to the download without the check above ever being applied to it. It is applied now. If the page ever offers a video on a host this actor does not fetch, the ad comes back uncharged saying exactly that, instead of saying the page carried no video, which would be a claim about your ad that was not true. An ad whose page genuinely has no video still says precisely that, word for word as before.
- Nothing else moved: no input field, output column, charged event or price changed, and no ad that delivers today stops delivering.

### 1.0.85 — 2026-09-14

- **A run that named a watchlist could stop dead in the middle of a delivery, and a refused list update said nothing.** A watchlist is a key-value store in your own Apify account, and this actor writes to it right after each ad is delivered and before that ad is charged. If the API token the run was started with could read that store but not write it — the same restricted-token case as above, without key-value store **Write** and **Create** — the refused write ended the run there and then, with ads already in your results and no explanation on the run page. The run now finishes normally: every ad is delivered and charged exactly once, and the refusal is reported exactly like a refused account-memory update — on the run page, in one uncharged note row, and in the run log beside Apify's own message — so you know those ads are not on your list and would be charged again next run unless the token is given key-value store Write and Create permission (or Actor runs is set to Full access).

- **A token that can READ your account's memory but not WRITE it was quietly re-charging you for every ad on your next run — the run now says so, and says what to grant.** The memory that stops you paying for the same ad twice is a key-value store in your own Apify account, and an API token limited with "Restrict what Actors can access using the scope of this Actor" can carry key-value store Read without Write (or Write without Create on a store your account does not have yet). Such a run opened the memory, handed back every ad you already had, correctly — and then saved nothing, so your NEXT run downloaded, transcribed and charged for the whole delivery all over again, with no sign of it anywhere. From this build the very first refused write is named on the run page, in one uncharged row pushed the moment it happens, and in the run log beside Apify's own message, each saying to give the token key-value store Write (and Create) under Settings → API & Integrations or set Actor runs to Full access. A run whose memory saves normally is unchanged in every respect, a passing network hiccup on a write is still just a hiccup, and no price, input field, output column or charged event changed.

- **A run started with a scoped API token now says why it could not skip the ads you already have — and how to fix it — instead of quietly charging for them again.** The memory that stops you paying twice is a key-value store in your own Apify account, and a token limited under "Restrict what Actors can access" cannot open it unless it carries key-value store Read, Write and Create permission (or has Actor runs set to Full access). Until now such a run said only that the repeat check was "unavailable", which named neither the cause nor the cure: the same token was used again and the same ads were downloaded, transcribed and charged a second time. The run page now names the cause, the run's first row carries the permission to grant and where to grant it as an uncharged `memory_note` so a script or an agent reading only rows sees it too, and the run log keeps Apify's own message and adds the fix after it. Runs started from the console, or with a full-access token, are untouched. "New ads only" without a watchlist, which compares against that same memory, now stops with the same explanation instead of asking you to re-run in a minute. No input field, output column, charged event or price changed, and the run still delivers and charges exactly as it did.

### 1.0.84 — 2026-09-13

- **The store card now says what a transcript costs.** The listing description told you what the actor does and what is never charged, and left the price to the page — so a buyer comparing this actor against others in the search results could not compare it on price at all. The card now carries the same headline the page does: from $8.00 per 1,000 ad video transcripts, the cheapest paid rate on the ladder. Nothing else moved: no input field, output column, charged event or price changed.
- **The table of sibling actors now names the Google Ads listing by its current title.** That listing is called Google Ad Copy Scraper — Ads Transparency Center, CTAs & OCR; it was renamed so its name says what it returns — the text of an ad rather than "ads" in general. The link here points at the same actor as before; only the words a reader sees changed.

### 1.0.83 — 2026-09-13

- **Pressing Start without naming any ads now comes back with transcripts, not with an empty result.** With nothing named, the run shows you a small sample of current Top Ads — and it used to pick a fixed number of ads and stop there, whatever they turned out to be. Plenty of TikTok ads are music-only, and when the ones it happened to pick were all of that kind the sample came back with uncharged "no speech" rows and no transcript at all. The sample now looks at a couple of extra ads and stops as soon as it has the transcripts it set out to deliver — so a run of music-only ads no longer ends the sample early. It still delivers the same number of ads it always promised, it still charges only for transcripts actually delivered, and a music-only ad still comes back as exactly that, uncharged. The extra look is strictly bounded: at most two ads beyond the sample's own count, so a sample can never go hunting. A run where you pass your own ad links or material IDs is untouched — your "Max ads to process" still counts ads, exactly as before.
- Nothing else moved: no input field, output column, event or price changed.

### 1.0.82 — 2026-09-13

- **The form now tells an AI agent what a run costs before it calls this actor.** Apify's MCP server hands an agent the input form and never this page, so the description at the top of the form was the one screen that had to carry the whole story and did not. It now opens with the call that works, names what one delivered ad costs on the Apify free plan and on a paid one with the arithmetic for a thousand ads, lists what is never charged, names the run option that caps the bill, and says where an ordinary media file or a Facebook Ad Library link belongs instead. **Ad market** now leads with the value shape and says in its second sentence that finding ads is never charged; the "leave it empty and press Start" instruction moved to the end.
- Nothing else moved: no input field, output column, event or price changed.

### 1.0.81 — 2026-09-12

- **A run chained to a dataset that turned out to be empty now says so, instead of finishing with nothing in it.** If you pointed **Dataset ID** at a run whose results were empty, this actor read the dataset, found no ads in it, and finished with an empty dataset and no sentence anywhere saying what had happened — the same ending a broken run gives. You now get one uncharged row saying the dataset was read and held no ads, and suggesting the two things that fix it: check that the run you chained finished with results, or paste the ad links directly. Nothing was charged for those runs before and nothing is charged now. The run's own summary also records the dataset as the one thing you asked about, so a run like this is no longer invisible in our own records.

### 1.0.80 — 2026-09-12

- **An aborted or migrated run now leaves its receipt from the first moment of the run, instead of only after start-up finishes.** The record that says how a run ended was only put in place once the run had finished starting up — reading your input, opening its stores, working out what a previous attempt had already delivered and charged. A run stopped inside that window ended with nothing written about it at all, which mattered most on a re-started run, where the thing not written about was the earlier attempt's work. It is now in place from the run's first moment. Nothing else moved: no input field, output column, event or price changed, and a run that reaches its work behaves exactly as before.
- **A maintenance detail:** the private record we keep of what a run costs us at the speech and text-reading service now also counts the deeper second look a silent ad packed with on-screen text sometimes needs. It is our own bookkeeping — nothing you type, nothing you get back and nothing you pay is different, and no input field, output column, event or price moved.

### 1.0.79 — 2026-09-12

- **Silent ads packed with on-screen text now come back with their text.** With **Read on-screen text on silent video ads** switched on, a kinetic-typography ad carrying a lot of copy could come back as a plain silent row with nothing on it — the ad was readable, our reader just ran out of room in one read, and running it again gave the same answer. Those ads now deliver: the reader takes a second, larger look, and where an ad still carries more copy than one read returns, the text ships cut short at about 3,500 characters with `"truncated": true` on the row so you can tell. Charged like any other on-screen-text row; ads with no readable copy are still uncharged. Every `onScreenText` row now carries `truncated`, `false` when the whole thing came back.
- **This actor has a new title on the store: TikTok Ads Transcript Scraper — Creative Center TikTok Top Ads.** Same actor, same id, same URL, same input, same output, same price — only the words on the listing changed, so nothing you have saved, scheduled or wired into an integration needs touching. The title now carries the phrase buyers actually search for, which is the only reason it moved.
- **Asking for a bigger cap than the actor can do no longer stops the run before it starts.** "How many ads to find" refused anything above 100 and "Max ads to process" anything above 10,000 — the platform checked both before the actor was even started, so a bigger number (a round one typed by hand, or guessed by an AI agent calling through MCP) came back as an error with no run, no rows and nothing to read. The limits are unchanged and still real; they are now applied by the actor. A bigger ask runs at the limit, and the dataset carries one uncharged row saying what you asked for and what the run used.
- **A search stopped by this run's own time limit is no longer reported as TikTok's answer.** When discovery ran out of the run's time part-way through, the row you got said "No TikTok Top Ads matched this search — that is TikTok's own answer, not a failure", or, on a run that stopped before it could ask the listing even once, "TikTok did not serve its Top Ads listing to this run". Both named TikTok for a limit that was ours, and the two have opposite answers to the only question worth asking: is there anything you can do about it. From this build each ending says whose it is. A search cut short by the run's own clock comes back as an uncharged, retryable `discovery_unavailable` row that says so and tells you to re-run or raise the run timeout — and, because it is not an answer, a resumed run asks again instead of repeating it. A search that found some ads but ran out of time before it found all you asked for now ships an uncharged `search_note` row beside them, where it used to ship nothing at all and a short result read as all TikTok had. On the default sample, ads TikTok listed that were dropped only for being longer than the sample's own 3-minute limit are now counted and named as ours, instead of coming back as "TikTok answered that listing with no ads right now". A search that genuinely ran to the end and matched nothing is unchanged, and still says it is TikTok's own answer. Nothing here is charged, and no price, event or input field moved.
- **A silent ad whose frames were too big for our text reader now gets read anyway.** With **Read on-screen text on silent video ads** switched on, a long or high-resolution creative could be bigger than the reader accepts in one request, and the ad then came back as a plain silent row with nothing on it — and running it again gave the same answer, because nothing about it was temporary. Such an ad is now sent once more at a smaller size, which is enough for almost all of them, and delivers its on-screen text. Where even that is too big the ad is unchanged: the same uncharged silent row it was before. Ads with no readable copy are still uncharged.
- Nothing else moved: no input field, event or price changed, and only delivered rows are charged.

### 1.0.78 — 2026-09-11

- **The actor has a new name on the store: TikTok Ads Transcript Scraper — Creative Center Top Ads to Text.** Same actor, same URL, same input, same output, same price — the title now says what you get out of it. Nothing you have saved, scheduled or wired into an integration needs changing.
- **AI agents can now pin this actor from the top of the README** — the pin link, the actor id, the one input field that turns discovery on and how to cap a run's bill are on the first screen of the page instead of far down it.
- **A maintenance detail:** the private run-report this actor writes for our own support — counts only, never anything you typed — now also records the per-event price your plan was billed at, and whether a run that stopped at your **Max ads to process** had actually filled it. Nothing a run does, delivers or costs is different.
- Nothing else moved: no price, event, input field or output column changed, and only delivered transcripts are charged.

### 1.0.77 — 2026-09-11

- **Paste a video file that is not TikTok's and you are now told which of our actors does read it.** This actor reads TikTok ad creatives off TikTok's own CDN, so a link to a video file you host somewhere else — your own storage, your own CDN, a file an agent already downloaded — comes back as an uncharged row. It always did. What it did not do was say where that file CAN be transcribed, so the answer was a dead end: correct, and useless. From this build that row names our media transcriber, which reads an audio or video file from any host, and the exact field to paste the link into. TikTok links are unchanged — a mangled or expired TikTok CDN link still gets the TikTok guidance it needs, and a Creative Center listing page still gets its own. Nothing was charged for these rows before and nothing is now.
- Nothing else moved: no price, event, input field or output column changed, and only delivered transcripts are charged.

### 1.0.76 — 2026-09-10

- **A made-up closing line after the end of the audio no longer hides a real transcript — and is never delivered.** Speech recognition sometimes invents a sign-off, most often "Thank you.", and stamps it with a time that falls past the end of the ad. When that stamp landed far enough out, the whole transcript was judged unreliable and the ad came back marked as having no audio: nothing delivered and nothing charged, even when there were 25 seconds of clear speech on it. It was found on a real customer run of our Facebook ads transcriber — a 30-second property ad with 25 seconds of clear voiceover that came back with nothing at all — and the same speech step runs here. From this build the invented line is removed before anything else is decided, so the real speech is delivered and charged exactly as usual — and the invented line is taken out of the transcript, the timed cues and the opening-line field too, so you are never handed, or billed for, a sentence the audio does not contain. The same invention was also slipping through quietly at the end of transcripts that DID deliver; it is gone from those as well. The length we measure for billing is unchanged.
- **An ad that came back empty because of that fault is tried again, instead of being handed back with the same empty answer.** Once an ad has been delivered to your account we never charge you for it twice, and to do that we remember what every ad answered. The trouble was that the memory also remembered the EMPTY answer — so re-running the same input handed the same nothing straight back from your own record, without fetching or listening to the ad at all. It was found on a real customer run of our Facebook ads transcriber, five runs in a row against the same ad, and the same memory works here. From this build, an ad whose remembered answer was an empty one — no speech found, no audio track, no on-screen text found — is tried again the first time you run it on a newer build, and charged once if it then delivers something. It could never have been charged before, because an empty answer is never billed. An ad ALREADY DELIVERED to you is untouched: it still comes back uncharged and is never charged a second time. Switching on the on-screen-text option now also re-tries the silent ads that option exists to answer.
- Nothing else moved: no price, event, input field or output column changed, and only delivered transcripts are charged.

### 1.0.75 — 2026-09-10

- **A run that hits a block now waits a length that fits your run, instead of skipping the wait entirely.** When TikTok refuses everything, the run pauses and tries again. Until now that pause was one fixed 90–180 second block: if your run's time limit could not hold a whole one, the run took no pause at all and you got "please re-run" with most of your time unspent. The pause is now sized to the time your run actually has left, and the first one is short — about 30 seconds — so a block that lifts quickly costs you far less waiting. A short run, a small ad limit or the untouched Start form now all get a real second attempt where they used to get none.
- **The waiting on the transcription step is also sized to what is still worth recovering**, so a run with only a couple of ads left to fetch no longer spends eight minutes waiting for them — while a run that has not delivered anything yet always gets one pause whatever its size, because the first thing you see should not be an empty result.
- **We can now see WHY a run came back short.** Every uncharged row already told you what happened to that ad; our own run record folded them all into one number, so "no ad matched your filters", "the ad has no audio track" and "this ad is no longer in Top Ads" looked identical to us and a problem worth fixing could sit unnoticed. Each is recorded separately now. Your rows, their wording and what you are charged are exactly as before.
- Nothing else moved: no price, event, input field or output column changed, and only delivered transcripts are charged.

### 1.0.74 — 2026-09-09

- **Two runs of the same watchlist started at the same time no longer erase each other's sightings.** Watchlists and the account memory are now merged on every write, so an item one run delivered stays remembered, and a later re-run hands it back instead of charging it again.
- Nothing else moved: no price, event, input field or output column changed, and only delivered transcripts are charged.

### 1.0.73 — 2026-09-09

- **Other transcript actors' field names now work here.** If you point this actor at input written for one of our other ad-transcript actors — `urls`, `adLibraryUrls` or `reelUrls` for ad links — those entries are read as "Ad video URLs or ad page links", and one uncharged row tells you which field name to use in a saved task or an API call. Nothing is renamed: every field this actor already published works exactly as before. A field name that names nothing this actor can search — an advertiser, a keyword, a creator handle — is still refused rather than guessed at, because a run that answers a different question and bills for it is worse than one that says it did not understand. Before this, input under an unrecognised name was ignored entirely and the run fell back to its own sample, which read as though your input had worked.
- **The store page now says how often re-running is worth it**, and what a repeat run costs you.
- Nothing else moved: no price, event, input field or output column changed, and only delivered transcripts are charged.

### 1.0.72 — 2026-09-08

- **An ad you re-run keeps the answer it actually got.** When an ad comes back to you from the account memory — one this account already asked about — the row used to say "Already delivered to your account", whatever the first run had found. On an ad that was never transcribed at all (no audio track, nothing readable on screen) that was simply wrong, and it wiped out the sentence explaining why. Those rows now keep their own reason word for word and add one honest line saying it is the same answer as before and nothing was charged again. An ad that really was transcribed, or whose on-screen text was read, still says it was already delivered. Re-running now shows you at least as much as running once.
- Nothing else moved: no price, event, input field or output column changed, and only delivered transcripts are charged.

### 1.0.71 — 2026-09-07

- **A music-only or silent ad is no longer charged as a transcript.** When an ad carried no speech, the transcriber sometimes invented a short caption for it — "Outro Music", "Música", "The End" — and that invented caption was delivered and billed as if it were the ad's own words. Short ads were where it happened: below fifteen seconds the only thing standing between an invented caption and your bill was a list of exact phrases, and anything not on the list went straight through. An ad whose transcript is nothing but a sound or a card is now recognised as such at any length and ships as an uncharged no-speech row — no result fee, exactly as the store page says.
- **A short ad that really does speak is still delivered.** Speech is now weighed against the part of the ad the words actually cover rather than the whole of its length, so a six-second ad carrying a three-word line reads as speech and not as silence. Short spoken ads that used to come back as "no speech" are delivered.
- **An ad in any language is delivered in every market.** A Russian-, Ukrainian-, Chinese-, Japanese-, Korean- or Arabic-language ad running in the US, UK, Canada, Australia, Ireland or New Zealand used to be treated as unreliable, returned with no transcript and no result fee, and remembered that way for the next run. That rule is gone. An ad is transcribed and delivered in whatever language it speaks, wherever it runs.
- Nothing else moved: no price, event, input field or output column changed.

### 1.0.69 — 2026-09-06

- **A video host we cannot reach directly is no longer reported as a host that does not exist.** Some media servers publish only one kind of internet address, and it is a kind our machines cannot dial straight out. The download used to demand the other kind, get "no such address" back, and answer as though the video were gone. It now *prefers* the address it can dial rather than demanding it, so a host that publishes both is reached exactly as before, and one that does not comes back with what actually happened instead of a verdict that was never true. Nothing was ever charged for either row.
- Nothing else moved: no price, event, output column or charge changed, and only delivered results are charged.

### 1.0.68 — 2026-09-06

- **A block of links pasted into one row is now read as the list you meant, whatever separates them.** "Ad video URLs or ad page links" and "Material IDs or ad page links" each take one entry per row. A whole block pasted into a single row was read as one very long address, so a list of fifty ads came back as a single row saying it was not a TikTok video link. A pasted block is now split back into its individual links whether they are separated by spaces, line breaks, tabs, commas, semicolons, pipes or nothing at all, so a column copied straight out of a spreadsheet works. Each one is then read, deduplicated and charged on its own, exactly as if you had pasted them one per row.
- **A link that carries another link inside it is still one link.** An address holding a second address in its query or its path is left whole, not broken in two, and a single link pasted on its own is never rewritten.
- **Text pasted around a link no longer breaks it.** A number, a bullet or a note sitting beside a link is ignored and the link itself is used. A row holding no link at all is still answered as one row, not one row per word.
- Nothing else moved: no price, event, output column or charge changed, and only delivered results are charged.

### 1.0.67 — 2026-09-06

- **An ad that was already transcribed when your cost cap was reached now reaches you.** If a run hit its **Maximum cost per run** in the moment an ad's transcript — or a silent ad's on-screen text — had just come back, that result was thrown away and you got an empty "cost cap reached" row in its place. You paid nothing for it, but the answer you had waited for was gone, and only a re-run at a higher cap could bring it back. That ad now arrives complete, with `charged: false` and a row saying the cap was reached and nothing was charged for it.
- **Your maximum cost is still never exceeded** — the cap takes the charge, not the result.
- **Ads the cap stopped before any work was done are unchanged:** they still ship as uncharged `skipped_budget` rows naming the cap, so you still get exactly one row per ad you asked for.
- **Nothing else moved:** no price, event or output column changed, and you are still charged only for ads that actually deliver.

### 1.0.66 — 2026-09-05

- **A busy CDN or speech service is now waited out, not reported.** Getting an ad's video and turning it into text was tried three times about a second apart, and then the ad came back as an uncharged "please re-run" row — while nearly all of the time you had paid for went unused, and a re-run started seconds later met the same busy minute. Both of the steps above it, finding the ads and refreshing an ad's link, already waited minutes for exactly this. Now a run that still holds time pauses (a minute and a half to three minutes, varied so parallel runs do not retry in step) and tries the whole thing again, up to eight minutes of waiting shared across the entire run so a storm on the first ad cannot use up the wait for the rest. If the service sends its own "try again in N seconds", that still wins.
- **A pause never re-buys anything.** A second try re-uses the audio already in hand, so a slow speech service costs one more attempt at the text, never a second download.
- **Everything that is an answer still comes back at once**: an expired link, an ad with no audio track, a video that arrived whole and would not convert, a refused service key, your own time limit.
- Nothing you see changed otherwise: no price, event, output column or charge moved, waiting is uncharged, and only delivered transcripts are charged.

### 1.0.65 — 2026-09-05

- **The run's own record now says how many ads it was asked for, and names every ad it did not deliver.** A discovery run reported its ask as the maximum-ads number, however few ads TikTok's Top Ads listing actually held — so a walk that found five ads under a twenty-ad ask read as five of twenty, and a run with a smaller cap reported the cap as the ask, which quietly dropped the ads the cap trimmed out of the count altogether. The ask is now what the run really planned: the ads the walk listed, plus the ad URLs, material IDs and dataset rows you sent. When TikTok refuses the listing outright nothing could be listed, so the whole ask is reported as unanswered rather than as a single failure.
- **Every ad the run planned and did not deliver is now counted under its own reason** — trimmed by your max-ads cap, skipped by your "new ads only" filter, or left behind when the platform moved the run to another server — and ads an earlier server of the same run had already delivered count as delivered rather than going unrecorded.
- **The run also records what stopped it** (your max-ads cap, your max charge, the run timeout, a move to another server, a mis-priced rail, a resume it could not read, or nothing — the work list simply ran out). This was declared and never written, so every run's record read as if nothing had stopped it.
- **Expired links and refused lookups are no longer counted twice.** Both are already part of the miss and failure totals the record carries; counting them again made a run report more ads than it was ever given.
- **Nothing about your results or your bill changed** — no row, price, event, column or charge, and the run page's wording is unchanged. Delivered ads are charged; misses, skips and guidance rows stay uncharged.

### 1.0.64 — 2026-09-05

- **A run that waits now always keeps room to finish and report.** When TikTok refuses the fresh-link lookups over and over, the run can pause and let those lookups resume — but the check that decided whether a pause was affordable kept back only half a minute for what came after it, while a full run of refusals here takes up to two and a half minutes. On a run whose time limit fell in a narrow band (roughly six to seven minutes, including a shorter limit you set yourself), the pause was allowed and the lookups after it then ran into the time the run keeps back for writing your rows and the summary. The same check on the Top Ads listing now keeps back a whole listing pass rather than half a minute. Both are measured from this actor's own timeouts and pacing, so a pause is only taken when the run can finish the work the pause was for.
- **Charges are unchanged.** Waiting is uncharged, a refused lookup is uncharged, and only delivered ads are charged.

### 1.0.63 — 2026-09-05

- **A run started with no input at all now has room to wait out a block.** Press Start on the untouched form and this actor transcribes three real ads from the Creative Center, charged like any run. The fresh-link lookups TikTok needs are sometimes refused for a few minutes at a time, and one round of refusals could use up most of the sample's own three-minute limit — so the wait-and-ask-again this actor gained two builds ago could never happen on the most common first run of all: it reported "paused, please re-run later" while the refusals were still clearing. The sample's time limit is now eight minutes, sized to hold one full round of refusals, one pause, another full round, and enough time left over to stop cleanly and report. A sample TikTok answers still finishes in seconds; only a blocked one uses the extra time, and nothing is charged unless a transcript is delivered.
- **How much of its remaining time a blocked run may spend waiting now follows the size of what you asked for.** A run with more ads still to transcribe keeps exactly the limit it had — no more than half the time left — so a wide block still leaves the later ads their share of the run. A run with a single ad may spend what is left on its one pause, because nothing else is waiting for that time.
- **The no-input sample now always fetches and transcribes for real.** It used to be answered from this account's own history of past runs, so a second Start handed the first one's ads back uncharged, without a Creative Center call, a download or a transcription. The sample now ignores that history in both directions: it fetches every time and does not add its own ads to the history. Two things are unchanged — a run with your own ads in it still hands back what this account already has, uncharged, and so does a no-input run where you asked for the history by name ("New ads only" or a watchlist name).

### 1.0.62 — 2026-09-05

- **Fresh-link lookups now pause and resume instead of switching off for the run.** When an ad's material ID is looked up for a fresh video link and Creative Center refuses several lookups in a row, the run used to stop looking up links for the rest of the run — every later ad came back as an uncharged "paused for the rest of this run" row, even on a run with most of an hour still to go. Now the run waits a couple of minutes, moves to a fresh connection and carries on, and waits again if it has to — up to eight minutes in total and never more than half of the time the run has left — so a short interruption no longer costs the rest of a long batch. When the lookups stay refused, the uncharged rows say how many refusals over how many minutes and whether the run waited, and they carry `retryable: true` because a later run can clear it. A page that comes back in a shape this actor cannot read is unchanged: no wait, and no re-run promised.
- **Finding ads (a Region set, or the default sample) also waits out a refusal when the run has the time.** A listing that turns every request away is tried again after a couple of minutes, inside the same bounds, and requests are spaced out instead of fired back to back. The default sample keeps its short window and never waits. The uncharged "did not serve its Top Ads listing" row says whether the run waited and for how long, and only calls the refusal "usually temporary" when the run did not wait it out.
- The run's own record now counts refused lookups apart from other failures, so a bad hour on Creative Center is visible to us as one. The README title now matches the store listing.

### 1.0.61 — 2026-09-05

- **The default sample delivers again for accounts that have not run it before.** Since 1.0.59 an ad is only downloaded when the run has enough time left to download and transcribe it in the worst case. The bare-Start sample (nothing set, or only a setting changed) runs under a short three-minute window, and that window was shorter than that worst case — so on an account that did not already hold the sample's three ads, every one came back as an uncharged "the run reached its time limit" row within seconds. The worst-case check now reads the run's real time limit; the three-minute window still bounds how long the sample keeps starting new ads. Runs that name their own ads were never affected.

### 1.0.60 — 2026-09-05

- **A fresh-link lookup that was refused now carries `retryable: true` when a re-run can clear it.** When an ad's material ID is looked up for a fresh video link and the lookup is refused or fails to connect, the uncharged `failed_resolve` row said "please re-run" but its `retryable` field said nothing, so anyone filtering on that field to feed a re-run skipped those ads. The field now agrees with the sentence. A lookup paused for the rest of the run after repeated refusals is unchanged: uncharged, and still without a re-run flag, because that row does not promise one.

### 1.0.59 — 2026-09-05

- **An ad near the run's time limit now stops cleanly instead of being cut off mid-transcription.** Downloading an ad's video and transcribing it takes time, and looking up a fresh video link takes a little more; if the run does not have enough time left, that work is not started — the ad comes back as an uncharged "please re-run" row (you can also raise the run timeout) rather than a run cut short. A run that reaches its limit ends with the transcripts it already has.

### 1.0.58 — 2026-09-04

- **Change a setting, name no ads, and the run now goes ahead.** Picking a time window, a sort order, an industry, a cap, "New ads only" or a watchlist name and pressing Start without naming any ads used to turn the run away with a single uncharged guidance row — so the buyer who narrowed one dropdown got less than the buyer who touched nothing. Those settings are now applied to the default sample and the run delivers, charged like any run. A cap smaller than the sample is honoured exactly; a bigger one never grows it, and the run never buys more than the sample's own size.
- **One uncharged `sample_note` row** says which settings were yours, what the sample supplied for the rest, and how to run your own search. It is never counted as a miss, and the run status line says the sample ran under your settings.
- Guidance is unchanged where it is the honest answer: a field name this actor does not recognise, a dropdown value it does not offer, or a target list you sent empty (`videoUrls: []`, `materialIds: []`, `datasetId: ""`) each still get their uncharged row naming the fix — a mistake is never answered with sample ads.
- Pressing Start with nothing set at all is unchanged: the same 3-ad sample of current US Top Ads.

### 1.0.57 — 2026-09-04

- **Every input problem now names the field it was about** in the run's own record — the region, the industry, the time window, the sort order, the ad links, a material ID, a watchlist name — instead of one undifferentiated "input error". The field NAME only; nothing you typed is stored. A refused dropdown still gets its own uncharged row per field it could not read, and ads you named yourself still run.
- The run's record now reports what you ASKED for alongside what was delivered, so a run that turned everything away can be seen as one. It previously reported the number of ads the run got as far as attempting — and that number was reduced again for every ad handed back from your account's memory, so a run where you already had everything reported an ask of zero.
- The Changelog tab no longer describes any row as "free" — the word is "uncharged"; running any actor still uses platform time you pay for. No prices, counts or behaviour changed.

### 1.0.56 — 2026-09-04

- **README wording: every uncharged case on this page now says so plainly.** Three lines described a row or a verdict as costing nothing; each now says **uncharged**, the same word the rows and the run status already use. Nothing about pricing, events, input or output changed.

### 1.0.55 — 2026-09-04

- **An ad already delivered to your account is never charged a second time.** Re-running the same material IDs, video URLs, chained rows or discovery used to transcribe and charge every ad again. The run now recognises an ad your account already has — under its Top Ads material ID or its video file, however it arrived last time — and hands it back from the run that delivered it: `repeat: true`, `firstSeenAt`, `firstSeenRunId`, `charged: false`, not downloaded or transcribed again. The status line counts them and `OUTPUT.repeats` holds the number. Your account keeps this memory in the key-value store `tiktok-ads-watch-account`; delete it to forget everything.
- **New ads only works without a watchlist name.** It compares against your account's memory; a named watchlist still keeps a separate list per market. Only when neither can be read does the run stop, uncharged, instead of charging you for ads you may already have.
- **If the memory cannot be read, the run still runs.** It delivers and charges as usual and says on the status line and the charged rows that the repeat check was unavailable.
- **Every row now carries `isNew` and `firstSeenAt`**, not only rows of a watchlist run.
- A watchlist written before this build kept no copies of its rows: the ads on it are processed again once, not charged, and kept from then on.

### 1.0.54 — 2026-09-04

- **Rows pasted into the "Dataset ID" field are read as your rows.** A paying buyer pasted a dataset's exported rows into that field; the run sent them to the platform as if they were an ID and stopped on the answer. The run now recognises pasted rows there, works them exactly like rows pasted into "Dataset items", and adds one uncharged note saying where they belong next time. Anything else that is not a dataset ID or dataset name — a link, a value with spaces, a very long value — is answered with one uncharged row naming what it looked like, before anything is sent anywhere. Nothing was charged for the runs that stopped, and nothing is charged for these notes.
- **A Dataset ID the platform itself cannot answer for is settled in seconds, not minutes.** When the platform's own dataset lookup fails on its side, the run used to wait through the platform client's long retry schedule — close to seven minutes on one lookup — and could then run out of its own time. The lookup now gets two quick tries plus one short pause, and the run moves on to its honest uncharged row. Nothing about what is charged changed.
- **A guidance row is never dropped by the run's time limit.** A row that only explains an input problem costs nothing and takes no time, so it now ships even when the run has reached its safety margin; only real work is skipped there.

### 1.0.52 — 2026-09-04

- **A chained Dataset ID the run cannot read now ends as one uncharged row, whatever the reason.** Until now a refusal the run did not recognise — anything other than "no such dataset" or "no access" — stopped the run within seconds with nothing delivered. Every answer now becomes an honest `input_error` row naming what the platform said, a momentary refusal gets one more try, a read that stops part-way keeps the rows already read and says where it stopped, and the run carries on with the rest of the input. A value that is not a dataset ID at all says so instead of reading as a temporary problem. Rows chained in an unexpected shape were already harmless and now have a test that keeps them so. Nothing was charged in any of these cases before, and nothing is now.
- **A run that stops on a fault of ours now tells us what kind of fault it was**, so it is looked at without anyone needing to share the run. Nothing about what is delivered or charged changed.

### 1.0.51 — 2026-09-04

- **A problem inside the speech-to-text service is no longer reported as a problem with the ad's audio.** Every refusal from that service used to read the same way — a permanent, uncharged miss saying the creative held nothing we could transcribe — so "the service changed something on its side" and "this file has no usable audio in it" looked identical in your dataset. A fault on the service's side now ships as an uncharged row marked `retryable` that asks for a re-run, and a file we genuinely cannot transcribe keeps the honest, uncharged miss it always had.
- **A refused fresh-link lookup now takes the second look it was already set up for.** When Creative Center turns a lookup down, the run moves onto a fresh connection for the next attempt — but the ad was written off as permanently unavailable before that fresh connection was ever tried. The lookup now takes that second look first, so an ad that answers on the retry is delivered instead of reported as gone.

Nothing about what is charged changed in either case.

### 1.0.50 — 2026-09-04

- **A run that stops before it starts now reports its outcome too** — a run refused for its memory setting, and a run stopped because a key on our side is missing, now reach us the same way every other run does: counts and reason codes only, never your input or your rows.

### 1.0.49 — 2026-09-04

- **Every run now reports its own outcome to us** — counts and reason codes only, never your input or your rows — so a run that goes wrong reaches us even when nobody shares it.
- **A chained Dataset ID is looked up read-only.** A mistyped ID creates nothing and returns one uncharged input-error row that names the ID as the problem; a real dataset is read page by page with an honest notice past 10,000 rows.
- **The watchlist clause of the status line no longer splits itself with a semicolon.**
- **Row notes state what happened and what was charged; the support ask lives on the run page.**
- **Console field descriptions no longer call a row "free"** — running any actor still uses platform time.

### 1.0.48 — 2026-09-04

- **The end-of-run summary now fits on the run page.** It was long enough to be cut off part-way through; the wording is tighter and every count, cap and charge statement survives.

### 1.0.47 — 2026-09-04

- **A run that ends with a problem now says where to reach us.** A miss, an input error, an early stop or a failed run closes by pointing at the Issues tab and naming the reply time; a run that delivered everything, including one that filled the row cap you set, is left alone.
- **One support promise across this page** — issues are answered in a couple of hours, always within a day.

### 1.0.46 — 2026-09-04

**A run that resumes after a platform restart now recognises every row it already delivered, so nothing is delivered or charged twice.**

Apify occasionally moves a running actor to another server. When that happens, the run re-reads its own dataset to remember what it already delivered. Until now it trusted the dataset's row count, which can lag for a moment after a restart; a lagging count could make the run start over and charge again for rows you already had, or stop reading before the end. The run now checks for real rows instead of trusting the count, and reads to the end whatever the count says. Rows, prices, charges and the status line on a normal run are exactly as before.

### 1.0.45 — 2026-09-04

**Internal bookkeeping only — nothing changes in your rows, prices, charges or status line.**

The record this actor keeps of its own running costs now reports itself: if it cannot be saved, the run log says so plainly instead of staying quiet, and every run's OUTPUT record carries whether it was saved. Your dataset, your charges, your prices and the status line are exactly as before.

### 1.0.44 — 2026-09-03

**A value the Industry, Time window or Rank ads by dropdown does not offer now stops the run instead of searching on the default.**

Set one of those three to something this actor does not recognise and you get one uncharged row naming the value and the options it accepts — and no search runs, no transcripts are delivered, and nothing is charged. Until now the run quietly searched on the default instead (all industries, the last 30 days, TikTok's recommended order) and charged you for transcripts of ads you had not asked for. Leaving a dropdown blank, or sending it as null, is unchanged and still means the documented default, silently. Clicking Start with nothing set still runs the 3-ad sample, and ads you supply yourself by link, material ID or chained dataset are unaffected.

### 1.0.43 — 2026-09-03

**Documentation only — nothing changes in your rows, prices, charges or status line.**

The example screenshots on this page now load from Apify's own storage, and the references that used to point off Apify are plain text now. The free n8n workflow templates are unchanged and still listed on our profile website. Your dataset, your charges and the status line are unchanged.

### 1.0.42 — 2026-09-02

**Internal accounting fix — nothing changes in your rows, prices, charges or status line.**

A small correction to how our own cost records count very short clips. Your dataset, your charges and the status line are unchanged.

### 1.0.40 — 2026-09-02

**Internal cost accounting only — nothing changes in your rows, prices, charges or status line.**

Runs started from our own account now record the processing they used, so our cost reports are measured instead of estimated. Nothing is added to your run or its storage, and your dataset, your charges and the status line are unchanged.

### 1.0.39 — 2026-09-02

**Start with the default form and get a real 3-ad sample instead of a placeholder row.**

Clicking Start with nothing set used to return one uncharged "demo" row and no transcripts. It now runs a small real sample — the 3 most recommended US Top Ads right now, all industries, short ads only — transcribed and charged like any run, so the first thing you see is the actual output. The status line and every row say it was the sample and how to run your own search. If TikTok does not serve its listing at that moment, you still get one uncharged row explaining it, never an empty result.

### 1.0.38 — 2026-09-02

**Text-heavy creatives are now read in full.**

A silent video ad carrying a lot of on-screen copy used to come back as an uncharged "service problem, please re-run" row, and a re-run gave the same answer. The text reader now gives such a creative a second, larger pass, so it delivers like any other. A creative that still cannot be read reliably ships as an uncharged row that says so, never as a charge.

### 1.0.37 — 2026-09-02

**Watchlists: re-run the same market, or the same ads, on a schedule and pay only for the ads that are new.**

Until now every run was a one-off. Ask for the same region tomorrow and you got — and paid for — the same Top Ads again. Two new fields change that:

- **Watchlist name** (`watchlistId`) — give the run a name such as `beauty-us` and the actor remembers every ad it has answered under that name, in a key-value store in **your own** Apify account (`tiktok-ads-watch-<name>`, yours to inspect or clear). Every row now says whether the ad is new to that list (`isNew`) and when it was first seen (`firstSeenAt`).
- **New ads only** (`newAdsOnly`) — with a watchlist set, ads already on the list are skipped **before anything is downloaded**: not fetched, not transcribed, not charged. The run's status line and its OUTPUT summary say how many were new, how many were skipped, and how many the list holds now.

Only an ad the actor actually answered goes on the list — a delivered transcript or on-screen text, or a final uncharged verdict such as no speech. An ad it could not answer (an expired link, a failed download, an ad your cost cap left out) is not remembered, so the next run tries it again. The list is written the moment a row lands in your dataset and before its charge posts, so an interrupted run can never forget an ad you paid for.

Two safety rules: **New ads only** without a watchlist name does not run at all (the actor cannot tell a new ad from one you already paid for), and a watchlist the run cannot open stops the run before any spend. Both ship as one uncharged, clearly-labelled row.

Without a watchlist name nothing changes: the two new columns read `null`, and every input mode, price and charging rule is exactly as before.

The README was refreshed at the same time: what you get and the price up front, a scheduling recipe for the watchlist, the one-click MCP link, and the suite links carry the actors' current names.

### 1.0.35 — 2026-09-01

**The step that reads on-screen text off a silent ad moves to our provider's current engine.**

If you have on-screen text extraction turned on, the engine reading those frames is being retired by the provider this month, with runs quietly redirected to its replacement two weeks from now. This build makes that move explicitly instead: charged output should never ride a silent switch.

Both engines were run side by side on the same real silent creatives before this shipped. The same text comes back, and the rule that matters most is unchanged: **an ad whose frames carry no real ad copy — b-roll, a bare logo, a watermark — is still the uncharged silent row it always was, and is never charged.** Nothing about what this actor charges changed.

### 1.0.34 — 2026-08-31

**Long videos no longer come back as `failed_processing`.**

This actor gave itself a fixed two minutes to pull the audio out of a creative, no matter how long that creative was. It was set once, for short ads, and never grew with the video — so a long-form creative was stopped partway and reported as a broken file. A sibling actor on Facebook inventory measured the damage on a real run: every creative past roughly twelve minutes was lost, and nothing under five minutes ever was.

- **The time budget is now earned by the video's own length**, up to a generous ceiling, and it is also held inside what is left of your run.
- **A silent video is recognised before any extraction work starts** — the uncharged `no_audio_stream` row (and the on-screen-text result behind it, if you have that turned on) is reached from the file's own stream list rather than after a failed attempt.
- **"Ran out of time" and "this file is broken" are now two different answers.** A run that ran out of time ships an uncharged `failed_processing` row marked `retryable`, and the row names the lever: re-run it with more run memory, which on this platform also buys more CPU.
- A stopped extraction can no longer leave a half-finished audio track behind, so a partial transcript can never be delivered or billed as a whole one.

Nothing about short creatives changed, and no failed row was ever charged.

**Follow-up in the same wave:** the first cut of this change computed the new time budget as a fraction of a millisecond, which the underlying process launcher refuses. Only durations between the fixed lower and upper bounds could produce a fraction, so the very short and the very long creatives were unaffected — but a creative in the middle of the range came back as a download failure, and its media was re-downloaded twice more before the run gave up. The budget is now always a whole number, and anything that stops the extraction from starting is reported as an extraction problem rather than a network one: one attempt, one honest uncharged row.

### 1.0.32 — 2026-08-31

**A field you left unset can now be sent as `null` — the run starts and uses the default.**

If you call this actor from a template — n8n, an agent framework, a chained workflow — the tool usually fills in *every* field it knows about and writes `null` into the ones you left blank. Until this build the platform refused those runs before they started, with an error like `Field input.maxAds must be integer`. Nothing ran, nothing was charged, and the fix was non-obvious: you had to delete the key entirely rather than leave it empty.

Every optional field now accepts an explicit `null` and reads it as **"use the default"** — identical to leaving the field out. `{"discoverRegion": "US", "discoverPeriod": null, "discoverSort": null, "maxAds": null}` runs exactly like `{"discoverRegion": "US"}`. A `null` never counts as a value: a null **Region** does not start a discovery search, a null list is not an empty list, and a null option does not switch on something that defaults off.

That includes the four dropdowns — **Region**, **Industry**, **Time period** and **Sort by** — which needed more than the other fields: a dropdown is validated against its list of allowed values and `null` is on no list, so all four keep their options and titles in the input form while the run itself now checks the value. One consequence worth stating plainly: a value that is not on a dropdown's list is no longer refused before the run. It is caught by the run instead, which uses the documented default and returns an uncharged row naming the value it did not recognise and what it used — the same way this actor has always answered a link or a material ID it could not read. **Time period** and **Sort by** previously fell back to 30 days / For You in silence; they now say so. Nothing about what this actor delivers, or charges, changed.

### 1.0.31 — 2026-08-31

**The opening hook is never blank again, and it now tells you when it starts.**

`hook3s` is the first 3 seconds of speech in an ad. Plenty of Top Ads — anything that opens on a music sting, a logo animation or a title card — say nothing at all in those 3 seconds, and until now those ads came back with the hook column **empty**: exactly what you would see if the actor had simply failed to get the line. On a charged row, that was our headline field arriving blank.

- **A late opening line is delivered, not dropped.** When nothing is spoken in the ad's first 3 seconds, `hook3s` now carries the first 3 seconds of speech from wherever speech actually begins — the real opening line.
- **New `hookStartSeconds` column.** The second that line starts, so a late hook can never be mistaken for one that opened the ad. `0` means the ad opens speaking. Sort by it to see which competitors make you wait for the pitch.
- **No more blank strings.** An ad with no speech at all leaves both fields empty (`null`), so "this ad opens silent" and "we lost the line" can no longer look identical in your spreadsheet.

Nothing about what this actor delivers otherwise, or charges, changed.

### 1.0.30 — 2026-08-31

**A run now spends the cost cap you set.** When several ads were being processed at once, an ad that turned out to carry no speech released the cost it had reserved — but by then the ads still waiting had already been answered with `skipped_budget` rows, so that released budget had nobody left to spend it and a run could finish having delivered nothing while charging nothing. Ads now wait for budget that is still in play instead of being skipped past it, so a run delivers as many transcripts as your "Maximum cost per run" allows. **Some ads failed to download with a network error no re-run could fix** — the video address the run reached for was not reachable from it, and every retry reached for the same one. Video downloads now take a route the run can always reach, so those ads come back as transcripts instead of "please re-run" rows. Nothing about what is delivered or charged changed.

### 1.0.29 — 2026-08-30

**The surcharge for long videos is now spelled out in the input form, under the name the Pricing tab uses.** The **Max ads to process** field's description now says: the first 3 minutes of every video are included in the transcript price, and each started minute beyond that is charged as an "Extra video minute" at $0.005 — only on delivered transcripts, never on on-screen text. The on-screen-text option and this page now call that event by its Pricing-tab name too, and the output table says it next to its "any length" promise. Nothing about what is delivered or charged changed.

### 1.0.28 — 2026-08-30

**The output description now names both kinds of charged row.** It still said only `transcribed` rows are charged, from before on-screen text could be read. If you switch on-screen text reading on, a delivered `onscreen_text_extracted` row is charged at the same price as a transcript — the Pricing tab and the option's own description already said so, and now the output description does too. Leave the option off and nothing but a transcript is ever charged. Nothing about what is delivered or charged changed.

### 1.0.27 — 2026-08-30

**One row per ad, always — and a run summary whose numbers add up.** An ad the run could not start because your "Maximum cost per run" or the run's time limit was reached now gets its own uncharged row (`skipped_budget` / `skipped_deadline`) carrying every field already known about it and `retryable: true`, so the dataset always holds exactly one row per ad you sent and a re-run can be fed straight from it. The run's status line now counts each ad exactly once — a silent ad is reported inside the uncharged-miss count rather than as a second outcome — and every stop names the setting that stopped it.

### 1.0.26 — 2026-08-30

**You no longer need ad IDs to start. Pick a region and the actor finds TikTok Top Ads for you, then transcribes them.** A new *Find ads for me* section takes a region (28 ad markets), an optional industry (21 categories), a time window and a ranking, and returns transcribed ads without any prior scrape, dataset or pasted link — a first run now needs nothing but the Start button. Finding the ads carries no result fee: you are still charged only for delivered transcripts.

Two new honest outcomes come with it. A search that genuinely matches no ads returns one uncharged `no_ads_match` row saying so, instead of an empty dataset; and if TikTok does not serve its Top Ads listing to a run, you get one uncharged `discovery_unavailable` row asking you to re-run, never a charge and never a silent empty result.

Every existing way in — video URLs, ad page links, material IDs, dataset ID chaining and pasted rows — works exactly as before, and can be combined with the new region search in the same run.

### 1.0.23 — 2026-08-30

**An ad with no real speech is never charged.** A transcript that is only the speech engine's own filler — including the subtitle-credit lines it sometimes invents on music-only ads — one whose timing does not fit the audio, or one in a language that cannot belong to the ad's own single declared country, now ships as an uncharged no-speech row. Genuinely spoken ads, in any language, are delivered and charged exactly as before.

### 1.0.22 — 2026-08-30

- **Runs now use a fraction of the memory, especially on long ads and big batches.** Downloaded video is processed from temporary storage and removed the moment it is no longer needed, instead of being held in memory for the whole ad — on an 8-ad batch of larger creatives, peak memory dropped about 3×. Long creatives no longer push a run toward its memory ceiling, and the default memory setting goes further.
- **Silent-ad on-screen text is read faster.** The five-frame strip behind **Read on-screen text on silent video ads** is now produced in one pass, and a rare video whose frames could not be sampled near the end of the clip now gets a second, more thorough pass instead of shipping without its on-screen text.

Nothing about what this actor delivers or charges changed.

### 1.0.21 — 2026-08-30

- **A momentarily busy transcription service no longer costs you the ad.** When the speech-to-text service asks this actor to wait a moment, the run now waits exactly as long as it was asked (never more than a minute) before trying the same audio again, instead of retrying on a fixed short timer that could run out before the service was ready. Ads that used to come back as an uncharged "please re-run" row during a busy spell now deliver on the first run. Nothing about what this actor charges changed.

### 1.0.20 — 2026-08-30

This page's wording refreshed — the silent-ad on-screen text option and failed-download handling are described more plainly, and a suite link carries a sibling's updated store title. Nothing about what this actor delivers or charges changed.

### 1.0.19 — 2026-08-30

Clearer release notes — this page's notes now read more plainly. Nothing about what this actor delivers or charges changed.

### 1.0.18 — 2026-08-30

Suite links now point at the full live shelf — every actor named in this README is a live
store link. Nothing about what this actor delivers or charges changed.

### 1.0.17 — 2026-08-30

- **A row can never say it was charged when the charge could not land.** When a run reaches its maximum cost just as a result becomes ready, that result now ships as an uncharged `skipped_budget` row telling you to raise "Maximum cost per run" and re-run for it — and the rest of the run stops buying media it can no longer bill.
- **An interrupted charge can no longer fail a resumed run.** If a charge left over from an interrupted run cannot be completed the moment the run resumes, the run carries on and settles it later. Nothing is ever charged twice.
- **A chained dataset is read in full, and a cut is always announced.** Chained datasets larger than one page no longer lose rows beyond the first page: everything up to the 10,000-row ceiling is read, and a dataset holding more than that gets one uncharged row saying how many rows this run processed and how to get the remainder.
- **An ad whose source declares an impossible video length is no longer refused.** A livestream-style placeholder length used to make such an ad look too expensive to ever fit a run's budget, with a "raise your maximum cost" hint that could not help. The declared length is now ignored and the ad is transcribed and billed on its real duration, like any other.

### 1.0.16 — 2026-08-29

- Clearer wording on uncharged no-speech rows: an ad whose audio turns out to be music-only or silent now says exactly that in `statusReason`, in plain language. The same ads are recognised as before, and they are still never charged.

### 1.0.15 — 2026-08-29

Silent ads can now be read instead of skipped — and every run says how many it saw.

- **New option: Read on-screen text on silent video ads (`includeOnScreenText`, off by default).** A music-only or voice-less Top Ad has its message on the screen, not in the audio. With the option on, the ad's own frames are read and delivered as `onScreenText` (headline, body, CTA, and the full visible text), charged like a transcript — same event, same price, never the extra-minute surcharge. With it off, nothing changes: silent ads ship uncharged exactly as before.
- **An ad with no readable on-screen copy is still never charged.** Silent b-roll whose only visible text is a logo or a placard comes back as an uncharged `no_onscreen_text` row rather than a charged row of noise, and the brand name always comes from TikTok's own metadata, never from reading a video frame.
- **Every run now reports the silent ads it found**, so you can see what the option would turn into results before you turn it on.
- **When the speech service refuses this actor's access mid-run, the ads left over now each get their own row** saying the problem is on our side and that a re-run should work — instead of vanishing into a single line in the run summary. Nothing is charged for them.
- **Extra-minute surcharge accounting is now belt-and-braces**: if that event is ever missing from the price list, a long ad delivers its transcript and reports zero surcharge minutes, instead of listing minutes that were never billed.

### 1.0.14 — 2026-08-29

A busy CDN is no longer reported as an expired link.

- **A busy or briefly failing video CDN is now a temporary, uncharged miss that asks you to re-run** — instead of telling you your whole batch of links was stale and sending you back to re-scrape them. Links that really did expire read exactly as before.
- **The run summary's "your links have expired" note now appears only for links that really did expire**, so a short upstream hiccup can no longer make a fresh batch look old.
- **When Creative Center answers a fresh-link lookup with a block page instead of the ad**, the row now says so and invites a re-run, and the lookup retries on a new connection. It used to be reported as an unreadable page with nothing you could do about it.
- **An ad page that redirects to a sign-in, consent or region page is now an uncharged, retryable miss.** Only a redirect back to the Top Ads listing still means the ad has been delisted.

### 1.0.13 — 2026-08-29

- **A typo in an input field name now gets a helpful pointer instead of sample rows.** A field the actor does not recognise is ignored by the platform, so an input like `adIds` used to look exactly like a blank run and came back with the uncharged demo row — as if the typo had worked. Such a run now returns one uncharged row that names the field it could not recognise, names the fields to use instead (`materialIds` / `videoUrls`, with the shape to paste) and the chaining fields. Nothing is charged, and a genuinely blank run still returns the demo row exactly as before.

### 1.0.12 — 2026-08-29

- **Rate-limit retries now reuse the video already in hand.** When the speech service asks the actor to wait, the retry transcribes the bytes it already downloaded instead of fetching the file (or re-opening the ad's detail page) again — retries are faster and cheaper, and a link that expired mid-run can no longer be misreported as permanently dead when the real cause was a brief wait.
- **A speech-service access problem now stops the run honestly.** If the speech service refuses this actor's access, the remaining ads are no longer processed one by one toward calls that cannot succeed: the run stops cleanly, the status line says how many ads were left and that the problem is on our side, and none of them are charged.

### 1.0.11 — 2026-08-28

- **Interrupted runs now resume without double-charging.** If the platform moves a run to another server mid-batch (or you resurrect a finished run), the actor now recognises every ad it already delivered: nothing is fetched, delivered, or charged a second time, and any charge the interruption cut short is settled honestly. Before, a moved or resurrected run could deliver duplicate rows and charge again for ads you already had.
- A run's time ceiling now counts from when the run started, not from the latest restart, so an interrupted run can no longer live (and cost) longer than the ceiling promises.
- Long-video surcharge budgeting now also reserves for ads still being processed by parallel workers, so a batch of long videos near your maximum cost stops honestly instead of being cut off by the platform.
- Chaining from a mistyped or unreadable Dataset ID now returns one uncharged row naming the ID and what to fix, instead of finishing quietly with an empty result.

### 1.0.10 — 2026-08-27

- Billing accuracy: a row now reports `charged: true` only when a charge was actually made on your run — before, a delivered transcript always said it had been charged, even on runs where nothing could be.
- A video file that arrives cut short is now spotted before transcription. The actor retries it, and if it still will not download in full you get an uncharged row saying so — instead of a transcript of part of the ad that you were charged for.
- A maximum cost per run that covers exactly one transcript now delivers that transcript, instead of stopping with nothing and asking you to raise the cap.
- Runs close to their maximum cost now reserve extra-minute headroom against the current prices, so long videos stop honestly instead of being cut off part-way.

### 1.0.9 — 2026-08-27

- **Price cut.** A delivered transcript now costs $0.020 down to $0.008 depending on your Apify plan tier — the volume price was $0.015. The free-plan price and the $0.005 extra-minute surcharge are unchanged, and you are still charged only when a transcript lands in your dataset.
- The pricing block in this page now shows the new figures.

### 1.0.8 — 2026-08-27

- Added a plain note that this is an independent actor — unofficial, and not affiliated with or endorsed by TikTok.
- The directory of other steadyfetch actors now covers every family (ad creatives, trends and keywords, YouTube, Instagram, jobs, Amazon, and general media) and names each one exactly as it appears on the Store, so a link always lands where the name says. It now sits at the end of the page, just above support.

### 1.0.7 — 2026-08-27

- The README now opens with an **Output** section — a sample results table, a screenshot of a real run's output, and the full JSON row — so you can see exactly what you get back before spending anything.
- Added a copy-and-paste block for AI agents and LLM clients: actor id, a complete input example, every output field, and the exact event prices.
- Added a directory of the other steadyfetch ad-intelligence actors (Facebook & Instagram, Google Ads, LinkedIn, Instagram Reels, Google Trends) with their free n8n templates.
- The example dataset linked from the README is now permanent, so it will not expire.

### 1.0.6 — 2026-08-25

- You can now paste Creative Center ad page links directly — the URL you get browsing [Top Ads](https://ads.tiktok.com/business/creativecenter/inspiration/topads/pc/en) (`ads.tiktok.com/business/creativecenter/topads/<id>`, with or without tracking parameters) works in both the video-URLs and material-IDs fields; a fresh video link is fetched for each. Pasting a listing page (Top Ads or Spotlight) now returns clearer uncharged guidance.

### 1.0.5 — 2026-08-21

- Added a ready-made n8n workflow and a live example dataset to the README.

### 1.0.4 — 2026-08-21

- Billing accuracy: the same ad supplied more than once (URL, id, or chained row) is delivered and charged once; long-video surcharge charges always match the per-row ledger, and runs near the spend cap stop honestly; improved music-only detection (still never charged).
- Fixes: `maxAds` now counts only transcribable ads, so uncharged informational rows always ship; corrupted numeric ad ids are refused with a clear reason instead of fetching the wrong ad; stricter media-URL and download-size safeguards; sturdier transcription timeouts.

### 1.0.3 — 2026-08-21

- Initial release: transcripts + first-3-seconds hooks for TikTok Creative Center Top Ads — from ad video URLs, Top Ads material IDs or detail-page URLs, or chained after any Creative Center scraper run (dataset ID or pasted rows). Brand, title, CTR, cost, likes, industry and objective ride along on every row.
- Built for TikTok's ~6-hour video links: expired links are detected before any download and come back as uncharged rows with the exact expiry time (`urlExpiresAt`); rows that carry a material ID get a fresh link fetched automatically — and that fresh link (with its expiry) is reported on the row whatever the outcome.
- Charged only on delivery: expired links, music-only or voice-less creatives, ads that dropped out of Top Ads, and input mistakes arrive as uncharged rows with the reason stated. The first 3 minutes of each video are included; longer videos add a small per-started-minute surcharge. No start fee.
