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:

  1. 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.
  2. 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’s hasEverPurchased) - 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 tagged field-guide-1-purchased instead 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). No fetch() 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-Key header 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 against discount_links (insert only - discount-redirect owns reads/updates) and purchase_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-purchased tag 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_token custom 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_KEY used by kit-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 as principles-audit/discount-redirect.
  • RESEND_API_KEY, EMAIL_FROM_ADDRESS, EMAIL_REPLY_TO - same Resend account/domain as principles-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-redirect has 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 as discount_links going 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.