Search for "TikTok shadowban checker" and you will find tools that ask for a username and return a verdict. We wanted to build one for our site, and the first thing we learned is that there is nothing to look up. TikTok has no public endpoint that reports whether an account is restricted. Its own help pages never use the word "shadowban" at all. They talk about content being "ineligible for recommendation" and about the For You feed (FYF).
So any outside website that claims to detect a shadowban is guessing. We decided to build something smaller and more honest: a self-check. The user answers questions about what TikTok itself shows them (a notice under their username, a warning sign in Account check, a strike in their inbox), plus whether any of the causes listed in TikTok's own guidelines apply, plus their recent view counts. The tool turns those answers into a verdict and next steps, entirely in the browser. You can try the result here: TikTok shadowban self-check.
The interesting engineering problem was not the UI. It was this: how do you keep a rules-based tool truthful when every rule comes from someone else's documentation? Here is the pattern we ended up with.
The constraints we set first
- No login, no lookup, no API calls, nothing stored. The logic file has no imports at all.
- Every statement about TikTok must trace back to an exact line on a TikTok page, with the date we read it.
- Exactly one threshold is ours, not TikTok's, and the page labels it as our rule of thumb.
The second constraint drove everything else.
Pattern 1: sources are data, not comments
Instead of a comment like // see TikTok help center, every source lives in a typed constant with its URL, the date we read it and the exact lines we rely on. A trimmed excerpt from lib/shadowbanCheck.ts:
export type Source = { id: string publisher: string title: string url: string /** Date we read the page (YYYY-MM-DD). */ date: string /** Exact lines the page is cited for. */ quotes: string[] } export const SOURCES = { accountStatus: { id: 'accountStatus', publisher: 'TikTok Help Center', title: 'Your account status', url: 'https://www.tiktok.com/en/safety-hc/account-and-user-safety/account-status', date: '2026-11-13', quotes: [ 'In the TikTok app, tap Profile at the bottom.', 'Tap the Menu ☰ button at the top, then select TikTok Studio.', 'Within More tools, tap Account check.', // ... ], }, // notRecommended, violations, fyf, integrity, recommendation, tips } satisfies Record<string, Source> export type SourceId = keyof typeof SOURCES satisfies is doing real work here. It checks every entry against Source, but keeps the literal keys, so SourceId becomes a union of the seven ids rather than plain string. Any question or result step that cites a source has to name one that exists, and the compiler enforces it.
The page renders its "Sources" list straight from Object.values(SOURCES), so the list a reader sees and the list the code cites can't drift apart.
Pattern 2: every question and every step carries a source id
The ten questions fall into three groups:
/** notice = something TikTok shows you itself; * cause = a cause TikTok names in its rules; * context = not a restriction. */ export type QuestionGroup = 'notice' | 'cause' | 'context' export type Question = { id: QuestionId group: QuestionGroup text: string hint: string unsureLabel: string // "Not checked", "Not sure", "Didn't test" source: SourceId } Result steps use the same idea: type Step = { text: string; source?: SourceId }. The widget renders each step with a link to its source's URL and publisher, and a test checks that every question cites a source that exists.
Pattern 3: an explicit priority order for verdicts
The scoring is deliberately boring. A simplified version of assess():
// Simplified from assess() — the real one also builds steps and summaries. if (told.length > 0) return 'told' // TikTok showed a notice if (causes.length || searchMiss) return 'signs' // a cause TikTok names if (bigDrop) return 'drop-no-signs' // views fell, nothing else return 'no-signs' Notices beat causes, causes beat view drops. Two details matter more than they look:
- "Not sure" never counts as yes. Unanswered notice questions are returned as a separate
notCheckedlist, and the summary sends the user to check those first. A self-check that guesses on the user's behalf is just a scraper with extra steps. - The one number of our own is isolated.
BIG_DROP_RATIO = 0.5: the median of up to ten recent videos is compared with the user's usual views. We use the median so one dud video doesn't swing the result. A test pins the edge case: exactly half is not "under half".
When views fall but nothing else applies, the result quotes TikTok's own line that a video that isn't getting many views "doesn't necessarily mean it broke a rule", and points to practical checks instead. We wrote those up separately in a guide on why a TikTok video gets no views.
Pattern 4: tests that fact-check the copy
This is the part we'd reuse on any docs-driven tool. In-app paths ("Profile → Menu ☰ → TikTok Studio → ...") are the most likely thing to go stale or be typed from memory. So paths are constants too:
export const PATHS = { studio: 'Profile → Menu ☰ → TikTok Studio → More tools → Account check', violations: 'Inbox → System notifications → Account updates', // ... } as const And a test walks every step of every path and requires it to appear inside a quoted line of its source, in TikTok's order:
test('every step of every in-app path is inside a quoted line of its TikTok source', () => { for (const [key, path] of Object.entries(sb.PATHS)) { const quotes = sb.SOURCES[PATH_SOURCE[key]].quotes let at = 0 for (const step of path.split(' → ').map(norm)) { const i = quotes.findIndex((q, j) => j >= at && q.includes(step)) assert.ok(i >= 0, `${key}: "${step}" is not in ${PATH_SOURCE[key]}`) at = i } } }) A second test reads the page, widget and copy files as text and fails if an arrow character appears anywhere outside the PATHS definitions. You can't hand-type a path into the UI; you have to go through a constant that the first test has already checked.
This came out of a failed internal review of our first version. The reviewer found paths we couldn't tie to a quoted line, one TikTok line cited to the wrong page, and one sentence that was our own opinion phrased like a fact. The fix was to re-read the three Help Center pages in a headless browser, copy the exact lines into quotes, rebuild the paths from them, and add the tests above so the same class of mistake fails npm test next time.
One more wrinkle: what our team saw in the app on 8 Nov 2026 (account status under Settings and privacy) didn't match the Help Center's paths. We kept it, but in a separate SEEN_IN_APP constant with a fixed label, "What we saw in the TikTok app on 8 Nov 2026". A test checks that the label always travels with the path, so an observation never gets presented as TikTok's wording.
Pattern 5: honesty as assertions
Some tests look odd until you think of them as a contract with the reader:
- No source quote may contain "shadow". We never put the word in TikTok's mouth.
- The limits line ("This self-check can't see TikTok's systems, and TikTok doesn't confirm shadowbans.") must render twice in the widget: before the questions and inside every result.
- A regex rejects promise language such as "go viral", "guarantee" or "unban" across the page, widget, logic and copy files.
- The widget file must start with
'use client'and must not containfetch(,/api/,localStorage,sessionStorage,document.cookieorindexedDB.
Grepping your own source files is brittle, and we accept that. These checks are cheap, they run on every npm test, and each one encodes a promise the page makes to the person using it.
A small parsing problem: what does "1,200, 300" mean?
Users paste view counts in every format. The rule we settled on: if the text contains spaces, semicolons or new lines, those separate the numbers and commas are thousands separators. With no spaces at all, commas separate. So "1,200, 300" is [1200, 300] and "150,200,90" is [150, 200, 90]. 1.2K and 3M work; decimals that don't land on a whole number are rejected with a readable error.
Running TypeScript tests with plain node --test
The project's test script is just node --test tests/*.test.mjs. To import .ts lib files without a test framework, a small helper registers Node module hooks (registerHooks from node:module) that resolve the @/ alias and transpile .ts with the project's own typescript package. The test file then does a plain await import('../lib/shadowbanCheck.ts'). No Jest, no Vitest, no extra build step.
What we'd tell anyone building a docs-driven tool
- Store quotes, URLs and read dates as typed data, and render your citations from that same data.
- Make the compiler reject citations to sources that don't exist.
- Build anything a user might copy (paths, menu names) from quoted lines, and test that it stays that way.
- Label your own numbers and observations as yours, in code, not only in prose.
- Read dates are a reminder, not a guarantee: help pages change, and the quotes need a fresh read when they do.
We build InayaFlow, free TikTok tools for creators. The self-check described here is one of them, at inayaflow.ai. Questions about the approach are welcome in the comments.