A reverse proxy for the Discord API, deployed as a Cloudflare Worker. Adds authentication, token management, snowflake validation, and server-specific business logic endpoints on top of the standard Discord API. Original motivation was for my Google Sheets to be able to call the Discord API, without my requests being rejected from Discord, because they would detect Google's IP addresses.
- Reverse proxy - Forwards any request to
https://discord.com/api/v10with automatic token injection - Dual static-token slots - Switches between bot and user tokens based on the endpoint or an explicit header. Optionally routes to a second user token (e.g. a premium alt account) when the request authenticates with
AUTH_KEY_PREMIUM. - Token rotator pool - On allow-listed user-token paths (search, member lookups, etc.), the request acquires a token from a Durable-Object-backed pool with per-Discord-bucket cooldown tracking. Multiple registered user tokens spread the rate-limit budget transparently. Cross-Worker consumers can share the same pool via the
script_nameDO binding pattern. - Per-token fingerprint hygiene - Each user-token request carries a deterministic browser fingerprint (UA, X-Super-Properties, X-Discord-Locale, ...). Profiles are pinned per token so the same identity is consistent across rotated calls; bot requests carry the Discord-compliant
DiscordBot (...)UA. A daily cron scrapes Discord's current webbuild_numberso the embedded super-properties stay current. - Admin API -
AUTH_KEY_ADMIN-gated sub-app at/admin/*for runtime pool + fingerprint management (register, list, reset, unregister, health, fingerprint profiles, static-token fingerprint mapping, build-number record). Distinct auth chain fromAUTH_KEY/AUTH_KEY_PREMIUM; fail-closed when the admin secret is unset. - Public healthcheck - Unauthenticated
GET /healthcheckreturning service status, build hash, and UTC timestamps. Mounted before the sieve so phone browsers, status pages, and uptime monitors can hit it without a key. - Snowflake validation - Validates Discord IDs in URL paths before forwarding, returning Discord-compatible error responses
- Rate limit interception - Reformats 429 responses into a consistent JSON envelope
- Custom endpoints - Server-specific business logic that processes Discord data server-side
- OpenAPI spec - Auto-generated via
@hono/zod-openapiwith Swagger UI (admin and healthcheck sub-apps are intentionally not exported to the public doc)
| Component | Technology |
|---|---|
| Runtime | Cloudflare Workers |
| Framework | Hono + @hono/zod-openapi |
| Language | TypeScript (strict mode) |
| Validation | Zod |
| Testing | Vitest + @cloudflare/vitest-pool-workers |
| Package Manager | Bun |
bun installCreate a .dev.vars file in the project root with your secrets:
DISCORD_TOKEN_BOT=your-bot-token
DISCORD_TOKEN_USER=your-user-token
AUTH_KEY=your-api-keyOptional - add a second user token (e.g. a premium account with access to locked channels) to route gated requests through a separate auth context:
DISCORD_TOKEN_USER_PREMIUM=your-premium-user-token
AUTH_KEY_PREMIUM=your-second-api-keyRequests authenticated with AUTH_KEY_PREMIUM and using the x-proxy-context: user path are proxied with DISCORD_TOKEN_USER_PREMIUM; everything else continues to use the default pair. Both premium bindings must be set together - if a user-context request arrives authenticated with AUTH_KEY_PREMIUM while DISCORD_TOKEN_USER_PREMIUM is unset, the proxy returns 503 rather than silently downgrading to the default user token. Bot-context requests always use DISCORD_TOKEN_BOT regardless of which auth key matched.
Optional - enable the admin sub-app at /admin/* for runtime token-pool management:
AUTH_KEY_ADMIN=your-admin-keyThe admin sub-app fail-closes with 503 when AUTH_KEY_ADMIN is unset, so leaving it out effectively disables /admin/*. The admin key is independent from AUTH_KEY / AUTH_KEY_PREMIUM - it does not grant proxy access, and proxy keys do not grant admin access.
Caution
I advise to use an alt account for the user token to avoid any future risks for your main account of being banned by Discord.
bun run lint # Type check (tsc --noEmit)
bun run test # Run all tests
bun run dev # Start local dev server via Wrangler/admin/* and /healthcheck are mounted before the main sieve so they have their own auth chains (or none). Every other request flows through the layered pipeline:
Request
|
+-- /healthcheck --> Public liveness probe (no auth, returns build metadata)
|
+-- /admin/* --> AUTH_KEY_ADMIN-gated sub-app (token pool management)
|
v
[Rate Limit Interceptor] Reformats 429 responses (post-processing)
|
v
[Auth Middleware] Validates x-auth-key or Authorization (AUTH_KEY / AUTH_KEY_PREMIUM); sets authSlot
|
v
[Discord Context] Selects bot/user static token + records discordTokenKind based on authSlot
|
v
[Token Rotator] On allow-listed user-token paths, acquires a token + fingerprint from the DO pool
|
v
[Snowflake Validator] Validates Discord IDs in URL path segments
|
v
[Subrequest Logger] Wraps proxyFetch for streaming visibility
|
v
[Custom Routes] /custom/* - Business logic endpoints
|
v
[Proxy Forwarder] /* - Composes fingerprint or bot UA, forwards to discord.com/api/v10, releases the pool token after fetch
Note
Custom endpoints under /custom/* come first; anything unmatched falls through to the proxy forwarder.
For each request the proxy decides which Discord token to use:
x-proxy-context: userheader - Forces user-token branchx-proxy-context: botheader - Forces bot-token branch- Path heuristic (default) - Paths containing
/guildsuse user token; everything else uses bot token
Once a user-token branch is selected:
AUTH_KEY->defaultslot,AUTH_KEY_PREMIUM->premiumslot. The static token (DISCORD_TOKEN_USER/DISCORD_TOKEN_USER_PREMIUM) is the default for that slot.- On allow-listed read paths (
/guilds/:id/messages/search,/guilds/:id/members*,/channels/:id/messages*, etc.), the token rotator middleware acquires a registered pool token in the matching slot. When no tokens are registered for the slot, the rotator falls through to the static token instead of erroring -bun run devand ad-hoc extraction scripts work with zero registration. - Premium pool isolation.
defaultconsumers never seepremiumtokens and vice versa - premium tokens are handpicked higher-access accounts, not a throughput tier.
Override the default LRU selection on a per-request basis with an optional X-Proxy-Token request header:
| Value | Behavior |
|---|---|
absent / auto |
Default LRU rotation across registered pool tokens (current behavior) |
static |
Skip the rotator entirely - use the static DISCORD_TOKEN_USER / DISCORD_TOKEN_USER_PREMIUM on context |
<label> |
Pin to that specific registered pool token via acquireByLabel. Returns 503 if the label is missing, status is not active, or the slot mismatches; returns 429 if the labeled token is in cooldown |
The header is consumed by the rotator middleware and stripped before the request is forwarded to Discord. Auth-gated by the existing AUTH_KEY chain; no separate admin auth needed. Use cases: pin a specific token for debugging which one is misbehaving, force-bypass the pool for predictable extraction with the static token, or run bun run dev against a single registered local token.
Each user-token request is decorated with a consistent browser fingerprint header set composed from the assigned profile + current Discord client build_number:
User-Agent,X-Super-Properties(base64 JSON),X-Discord-Locale,X-Debug-Options,Accept,Accept-Language,Origin,Referer.- Pool tokens. Each registered token is assigned a profile id on first
acquire()via a stable hash of its label. The assignment is persisted, so a token's identity is consistent across rotated calls and across cold starts. Operators can override the assignment viaPOST /admin/tokens/:label/fingerprint. - Static tokens.
DISCORD_TOKEN_USERandDISCORD_TOKEN_USER_PREMIUMget their own fingerprint identity viaPOST /admin/static-fingerprint { kind, profileId }. The mapping is stored in the same Durable Object as the pool. - Bot tokens carry only
User-Agent: DiscordBot (https://github.com/Synertry/discord-api-proxy, <build hash>)per Discord's API docs; no super-properties. - Build number. A daily cron (
0 4 * * *UTC) scrapesdiscord.com/loginand persists the currentbuild_numberto the DO. Stale records (>7 days) fall back to a hardcoded constant. Manual refresh:POST /admin/build-number/refresh.
src/
index.ts App factory + middleware sieve + sub-app mounting
types.ts Shared type definitions (Bindings, DiscordUser)
global.d.ts Build-time constants (BUILD_HASH, BUILD_TIMESTAMP)
middleware/
auth.ts API key authentication (sets authSlot)
discord-context.ts Static-token selection + discordTokenKind; looks up static fingerprint
token-rotator.ts Pool acquire on allow-listed rotatable paths
subrequest-logger.ts Wraps proxyFetch for streaming visibility
snowflake-validator.ts Discord ID format validation
routes/
proxy.ts Catch-all reverse proxy; composes fingerprint or bot UA, releases pool token post-fetch
custom.ts Custom business logic route tree
admin.ts AUTH_KEY_ADMIN sub-app (token pool + fingerprint management)
healthcheck.ts Public unauthenticated liveness probe
rotator/
do.ts TokenPoolDO Durable Object class + RPC methods
types.ts TokenState, AcquireResult, ReleaseInput, etc.
bucket.ts Route -> Discord-bucket lookup
selection.ts Pure LRU + cooldown filtering
validators.ts Token format + pool-cap + bucket-states housekeeping
release-input.ts Parse X-RateLimit-* response headers
client.ts createTokenPoolClient(stub) + getPoolStub(env) factory
fingerprint/ Pure, runtime-agnostic
profiles.ts FingerprintProfile registry + FALLBACK_PROFILE_ID
compose.ts composeFingerprint + composeBotUserAgent (pure)
build-number.ts selectBuildNumber (pure) + isolated DO read helper
scheduled/
build-number-refresh.ts Daily cron handler scraping discord.com/login
custom/
chillzone/events/
bingo/ Bingo participant counts (uses the rotator)
kindness-cascade/ Kindness Cascade tallying module (see its own README)
test/
env.d.ts Cloudflare test type augmentation
middleware/ Unit tests for each middleware (incl. token-rotator)
routes/ Integration tests for proxy, custom, admin, healthcheck
rotator/ DO + pure-function tests via @cloudflare/vitest-pool-workers
custom/chillzone/events/ Classifier, tallier, formatter, and handler tests
Tallies submissions for the ChillZone server's Kindness Cascade event. Fetches all messages from a channel, classifies each one, and returns ranked leaderboards.
GET /custom/chillzone/events/kindness-cascade?guildId={id}&channelId={id}
GET /custom/chillzone/events/kindness-cascade?guildId={id}&channelId={id}&formattedMessage=true
See src/custom/chillzone/events/kindness-cascade/README.md for full documentation.
GET /custom/chillzone/events/cupids-inbox
Returns { "tally": 0 }. Placeholder for now. Not yet imported from my private project.
bun run test # Run all 348 tests across 29 suites
bun run test -- --ui # Open Vitest UITests use @cloudflare/vitest-pool-workers to run in a Workers-compatible runtime. Discord API calls are mocked at the fetch level. TokenPoolDO runs in the real DO simulation; tests inject either a mock pool client (createApp(mockFetch, mockTokenPool)) or exercise the DO directly via runInDurableObject.
Deployment is handled by a three-stage CI/CD pipeline:
- Push to
main—ci.yamlruns linting and tests. Dependabot dependency bumps are auto-merged directly toproduction. - Pre-production review —
pre-production-review.yamlgenerates a change report and requests reviewer approval via thestatus/approvedlabel. - Deploy —
deploy.yamltriggers on push toproduction, uploading and promoting the Worker via Wrangler's gradual deployment (wrangler versions upload→wrangler versions deploy).
bootstrap-wrangler-migration.yaml is a workflow_dispatch-only job that runs a non-versioned wrangler deploy. Cloudflare refuses wrangler versions upload when a Worker migration is part of the upload (e.g. introducing a new Durable Object class), so this workflow gets triggered manually from the Actions tab once per migration. Future versioned deploys via deploy.yaml resume working after.
Set these in your repository settings under Settings → Secrets and variables → Actions:
| Secret | Where to get it |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare Dashboard → My Profile → API Tokens — create a token with the Edit Cloudflare Workers template |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare Dashboard → select your account → copy the Account ID from the right sidebar on the overview page |
CUSTOM_DOMAIN |
Your Cloudflare Workers custom domain (e.g. api.example.com). Injected into wrangler.jsonc at deploy time via the __CUSTOM_DOMAIN__ placeholder |
PAT |
GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens — needs Contents: Read and write and Pull requests: Read and write permissions for this repo. Required because pushes made with the default GITHUB_TOKEN do not trigger downstream workflows |
Set the runtime Wrangler secrets via wrangler secret put <NAME> once after the first deploy: DISCORD_TOKEN_BOT, DISCORD_TOKEN_USER, AUTH_KEY, plus optional DISCORD_TOKEN_USER_PREMIUM + AUTH_KEY_PREMIUM and AUTH_KEY_ADMIN.