HonorBox

Instant delivery

HonorBox's default fulfillment is a poll every 30 minutes: zero infrastructure, zero accounts beyond Stripe + GitHub, and an invite a median of ~15 minutes after payment, sometimes hours when GitHub's cron drifts. (The cadence is set by the free Actions tier, not by taste; the arithmetic is in setup.md.) The payment is always instant; this page is about shrinking the wait for the invite.

Both upgrades here are opt-in, and setup §6 is where you decide. The poll stays on either way; it is the safety net, and skipping these keeps a perfectly working store rather than a degraded one.

ModeTypical deliveryWorst caseWhat you add
Poll (default)~15 min medianhours (cron drift)nothing
+ Heartbeat~15 min mediantwo independent crons must both drift1 fine-grained token
+ Webhook relay~15-60 secondsfalls back to the poll1 free serverless endpoint + webhook secret + 1 token

Webhook mode: delivery in seconds

buyer pays
  └▶ Stripe fires checkout.session.completed ──▶ relay (free serverless fn)
                                                   1. verify Stripe signature
                                                   2. POST repository_dispatch
                                                      "honorbox_sale" ──▶ ops repo
                                                                            └▶ fulfill-on-sale.yml
                                                                               runs the same fulfill.js
meanwhile the scheduled fulfill.yml keeps polling; whichever runs first
delivers; the other is a no-op (processed-set + ledger-ref guard)

The relay is one file, 140 lines on Cloudflare Workers and 185 on Node, dependency-free, in webhook-mode/: a Cloudflare Workers variant and a Val Town / plain-Node variant. Both free tiers take no card. What it does is deliberately tiny:

The exact dispatch payload:

{
  "event_type": "honorbox_sale",
  "client_payload": {
    "event": "checkout.session.completed",
    "livemode": true,
    "created": 1700000000,
    "ref": "58f4e5e2f1"
  }
}

ref is the same 10-char SHA-256 prefix of the session id the ledger uses, so a workflow run can be matched to its ledger row. Note what is not in there: no session id, no username, no amount, no email. The workflow never trusts webhook content. It runs the same poller, which re-reads the truth from Stripe's API. A forged dispatch can at worst trigger an empty run.

Turning it on (~10 minutes)

  1. Workflow: copy setup/workflows/fulfill-on-sale.yml to .github/workflows/fulfill-on-sale.yml in your private ops repo. It uses the secrets the ops repo already has; nothing new.
  2. Token: GitHub → Settings → Developer settings → Fine-grained tokens → new token: Repository access = only your ops repo, Permissions = Contents: Read and write. That is GitHub's minimum for repository_dispatch (there is no dispatch-only permission).
  3. Relay: Cloudflare dash.cloudflare.com → Workers & Pages → Create → paste webhook-mode/relay-cloudflare.mjs. Add secrets GITHUB_TOKEN (step 2) and GITHUB_REPO (you/yourstore-ops). (Val Town instead: new HTTP val, paste relay-node.mjs, same env vars.)
  4. Stripe: Dashboard → Developers → Webhooks → Add endpoint: the relay's URL, events checkout.session.completed + checkout.session.async_payment_succeeded. Copy the signing secret (whsec_…) into the relay's STRIPE_WEBHOOK_SECRET.
  5. Test: Stripe's endpoint page → "Send test event" → checkout.session.completed. Within seconds the ops repo should show a "Fulfill on sale" run; the run finds no real paid session and no-ops. For a full rehearsal, place a $0 test order using either method in setup §8 and watch the invite arrive in seconds.

Threat model

ConcernAnswer
Forged webhook to the relayRejected: signature is verified against your endpoint secret before anything else. Replays older than 5 min are rejected even with a valid signature.
Signature check removed/brokenWorst case is dispatch spam → empty workflow runs burning Actions minutes. Fulfillment can't be forged, because the workflow re-derives sales from Stripe's API and invites are idempotent. Still: the check is mandatory, and the tests pin it (scripts/test/dispatch.test.js).
Relay compromisedIt holds two secrets: the webhook secret (can only make the relay believe Stripe called) and the GitHub token. It has no Stripe API key, so it cannot read sales or move money.
GitHub token leakedFine-grained, single-repo. Contents R/W on the ops repo is real blast radius (it can push there). Scope it to only the ops repo, never an org-wide or classic PAT. Set a real expiry (6-12 months) and calendar the rotation; GitHub allows non-expiring fine-grained PATs now, don't use one. Tighter-token option: point the relay at POST …/actions/workflows/fulfill.yml/dispatches instead and the token needs only Actions: R/W (can't push), at the price of losing the typed event and the payload ref.
Secrets in the repoNever. Webhook secret and token live only in the relay platform's env/secret store; the workflow uses the ops repo's existing Actions secrets.
Relay down / Stripe webhook outageDelivery degrades to the poll, which is exactly today's behavior. Stripe also retries failed webhook deliveries for days and emails you about a failing endpoint.
Double delivery (webhook + poll race)The two workflows share a concurrency group so they serialize; the processed-set and ledger-ref guard make the second run a no-op; the GitHub invite call itself is idempotent (204 if already invited).

Heartbeat: a second scheduler, no new accounts

No relay and no webhook, just a second, independent scheduler. A workflow in a different repo (your public storefront fork is the natural home) fires hourly and workflow_dispatches the ops fulfill workflow. GitHub crons drift independently per repo, and a dispatched run starts promptly, so two schedulers cut the odds of a long gap without any always-on machine.

Heartbeat buys reliability, not speed. Pair it with an hourly poll (0 * * * * in fulfill.yml, 30 * * * * here) and you get the same ~15-minute median as the shipped */30 poll, for the same 1,488 Actions minutes a month, but from two schedulers instead of one, so a single scheduler drifting no longer means a long silence. It does not beat the webhook relay, and it is not free to run tighter: see the cost note below.

Set up: copy setup/workflows/heartbeat.yml into the storefront repo's .github/workflows/, add secret OPS_DISPATCH_TOKEN (fine-grained PAT: only the ops repo, Actions: Read and write; it can start runs but cannot read code or secrets, and cannot push) and variable OPS_REPO. Details and limits are in the template's header. The big one: on public repos GitHub disables schedules after 60 days without repo activity, so a dormant store eventually loses its heartbeat (you get an email; any push re-arms).

What this costs you

What none of this speeds up: GitHub's cap of 50 repository invitations per 24 hours. Webhook mode makes the 50th delivery instant; it does not make the 51st possible, and nothing does. Moving the repo into an organization does not lift it either: inviting a buyer to the org is itself capped, and it would let every buyer list every other buyer. Past the 50th, the queue is the answer, and it drains on its own. See how-it-works.md.