- Settings sent wrapped in an extra "input" object are now read. A run whose input arrives as
{"input": {…}} — for example from run_input={"input": {...}} in the Python client — now reads those settings as if sent directly and adds one uncharged note row naming them. Before, the run stopped with a guidance row and transcribed nothing. A setting you also filled at the top level keeps your top-level value. Nothing changes about the price, the input fields or the output columns.
- Links to the sibling ad actors now use short names. The table of sibling ad actors near the end of this page now names each one by what it does instead of quoting its full store title. No input field, output column, charged event or price changed.
- Short run timeouts now transcribe the ads they have time for. A run with a timeout under about 6 minutes used to skip every video ad as
skipped_deadline, even when each ad needed only seconds — a 170-second run over six ads delivered none. An ad is now started whenever the run's remaining time can realistically fetch and transcribe it: about 1 minute 30 seconds of run time left for a typical ad, more for a long video once its length is known. An ad that truly cannot fit still ships as an uncharged skipped_deadline row, and that row now says how much time was left, how much the ad needs and the run timeout to set. No price, charged event, input field or output column changed.
- A run that Apify restarts after it has already delivered keeps its own record of that delivery. If the restarted part of a run delivers nothing new, the run's internal record now keeps what the earlier part delivered and charged, with a note of the restart, instead of being rewritten as if nothing had been delivered; one line in the run log says so. Nothing changes about what is delivered, which rows are charged, the price, the input fields or the output columns.
- The top of this page now says how to follow a competitor over time. A short paragraph near the top explains that the Ad Library changes every day and gives the exact input to save as a Task and put on an Apify Schedule: a watchlist name with New ads only, so each run transcribes and charges only the ads your account has not had before. Nothing changes about how a run works, which rows are charged, the price, the input fields or the output columns.
- The input form now opens the search field with what to type, and the input description shows the scheduled watchlist call. "Search keywords or advertisers" starts with the value to pass and an example; the note on the empty-form sample moved to the end of that help text.
- Long videos near your "Maximum cost per run" are now checked against it before they are transcribed, not after. When several videos over 3 minutes are worked on at once and the limit can pay for only some of them, each one is checked as soon as its length is known: the ones that fit are transcribed and billed for their full length, and the rest ship as uncharged
skipped_budget rows naming the limit — raise it and re-run to get them. Before, both could be transcribed and one billed for only part of its length. The last video a run is working on still delivers as before. Nothing changes for videos of 3 minutes or less, for runs with room to spare, or for any price, input field or output column.
- The README now says how to make a run faster: raise the memory from the default 2 GB to 4 GB. The default run settings (2 GB, one hour) are unchanged, and the price per ad is the same at any memory.
- The run log no longer prints an internal usage summary at the end of a run, and a failed internal bookkeeping write no longer prints where it was being saved. Nothing changes about what is delivered, which rows are charged, the price, the input fields or the output columns.
- Near your "Maximum cost per run", a flat per-ad charge beside the transcript now has its room set aside before the ad is started, and a row lists a charge only when your cap can actually pay it. Such a charge used to come out of the same cap outside the room the run holds for each transcript, so the last ad before the cap could be transcribed with nothing left to bill it, or carry a charge on its row that the cap then refused. Its room is now held together with the transcript's before any work starts, and when the cap cannot hold both, it is that small charge that is left off — never the transcript and never the row. On today's pricing nothing you see changes: no price, charged event, input field or output column changed.
- Progress lines now arrive at least every 30 seconds, including during transcription. The run promises a line at least every 30 seconds while it works, but the check behind that promise could leave up to 40 seconds of quiet — most visibly while a search or a batch of ads was being transcribed. It now speaks after 25 seconds of quiet and checks every 5 seconds, so no gap can run past the 30 seconds the run promises. The lines themselves are unchanged: counts and fixed words only, no row and no charge. No price, charged event, input field or output column changed.
- A failed row and the run log no longer name or quote our upstream services. When the speech or image-text service turns a request away, the row now says so in plain words instead of passing on that service's own message and links. A verdict about your own file ("file is empty") still reads as before. The "How it compares" table now lists four products. Nothing about charging changed.
- Your country's own name now works in the Country box, and a country that only scopes searches no longer files a note against a run that pasted links. "Deutschland", "Österreich", "España", "Italia", "Brasil", "Nederland", "Polska", "Sverige", "Еллάδα"-style spellings, 日本, 한국, 中国, الإمارات, مصر and the rest of the everyday native spellings are read as the market they name, alongside the two-letter code, the three-letter code and the English name that already worked — accents and capitals do not matter, and a run that has to read a spelling still tells you on one uncharged note row which market it searched. A value that names no country at all is still refused, and the row now lists every accepted form. Separately: "Country" scopes keyword searches and advertiser Ad Library page searches and nothing else, so a run that only pasted ad links, a dataset ID or video URLs used to get an extra uncharged row ending "nothing was searched and nothing was charged" sitting next to an ad it had just delivered and charged for — that row now says what the field actually scopes and what the run actually did, or is not written at all when the country was understood and simply did not apply. Uncharged note rows also stop putting the value you typed in the "Video URL" column: a note row carries it in its own
inputField and inputValue fields with "Video URL" left empty, and rows for ads are unchanged — they grow no new column. No price, charged event or input field changed.
- A run billed at the Apify free plan column now says so on its own run page, and names what the same run costs on a paid plan. A delivered result is $0.020 on the Apify free plan, $0.016 on Bronze, $0.0125 on Silver and $0.010 from Gold, and that ladder has been on the store page since the first build. The run page — the surface you are actually looking at while a run spends your credit — said nothing about it, so a buyer on the free plan watching $0.020 go on every ad had no way to see from that page that the same run is half the price one plan up. The status line now carries one clause naming the column that billed the run and what each paid rung costs. Every number in it is read from the same table the run charges from, so it cannot drift from your bill. The long-video surcharge is not quoted: it is $0.005 on every plan and names no column. On a crowded run the clause is the first thing dropped to make room — it is a standing price the store page carries in full, while everything else on that line is a fact about your run. A run on a paid plan reads exactly as it did before. No price, input field, output column or charged event changed.
- On a run where everything went wrong at once, the status line no longer overflows and loses its tail. The run page cuts the status message at 500 characters and replaces the rest with "…", and on a run that piled up misses, failures, unsearched keywords, unresolved links, the charge cap, the run timeout, a speech-service outage, maxAds, a server move and a repeat-memory refusal, this actor's line ran to 1,185 characters — so the platform silently ate the end of it, which on this actor is the sentence saying your next run will be charged again for what this one just delivered. The line now keeps giving way until it fits: the per-reason breakdown collapses into one counted, uncharged clause ("37 ads not delivered, none charged — each row says why") and the run names the one stop that ended it with the setting that moves it. Every number you can act on survives — how many transcripts, how many you already had, how many folded onto one creative and were charged once, what was not charged, and the cap that bound. The reasons themselves are unchanged and still on each row. Nothing about the price, the charged event, the input form or any output column changed.
- Ads pasted under the field name another Facebook ads scraper uses are now read instead of refused. If you send your Ad Library links or page links as
startUrls, or your keywords as searchTerms — the names the store's most-used Facebook ads listing puts on those boxes — this actor now reads them exactly as if you had typed them into "Ad Library links" and "Search keywords", sorting every entry by what it actually is, and says so on one uncharged note row naming the field it used so the next run needs no translation. Links sent in the list-of-objects form that listing produces are read too. Nothing else changed: your own field names behave exactly as before, a value that is not an Ad Library reference is still refused with the same explanation it always had, and no input field, output column, charged event or price moved.
- 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 the ad rows themselves 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.
- A run near its time limit no longer spends up to a minute on a step it cannot finish. Since 1.0.113 the time limit you set has bounded every fetch and every transcription call. Two local steps were still running on clocks of their own: the quick inspection that reads each video's length before the audio is extracted, which took up to thirty seconds whatever the run had left, and the one re-encode this actor does when an image ad is too large for the text reader to accept, which took up to a minute. Both now take the smaller of their own allowance and whatever time the run actually has, so a run in its final seconds spends them closing properly instead of on work that cannot land. And when it is the run's own time limit that ends the audio extract, the ad is now reported the way a time limit has always been reported — one uncharged row naming the setting and what to raise — rather than as a processing failure telling you to re-run something that was never broken. Runs with time to spare behave exactly as before: every step keeps its full allowance. No input field, output column, charged event or price changed.
- Type a country's name into "Country" and the run now searches that market, instead of refusing. Until this build "Country" took a two-letter code and nothing else: someone who typed "India" got an uncharged refusal row, no ads, and no way forward except guessing the code. The three-letter code (IND, GBR, USA), the country's full English name ("India", "United Kingdom", "Saudi Arabia"), and the ordinary spellings people actually use (USA, UK, Great Britain, Holland, South Korea, UAE) are all read now, along with punctuation and accents ("U.S.A.", "Türkiye"). Where the run has to read a spelling rather than a code it says so on one uncharged note row — "Read "India" as IN" — so you always see which market was searched, and the same note tells you the code to set next time. Two things deliberately did NOT change: a value that names no country at all is still refused rather than guessed at, with the row now listing every form that works; and a country can never be swapped for a different one, because the only answers the reader can give are markets in its own ISO 3166-1 table. Two-letter codes behave exactly as before.
- An ad is no longer started when the run cannot finish it. A video ad is a download, an audio extract and a transcription, one after the other. The check that decided whether to start one counted the download and the transcription and left out the extract in the middle, so an ad begun near the end of a run could download its video, spend the processing, and then reach the transcription with no time left — you got no transcript, and the run had spent minutes of its own clock getting there. All three steps are counted now. An ad the run genuinely has no time for gets the same uncharged, retryable row that a time limit has always produced, saying so plainly rather than reporting it as a failure you should re-run; and the audio extract now leaves room for the transcription that has to follow it. Runs with time to spare are completely unchanged. No input field, output column, charged event or price changed.
- A country this actor could not read used to stop your keywords and then search your pasted advertiser page in the US anyway. "Country" takes a two-letter code (US, GB, DE, IN, SA …); type a country's name into it and the run correctly refused to guess which market you meant — but the refusal only ever stopped search keywords. An advertiser Ad Library PAGE link pasted beside them went on and was searched in the US, the market nobody chose, and the note beside it said the search had run "under the country you set" when that country had just been refused. Now one unreadable setting stops every search the run makes, keywords and page links alike: the row that names it tells you the accepted form, and each keyword and each page gets its own row saying it was not searched. Nothing is charged for any of them. Two further things you asked for: "Country" now says in the input form that it scopes advertiser page links too — it always did, so there was no way to pick a market for a pasted page — and if the link you paste names a different country from the one you set, the note says which market the run actually searched and how to change it. The same applies to "Ad format" and "Ad status". Nothing changed for runs whose settings this actor can read, and no input field, output column, charged event or price changed.
- A run now stops when the time limit you set says so, instead of overshooting it and losing its own receipt. Until now the time limit only decided whether a NEW ad was started: once an ad was under way, each step — fetching the creative, extracting the audio, the transcription call, reading an image's text — ran on a clock of its own with no reference to the run's. A single slow fetch or a stalled transcription could therefore carry on well past your limit, and when the platform ended the run in the middle of one you lost the closing summary row, the run output and the final status line. (The transcripts already delivered were still yours and still charged exactly as their rows said — no ad was ever charged twice and none was charged unfairly — but the run could not tell you what it had done.) Every step is now bounded by whatever the run has left, so the run closes inside your limit with its summary, its per-ad rows and its "what's left" explanation intact. A run with time to spare is completely unchanged: every fetch and every transcription keeps the full allowance it always had. No input field, output column, charged event or price changed.
- 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 — which is what a token restricted with "Restrict what Actors can access using the scope of this Actor" does 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 the way a refused account-memory update already is — on the run page, in one uncharged note row carrying the whole explanation, 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). Runs started from the console or with a full-access token are unchanged, as is every price, input and delivered column.
- 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 check was "unavailable", which named neither the cause nor the cure: the same token was used again and the same ads were transcribed and charged a second time. The run page now names the permission to grant and where to grant it, the run's first row carries the same sentence uncharged 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 — nothing about them changed. "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.
- A token that can read your account's memory but not write to it no longer stays silent about it. The other half of the same scope: a restricted token can carry key-value store Read without Write — or without Create, on a store your account does not have yet — and a run started with one opens the memory, skips every repeat correctly, and then cannot record what it has just delivered. Everything looked perfect, and your next run paid for the whole delivery all over again. Now the run page says the memory was not updated and that those ads will be charged again next run, one uncharged note row carries the whole sentence with the permission to grant and where, and the run log keeps Apify's own message and adds the fix after it. The note row lands the moment the first write is refused, so a long run tells you early rather than at the end. Nothing about the delivery changed and nothing new is charged.
- A crowded run page no longer cuts off the sentence about being charged twice. Apify replaces everything past 500 characters of a run's status line with "...", and this actor has the most optional clauses in the portfolio. The clauses now give way in a fixed order — counts, caps and anything about money stay whatever else goes, the "open an issue" ask is the first thing dropped, and the run's own census lines go after it. Four long lines were also shortened without losing a single number. The widest run page this actor can produce now measures 498 characters with the whole money sentence on it; before, that sentence was the first thing dropped to make room.
- 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.
- Nothing else moved: no input field, output column, charged event or price changed.
- Writing a field name in the singular —
url, searchQuery, adLibraryUrl, videoUrl, adId — now returns results instead of an error row. This actor's input fields are plural (searchQueries, adLibraryUrls, videoUrls, datasetItems), and anything sent under another name was ignored: a run that named one ad or one keyword in the singular came back with nothing delivered and one uncharged row saying the field was not recognised. Those names are now read as the fields they obviously mean — a keyword or advertiser name goes to the search, an ad link, an advertiser's page link or a bare ad ID goes to the ad lookup, a Facebook video link goes to the video field, a pasted row goes to the rows field — with one uncharged note row saying which name you used and which field this actor reads, so the next run needs no note. query, queries, keyword, advertiser and adArchiveId are read the same way. Send the same value under both the singular and the plural and it is asked for once, not twice. A run page that still could not read a field now names that field beside the count, instead of naming only this actor's own. Nothing about the input form, the output columns, the events or the prices changed: a field left empty still says so and is never answered with the default sample, pressing Start with nothing set still runs the same sample, and nothing extra is charged.
- Pressing Start with nothing named, or with only a setting changed, now comes back with the number of transcripts it promises whenever there are enough speech-bearing ads to draw from. The sample looks at a couple of extra ads beyond the number it means to deliver, precisely because plenty of ads carry music and no speech — but the run processes several ads at once, and those extra ads were being dropped the moment the ads already under way looked like enough, before any of them had come back. When those came back silent, there was nothing left to fall back on and the sample delivered fewer transcripts than it set out to, sometimes none. An extra ad is now held until the ads ahead of it have actually answered, and is used if they came back without speech. The promised number is still never exceeded, the extra look is still bounded to at most two ads beyond the sample's own count, a silent ad still comes back as exactly that and uncharged, and you are still charged only for transcripts actually delivered. A run where you pass your own keywords, links or ads is untouched. No input field, output column, event or price changed.
- Pressing Start without naming a keyword or an ad now comes back with transcripts, not with an empty result. With nothing named, the run shows you a small sample of current ads for one keyword — and it used to take exactly as many ads as it meant to deliver, whatever those ads turned out to be. Plenty of ads carry music and no speech, so when the ones it happened to take were all of that kind, the sample came back with uncharged "no speech" rows and no transcript at all, while ads that do speak sat unasked in the very same search. The sample now looks at a couple of extra ads and stops as soon as it has the transcripts it set out to deliver. It still delivers the same number of ads it always promised, it still charges only for results actually delivered, and a silent ad still comes back as exactly that, uncharged. The extra look is strictly bounded: at most two ads beyond the sample's own count. If you set "Max ads per keyword" yourself, your ceiling is used exactly as you typed it and is never widened, and a run where you pass your own keywords, links or ads is untouched — "Max ads to process" still counts ads, exactly as before.
- Nothing else moved: no input field, output column, event or price changed.
- A silent video ad whose on-screen text this run could not afford to read now says so on its row, instead of coming back as an ordinary silent ad. This actor limits how much one run may spend reading media that comes back with nothing chargeable on it — a wall of silent or text-free creatives stops buying reads, honestly and uncharged. Until this build that limit only ever stopped an image ad: a silent video with Read on-screen text on silent ads turned on was read whatever the run had already spent, so a long run of silent ads could finish past its own limit while the rows said nothing about it. The read is now checked against the limit before it is made, and on a run that has already spent it such an ad ships exactly as it did before the option existed — its metadata, its duration and the sentence saying the ad has no speech on it — with one more sentence saying its on-screen text was not read and that re-running it with fewer ads ahead of it returns the text. It is never charged for text it did not get. The same sentence now also lands where an unusually text-heavy silent ad needed a deeper read the run could not buy; that case used to ship the plain silent row with no explanation at all. Nothing else moved: a read the run can afford goes out exactly as it did, a charged delivery still widens the allowance exactly as before, and no input field, output column, event or price changed.
- The same video reached two different ways in one run is now charged once, whichever ways they were. This actor has always delivered a row for every ad you asked about and charged only once when two of them run the same video. That worked when both arrived carrying the advertiser's name — two ads from one search, two rows from one chained dataset. It did not when one of them arrived carrying nothing but the file: a video URL you pasted into "Ad video URLs" names no advertiser, and neither does a scraper row that never held a page name, so the run treated "no advertiser" as if it were a different advertiser and bought, transcribed and charged that video a second time. Paste a video URL and search for the same ad in one run, or paste the URL and its Ad Library link together, and you were charged twice for one file. From this build you are charged once: both rows still arrive with the transcript in full, and the second says which ad it repeats. Two ads that each name a different advertiser are still two ads and still charged separately.
- 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.
- The extra, deeper reads an unusually text-heavy ad needs are now counted against the run's own media allowance before they are bought. This actor already limits how much one run may spend on media that comes back with nothing chargeable on it — a wall of silent or text-free creatives stops buying reads, honestly and uncharged. An ad carrying a lot of copy can need up to three reads of increasing size instead of one, and only the first of the three was ever checked against that limit, so a single such ad could spend several times what the run had been allowed for it. All three are now checked, each for what it actually costs. What you may see: on a run that has already spent its media allowance, a very text-heavy ad can ship with its metadata and no text, saying so and naming the reason — it is never charged, and re-running it with fewer ads ahead of it returns the text. An ordinary ad, and any run that is delivering charged rows, is unaffected: a charged delivery still widens the allowance exactly as before. No input field, output column, event or price changed.
- The links to our other scrapers on this page name them correctly again. Several of those actors were retitled on the store, and this page still used their old names. The links always pointed at the right actors; only the words were out of date.
- The input form now says what a run costs before you start it. The text at the top of the input names a call that works, what one ad costs on each plan with the arithmetic for a 25-ad keyword and a 200-ad advertiser, everything that is never charged, and the run option that caps what a run can spend. It is written for an AI agent calling this actor through Apify's MCP server — an agent sees the input schema and not this page — and it is the same answer anyone else opening the form wanted.
- "Ad video URLs" now says in its second sentence where an ordinary Facebook video or fb.watch link belongs. That pointer to our media transcriber (apify.com/steadyfetch/media-transcriber) was already in the field, but sat far enough down the text to be cut from an agent's view of it — so the one wrong turn it exists to prevent kept happening. "Ad Library links" got the same move: that a link we cannot resolve ships as a labelled row and is never charged now sits near the top of the field instead of at the end.
- A run that delivered nothing now says why on the run's own status line, and what to do about it. An ad the Ad Library says is no longer there used to end as a count — "1 ad link could not be resolved" — with the reason waiting in the row below. The line now carries that reason and, where another one of our actors reads what you are holding, names it and the field to paste into. One sentence, for the reason that accounts for most of the run, and the rows stay the full record.
- Asking for more ads than the actor can do in one run no longer stops the run before it starts. "Max ads per keyword" and "Max ads to process" used to refuse anything above 500 and 10,000: the platform checked those limits 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 instead. A bigger ask runs at the limit and the dataset carries one uncharged row saying what you asked for and what the run used.
- An ad with a lot of copy on it now delivers its text instead of coming back empty. A text-dense creative — a report-style ad, a long offer, a dense international ad — could carry more text than our reader returned in one read, and the ad then shipped as an uncharged row with no text at all, as though nothing could be read on it. Such an ad now gets a second, larger read asking only for the visible text, and it delivers. Where an ad carries more copy even than that, the text ships cut short at about 3,500 characters with
truncated: true on imageText / onScreenText and a note on the row saying so, rather than being dropped. truncated is a new column inside those two objects and is false on every ordinary ad. An ad whose image is too big to send to the reader at all now says that plainly and is never charged.
- A keyword search that came back short now tells you why, and the reason is no longer the advertiser's fault when it was ours. Two endings used to be told wrong. A keyword whose every ad this run had already collected under an earlier keyword was reported as ads that "had no video creative to transcribe" — a statement about the advertiser's creatives that was simply untrue; it now says those ads are already in your output under the keyword that found them. And a search this actor's own page limits stopped before your "Max ads per keyword" was filled used to say nothing at all: it now ships one uncharged row saying how many of how many it collected, which of our limits stopped it, and what to change — a narrower keyword, "All formats", or running that keyword on its own. Nothing about either is charged, and a search Facebook genuinely ran out of pages for still reads exactly as it did.
- A search one of this actor's own limits stopped is now recorded as that, instead of as a keyword that matched nothing. The row in your dataset already said which of our limits ended the walk and that Facebook still had more pages to give. The run's own OUTPUT record disagreed with it, filing the same search under "the search returned nothing" — the opposite statement about whether anything you change would help. That ending now carries its own count on OUTPUT: one for a search stopped after two pages in a row that added nothing new, one for a search that reached the 40 pages a single search walks. Nothing in the dataset moved — the same rows, the same wording, the same uncharged status — and the run's status line still reads one total for every search that came back short. A search Facebook genuinely answered with nothing is recorded exactly as it was.
- An image ad too big for our text reader is now re-sent at a smaller size instead of coming back with no text. A large creative — typically a lossless PNG straight off the Ad Library — could be bigger than our reader accepts in one request, and that ad shipped as an uncharged row with nothing read off it, even though the same picture re-encoded at a sensible width reads perfectly. Such an ad now gets exactly one more attempt at a smaller size and then delivers. Only an ad still too big after that ships uncharged, and it says so plainly. One smaller attempt, never a chain of them — nothing about your run's timing or cost changes.
- Nothing else moved: no input field, event or price changed.
- An ad we look up in the Ad Library now comes back with the rest of its Ad Library row, beside the transcript. Every ad found by a keyword, by an advertiser's page link, or resolved from an Ad Library link you pasted carries a new
adLibrary column: when the ad started and stopped and whether it is still running, the platforms and countries Meta lists it on, its landing page with that page's headline and caption, the advertiser's page ID, profile, categories and follower count, the collation it belongs to and how many ad IDs Meta files under that one creative, how Meta classifies it, and the EU spend and impressions bands where Meta publishes them. It rides on every row the run writes for such an ad, delivered or not, so a competitor sweep no longer needs a second scraper beside this one. An ad you supplied as a direct video URL, or one that arrived as a chained scraper row, carries adLibrary: null — we did not look it up, which is a different thing from the Ad Library having had nothing to say about it.
- A note row that points you at another one of our actors now names the box to paste into. When your input uses a field that belongs to a sibling — an Instagram creator, a TikTok material ID, a YouTube channel — the uncharged note row links straight to that actor and names the exact input field there, instead of only naming the actor and leaving you to find the field. It was already doing this for a self-hosted media file; it now does it for every route.
- The Search keywords field says what a query that finds ads actually looks like. An advertiser's page name spelled as it appears on their Facebook page, or words that appear in the ads themselves — and that a website address is not a page name and finds nothing. The commonest wrong turn is now headed off before the run instead of explained after it.
- The top of the store page is now readable by an AI agent. The one-click MCP pin, this actor's id, the input field to fill with a real example, and the field that caps what a run can spend all sit in the opening section now. An agent reads only the first part of a listing, and until this build every one of those sat below the cut.
- A maintenance note. The private run-report this actor writes for our own support — counts only, never anything you typed — now records whether a run that stopped at a cap you set had actually filled it, names a creative our own size limit turned away as its own reason instead of folding it into a general one, and carries the per-event prices the run was billed at. Nothing you can see moved with any of it.
- Nothing else moved: no price or charged event changed, and only delivered transcripts and delivered image-ad text are charged — expired links, text-free images, music-only ads and keywords that match nothing are still never charged.
- A video or audio file you host yourself is no longer a dead end — the row now tells you which of our actors reads it. This actor transcribes Meta ad creatives from Facebook's CDN, so a link to a file on your own storage, a CDN or a signed URL was refused: correct, but the refusal stopped there and left you with a file and nowhere to put it. That happened on real runs. From this build both refusals — a link pasted into "Ad video URLs", and a chained row whose only media link is on another host — name our media transcriber (apify.com/steadyfetch/media-transcriber) and the exact field to paste the link into, so the next step is one click instead of a search. Nothing about it is charged, and a link that really is a Facebook creative, a mistyped one, or an image ad keeps the guidance it always had.
- The same pointer is now on the "Ad video URLs" field itself and in the README, so you can see it before a run rather than after one.
- Nothing else moved: no price, event, input field or output column changed, and only delivered transcripts are charged.
- 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. This is exactly what happened on a real customer run of ours: a 30-second property ad with 25 seconds of clear voiceover came back with nothing at all. 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 above — so re-running the same search handed the same nothing straight back from your own record, without fetching or listening to the ad at all. One customer hit exactly that five runs in a row, including once after switching on the on-screen-text option that our own row had told them to try. From this build, an ad whose remembered answer was an empty one — no speech found, no on-screen text found, no text on the image — 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, from the run that delivered it, and is never charged a second time. Switching on "Read on-screen text on silent ads" now also re-tries the silent ads that option exists to answer, instead of replaying the same refusal.
- Nothing else moved: no price, event, input field or output column changed, and only delivered transcripts are charged.
- A run that hits a block now waits a length that fits your run, instead of skipping the wait entirely. When the Ad Library refuses every connection, the run pauses and walks the whole ladder again on fresh exits. 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 in a few minutes" with almost all 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 to 60 seconds — so a block that lifts quickly costs you far less waiting. A short run, a small
maxAds and the untouched Start form now all get a real second walk where they used to get none.
- The waiting 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 a music-only ad, an ad Meta has taken down, a link that would not open on that run, a creative link past its expiry, our own image reader failing and our own per-run allowance stopping the ad all 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.
- 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, no event, no input or output field was renamed or removed, and only delivered results are charged.
- An advertiser's Ad Library page link now works — it searches that advertiser. Pasting the address bar from an advertiser's Ad Library page (the kind ending in
view_all_page_id=…, with no ?id= in it) used to come back with a row explaining that it pointed at all of their ads rather than one, and suggesting you type their page name into Search keywords instead. That was honest, but it was still a refusal of something you had already told us clearly. The run now reads the page out of the link and searches that advertiser's ads directly, under the country, ad format and status you set, with your own cap. Each ad found is delivered and charged exactly as a keyword search would be — nothing new is charged — and one uncharged note row says the link was read that way. A link that names neither one ad nor a page still gets the explanation it got before.
- Two ad IDs running the same video are charged once. Meta lists the same video under separate ad IDs when an advertiser runs the same creative more than once, and both can come back in a single search — one search page in our own testing held four such pairs among thirty ads. Both were being downloaded, transcribed and billed. From this build the video is transcribed once: you still get a row for every ad ID you asked about, each carrying the transcript, hook, language and duration in full, and only the first is charged. The second row says which ad it repeats. Two genuinely different ads are never merged — the run compares the video file itself and the advertiser, not the web address, which changes every time Facebook hands it out.
- Field names from our other transcribers are accepted here. If your input uses
urls, keywords, advertisers or accountOwners — the names our Media Transcriber, LinkedIn and Google ads transcribers use — this actor now reads them instead of ignoring them, routes each entry to the right place, and adds one uncharged note naming its own field so the next run needs no translation. Nothing was renamed: every field this actor has always had is unchanged. A field naming something the Facebook Ad Library has no equivalent for (handles, reelUrls, materialIds, channels, domains) is still not used, but the row now says which actor it belongs to and what works here, instead of saying it is not an input field at all.
- Two more inputs are read instead of turned away. An ad ID or an Ad Library link typed into Search keywords is now used as the ad reference it is, rather than searched for as a phrase that matches nothing. A Facebook image address pasted into Ad video URLs is now told it is an image creative — which this actor does read — and pointed at Dataset items, rather than only told what it is not.
- Nothing else moved: no price, no event, no input or output field was renamed or removed, and only delivered results are charged.
- A running job now tells you it is alive. This actor went quiet in two places, and between them they covered most of a run. Searching the Ad Library for a keyword said nothing at all until the search was finished — and on a busy day, with Meta throttling us, that one step can run for the better part of an hour. Each individual ad was the same: the download, the audio extraction and the transcription happened in silence until the ad was delivered, with four ads in flight at once. Nothing was wrong during either stretch and the work was going ahead normally, but there was no way for you to tell, and a run you cannot distinguish from a stuck one is a run you end up cancelling. From this build the log carries a line at least every thirty seconds while the run is working: how long it has been going, what it is doing right now ("searching the Ad Library", "transcribing the ads"), and how many ads have been delivered so far. The same line now also appears on the run page beside the run itself, so you can see it progressing without opening the log at all. The opening line states that cadence up front, so a gap longer than thirty seconds is something you can act on rather than guess about.
- Nothing else moved: no price, event, input field or output column changed, and only delivered transcripts are charged.
- 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, no text on the image) 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 image or 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.
- 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.
- An Ad Library page link is no longer told it is not an Ad Library link. Pasting the address bar from an advertiser's Ad Library page — the kind of address that ends in
view_all_page_id=… and has no ?id= in it — into Ad Library links came back with a row saying it was "not a Facebook Ad Library ad link or ad ID". That was simply untrue: it is an Ad Library link, it just points at all of an advertiser's ads rather than at one ad. The row now says exactly that, and says what to do next: put the advertiser's page name in Search keywords to get their ads, or open one specific ad and paste that ad's own link.
- The same message on both fields. A page link pasted into Ad video URLs already got the honest wording; both fields now share one message, so the two can never drift apart again.
- A long address is quoted honestly. A pasted address longer than 200 characters is still shortened in the row, but now ends in
… so it is clear the row is quoting part of it — before, the shortened address could lose the very part that explained the problem.
- Nothing else moved: no price, event, input field, output column or charge changed. These rows are uncharged, exactly as before.
- A block of links pasted into one row is now read as the list you meant, whatever separates them. "Ad Library links" and "Ad video URLs" each take one link 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 valid 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.
- An ad your cost cap or the run timeout left unstarted is no longer recorded as delivered. Those ads have always shipped as uncharged
skipped_budget / skipped_deadline rows, and that has not changed — but the row was also being written into the run's own delivery record. So if that run was interrupted and resumed, or picked up again after you raised Maximum cost per run, it read those ads as already done and never fetched them: the cap you raised bought nothing for them, and the run's summary counted them among the ads it had delivered. A skipped ad is now recorded as skipped, so resuming a run — or resuming it with more room — collects exactly the ads that were left behind.
- You still never read the same skip twice. A resumed run that reaches the same stop for the same ad counts it and writes no second row.
- Nothing was fetched and nothing is charged for these rows — the ad was never started, so there is no download, no speech-to-text, no image read and no result fee.
- Nothing about pricing, events, input or output columns changed — the correction is in a hidden bookkeeping field, not in anything the Output tab shows.
- Every ad the run timeout left behind now arrives as its own row. When a run reached its time limit before an ad's turn, that ad was simply absent from your dataset: the run page said how many were left, but nothing named them, so a list of ten ads that ran out of clock after three gave you three rows and no way to tell which seven were missing. Each one now ships as an uncharged
skipped_deadline row carrying everything the run already knew about it — the ad id, the page, the ad text, the creative link — and a reason saying the run reached its time limit before it was started.
- The row says how to get it: raise the run timeout, or split the batch, and re-run. The rows are flagged
retryable, so you can filter exactly what a re-run would bring back.
- Nothing was fetched and nothing is charged for these rows — the ad was never started, so there is no download, no transcription and no result fee.
- Ads stopped by your cost cap are unchanged — they already shipped as uncharged
skipped_budget rows, and this is the same rail for the clock.
- Nothing about pricing, events, input or output columns changed.
- 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 an image ad's on-image text, 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.
- An ad whose cache server the run cannot reach is now fetched from Facebook's global server, at no extra cost. Facebook hands out some ad files from a cache inside an internet provider's own network, and a machine that has no route to that cache can never download them however long it waits or however it connects — which is why yesterday's fix, which tries a different connection route, could not answer this on its own. The run now asks Facebook's main media server for exactly the same file first: the link, the file and the signature are unchanged, only which server is asked. It is tried before anything is bought or waited for, and only when the server really could not be reached — a server that answers, even to refuse, is not asked twice.
- The same for image ads, whose on-image text is a step you pay for.
- The three steps a run can take for one ad are now ordered by what they cost you: the global server first (nothing), then a different connection route, then the single wait. Ads that are genuinely gone — an expired link, a creative too large to download, a video with no sound — still come back at once, as before.
- Nothing you see changed otherwise: no price, event or output column moved, waiting and re-routing are uncharged, and only delivered transcripts and image-text extractions are charged.
- An ad the servers would not hand over on one connection is now fetched over another. When the machine a run landed on could not reach Facebook's video or image servers at all, every ad in that run failed the same way: the run waited a minute or two for the block to lift, tried again from the same place, and ended with nothing delivered and nothing charged — while the very same links, run again a minute later on another machine, came back in full. Waiting could never have fixed it, because the problem was the route out, not the ads. A run that meets that now tries a different connection route first, and only falls back to waiting if the second route is turned away too. Ads that are genuinely gone — an expired link, a creative too large to download, a video with no sound — still come back at once, as before; no amount of routing changes an answer.
- The same for image ads, whose on-image text is a step you pay for, so a run that could not reach the image servers used to hand back uncharged "please re-run" rows with almost all of its time unspent.
- A run that cannot reach the servers now says so in words. Those rows carried a raw network error and an internal address instead of a sentence — nothing you could act on. They now read: the media servers could not be reached from this run, nothing was charged, please re-run.
- The run's own record now names which step failed — the download, the file itself, or the speech service — instead of one undivided "failed" count. No row, column, price or event changed, and the totals are the same numbers split three ways.
- Nothing you see changed otherwise: no price, event or output column moved, waiting and re-routing are uncharged, and only delivered transcripts and image-text extractions are charged.
- A speech service that will not answer is now waited out, not reported. When an ad's audio was ready and the speech service kept refusing, the run made three calls in about a second, gave up on that ad and asked you to re-run — while nearly all the time you had paid for went unused, and the download step right before it was already willing to wait minutes for exactly the same kind of refusal. A run that still holds time now pauses once for that ad (a minute and a half to three minutes, varied so parallel runs do not retry in step) and tries again on the audio it already has, so the pause never re-buys the video. If the service sends its own "try again in N seconds", that still wins.
- Image ads get the same wait. Reading the text on an image ad is a step you pay for, and its download was the one paid step with no waiting at all — three tries about a second apart and then an uncharged "please re-run" row. It now takes the same single pause on the same budget. One pause per ad, whichever step needed it.
- A chained dataset with nothing in it now says so. Pointing the run at a dataset that had been read successfully but held no ads ended the run with an empty result and no explanation at all. It now comes back as one uncharged row saying the dataset was read and had no ads in it, with what to check next — and the run's own record counts it, instead of the run disappearing from the record entirely.
- Nothing you see changed otherwise: no price, event, output column or charge moved, waiting is uncharged, and only delivered transcripts and image-text extractions are charged.
- Withdrawn before it was ever tagged as the current version. This build carried the changes released as 1.0.87 above. Its release check ran two sample runs side by side on the same build: one delivered and charged normally, the other could not reach the ad-video servers at all from the machine it happened to land on, so the build was not promoted and no run ever used it. 1.0.85 stayed the current version until 1.0.87 replaced it.
- The run's own record now says how many ads it was asked for, and names every ad it did not deliver. A run that searched a keyword reported its ask as the maximum-ads cap — 1,000 by default — however few ads the Ad Library actually listed, so a search that found twenty read as twenty of a thousand; and a run that set 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 each search listed, the ad links and dataset rows you sent, one for each keyword a refused setting stopped, and one for a search that was answered with nothing. 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, 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.
- A keyword stopped by a setting this actor does not offer is now recorded against that setting, so one mistyped dropdown reads as one strict setting rather than as unexplained missing ads. The run page's wording is unchanged: it still says one input error, however many keywords that refusal stopped.
- Nothing about your results or your bill changed — no row, price, event, column or charge. Delivered ads are charged; misses, skips and guidance rows stay uncharged.
- A run that waits now always keeps room to finish and report. When the Ad Library turns every connection away, the run can pause and walk the search ladder again — but the check that decided whether a pause was affordable kept back only half a minute for that second walk, while a real walk here takes about a minute and a half. On a short run — the five-minute limit the daily health check uses among them — a shorter pause could be allowed and the walk after it then ran into the time the run keeps back for writing your rows and the summary, leaving about sixteen seconds where thirty were needed. The check now keeps back a whole walk, measured from this ladder's own attempts, pacing and backoff, so a pause is only taken when the run can finish the walk the pause was for. A run with room to wait waits exactly as it did before.
- Charges are unchanged. Waiting is uncharged, a blocked search is uncharged, and only delivered ads are charged.
- A search Facebook blocks is now waited out instead of reported. When the Ad Library refused every connection a run made for a keyword — a rate limit, a throttle, an unreadable answer — the run gave up after about a minute and a half and asked you to re-run, while nearly all of the time you had paid for went unused, and a re-run started minutes later usually met the same block. A run that still has time now waits for the block to lift (a minute and a half to three minutes, varied so parallel runs do not retry in step), then searches again from a fresh connection. A keyword that Facebook genuinely answered with no ads, and an ad that is no longer in the Ad Library, come back at once as before — no waiting can change an answer. The same waiting now covers a pasted Ad Library link, and a video the content network refuses to hand over is downloaded again once the block has been waited out.
- The default sample can now use that waiting. Pressing Start with nothing set (or with only settings set) runs a small sample under its own time limit, and that limit was shorter than a single blocked search — so on the run most people see first, the waiting above could never have happened. The sample's limit is now sized to hold a blocked search, the wait, a second search and the time needed to write the results: seven minutes at its very worst, and a sample that is served still finishes in well under two.
- The default sample always runs live. It is no longer answered from the record of ads your account already has, and its ads are not added to that record: a bare Start searches the Ad Library, transcribes what it finds and charges for it like any run, every time. Pressing Start twice therefore pays for the sample's three ads twice — a few cents — and in exchange the sample always shows current ads and always proves the actor is working. Any run that names its own keywords, ad links, dataset or video URLs is unchanged: ads your account already has still come back from the run that delivered them, uncharged.
- A run that pasted Ad Library links reports how many it was asked for. The run's own record of the size of your request counted one for a whole list of pasted links; it now counts the links.
- Blocked searches and empty searches are now counted apart in the run's record, so a Facebook block can be told from a keyword that matched nothing. No row, price, event or output column changed.
- Pasted Ad Library links are answered with their own ad again. Since 1.0.76, once one pasted link had been answered in an account, every later pasted link in that account — in "Ad Library links", in "Video URLs", or as a link string among dataset items — could come back as a repeat of that FIRST ad: its ad ID, page name and verdict,
repeat: true, uncharged, without the pasted ad ever being looked up. The run now looks every pasted link up unless the account already has that ad. Accounts affected before this build are answered correctly on their next run, with no change needed on your side. An ad your account already has still comes back as a repeat, uncharged, under its own ad ID and its own video or image file — now including an ad first delivered from a video URL and pasted as a link later.
- A remembered row is never handed back as a different ad. When the stored row and the ad being run both carry an ad ID and they differ, the ad is processed as new and charged as new.
OUTPUT.repeatKeysIgnored counts the entries in the account's memory this build no longer matches; OUTPUT.repeatMismatches counts the rows it declined to hand back. Nothing in the memory is deleted, and no price, event or row shape changed.
- The default sample delivers again for accounts that have not run it before. Since 1.0.80 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 keywords, ad links, video URLs or datasets were never affected.
- An ad near the run's time limit now stops cleanly instead of being cut off mid-transcription. Downloading a video ad and transcribing it takes time; if the run does not have enough left for both, the ad is not started — it 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.
- Setting a country, a format or a cap and pressing Start now gives you ads, not a note. Clicking Start with nothing set has run a real 3-ad sample since 1.0.58 — but changing one setting first and leaving the search box empty was treated as an unfinished input, so it came back with one uncharged row of advice and no ads at all. A buyer who narrowed the search was served worse than one who touched nothing. Now the same sample runs under the settings you did set: your country, ad format, ads-to-include, cost caps, image-text and on-screen-text options and watchlist all apply, and everything you did not set takes the sample's own value. It is charged like any run, and one uncharged note row names the settings it used.
- A limit you set is a ceiling, not a target. A sample run under
maxAds: 50 still delivers the sample's three ads — nobody is billed for a large run they did not ask for — while a limit below that binds normally, so maxAds: 1 delivers one.
- A typo, and an empty list, still get the advice row. A field name this actor does not recognise is named back to you, and a search-keyword, ad-link or dataset field you set and left empty is still your list, not an untouched form — both come back as before, uncharged.
- The run's status line and the run log now say when a sample ran under your settings, and the
sample_note row explains it in the results.
- A refused search filter no longer swallows your whole keyword list. "Ad format to search for", "Ads to include" and the country scope the SEARCH, so an unrecognised value stops the search — that part is right, and searching on a filter you did not choose would deliver, and charge for, different ads. But until now the run emptied the list: eight keywords came back as ONE guidance row. Every keyword you sent now gets its own uncharged row saying it was not searched and which field stopped it, and the status line counts them apart from the input error itself. Pasted ad links, chained dataset runs and direct video URLs are unaffected, as before.
- Every input problem now names the field it was about in the run's own record — the media type, the active status, the country, the ad links, the search keywords, a watchlist name — instead of one undifferentiated "input error". The field NAME only; nothing you typed is stored.
- 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, which is 0 on exactly the runs worth looking at.
- 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.
- README wording: an ad you already have is described as uncharged on re-run. The page said it comes back "at no charge"; it now says uncharged, the same word the rows and the run status already use. Nothing about pricing, events, input or output changed.
- An ad already delivered to your account is never charged a second time. Re-running the same video URLs, links, search or chained rows used to transcribe and charge every ad again. The run now recognises an ad your account already has — under its Ad Library ID, its video file or its image 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 fb-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 competitor set. 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.
- 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.
- 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 download that fails without a message now names the failure instead of ending with "download failed:" and nothing after it.
- 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.
- A problem inside the speech-to-text service is no longer reported as a problem with your 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. Nothing about what is charged changed.
- Ad Library lookups now keep one network route for the whole run again. A routine notice Facebook attaches to every good response was being read as a refusal, so the run switched to a fresh route after each successful call — and frequent route changes are themselves what triggers a rate-limit wall. A real rate-limit answer still switches route exactly as before.
- 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.
- 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.
- Vendor calls on media that yields nothing chargeable are bounded per run — a small allowance that every charged delivery widens, so a paid run is never cut short; past it, items ship as uncharged
vendor_budget rows.
- 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.
- Run status lines fit the run page again; sample-run wording shortened.
- Rows that carry no result fee are now described as uncharged, not free — running any actor still uses platform time.
- 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.
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.
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.
A value the two dropdowns do not recognise now stops the search instead of quietly running it on the default filter.
Ad format to search for (mediaType) and Ads to include (activeStatus) used to answer an unrecognised value — a typo, a synonym such as "paused" or "gif" — by searching on the default (video / active) and mentioning it in an uncharged row. That still delivered, and charged for, ads matching a filter you had not chosen. Now the search does not run at all: you get one uncharged row naming the value you sent and the options that field accepts, and nothing is charged. Leaving the field out, or sending null, still means the default exactly as before, silently. Pasted ad links, chained dataset runs and direct video URLs are unaffected — neither dropdown applies to them.
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.
Completes the internal cost-accounting change from the previous build — nothing changes in your rows, prices, charges or status line.
A storage fix on our side: runs started from any account now contribute to our internal cost accounting the same way our own runs do. Your dataset, your charges and the status line are unchanged.
Our cost records are now complete for every run — nothing changes in your rows, prices, charges or status line.
Runs started from any account now contribute to our internal cost accounting the same way our own runs do. Your dataset, your charges and the status line are unchanged.
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.
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.
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 transcript. It now runs a small real sample — the US Ad Library searched for currently running video ads matching "fitness app", up to 3 ads — transcribed and charged like any run, so the first thing you see is the actual output. The status line says it was the sample and how to run your own search. If the Ad Library cannot be searched at that moment, you still get one uncharged row explaining it, never an empty result.
Text-heavy image ads are now read in full, and watchlist timestamps match exactly between your list and your rows.
- An image ad carrying a lot of copy — a report-style creative, a wall of bullet points — 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 image ad. A creative that still cannot be read reliably ships as an uncharged row that says so, never as a charge.
- On a watchlist run, the
firstSeenAt your list remembers is now byte-for-byte the value on the row you received. Previously the two could differ by a few milliseconds, so a row from the first run and the same ad on a later run did not read as the same instant. Existing lists keep working; nothing is re-charged.
- Screenshots in this description load from the right place again.
Watchlists: re-run the same competitors on a schedule and pay only for the ads that are new.
Until now every run was a one-off. Ask for the same advertiser tomorrow and you got — and paid for — the same ads again. Two new fields change that:
- Watchlist name (
watchlistId) — give the run a name such as acme-competitors and the actor remembers every ad it has answered under that name, in a key-value store in your own Apify account (fb-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, image text or on-screen text, or a final uncharged verdict such as no speech. An ad it could not answer (a failed download, a link it could not resolve, 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 rewritten at the same time: what you get and the price up front, a scheduling recipe for the watchlist, a comparison with the other ways to get this data, and the one-click MCP link.
The step that reads text off a creative moves to our provider's current engine.
The engine behind image-ad text extraction — and behind on-screen text on silent video ads, if you have that turned on — 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 Facebook and Instagram creatives before this shipped. The same text comes back, and the rule that matters most is unchanged: a creative with no readable ad copy is still an uncharged, honest miss and is never charged — verified on blank frames, product photos with no copy, and b-roll whose only visible text is a logo. On a text-heavy creative the new engine fills in the headline field a little more often. Nothing about what this actor charges changed.
Completes 1.0.53: videos between two and twenty minutes are now covered too.
1.0.53 gave each creative a time budget earned by its own length. On the first live run of that build the very long ads (twenty-one and sixty-three minutes) transcribed correctly for the first time — but ads in the middle of the range, roughly two to twenty minutes, came back as failed_download instead. The new budget was being computed as a fraction of a millisecond, which the underlying process launcher refuses outright; only durations between the fixed lower and upper bounds could produce a fraction, so the very short and the very long ads were unaffected.
Two things changed:
- The budget is always a whole number of milliseconds.
- Anything that stops the extraction from starting is now reported as an extraction problem, not as a download problem. Before, it read as a network hiccup, so the ad's media was downloaded again — twice more — before the run gave up. One attempt now, one honest uncharged row.
No row was ever charged for either failure. Ads under two minutes behaved correctly throughout.
Long video ads now transcribe. Until this build, an ad much past ten minutes came back as a failure — and the store page's "any video length" promise was not true.
On a 20-ad run of real Facebook video ads, five came back as failed_processing. Every one of them was a long-form creative: 12, 13, 14, 15 and 63 minutes. Nothing under five minutes ever failed. Short ads were never affected, and nothing was ever charged for a failed row — but a quarter of that run was lost, and the rows said nothing a buyer could act on.
The cause was a fixed ceiling on how long this actor would spend pulling the audio out of one creative. It was set once, for short ads, and never grew with the video. A 63-minute ad needs several minutes of that work; it was being stopped after two and reported as a broken file.
What changed
- 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 long ad gets the minutes it needs; a short ad behaves exactly as it always did.
- A silent ad is now recognised before any extraction work starts. A video with no audio track has always shipped as an uncharged
no_audio row (and, with on-screen text extraction turned on, as an on-screen-text result instead) — that verdict is now reached from the file's own stream list rather than after a failed attempt, so it is faster and cannot be confused with anything else.
- "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 tells you what to do: re-run it, and give the run more memory — memory is also CPU on this platform, and CPU is what a long video needs.
- 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.
Verified on the exact ads that were lost, including the 63-minute one: full transcript, 1,765 timed segments.
The two dropdown fields now accept null as well, so a template can null every field it does not set.
1.0.51 made an unset field sendable as null — but it could not cover the two dropdowns, Ad format to search for (mediaType) and Ads to include (activeStatus). A dropdown is validated against its list of allowed values, and null is not on any list, so a template that nulled all its unset fields still had its run refused before it started.
Both dropdowns keep their options and titles in the input form, and both now accept null as "use the default" — video and active respectively. {"searchQueries": ["fitness app"], "mediaType": null, "activeStatus": null, "maxAds": null} now runs exactly like {"searchQueries": ["fitness app"]}. With this, every optional field on this actor takes null.
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 telling you which value it did not recognise and what it used — the same way this actor has always answered a country code or a link it could not read. Nothing about what this actor delivers, or charges, changed.
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.
Optional fields now accept an explicit null and read it as "use the default" — identical to leaving the field out. {"searchQueries": ["fitness app"], "country": null, "maxAds": null} runs exactly like {"searchQueries": ["fitness app"]}, searching the US with the standard 1,000-ad cap. A null never counts as a value: it will not read as an empty keyword list, and it will not turn on an option that defaults off. (The two dropdown fields still needed null support at this point — 1.0.52 finishes the job.)
Nothing about what this actor delivers, or charges, changed.
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 Facebook creatives — brand films, long title cards, anything that opens on music or a logo sting — 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.
A safety belt on what this actor may charge you for. Most of what a run writes is deliberately not charged: the sample row a blank run returns, the guidance row when an input field is missing or misspelled, a keyword search that found nothing, an ad whose creative link had expired, a silent ad, a download that failed, and every ad left over at your maximum cost. Those rows are the product — they are how you can see what happened to each ad you asked for. This build makes that promise structural: the run now reads which billing model it is on before it writes anything, and if this actor were ever moved to a model that charges for each result row, those uncharged rows would be reported in the run summary and status line instead of being written as rows you would be charged for. On such a model a delivered result also can no longer be charged twice, and a run resumed after an interruption cannot re-charge for results the first attempt already billed. Nothing charged today changes: on the current pricing every one of those rows still ships exactly as before, still uncharged.
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 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 and image downloads now take a route the run can always reach, so those ads come back as transcripts instead of "please re-run" rows. Ads left over at the cap are now marked retryable: true alongside their skipped_budget status, and the run summary names your maximum cost in dollars instead of just mentioning it. Prices and what is charged are unchanged.
The long-video surcharge is now spelled out in the input form. The Max ads to process field's description now says what the Pricing tab and this page already did: the first 3 minutes of every video are included in the transcript price, and each started minute beyond that is charged as a "Long-video minute (surcharge)" at $0.005 — only on delivered transcripts, never on image ads or on-screen text. The output table on this page now says the same next to its "any length" promise. Prices and what is charged are unchanged.
Every ad you asked for gets a row — including the ones your cost cap could not cover. When a run reaches its maximum cost, each ad left over now ships as an uncharged skipped_budget row with everything already known about it (advertiser, ad text, CTA, archive id), so the dataset always holds one row per ad and the run summary's count matches what you can see. Silent ads now tell you how to read them: a no_audio row and the run's status line both point at the "Read on-screen text on silent ads" option — many music-only ads carry their whole message on screen. Dynamic product ads ship real copy: ads Facebook serves as a {{product.name}} template now deliver the wording of the creative variant that was actually transcribed; in the rare case no variant copy exists, the row says so with adTextIsTemplate: true instead of looking broken. Prices and what is charged are unchanged.
Search for ads by keyword or advertiser — no ad IDs needed. Put something like fitness app, or a competitor's page name, into the new Search keywords field, pick a country, and the ads running there come back already transcribed. Nothing else is required: no links to paste, no other scraper to run first. Each keyword has its own cap on how many ads it may return, you can include stopped ads or image formats, and every delivered row says which keyword found it. A keyword that matches nothing, or a search Facebook declines that minute, ships as an uncharged row saying exactly that — searching itself is never charged, and the price per delivered result is unchanged. Ad Library links, video URLs and dataset chaining all work exactly as before.
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 declared country, now ships as an uncharged no-speech row. Genuinely spoken ads, in any language, are delivered and charged exactly as before.
- Runs now use a fraction of the memory, especially on long videos 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 a 10-ad batch of one-minute videos, peak memory dropped about 4×. Long creatives no longer push a run toward its memory ceiling, and the