Three TikToks, One Signature: 683 Rounds of Being Wrong About What I Was Measuring

November 6, 2026 (Today)

Support me on Patreon to write more tutorials like this!

profile picture
Tony Wang

The empty 200

The first thing I learned building a TikTok integration is that the platform will answer HTTP 200 with an empty body when it does not want to answer you.

There is no error page. No 403. You get a valid status line, valid headers, and zero bytes of content. Four unrelated things produce it: an identity that was never registered, a signature the gateway did not accept, the wrong regional host, and a TLS fingerprint that does not match the app's. From outside they look identical, so any code that branches on status < 400 will store {} and call it data. I know because I wrote that version first.

The integration that replaced it now has ninety-four registered REST endpoints, ninety of them live, across three TikTok surfaces. It has a request layer that has to look like the app down to byte order, an identity system with three separate clocks, and a signature problem that took 683 numbered research rounds between 13 and 24 October 2026.

The round count is the part I keep coming back to, and not because the target was exotic. Almost every round that stalled stalled on a measurement: a harness that changed the app's behaviour, a script whose timing became the result, a reader too short to see the value, a binary that was not the binary, a signature block that looked perfect and got rejected anyway. The technical work moved only after each of those was ruled out.

So there are two stories here. One is what a serious TikTok integration contains in 2026. The other is why the method mattered more than the target.

TL;DR

  • TikTok is at least three APIs wearing one logo: 12 web routes, 63 app routes, and 19 ads-side routes. That is 94 registered endpoints, 90 live, with 92 Swagger operations and 93 markdown pages beside them.
  • The app surface is the product surface. On the same 100 creators, the app route returned counters for 98 and the public web route for 20. Average posts per call: 29.5 against 6.8.
  • Most of the 683 rounds were measurement defects. Five went on the wrong library, 110 filed deliberate anti-tamper crashes as tooling limits, and one reproducible "45" turned out to be my interaction script's timing.
  • A well-shaped artifact is not an accepted artifact. A signature block that looked right got soft-blocked while a device-signed control on the same URL returned 96,333 bytes.
  • The gateway says no with HTTP 200, so classify by content. Retry the soft blocks and leave its answers alone.
  • Scaling hit an app-level ceiling. More pods and disjoint exit IPs both failed, and sustained throughput settled at 51.5 written a minute with 0.02% hard errors.
  • What transfers is a checklist about instruments, not a story about TikTok.

"TikTok" is not one API with a mobile client on top. It is at least three data surfaces with different auth, different response shapes, and different personalities.

SurfaceRoutesWhat it gives youWhat it needs
Public web12Profile, posts, post detail, comments, hashtag, hashtag list, explore, category, trending, three search variantsAn anonymous session, signed query parameters, a browser-backed signer
App63Feed, comments, hashtags, music, search, trending, users, video, live, places, playlists, collections, subtitles, translationA signature header block, an identity, per-route host routing
Ads19Creative Center top content, popular trends, Top AdsNothing upstream: plain cookie-free JSON, no session and no signature

The first surprise is how small the public web surface is. Twelve routes. They are the polite front door: the same payloads the website's own pages use, enough to profile a creator, list their posts, read a video's comments and search hashtags. Two acquisition patterns sit behind that number. Two routes parse the rehydration JSON embedded in a server-rendered page. The rest call a JSON API that needs an anonymous session shaped like a browser's: cookies on seven-day and five-day queues, a contextual parameter block of its own, and two signed query parameters. The browser bootstraps the session; it does not sign every call. That bootstrap runs through a signer service with a 15-second budget per attempt, because a cold session can take 15 to 25 seconds to re-initialise. When it is cold, requests are not wrong, just slow, which is its own kind of failure.

The second surprise is how much lives only in the app. Sixty-three routes over 59 upstream app routes plus one web twin. Feed, including an offline variant. Comments and replies. Hashtag info, search, fresh and popular posts. The entire music family: detail, posts, picks, charts, related sounds, beats, collections. Search in nine flavours, including a streaming form. Trending categories and effects. User profile, posts, followers, following, recommendations, original music, original effects. Video detail and subtitles. Live rooms, places, playlists, collections and translation.

That surface is where the product is. On a sample of 100 creators drawn in the worker's backlog order, the app route returned counters for 98 of them at 29.5 posts per call and a p50 of 1.74 seconds. The public web route returned 20 at 6.8 posts per call. The gap is not a tuning difference. It is the difference between reading a marketing page and reading the product.

The third surface is the ads side: 19 routes split across the Creative Center (4), popular trends (5) and Top Ads (10). It is the only one of the three that needs neither a session nor a signature. Plain, cookie-free JSON. That makes it the easiest data in the integration to read, which is worth noticing: the surface that fights least answers most readily. Its shape still forced design decisions. Creative Center's Top Content publishes exactly four objects per daily snapshot, with no pagination and no history, so "the complete set" is four rows and a title is not a stable identifier across days. The video and cover URLs are signed, time-limited CDN links that rotate per response. Anonymous access is capped too: exactly three hashtags per filter combination, and page one of four videos even when the API reports a hundred. The answer was not to defeat the cap but to iterate the taxonomy. Eighteen industries across countries and periods yield up to 54 rows instead of three. And when the "proper" JSON detail endpoint showed a transient 403 under load, the fix was to read the server-rendered page that already embeds the payload.

Alongside the routes there are 92 Swagger operations, 93 markdown pages, billing weights, handler and service tests, a hosted smoke suite, and 27 uptime monitors keyed by operation id. That layer is easy to underestimate. The endpoints are the visible tenth. Three review findings are worth recording. Twelve of the registered paths are a /web naming alias rather than endpoints: same handler, no second operation, and the billing layer normalises the path because otherwise the alias would bill one unit instead of eight. Creative Center, Top Ads and popular trends are deliberately not mirrored, since they read the ads portal rather than the web page. Four of the 94 routes are 410 tombstones with Deprecation and Sunset headers, and two have no Swagger annotation, which makes them unmonitored by construction. One tombstoned route still has a markdown page showing a working example with code: 200.

The 63 app routes split 39 and 22. That split is a design artifact rather than a fact about TikTok. Thirty-nine could be served device-free on the measured path; 22 needed a real signer. The gated ones fail closed with a deliberately generic "temporarily unavailable" message instead of returning the gateway's empty 200. The operator detail is logged server-side only. That is the right trade: an honest failure is debuggable, and a fake success is not.

What an app request actually is

An app request is four layers stacked in a fixed order, and the order is not cosmetic.

  1. A single ordered parameter block of 48 keys. The order is preserved deliberately, because the request has to mirror the app's own byte string. A reordered map is a different request.
  2. Identity overrides layered on top, so the identity in the query and the identity in any minted cookies come from the same record. Mixing them produces requests that look plausible and answer empty.
  3. An optional cookie header, allowed only on an explicit route allowlist. By default that allowlist has exactly one route. Everywhere else, no cookie is sent at all.
  4. The signature header block, plus per-route routing headers.

Each route carries its own edge host, origin host, path and egress country, and the origin is transmitted as a paired header. A mismatched pair is not a subtle degradation; it answers 455. So the pair is measured per route, never assumed. The route table is data rather than a switch statement.

Then there is identity, which behaves less like configuration and more like a resource with three separate clocks.

KindWhat it isCost to replaceRotates on
Generated identityA locally minted block that looks like an install; never registeredFree, client-side6 hours, a transport soft block, an empty page
Registered identityAn identity the platform issued, plus its activation hopScarce; registration is throttled per egressSoft block only, deliberately; 168-hour default age with 24-hour deterministic jitter
Egress peerThe exit IP a connection leaves fromFree, but rate-limited per IPNew connection

The rotator owns the active identity and keeps four triggers: max age, max requests, standby replenishment and health. If minting or the health probe fails, it keeps the current identity rather than degrading the data path. That sounds obvious until you have watched a "self-healing" rotator take a working fleet down. Rotation is serialized behind a semaphore so a slow mint never stalls requests, and state lives in Redis so every replica rotates as one unit. A standby identity is pre-minted and promoted atomically, so a forced rotation never waits on a throttled registration.

Cookies are the least satisfying part. One cookie header, two independent halves: 19 production cookie names, of which 10 are mintable device-free and 9 are a login artifact that cannot be renewed. When the session half dies nothing fails loudly. The app-shaped answer is an empty 200. The merge had to be overlay-first because identity-first precedence made the one session-gated route answer a real upstream 403. That bug exists only because identity and session are different things wearing one header.

And then the signer, which is the part that fought back. The service talks to it through three optional interfaces: sign a URL, sign a route, or sign a route with its body. Three implementations sit behind them. A device-free signer is enough for 39 routes. A signing sidecar covers the 22 that need it. A hybrid routes by path and never silently falls back. That last clause is load-bearing: a fallback hides a broken signer behind a device dependency, so the hybrid fails loudly instead.

None of that is unusual API engineering. The unusual part was finding out that the signature was not a library call, and that most of what I believed about finding it was wrong.

683 rounds

The work was logged in numbered rounds, and the ledger eventually ran to 683. The target was one thing: reproduce the app's signature headers well enough that the gateway served data. Everything below comes from that ledger, and the numbers are real.

Build the oracle before you build anything else

The first useful artifact was not code. It was a corpus: 1,992 signed requests joined to their exact inputs, zero unmatched, covering 615 distinct URLs. Joining on host, path, body length and nearest timestamp worked because the capture wrote an index line a few milliseconds before each body. Every later hypothesis became testable against a known (input, output) pair instead of a live app and a hunch. For anything with a request/response boundary, the corpus is the cheapest thing you can build and the most expensive thing to skip.

Five rounds on the wrong library

The first wrong turn was structural. A Java-to-native bridge led to a library that looked exactly like the signer: a command registry, a dispatcher, native handler addresses, all correct. It was the device-fingerprint and risk SDK. It contained no cryptography at all.

The fix was one command, and it should have come first. Grep every shipped library for the vocabulary of the feature: header names, protocol strings, config keys. Of the app's 229 libraries, exactly one matched. Rank candidates by hit count, then decompile. Do not let a promising lead stand in for evidence about the target.

A cheap experiment kills a load-bearing inference

Rounds 2 and 3 concluded the library "self-modifies at runtime", because a routine scanned for a marker byte pattern that looked like a code-patching trampoline. It was plausible, and it explained why the decompiler kept producing wrappers.

It was wrong. Reading all of the code section from the live process and diffing it against the shipped file took one script:

disk bytes: 1308900   live bytes: 1308900   differing bytes: 0

Static analysis had been valid the whole time, and the stalls were a decompiler-flow problem. The lesson is not "diff things". It is that when an inference is load-bearing enough to justify abandoning an approach, spend the effort to falsify it directly. This check was an order of magnitude cheaper than the work it invalidated.

The instrument is part of the experiment

For several rounds the ledger said the app had "gone quiet": no signed requests since a given time. That read like a property of the app. It was not. Every leg of the capture rig worked when tested independently. A plain host, a non-target URL and a device browser all produced entries. The app's own log showed nothing at all. Its sockets were sitting in SYN_SENT to port 443: it was connecting directly, bypassing the proxy, and those connections never completed. The app was not idle. Its network path was broken, so it had nothing to sign.

The next version is worse. The app was sending requests, and they were dying at the TLS handshake because it rejected the interception certificate. A failed handshake never reaches a proxy's request hook, so every log looked like silence. The answer was in the app's own log the whole time: net::ERR_CERT_AUTHORITY_INVALID. When a client appears not to send traffic, read its log before the proxy's.

Then the version that should have ended the program's trust in its own harness. Two attach runs and one spawn run of the same app, with the same hook, gave fire counts of 5, 0 and 5, reproducible, with different payloads each time. Installing the hook from process start changed the app's behaviour. The app ships its own instrumentation frameworks and can notice being hooked. The suppression was selective, which is exactly why the broken runs looked plausible: one code path ran normally while the one under measurement did not. From then on the rule was launch, wait, attach by PID, never spawn.

The same file has the counterexample that makes the point general. For seven rounds every run measured exactly 45 dispatcher calls, and the consistency read as a fact about the app. It was a fact about the procedure. Spawning resets the app to its splash screen, the feed takes over 20 seconds to appear, and the interaction script started tapping at second 18 to 22, on the splash screen. A reproducible number can be a property of your method.

The cheapest variant of the same lesson: rounds 213 to 224 built careful experiments around a signing process that was not running at all, because one device setting pointed at a port with nothing listening. The cheapest check is always whether the thing you intend to measure is happening, and what the subject's own configuration says.

A method can succeed loudly and still produce junk

The most expensive single mistake was not a wrong fact. It was a right-looking result.

A constant-folding walker de-obfuscated the cipher and returned 2,136 instructions with only three assumptions. That looked like a solved problem, and I reported it as "the obfuscation is defeated". It was producing a junk path. The walk ended by writing constants to the output, and the real signature varies per request. The predicates that misled it depend on runtime data, so constant folding cannot decide them and silently guesses.

The asymmetry makes this class dangerous. A de-obfuscation that produces no output is caught instantly. One that produces plausible output passes every internal check you have. Validate the artifact, not the method's confidence, and ask what it should produce before believing that it does.

Read the import table before the code

For 110 rounds the ledger recorded "hooking the cipher kills the app", "hooking block interiors crashed it" and "the memory-scanning probes die mid-run" as limitations of the tooling.

They were not. One instruction at one of the signer's direct callees wrote a stack frame, moved the stack pointer below it, loaded an address as data and branched to it, where the instruction was deliberately undefined. The library ships traps, and any disturbance of execution flow routes into one. The crashes were the anti-tamper design working exactly as intended. A failure that is consistent, reproducible and points at the artifact is data about the artifact, not noise about your tools.

Then the punchline. Five rounds produced five wrong identifications of the same function by reading instructions out of context. One table, 166 imports resolved against the relocation table, established the library's entire external capability surface: what it can do rather than what its code looks like. That table is static, needs no device, and cannot be misread the way a single instruction can. It also named the mechanism behind two mysteries that had stood for a hundred rounds. When a binary resists analysis, ask what it can do before asking what its code looks like.

The binary is not the binary

Round 360 profiled the wrong library: 1,849,224 bytes instead of the device's 2,065,744. Five copies on disk spanned two builds. Every address derived from the wrong one was meaningless. The fix was to digest the artifact before profiling it and record the digest beside every finding. That sounds like bureaucracy until you have spent a day analysing a file the app does not load.

The same class appears at the transport level. A binary pulled through a shell pty silently grew by one byte per line feed. A failed pull redirected onto the only local copy truncated two hours of capture index to zero bytes, because a failed source silently becomes an empty destination. Any pipeline whose failure mode is "produced nothing" rather than "exited non-zero" needs a temporary file, a non-empty assertion and a parse check.

Verified, and still not the function the caller uses

Rounds 367 to 395 are the most instructive failure in the ledger. The primitive was found, verified twice against live captures (once in Python, once in Go) and wired into the signer. Then the real calls were captured and carried archive data, not cipher input. "This function is correct" and "this function is what the caller uses" are different claims. The second requires capturing the caller, not characterising the callee. The verified primitive had been assumed to be the signer's, and every later measurement inherited it.

The answer was not in the shipped code

Rounds 258 to 260 produced the finding that should have been reached in round three. The library that looked like the signer is a host. It loads a generic runtime and makes 38 calls into it to start, run and manage tasks. That runtime is 5 million bytes with 1,599 dynamic exports and zero occurrences of any signature name. Two shipped libraries, 6.9 megabytes, and neither contains the signer.

The signer is downloaded at runtime as an obfuscated task package. That is why 257 rounds of static and dynamic analysis found nothing: the cipher is not in the app bundle. The evidence was on the device: a directory of downloaded task packages, no ELF anywhere in the tree, and a manifest per task.

It is the cleanest example of a failure mode that does not look like failure. Two hundred and fifty-seven rounds of correct work on the wrong object. The cheap check that would have caught it, grepping every shipped artifact for the vocabulary of the thing you want, was available in round three.

Produced is not accepted

Everything above is about finding the code. The harder standard is acceptance.

The emulator eventually returned a correct-looking four-header block, and the gateway soft-blocked it: zero bytes. The device-signed control on the identical URL returned 96,333 bytes with a zero status code and a 147-key detail object. Shape and length are not acceptance.

The experiment that resolved it is my favourite in the program, because it needed no decompilation. Keep the device's three headers, swap only the fourth: accepted, 95,937 bytes. Swap the whole block: rejected. Every subset: rejected. One experiment proved the emulated component valid, the other two invalid, and all four headers required. Later, serving the signer's captured state file turned the rejected pair into real ones: 95,960 bytes, then 95,951 with a fresh timestamp, 96,714 with different identity values, 96,088 with a different user agent. And two components are bound as a pair. Substituting either alone is rejected; both together are accepted. A single-variable substitution matrix gives a false negative when the variables are coupled.

The device-free path finished at 39 of 39 live route canaries in 50.5 seconds, with a persistent sidecar that starts in about 20 seconds and signs in about 35 milliseconds. It shipped into production on 23 October. The three failures that only appeared in the cluster were not about cryptography. A single HTTP worker starved the health endpoint during a signing, the default JVM heap was too small, and the pod's user could not write its own working directory.

A 200 is a sentence

Back to the empty body. The gateway's refusal vocabulary is wider than its status codes suggest. Four unrelated causes produce the same 200, and the measured taxonomy has five refusal shapes, of which only one is retriable. So the integration classifies by content, in order: HTTP status, a length floor, does it parse as a JSON object, then a non-zero internal status code. Only then is it data.

The trap is that "empty" arrives in two places. A transport soft block, 200 with an empty body, is visible to the fetch wrapper. A framed or streaming soft block, or a well-formed empty page, is only visible after parsing. Each needs its own rotation path, and a single classifier at one layer will miss one of them.

The retry policy that survived is narrower than instinct suggests. Retry transport soft blocks, framed empty pages, 429, 503 and connection errors. Do not retry what the platform has answered. A missing user and a hidden following list are answers, not failures, and retrying them burns an identity. Keep written, empty and error as three metrics, because empty is ambiguous: a creator with no posts and a throttled request look identical. Never tombstone on it.

Rotation on the soft block works, and I can show it because it was forced under load in production. A 400-request burst with 16 in flight produced 367 successes and 33 503s. A second burst of 240 with 12 in flight produced 223 and 17. Both rotation paths logged in production, on the current commit, without a restart. Then the interesting part: the same pod answered everything with real data again about ten minutes later, with no restart. Rotation replaces the identity, but a throttle keyed on egress or on the query can outlast one rotation. That is why the retry is bounded at one, and why "rotate harder" is not a scaling strategy.

The wall

The app surface was not just better data. It was the only way to run the workload at a useful rate. Then it hit a wall that took a while to describe honestly. Same queue, 8 to 12 minute windows:

ShapeWritten / minPer-pod yield
1 pod, 4 fetchers12.20.70
1 pod, 8 fetchers38.10.78
2 pods, 8 fetchers, no rotation30.20.37 / 0.055
2 pods, 8 fetchers, rotate on empty46.60.295 / 0.36
2 pods, 12 fetchers, 500 ms spacing87.4 (peak)0.185 / 0.305

The 87.4 was a peak, not a rate. The 24-hour check on the same shape came in at 74,217 written, 51.5 a minute, 27 errors (0.02%), 810 breaker sheds and an average yield of 0.39. A 10-minute window cannot resolve a difference under about 25% when the throttle is drifting. The same configuration read 46.6 in one window and 36.0 in the next. Trust the yield column over the throughput column, and trust very large gaps and very small yields over anything in between.

The two intuitive fixes both failed.

More pods does not scale. Two independent pods on different nodes and different node IPs degraded together: yields 0.37 and 0.055. The limit is not pod CPU. At the 12-fetcher step the worker used 33 to 398 millicores of a one-core limit, and the signer 238 to 341 millicores. It is not our pacing either, because 500 ms spacing allowed 120 starts a minute per pod while the pods ran about 54.

So I tested the better theory: it is per exit IP. One pod stayed on the default pool. A second drew from a disjoint set of exit IPs. In a 30.4-minute window the first wrote 1,028 aggregates with yield sliding from 0.265 to 0.065, the second wrote 1,150 with yield sliding from 0.215 to 0.03, and the fleet wrote 71.7 a minute with zero errors and closed breakers. Both pods collapsed together while sharing no exit IPs. I reverted the split.

The honest statement is that the ceiling is app-level, some shared budget I cannot see, and that "app-level" is elimination, not demonstration. I have not measured the mechanism.

What did help was less satisfying. A pod up for about a day drifts into a throttled state. A 44-hour pod sat at 0.005 yield with its breaker open while its sibling ran 0.19, and deleting it put the replacement at 0.755 within nine minutes. Identity rotation does not clear it; a fresh process does. So a daily recycler restarts the mobile pods at 04:30 UTC. It keeps the fleet alive and explains nothing: a restart resets the identity block, every connection and exit peer, the breaker's window and shed state, and process caches at once. Two single-cause hypotheses were refuted. Naming the one that remains would be a guess.

What I'd keep

The integration is now ninety-four endpoints, a device-free signer, a sidecar, an identity rotator with a Redis-backed standby, a discovery miner, and a worker fleet with a breaker and a recycler. The API-shaped parts are ordinary. The checklist is not.

  1. Enumerate the surface before committing to a target. Grep every shipped artifact for the vocabulary of the feature, rank by hit count, and only then start.
  2. Digest the artifact before profiling it, and record the digest beside every address. If you cannot name the binary an address belongs to, the address is not evidence.
  3. Prove the harness before blaming the target. Test every hop with something you control, and read the subject's own log before the proxy's.
  4. Put a positive control in every run. A run without one cannot distinguish "nothing happened" from "the instrument was suppressed".
  5. Prefer a decisive cheap experiment to a plausible inference, especially when the inference is load-bearing enough to justify abandoning an approach.
  6. Capture instead of inferring. A capture costs one round and settles a question permanently; an inference can consume five and still be wrong.
  7. Treat an empty result as a claim about your reader, not about the target. An incomplete reader looks exactly like an absent value, and it will do so eight times.
  8. Validate the artifact against its consumer, with a known-good control in the same run. Shape and length are not acceptance.
  9. When one substitution fails but the full set passes, suspect binding between the components before concluding either is wrong.
  10. Commit negatives. A committed elimination saves the next person a round. An uncommitted one saves nobody. And correct the record loudly at the point of the old claim, because someone will read the middle without reading the end.

Two operational rules belong on the same list. Count attempts per success, not just successes. 46.6 written a minute at 61.6 attempts is 1.32 attempts per aggregate, and a "faster" configuration that burns the same upstream budget for less data is not faster. And never benchmark the first ten minutes. A fresh pod or identity starts around 0.09 yield and takes about ten minutes to recover to 0.78.

One more item is not about measurement, and it matters more than the rest because this work involves real credentials. The standing rule was that issued device and install identifiers, query strings, cookies and signature values never enter the repository, while method and identifiers recovered from binaries may. That is easy to write and hard to obey, because the leak happens in the analysis output: the moment you write "this field is constant" and print one example, a device identifier is already in the text. One near-miss put a device's identifiers into a manifest and was caught only before the merge. So the rule became: classify the field before you print it, redact at extraction rather than before commit, print match and length rather than the captured value, and grep the literal over the final tree.

What I still don't know

  • The mechanism behind the throughput ceiling. "App-level" is elimination, not measurement.
  • The derivation of three per-call constants in one signing path; the public reference uses constants there.
  • How the signer's on-disk state file is generated, and whether one captured state serves many identities.
  • Total cost: no figure for engineer-hours, wall clock or spend. The round count and dates are the only proxy.
  • The first-attempt versus retry success split, which is the measurement that would say whether rotation or pacing is doing the work.

The map, not the territory

For a while I described this work as "scraping TikTok", which made it sound like a parsing problem. It was not. The parsing was the easy part: typed structs, JSON numbers decoded as json.Number so 64-bit ids are not silently rounded, protobuf where the app frames it.

The hard part was knowing whether what came back was real. The app does not say no; it says 200 with nothing in it. The gateway does not reject a correct-looking signature; it soft-blocks it. The harness does not tell you it changed the app; it gives you a number that looks like a fact. And the most dangerous result is not an error. It is a plausible artifact produced by a method that succeeded loudly.

Almost none of this is about TikTok. That is what I would keep.