I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads Truth Social: you hand it usernames or post links and it returns one row per post — text, exact publish time, likes, ReTruths, replies count, media files, link preview card, quoted post, author. No account, no cookies, no login. Truth Social runs a Mastodon-compatible API, and three of its endpoints answer a logged-out visitor: account lookup, an account's statuses, and a single status.
I have been running a small health check on it twice a day since 5 October, which accidentally left me a logged corpus: 169 runs, 4,475 rows, 1,000 distinct posts, some of them read 53 times. Today I added one deliberate pull — 250 posts of @realdonaldtrump, 18 seconds, $1.00 on the Free plan — and the two together say something I had wrong in my own documentation.
The posting rate is not a rate
My health check asks for the 20 newest posts, twice a day. For most of October that was comfortable: 12 to 27 new posts a day from @realdonaldtrump, so a daily read caught almost everything.
Then the 250-post pull came back with this day distribution:
2026-11-07 3 2026-11-06 7 2026-11-05 4 2026-11-04 9 2026-11-03 206 <- 2026-11-02 21 (partial - the 250 cap cut here) 206 posts on 3 November — the day before the US midterms. 141 of them carry a link card to swampthevoteusa.com, state-by-state endorsement lists: "TO THE GREAT PEOPLE OF KENTUCKY, GET OUT AND VOTE FOR: – Andy Barr for U.S. Senate…". The median gap between two of those 141 posts is 9 seconds, and 119 of the 140 gaps are under 30 seconds. One 15-minute window, 13:15–13:30 UTC, holds 52 posts.
So I simulated what a schedule would have delivered that day, replaying the real timestamps against a read that returns the 20 newest posts:
| Schedule | Reads in the day | Posts delivered of 206 |
|---|---|---|
| every 15 min | 97 | 138 (67%) |
| hourly | 25 | 79 (38%) |
| every 3 h | 9 | 58 (28%) |
| daily | 2 | 20 (10%) |
A 15-minute monitor — the cadence I recommend in my own README — would have missed a third of election eve. Not because of a block, a timeout or a bug: because 20 is smaller than 52.
The fix is not a faster schedule, it is a bigger bite. maxPostsPerUser costs nothing when nothing new was posted, because my Actor charges per row and rows it has already delivered under an "only new posts" memory are free. Setting it to 200 on a 15-minute schedule costs exactly the same as 20 on 364 quiet days a year, and on the 365th it is the difference between a feed and a sample. I have raised my own default guidance accordingly.
postedAfter does not define your window — the cap does
I asked for postedAfter: "7 days" with maxPostsPerUser: 250. What came back spans 4.2 days, from 2 November 23:08 to 7 November 03:12. The timeline is read newest-first; the cap hit before the date did, and the oldest row sits mid-burst on 2 November.
The row count alone cannot tell you this happened — 250 is just 250. What tells you is comparing the oldest createdAt to the date you asked for. If they disagree, your window is truncated and you have no idea what is behind it. Any date-bounded pull needs that assertion:
oldest = min(r["createdAt"] for r in posts) assert oldest <= wanted_from, f"capped at {oldest}, asked for {wanted_from}" Catch a post in the first minute and its engagement is a rounding error
The upside of a 15-minute schedule is latency. The cost is that every engagement number on a fresh post is close to meaningless, and my logged corpus happens to measure exactly how close.
Twelve posts were first read within six hours of publication and read again just past the 24-hour mark. Likes at first read, as a share of the 24-hour value:
| Age at first read | Likes | % of the 24 h value | Account |
|---|---|---|---|
| 6 seconds | 2 | 0.0% | realDonaldTrump |
| 1.3 min | 183 | 1.9% | realDonaldTrump |
| 2.5 min | 216 | 2.0% | realDonaldTrump |
| 23 min | 2,592 | 21.6% | realDonaldTrump |
| 35 min | 556 | 21.8% | WhiteHouse |
| 1.7 h | 8,256 | 49.0% | realDonaldTrump |
| 2.2 h | 3,494 | 48.5% | realDonaldTrump |
| 3.0 h | 6,034 | 58.1% | realDonaldTrump |
| 4.7 h | 1,424 | 70.7% | WhiteHouse |
| 5.2 h | 1,675 | 71.5% | WhiteHouse |
The curve is monotone across two accounts and three orders of magnitude of audience. Half the likes arrive in the first two hours; a post caught at one minute old shows you 2% of them. The 6-second row is the one worth remembering: likesCount: 2 on a post that finished the day at 13,707.
Practically: a monitoring pipeline must treat the first row as text and timestamp only. If you want engagement, re-read the post once a day later. The same post asked for twice is returned and billed once per run, so a second pass costs one row, and the exact createdAt makes the age arithmetic trivial.
One more wrinkle: counts are not monotone. Across 3,086 consecutive re-reads of the same post, 57 (1.8%) came back with fewer likes than the previous read — people unlike things. The largest drop I logged was 5. So max() across reads, not last(), if you want a high-water mark.
Follower counts, unlike Telegram's rounded 6.3M, arrive exact to the digit, which makes a daily series readable. @realdonaldtrump over my 20 logged days: 13,094,290 → 13,173,870, +79,580. The interesting rows are the outliers — −121 on 1 November (a net loss, so the field really is a live count and not a counter) and +21,193 on 4 November, the day after the 206-post burst, against a ~1,500/day baseline.
A third of the sample had no text — and then none of it did
My Actor's text field is plain text with the HTML stripped. In the October corpus, 190 of 539 @realdonaldtrump posts (35.3%) had an empty text. All 190 had media: 103 single videos, 79 single images, 8 image sets. A keyword filter or an LLM classifier reading text sees nothing on a third of his posts.
Quote posts make it worse. Of 65 quotes in that sample, 45 had an empty quotedText — the quoted post was itself a bare video or picture — and 40 of those also had no text of their own. The row is structurally complete and semantically empty; the only thing to read is a .mp4 URL.
Then the election-week pull came back with just 12 of 250 (4.8%) empty, median text length 1,116 characters, and 164 of 250 carrying a link card. Same account, same field, same parser. So the useful number is not "35%" or "5%" — it is don't branch on text being present. Check mediaCount and quotedMediaUrls before you decide a post said nothing, and expect the ratio to move with whatever the account is doing that week.
What the site will not give you without a login
Measured on 1 October across the public API surface, and still true: /api/v2/search, /api/v1/trends, the trending-truths feed, reblogged_by, favourited_by and the reply tree all answer 401 to a guest. /api/v1/statuses/<id>/context is hard-blocked by the WAF every time. The hashtag timeline is the sneaky one — it answers HTTP 200 with [], so a naive client reports "no posts about that tag" instead of "you are not allowed to ask".
So: no search, no hashtag feeds, no trends, no comment threads. You bring the usernames. Each post row carries repliesCount, which tells you a discussion exists without letting you read it. A handful of accounts also hide themselves from logged-out visitors — @DanScavino's lookup returns 401 — and those come back as a free error row rather than taking the run down.
Transport is the other half. Datacenter addresses get Cloudflare's block page on 100% of requests. On residential addresses the verdict is per-address and sticky: roughly a quarter are challenged and stay challenged. The surprise was the TLS fingerprint — measuring 4 fingerprints × 8 sessions × 7 endpoints, the Firefox and Chrome 151 fingerprints passed while plain Chrome and Firefox 144 were challenged from a good address. So the client keeps whatever session last worked and, on a challenge, rotates both the address and the fingerprint with a back-off. One more server-side quirk: exclude_reblogs is accepted and ignored, so ReTruth filtering has to happen on your side.
Calling it
{ "usernames": ["realDonaldTrump", "WhiteHouse"], "maxPostsPerUser": 200, "postedAfter": "2 days", "onlyNewRowsStore": "trump-watch" } A real row from today's run, trimmed:
{ "type": "post", "id": "117552785929148974", "url": "https://truthsocial.com/@realDonaldTrump/117552785929148974", "createdAt": "2026-11-03T13:39:24.203Z", "username": "realDonaldTrump", "authorFollowers": 13175051, "text": "TO THE GREAT PEOPLE OF KENTUCKY, GET OUT AND VOTE FOR:\n\n– Andy Barr for U.S. Senate\n…", "isRepost": false, "isQuote": false, "repliesCount": 111, "repostsCount": 1421, "likesCount": 5092, "mediaTypes": ["image"], "linkUrl": "https://swampthevoteusa.com/kentucky/", "linkDomain": "swampthevoteusa.com" } From Python, run and read in one call:
import requests rows = requests.post( "https://api.apify.com/v2/acts/lergassy~truth-social-scraper/run-sync-get-dataset-items", params={"token": "apify_api_..."}, json={"usernames": ["realDonaldTrump"], "postedAfter": "1 day", "maxPostsPerUser": 200}, timeout=300, ).json() posts = [r for r in rows if r.get("type") == "post"] textless = [p for p in posts if not p["text"].strip()] print(len(posts), "posts;", len(textless), "with no text;", min(p["createdAt"] for p in posts), "oldest") The Actor is Truth Social Scraper. It bills per post row and per profile row, no start fee; error rows, posts cut by your date or ReTruth filters, duplicates and posts already delivered under an only-new memory are free — which is why raising maxPostsPerUser to 200 on a schedule is not the expensive mistake it looks like.
When it is the wrong tool. If you need the comments under a post, a search across Truth Social, or trending topics, stop here — none of that is public, and no amount of proxy budget changes it. If you want sentiment on a video-only post, this gives you the file URL and nothing else; the transcription is a separate job. And if you are building a market signal, remember what the 6-second row says: you can have the text fast or the engagement right, and on the first read you do not get both.
