Displays your TRMNL order queue position on a TRMNL e-ink device via a private plugin.
The maintainer-hosted instance lives at https://trmnl.bytefit.io/order-queue; you can fork the repo and deploy your own to wherever you like. See ROADMAP.md for the bigger picture.
- TRMNL's private plugin polls a secured Cloudflare Worker on a schedule.
- The Worker proxies the public TRMNL order tracker, handling the two-step CSRF flow that TRMNL's built-in polling can't do natively.
- The Worker returns JSON merge variables that feed a Liquid template on the device.
The public order tracker at trmnl.com/order-tracker is a Rails app. Getting queue data requires two HTTP calls:
# 1. GET the page to obtain a CSRF token + session cookie
TOKEN=$(curl -sc /tmp/trmnl-cookies https://trmnl.com/order-tracker 2>/dev/null \
| rg -o 'name="authenticity_token" value="([^"]*)"' -r '$1' | head -1)
# 2. POST with token + cookie to get JSON
curl -sb /tmp/trmnl-cookies https://trmnl.com/order_trackers \
-H 'Accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "authenticity_token=${TOKEN}&order_trackers%5Border_number%5D=12345"Response:
{"queue": 6303, "outstanding_orders": 7525}queue- your position in lineoutstanding_orders- total orders ahead of fulfillment
The authenticity_token is a Rails CSRF token embedded in the HTML as a hidden form field. It's ephemeral and paired with the session cookie - both must come from the same GET request. This is why TRMNL's polling config (static URL + headers + body) can't call the endpoint directly.
Source: src/worker.ts. Handles the CSRF dance and returns clean JSON merge variables.
Endpoint shape:
- Method:
POST - Required header:
Authorization: Bearer <clerk-issued JWT> - Body: JSON with a numeric
order_number - Response shape:
{
"order_number": "12345",
"queue": 6303,
"outstanding_orders": 7530,
"updated_at": "2026-05-01T00:56:24.258Z"
}Config (wrangler.jsonc):
- Custom domain route on the deploying account's zone
workers_devdisabled so the Worker is exposed only through the custom domainCLERK_DOMAINvar (public; matches theissclaim in JWTs the Worker accepts)
The Worker verifies each request's bearer token against Clerk's JWKS at ${CLERK_DOMAIN}/.well-known/jwks.json and checks the iss claim. There are no Worker secrets; auth is fully delegated to Clerk.
Local dev:
bun install
npx wrangler dev
# obtain a test JWT from Clerk (Sessions in dashboard, or via Clerk Backend API), then:
curl http://localhost:8787/order-queue \
-H "Authorization: Bearer ${CLERK_JWT}" \
-H 'content-type: application/json' \
-d '{"order_number":"12345"}'Deploy:
npx wrangler deploy- Strategy: Polling
- Polling URL: your deployed Worker URL (e.g.
https://your-subdomain.your-domain.tld/order-queue) - HTTP Method: POST
- Refresh rate: Every 15 mins
- OAuth: enable "OAuth Authentication" on the plugin and configure it to point at your Clerk instance (see Setup for your own deployment). TRMNL handles the authorization-code dance and refresh-token rotation; your Worker just verifies the JWT it polls with.
- Polling headers (query-string format on the plugin settings page, not JSON):
Authorization=Bearer {{ oauth_access_token }}&content-type=application/json
- Polling body:
{
"order_number": "{{ order_number }}"
}- Order number field (form builder): the numeric order number from your TRMNL order confirmation email.
The TRMNL marketplace requires a markup variant for each layout the plugin supports. There's one Liquid file per variant; paste each into the corresponding tab in the plugin's Markup editor:
| File | TRMNL tab | Dimensions |
|---|---|---|
trmnl/markup.liquid |
Full | 800x480 |
trmnl/markup_half_horizontal.liquid |
Half horizontal | 800x240 |
trmnl/markup_half_vertical.liquid |
Half vertical | 400x480 |
trmnl/markup_quadrant.liquid |
Quadrant | 400x240 |
trmnl/shared.liquid |
Shared | (prepended to each view; defines the derived metrics) |
All four show the same primary metric: orders ahead of you (the actionable countdown to fulfillment), with progressively less context as the layout shrinks. The percent value (Of queue ahead) is the share of outstanding orders at-or-ahead of your position; it drops toward 0% as you advance.
trmnl/form-builder.yaml defines the plugin's settings metadata. Paste it into the form builder field on the plugin settings page.
Local preview renders all four layouts on one page at their actual e-ink dimensions:
bun run preview:trmnl
# open http://localhost:3937- Fork + install: fork the repo, clone your fork,
bun install. - Clerk: create a free Clerk application (clerk.com).
- Enable Google as a Social Connection (Configure → SSO Connections → Google). Clerk's hosted credentials are fine for low volume.
- Create an OAuth Application (Configure → OAuth Applications → New OAuth App). Set the Callback URL to TRMNL's auto-generated OAuth Redirect URL (visible in the plugin's Configure OAuth dialog). Scopes:
email profile openid. - Note the OAuth App's
client_id,client_secret, and your Clerk instance domain (e.g.https://my-app.clerk.accounts.dev).
- Cloudflare Worker: edit
wrangler.jsonc- setroutes[0].patternto a custom domain you control, andvars.CLERK_DOMAINto your Clerk instance domain. Thennpx wrangler deploy. - TRMNL plugin: create a private plugin in your TRMNL account with the polling config above.
- Toggle Enable OAuth Authentication on. In the Configure OAuth dialog:
- Authorization URL:
${CLERK_DOMAIN}/oauth/authorize - Token URL:
${CLERK_DOMAIN}/oauth/token - Client ID / Client Secret: from your Clerk OAuth Application
- Scopes:
email profile openid - PKCE: enable (defense in depth)
- Authorization URL:
- Set the polling URL to your Worker, polling headers to
Authorization=Bearer {{ oauth_access_token }}&content-type=application/json, polling body to{"order_number":"{{ order_number }}"}. - Click Connect - Clerk hosts the Google sign-in + consent screen, then redirects back to TRMNL with a code TRMNL exchanges for tokens.
- Toggle Enable OAuth Authentication on. In the Configure OAuth dialog:
- Markup + form builder: paste each file in
trmnl/into its corresponding tab in the plugin's Markup editor (Full, Half horizontal, Half vertical, Quadrant, and Shared per the table above), and pastetrmnl/form-builder.yamlinto the form builder field. - Hardening (recommended, optional): add the Cloudflare protections described below.
The maintainer-hosted Worker sits behind a layered defense at the Cloudflare edge:
- A WAF custom rule blocks any non-POST or any POST to
/order-queuethat's missing anAuthorizationheader (so random scanners andGET /bots never reach the Worker). - A second WAF rule allowlists only the ASN(s) that TRMNL is known to poll from, blocking everything else at the edge.
- A rate-limit rule caps requests per source IP keyed on the path so a single source can't burn through Worker invocations.
- Bot Fight Mode is off on the zone (see below for why).
The Worker itself verifies the JWT signature against Clerk's JWKS as a final layer. Specific rule expressions are deliberately omitted from this README; deploy with whatever expressions and thresholds suit your zone.
Cloudflare Bot Fight Mode (Security → Settings → Bot fight mode) presents a JS-based managed challenge to non-residential ASNs. TRMNL polls from datacenter IPs, so every poll gets challenged before it reaches the WAF or the Worker. TRMNL's HTTP client can't solve the challenge, records empty merge_variables, and the device renders NaN%.
If the device starts showing blank values or NaN%, first thing to check is whether Bot Fight Mode got re-enabled. Diagnostic signature, in order of decisiveness:
- Cloudflare → Security → Events filtered by your Worker's host shows
Managed Challengerows from datacenter ASNs at the polling timestamps. npx wrangler tailduring a manual TRMNL Force Refresh shows zero requests reaching the Worker, while a curl from your laptop returns200(residential ISP isn't challenged - Bot Fight Mode keys on ASN reputation).- TRMNL plugin debug logs show
Processed merge_variables for polling URLwithin ~125ms ofProcessing polling URL- too fast for the Worker's CSRF dance againsttrmnl.com(which takes 500ms+). That gap means the request was never made; TRMNL got a non-2xx and silently recorded empty merge_variables.
The custom WAF rule plus the Worker's token check do the actual security work. Bot Fight Mode adds nothing for an API endpoint that's already gated by a shared secret, and it false-positives any legitimate datacenter caller. Keep it off.
Authentication is delegated to Clerk acting as a hosted OAuth 2.0 provider. Clerk runs the sign-in UI, Google IdP integration, consent screen, token issuance, and refresh-token rotation. TRMNL's private-plugin OAuth toggle does the client-side dance. The Worker is a pure resource server: on every poll it verifies the bearer JWT against Clerk's JWKS and checks the iss claim. There is no per-user state on the Worker side - the JWT identifies the user and the form-builder's order_number field carries their data. See ROADMAP.md for the bigger picture.
At TRMNL's 15-minute polling cadence, each user generates 96 polls/day. The Cloudflare Workers free tier is 100,000 requests/day, so a single shared deployment maxes out at roughly 1,040 active users. Workers Paid ($5/mo, 10M req/mo) lifts that to roughly 104,000 users. If you're forking this for personal use you'll never approach the free tier. If you're hosting for many users, plan accordingly.