DEV Community

Martinius
Martinius

Posted on

Sell digital products with a Stripe Payment Link and a scheduled GitHub Action

A Stripe Payment Link takes two minutes to create and handles card entry, 3DS, receipts, currency and tax collection. When a buyer pays, you get money and a checkout.session object with status: "complete".

What you do not get is anything that hands the buyer the thing they paid for. That step is yours, and it is the step that decides whether you need infrastructure.

The default answer is a webhook. Stand up an HTTPS endpoint, verify the signature on checkout.session.completed, fulfill. It works. It also costs you a deployed service, TLS, a signing secret, replay handling, and an on-call posture for an endpoint that fires a few times a week.

There is a second answer that fits low-volume digital goods: delete the endpoint and poll. A scheduled job lists recent Checkout Sessions, picks the paid ones it has not seen before, and fulfills them. No inbound network surface, no webhook secret, no server.

The interesting part is not the polling. It is the four correctness problems polling forces you to solve explicitly, which a webhook lets you ignore right up until it drops an event.

Disclosure: we build HonorBox, an
MIT-licensed store engine built on this pattern, and the code in this post is lifted from it. The pattern is the point. You can build it yourself in an afternoon and never touch our product.

The trade, stated plainly

Webhook Poll from CI
Infrastructure endpoint, TLS, uptime none
Secrets API key + webhook signing secret API key only
Latency seconds one poll interval, typically minutes
Missed-event risk endpoint down during retry window re-scan window covers it
Replay/duplicate handling you must build it you must build it
Debugging a lost sale log dive on a live service re-run the job

Polling wins when latency in minutes is acceptable and you would rather not operate a service. Webhooks win when the buyer is staring at a spinner.

The shape

Four moving parts:

  1. A Payment Link with a custom field that collects the delivery handle.
  2. A grant table mapping a payment link (or price) to what the buyer gets.
  3. A scheduled job that lists sessions, filters, and fulfills.
  4. Committed state: a cursor and a set of processed session ids.

Delivery in this example is a GitHub collaborator invite to a private repo. Substitute your own: mail a license key, provision a tenant, whatever.

1. The Payment Link

Create the product and price, then a Payment Link, and add a custom field with key github_username, type text, required. Set the post-payment confirmation message to state the delivery method and the real latency: "your repo invite usually arrives within minutes, always within a few hours."

One detail worth getting right the first time. The link gives you two different values and they are not interchangeable: the URL goes on your buy button, and the id (plink_...) is what appears on the session object. Configure your grant with the URL and it matches nothing, forever, with a green build.

2. Listing sessions

Two calls run this whole engine, and both are GETs against one resource.

async function stripeGet(pathname, params, key) {
  const url = new URL(`https://api.stripe.com${pathname}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, {
    headers: {
      Authorization: `Basic ${Buffer.from(key + ':').toString('base64')}`,
      'Stripe-Version': '2024-06-20',
    },
  });
  if (!res.ok) throw new Error(`Stripe ${pathname} -> ${res.status}: ${await res.text()}`);
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Pin Stripe-Version. Your account's default API version is a setting that can change out from under your parsing code, and a fulfillment job is exactly the kind of thing nobody re-tests after an account-level change.

Pagination, with line items expanded so you can match on price:

async function listSessionsSince(createdGt, key) {
  const sessions = [];
  let startingAfter = null;
  for (;;) {
    const params = {
      limit: '100',
      'created[gt]': String(createdGt),
      'expand[]': 'data.line_items',
    };
    if (startingAfter) params.starting_after = startingAfter;
    const page = await stripeGet('/v1/checkout/sessions', params, key);
    sessions.push(...page.data);
    if (!page.has_more || page.data.length === 0) break;
    startingAfter = page.data[page.data.length - 1].id;
  }
  return sessions;
}
Enter fullscreen mode Exit fullscreen mode

3. Deciding what counts as a sale

function isPaidComplete(session) {
  return (
    session.status === 'complete' &&
    (session.payment_status === 'paid' ||
      session.payment_status === 'no_payment_required')
  );
}
Enter fullscreen mode Exit fullscreen mode

no_payment_required is the one people miss. A 100% off promotion code produces a completed session that was never charged. Filter on paid alone and your own launch coupon silently fails to deliver, which you will discover from a buyer rather than from a log.

Matching a session to a product handles both payment links and server-created sessions:

function sessionPrices(session) {
  const items = (session.line_items && session.line_items.data) || [];
  return items.map((li) => li.price && li.price.id).filter(Boolean);
}

function matchGrant(session, grants) {
  const byLink = session.payment_link
    ? grants.find((g) => g.payment_link && g.payment_link === session.payment_link)
    : null;
  if (byLink) return byLink;
  const prices = sessionPrices(session);
  return grants.find((g) => g.price && prices.includes(g.price)) || null;
}
Enter fullscreen mode Exit fullscreen mode

Server-created Checkout Sessions have no payment_link, so price matching is the fallback. That is why the list call expands line items.

4. Validating buyer input

The delivery handle is untrusted text that a stranger typed into a form, and it is about to be interpolated into an API URL.

const USERNAME_RE = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9]){0,38}$/;

function validUsername(u) {
  return typeof u === 'string' && USERNAME_RE.test(u) && !u.includes('--');
}
Enter fullscreen mode Exit fullscreen mode

That is GitHub's actual username grammar: 1 to 39 characters, alphanumeric and hyphen, no leading or trailing hyphen, no doubled hyphens. Anything that fails never reaches a URL. The order gets flagged for a human instead.

Buyers also paste things that are almost a username, often enough that it is worth normalizing rather than failing:

function extractGithubUsername(session, fieldKey = 'github_username') {
  const fields = session.custom_fields || [];
  const f = fields.find((x) => x.key === fieldKey);
  const raw = f && f.text && f.text.value;
  if (!raw) return null;
  let u = raw.trim().replace(/^@/, '');
  // Only a BARE profile link yields a username; anything deeper passes
  // through so validation rejects it instead of guessing.
  const m = /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/?$/i.exec(u);
  if (m) u = m[1].replace(/^@/, '');
  return u;
}
Enter fullscreen mode Exit fullscreen mode

Strip a leading @, accept a bare profile URL, and refuse to guess at anything else. Guessing at github.com/user/repo/tree/main would invite the wrong account to your private repo.

5. The cursor and the 25 hour window

Here is the problem that makes polling subtle, and the one we got wrong.

The obvious cursor is "the newest session creation time I have seen", and the obvious query is created > cursor. To absorb clock skew and overlapping runs you subtract a safety window: created > cursor - OVERLAP.

We shipped that with a 6 hour window. It loses sales.

Stripe's docs are explicit that Checkout Sessions expire 24 hours after creation by default. A session is completable for that entire time. So a buyer can open checkout at 09:00, close the tab, come back at 20:00, and pay. That session's created is still 09:00.

Meanwhile the cursor tracks creation time and advances whenever any newer session appears. One unrelated sale at 15:00 pushes the cursor to 15:00. With a 6 hour window the scan floor is now 09:00, and it keeps climbing. By the time the straggler completes at 20:00, its creation time sits permanently outside the scan window. It is never seen. The run stays green, and nobody is invited.

The window has to outlive the session, not the poll interval:

// Checkout Sessions can complete up to 24h after creation, and the cursor
// tracks creation time, so the window must outlive a session: 24h + 1h slack.
// Re-scans are free (processed-id set), a missed sale is not.
const OVERLAP_SECONDS = 25 * 3600;
Enter fullscreen mode Exit fullscreen mode

The asymmetry is the whole argument. Re-scanning sessions you have already processed costs one extra API page and a set lookup. Missing one costs a customer. When the two error directions are that lopsided, size the window against the worst case and let the idempotency layer absorb the cost.

Two caveats if you copy this. expires_at is configurable, so if you extend a session's lifetime past 24 hours, widen the window to match. And keep the cursor monotonic so a late arrival cannot drag your scan floor backwards:

function nextCursor(sessions, prevCursor) {
  const newest = sessions.reduce((acc, s) => Math.max(acc, s.created || 0), 0);
  return Math.max(prevCursor || 0, newest);
}
Enter fullscreen mode Exit fullscreen mode

6. Idempotency, in two layers

A 25 hour re-scan means the job re-reads the same paid sessions roughly a hundred times. Every one of those must be a no-op.

Layer one is a committed set of processed session ids:

function pickNewPaidSessions(sessions, processedIds, grants) {
  const seen = new Set(processedIds);
  return sessions.filter(
    (s) => isPaidComplete(s) && !seen.has(s.id) && matchGrant(s, grants) !== null
  );
}
Enter fullscreen mode Exit fullscreen mode

Layer one handles sequential runs. It does not handle two runners overlapping, which happens the moment you add a second trigger or a manual dispatch races the cron. Both read the same state file, both see the session as new.

Layer two dedupes at the write:

const ledgerRefs = new Set(ledger.rows.map((r) => r.ref));
// ...
const row = ledgerRow(s, grant);
if (ledgerRefs.has(row.ref)) { state.processed.push(s.id); continue; }
Enter fullscreen mode Exit fullscreen mode

where ref is derived from the session id, so it is stable across runs:

function ledgerRow(session, grant) {
  return {
    ts: new Date(session.created * 1000).toISOString(),
    product: grant.product,
    amount: (session.amount_total ?? 0) / 100,
    currency: (session.currency || '').toUpperCase(),
    country: (session.customer_details &&
      session.customer_details.address &&
      session.customer_details.address.country) || null,
    ref: crypto.createHash('sha256').update(session.id).digest('hex').slice(0, 10),
  };
}
Enter fullscreen mode Exit fullscreen mode

That row is also the bookkeeping artifact, which is why it carries no name, no email, and no session id in the clear. A hash prefix is enough to reconcile against Stripe when you need to and safe to publish if you ever want to.

Also add a concurrency guard at the workflow level. Both layers are cheap insurance, and neither replaces not running two copies at once.

7. Transient failures versus permanent ones

Delivery is one call:

async function inviteCollaborator(repo, username, token) {
  const res = await fetch(
    `https://api.github.com/repos/${repo}/collaborators/${username}`,
    {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/vnd.github+json',
        'User-Agent': 'honorbox-fulfill',
        'X-GitHub-Api-Version': '2022-11-28',
      },
      body: JSON.stringify({ permission: 'pull' }),
    }
  );
  // 201 = invitation created, 204 = already a collaborator
  if (res.status === 201 || res.status === 204) return res.status;
  const hint = res.status === 404 ? ' (no such GitHub user, check for a typo)' : '';
  const msg = `GitHub invite ${repo} <- ${username} -> ${res.status}${hint}`;
  throw Object.assign(new Error(msg), { status: res.status });
}
Enter fullscreen mode Exit fullscreen mode

Distinguish 201 from 204 in your logs. Both mean success, and collapsing them into "invited" makes your own test purchase read like a real delivery. It also erases the exact distinction you need when a buyer says no invite arrived.

Failures split two ways, and the split determines whether the session gets marked processed:

function isTransientInviteError(err) {
  if (!err || err.permanent) return false;
  if (err.status == null) return true; // fetch threw: DNS, timeout, reset
  if (err.status === 429 || err.status >= 500) return true;
  return err.status === 403 && /rate limit|secondary/i.test(String(err.message));
}
Enter fullscreen mode Exit fullscreen mode

Transient means the next poll should try again, so the session is not added to the processed set. Permanent, such as a 404 for a username that does not exist, means no amount of retrying helps: record it for a human and move on.

Bound the retrying by time rather than by attempt count:

const INVITE_RETRY_WINDOW_SECONDS = 6 * 3600;

function shouldRetryInvite(err, failures, sessionId, now = Date.now()) {
  if (!isTransientInviteError(err)) return false;
  const first = failures.find((f) => f.session === sessionId && f.transient);
  return !first || now - Date.parse(first.ts) < INVITE_RETRY_WINDOW_SECONDS * 1000;
}
Enter fullscreen mode Exit fullscreen mode

An attempt cap is the intuitive choice and it is wrong at this cadence. Five attempts on a 15 minute poll burns out in about an hour, which a routine provider incident outlasts. A time budget survives the incident and still escalates to a human on the same clock as your delivery promise.

8. Two credentials, both scoped to almost nothing

The reflex objection to this design is "you want my Stripe secret key in a GitHub Action?" Correct instinct. The answer is that neither credential needs to be powerful.

The engine's entire API surface is GET /v1/checkout/sessions and PUT /repos/{repo}/collaborators/{user}. So:

  • Stripe: a restricted key (rk_...) with Checkout Sessions: Read and every other resource set to None. The poll only issues GETs. If it leaks, the holder can read your checkout sessions, which is real customer PII worth protecting, and cannot create a charge, issue a refund, or move a cent.
  • GitHub: a fine-grained PAT scoped to only the product repo, with Administration: Read and write and nothing else. If it leaks, the holder administers that one repo and cannot see any other repository you own.

Note that the job's own state commits should use the workflow's built-in GITHUB_TOKEN with permissions: contents: write, not your PAT. The PAT never needs access to the repo the job runs in.

Verify the scoping instead of trusting it: run the job once and read the log. If a Stripe permission is missing, the API rejects the call and names the permission you need.

9. The workflow

name: Fulfill orders

on:
  schedule:
    - cron: "*/15 * * * *"
  workflow_dispatch:

permissions:
  contents: write

concurrency:
  group: fulfill
  cancel-in-progress: false

jobs:
  fulfill:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
          node-version: 22
      - name: Poll Stripe and fulfill
        env:
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
          GH_FULFILL_TOKEN: ${{ secrets.GH_FULFILL_TOKEN }}
        run: node fulfill.js
      - name: Commit state
        run: |
          git config user.name "fulfill-bot"
          git config user.email "actions@users.noreply.github.com"
          git add state/ ledger/
          git diff --cached --quiet || git commit -m "fulfill: $(date -u +%FT%TZ)"
          git push
Enter fullscreen mode Exit fullscreen mode

Three things doing real work here. concurrency stops two runs from racing. Pinned action SHAs mean a compromised tag cannot reach a job holding your secrets. And the state commit is what makes the whole design work: your idempotency set and cursor live in git, which gives you durable state, an audit trail, and a diff per sale without running a database.

Run this in a private repo. It holds a live key and your sales state.

A note on the cron. */15 is the interval you request, not a guarantee. Scheduled Actions are best effort and can fire late when the platform is busy, which is why the confirmation message should promise minutes and commit to hours. The 25 hour window means a delay costs latency and never a sale.

Where this is the wrong tool

  • Your buyers need GitHub accounts. Fine for code, templates, CLI tools, plugins and agent packs. Wrong for an ebook aimed at people who have never heard of git. Change the delivery step and the rest of the design still holds.
  • Delivery is minutes, not seconds. If your product needs an instant unlock, use a webhook.
  • You are the merchant of record. Selling on your own Stripe account means VAT and sales tax are your problem, unlike a platform that acts as the seller. For most small sellers this is a threshold question and Stripe Tax handles the collection, but read your own jurisdiction's rules once before launch.
  • This is not a subscription engine. Everything above assumes one-time payments. Recurring billing has a lifecycle (renewals, failures, dunning, cancellations) that genuinely wants webhooks.

Putting it together

The driver loop, roughly:

const state = readJson(statePath, { cursor: 0, processed: [], failures: [] });
const since = Math.max(0, (state.cursor || 0) - OVERLAP_SECONDS);
const sessions = await listSessionsSince(since, stripeKey);
const fresh = pickNewPaidSessions(sessions, state.processed, config.fulfillment);

for (const s of fresh) {
  const grant = matchGrant(s, config.fulfillment);
  const username = extractGithubUsername(s);
  const row = ledgerRow(s, grant);
  if (ledgerRefs.has(row.ref)) { state.processed.push(s.id); continue; }
  try {
    if (!validUsername(username)) {
      throw Object.assign(new Error(`invalid username`), { permanent: true });
    }
    await inviteCollaborator(grant.repo, username, ghToken);
    ledger.rows.push(row);
    ledgerRefs.add(row.ref);
  } catch (err) {
    if (shouldRetryInvite(err, state.failures, s.id)) {
      state.failures.push({ session: s.id, ts: new Date().toISOString(), transient: true });
      continue; // NOT marked processed: the next poll retries it
    }
    ledger.rows.push({ ...row, needs_attention: true });
  }
  state.processed.push(s.id);
}

state.cursor = nextCursor(sessions, state.cursor);
Enter fullscreen mode Exit fullscreen mode

That is the entire fulfillment path. In our implementation it is 190 lines of I/O in fulfill.js plus 164 lines of pure logic in fulfill-core.js, with no dependencies, on top of Node's built-in fetch, crypto and node:test. The split exists so every rule above (window sizing, cursor monotonicity, the transient/permanent classification, username grammar) is unit-testable without a network.

Test the boring things. The ones that caught real bugs for us were: a session completing 23 hours after creation still gets fulfilled, a transient failure past the retry window converts to a flagged row, a second run over the same data does nothing, and a grant configured with a checkout URL instead of a plink_ id is reported loudly rather than silently matching zero sales.

Before you launch

Make a 100% off promotion code with a redemption limit of one, buy your own product with it, and watch the whole pipe run. It costs nothing, it exercises the no_payment_required branch, and it is the only way to find out that your grant is configured with a URL.

The full implementation is MIT licensed at github.com/Honorboxx/honorbox if you want to read the finished version, and the least-privilege doc has the exact permission toggles for both credentials. Copy the pattern, copy the file, or just steal the 25 hour window. That one is the bug most worth not rediscovering.

Top comments (3)

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

the poll-from-CI framing is genuinely useful, especially the no-inbound-surface bit. the one that bites people on your replay/duplicate row is that CI is stateless, so "the paid ones it hasn't seen before" has nowhere to live between runs. you either commit the fulfilled session ids back to the repo (works, but now every sale is a git commit), or write a marker into the session's metadata via the API right after fulfilling and filter on it next run, which keeps the source of truth in Stripe where the money already is. i lean toward the metadata approach, since a failed git commit shouldn't be able to cause a double-fulfill. nice writeup.

Collapse
 
martiniuss profile image
Martinius

The commit route survives this because delivery is idempotent anyway. Adding a collaborator is a PUT, so if the state commit fails and the next run re-scans the same session, github just says "already invited" and that gets logged separately from a fresh invite. The ledger commit is bookkeeping, not the dedup mechanism. Lost commit = a no-op re-run, not a double fulfill. I almost went metadata but it needs a write-capable stripe key in CI, and half the point of polling from CI is that the job runs on a restricted read-only key, leaked secret can list sessions and nothing else. Wasn't willing to trade that for cleaner state. Agree metadata wins once you have multiple runners and no shared repo though. (the other half of the answer: the scan window overlaps ~25h since sessions can complete almost a day late, and the cursor holds while anything is pending, so a failed run doesn't drop sales either)

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

You're right on both, and the read-only key is the better argument. I was optimising for state correctness and you were optimising for blast radius, and blast radius wins when the delivery is idempotent anyway.

Worth making that idempotency explicit for anyone lifting the pattern though, because it is doing the real work here. A collaborator invite is a PUT, so a lost commit is a no-op re-run. A license key email, a signed download link, or a balance credit is not, and there the ledger stops being bookkeeping and becomes the dedup mechanism, which is exactly the case where keeping that state in git gets uncomfortable. The pattern is sound as you built it. It is the copy-paste version with a non-idempotent delivery that bites.

The ~25h overlap with the cursor held while anything is pending is the part I would have got wrong, since a same-day window looks fine right up until someone completes a session 20 hours late.