field-guide-purchase-sync Worker
field-guide-purchase-sync Worker
Cron-only, no public routes. See ../../_private/kit-email-sequence-plan.md
(“Prerequisite: fix purchase tracking” and Stage 2’s discount mechanism)
for the full background. Does two jobs on one daily schedule:
- Purchase tagging - pulls recent Lemon Squeezy orders and tags
matching Kit subscribers
field-guide-1-purchased. This is the replacement for the broken Zapier automation, and what the Kit automation’s “Has field-guide-1-purchased tag?” condition and the Field-Guide-Pitch mid-sequence interrupt actually depend on. - Discount token minting - generates the opaque per-subscriber token
used in email 3’s discount link and writes it to a Kit custom field,
for anyone active in the Field Guide Pitch sequence who doesn’t have one
yet. See
../discount-redirect, which reads what this writes. Before minting, each candidate gets a direct, exact-match Lemon Squeezy lookup by their own email (lemonsqueezy.js’shasEverPurchased) - this is what catches someone who bought the guide before subscribing to the newsletter, once their order has aged out of job 1’s recent-orders window (see “Purchase tagging” above, which only catches purchases made while already a Kit subscriber). If a candidate has already purchased, they get taggedfield-guide-1-purchasedinstead of getting a token.
Both jobs exist because Kit’s automation builder can’t make outbound API calls mid-flow - nothing inside Kit can keep this state correct on its own, so it has to be pushed in from outside on a schedule. Both are idempotent and re-check a rolling window every run, so a missed or failed run is caught automatically on the next one - no manual catch-up required.
Files
index.js- the Worker:scheduled()runs both jobs, then monitoring (see below). Nofetch()handler - this Worker has no public surface.lemonsqueezy.js- fetch() against Lemon Squeezy’s v1 Orders API.kit.js- fetch() against Kit’s v4 API (X-Kit-Api-Keyheader auth) - a different key/endpoint family than this repo’s other Workers, which use v3. v4 is needed here for “list subscribers for a sequence,” which v3 has no equivalent for.supabase.js- PostgREST helpers againstdiscount_links(insert only -discount-redirectowns reads/updates) andpurchase_sync_runs.email.js- Resend alert emails. Should send zero emails under normal healthy operation - every send here means something worth checking.../../supabase/migrations/002_discount_links.sql,003_purchase_sync_runs.sql- table schemas.
One-time setup before this can run
- Create the
field-guide-1-purchasedtag in Kit if it doesn’t already exist, and note its numeric ID (KIT_PURCHASE_TAG_ID- find via Kit’s dashboard or the List Tags v4 endpoint). - Note the Field Guide Pitch sequence’s numeric ID
(
KIT_FIELD_GUIDE_PITCH_SEQUENCE_ID). - Create a
discount_tokencustom field in Kit (Settings -> Custom Fields) - the token-minting job writes to it, and the live email’s merge tag (``) reads from it. - Generate a v4 Kit API key (account settings -> Developer -> “Add a
new key”) - this is a different key/type than the v3
KIT_API_KEYused bykit-subscribe/principles-audit. - Note the Lemon Squeezy store ID (
LEMONSQUEEZY_STORE_ID).
Secrets (set via wrangler secret put <NAME>)
LEMONSQUEEZY_API_KEY- Lemon Squeezy dashboard -> Settings -> API.KIT_V4_API_KEY- see above.SUPABASE_URL,SUPABASE_SECRET_KEY- the same dedicated Supabase project asprinciples-audit/discount-redirect.RESEND_API_KEY,EMAIL_FROM_ADDRESS,EMAIL_REPLY_TO- same Resend account/domain asprinciples-audit.ALERT_EMAIL_TO- where failure/anomaly alerts land.
LEMONSQUEEZY_STORE_ID, KIT_PURCHASE_TAG_ID,
KIT_FIELD_GUIDE_PITCH_SEQUENCE_ID are plain vars in wrangler.toml
(not secret, just IDs) - replace the placeholders there before deploying.
Monitoring
- Loud failures (a thrown error in either job) - alerts immediately via Resend, then rethrows so Cloudflare’s own cron-failure notification is a second, independent backstop.
- Silent failures (the run finishes without throwing but did nothing useful) - alerts if zero orders turn up for 7+ days running (possible Lemon Squeezy response-shape change silently zeroing out parsing), or if orders were found but zero subscribers got tagged (every Kit tag request failing without throwing).
purchase_sync_runs- a secondary spot-check log, not the primary safety net.- Known gap, deliberately not covered: silent failures in job 2 (token minting) - only job 1 (purchase tagging) has silent-failure heuristics. Considered 2026-09-18 and declined: job 2’s candidate count naturally swings between “today’s whole backlog” and “zero new entrants some days” once steady-state, so a naive threshold would false-positive on ordinary quiet days and train Ben to ignore alerts - worse than no check. A loud failure (a thrown error) in job 2 is still caught and alerted as normal.
discount-redirecthas no monitoring at all, by design - it fails open silently on any error rather than blocking a sale. A real problem there would only surface asdiscount_linksgoing quiet, not an email. Considered building Supabase-side alerting as an alternative and declined 2026-09-18: it wouldn’t reduce risk, just relocate it into a second, untested alerting pipeline with its own new secrets to misconfigure.
Local dev
wrangler dev --test-scheduled in this directory, then
curl "http://localhost:8787/__scheduled?cron=0+6+*+*+*" to trigger a run
without waiting for the real cron. Needs all secrets above set locally
(.dev.vars, gitignored).
DRY_RUN
Set DRY_RUN=true (in .dev.vars for local dev - never as a real
Cloudflare var/secret, since production runs must never be dry) to skip
every write (Kit tag/update calls, Supabase inserts) in favour of a
console.log describing what would have happened. Reads (fetching orders,
listing sequence subscribers) still hit the real APIs, so this is a safe
way to see exactly what a run would do against real Kit/Lemon Squeezy data
- including the already-purchased check above - with zero side effects, before ever running for real.
Testing the logic
node scripts/test-purchase-sync-logic.mjs (from the repo root) runs the
real exported functions (syncPurchaseTags, mintDiscountTokens) against
mocked fetch responses - no real credentials or network access needed.
Covers: the recent-orders scan tagging a purchaser, the already-purchased
check correctly redirecting a stale-window purchaser away from getting a
token, a subscriber who already has a token being skipped without an extra
API call, and a genuinely new subscriber still getting a token normally.
Complements, doesn’t replace, an actual DRY_RUN check against real data.