XWipe

Bulk deletion of a social account's history, built against a metered third-party API.

A bulk deletion run in progress.

XWipe deletes posts, likes, retweets and bookmarks from an X account in bulk. It is at getxwipe.com, with an iOS app. We use X's public API and are not affiliated with X.

What follows is the engineering, not the pitch.

The constraint

Everything here is somebody else's rate limit.

The API is metered pay-as-you-go, so every deletion costs real money and an empty wallet surfaces as an HTTP 402 that looks exactly like a code fault in a log digest. It is not one, and knowing that saves an afternoon each time. The API also only reads back roughly the last 3,200 posts, so anyone wanting to clear a decade of history has to upload their account archive and we work from the identifiers in it.

The part that cost us the most is quieter. X's OAuth 2.0 PKCE flow uses single-use rotating refresh tokens. Each refresh consumes the stored token and issues a new one, invalidating the old one immediately.

The stack, and why

Next.js with Prisma over Postgres, Redis with BullMQ for the job queue, NextAuth v5 for the OAuth handshake, and the X API v2. Everything runs on Railway: a web service, a worker service switched by a WORKER_MODE environment variable, Postgres and Redis in one project so the web app can use internal hostnames.

The worker is a separate service rather than a background function because a deletion run is measured in hours, not seconds. It pauses on rate limits, waits, and resumes. There is nowhere to put that inside a request.

the token lock. Four caller boxes at left, `web request`, `worker startup`, `deletion loop`, `admin action`, all funnelling into one gate labelled `getFreshAccessToken() - Redis lock per user`.

Three decisions

Exactly one code path may refresh a token. getFreshAccessToken(userId) is it, guarded by a per-user Redis lock. A caller that arrives while the lock is held waits and reuses the token the holder just produced, detected by the expiry timestamp advancing past what it first read, rather than rotating again. It degrades to an unlocked refresh if Redis is down. A verification script fires eight concurrent callers at an expired token and asserts exactly one refresh. The rule that goes with it is absolute: never call the underlying refresh function directly, and never add a second path.

A dead session pauses a job instead of failing it. When authentication dies mid-run, the job goes to paused with progress preserved. When the user reconnects, the NextAuth sign-in callback re-enqueues paused jobs, claimed atomically so a reconnect cannot start the same job twice, and the loop skips items already processed. One reconnect, no lost progress, no re-burned quota.

That decision has a billing consequence we did not anticipate. A paused job is billed at the pause and again at completion, so the original "have we credited this job" boolean was wrong. It was replaced with a creditedCount column and a delta calculation at every crediting site. Any design where a unit of work can complete more than once needs delta accounting, not a flag.

A failure streak stops the job early. The circuit breaker trips after three consecutive failures, each already past its own retries, and resets on any success. It was ten. Three cuts the API spend on a doomed run by roughly seventy percent, at the cost of occasionally stopping a job that would have recovered. On a metered API that trade is easy.

What broke

The same 401, five times, each time from a different place.

Jobs stopped mid-run and then recovered on their own, which is the least helpful failure signature there is. Logs showed ten consecutive Token refresh failed: 401, the breaker marking the job failed, and then a later run completing fine. The apparent correlation with the rate-limit window filling up was a red herring: a long rate-limit wait triggered a staleness check that collided with another refresher.

Three independent unlocked refresh paths existed. The lock fixed the collisions.

Then jobs still failed, because the deletion loop refreshed once per failing item, so a streak of 401s produced ten rotations in a few minutes and X's own abuse detection started rejecting the refresh endpoint. Fixed with a job-scoped five-minute cooldown, set deliberately longer than the time the breaker takes to trip, so a doomed run rotates exactly once and then fails cleanly.

Then returning users saw every job fail at 0 of 0 with a raw 401, because the token refresher was constructed before the loop began and threw unguarded, which also triggered three BullMQ retries hammering an already dead token. Wrapped, with a graceful end and a reconnect prompt.

Then three admin endpoints turned out to be rotating outside the lock, including the "Refresh X Token" rescue button. So clicking the button to fix a user could invalidate the token family and cause the exact reconnect it was meant to prevent.

The lesson is not about tokens. A rule enforced by convention has as many holes as it has call sites, and the last hole is always in the admin panel.

Where it is now

Live on the web with multiple locales, and on iOS. The iOS app sells credit packs as consumables rather than subscriptions, with the store transaction identifier used as the idempotency key so a redelivered receipt cannot double-credit an account.

Built by BestDid

We built XWipe and we run it. Plans and the free tier are at getxwipe.com.

Related: the Geotally case study covers orchestrating six third-party APIs at once, and the BDShield case study covers maintaining twelve products on one architecture. The rest is on our work page. To discuss a build, see services.

Visit XWipe