Selling digital products like software licenses, templates, courses, or memberships often requires infrastructure that many small businesses prefer not to manage. However, with the right tools you can automate delivery without keeping a permanently active server. At Q2BSTUDIO, as a company specialized in custom software development, we know the key is simplifying processes without compromising security or buyer experience.
Stripe Payment Links allows you to create a payment link in under two minutes. It handles card entry, 3D Secure authentication, receipts, currency conversion, and tax collection. When the buyer pays, you receive the money and a checkout.session object with status 'complete'. What you don’t get is product delivery. That step is yours and determines whether you need additional infrastructure.
The most common solution is a webhook: set up an HTTPS endpoint, verify the signature on checkout.session.completed, and fulfill. It works, but it means having a deployed service, TLS, a signing secret, retry handling, and an on-call posture for an endpoint that fires a few times a week. For low-volume digital products, there’s a lighter alternative: drop the endpoint and use polling. A scheduled job lists recent Checkout Sessions, picks paid ones it hasn’t seen before, and fulfills them. No inbound network surface, no webhook secret, no server.
The interesting part isn’t the polling itself, but the four correctness problems polling forces you to solve explicitly, which a webhook lets you ignore until it drops an event. Below, we break down how to implement this pattern using GitHub Actions as the orchestrator.
System components
The system has four moving parts: a Payment Link with a custom field to collect the delivery handle (e.g., a GitHub username); a grant table mapping each link or price to the product being delivered; a scheduled job that lists sessions, filters, and fulfills; and committed state including a cursor and a set of processed session IDs.
In this example delivery is an invitation to a private GitHub repository as a collaborator. But the pattern applies to any digital product: sending a license key, provisioning a SaaS tenant, downloading an ebook, etc.
Creating the Payment Link
First, create the product and price in Stripe. Then generate a Payment Link and add a custom field with key 'github_username', type text, required. In the post-payment confirmation message, specify the delivery method and real latency: 'Your repo invite usually arrives within minutes, always within a few hours.'
One important detail: the link gives you two different values: the URL (used on the buy button) and the id (plink_...). When you configure the grant, use the id, not the URL. If you use the URL, it will never match the session object and deliveries will silently fail, with a green build.
Listing sessions
The entire engine relies on two GET calls to /v1/checkout/sessions on Stripe. The first retrieves a page of sessions with pagination and expanded line_items to allow matching by price. The code is straightforward: a function that iterates until no more pages exist.
It’s critical to pin the Stripe API version (Stripe-Version) in the request, because your account’s default version can change and break your parsing code without notice. A fulfillment job is exactly the kind of thing nobody retests after an account-level change.
Deciding what counts as a sale
Not all completed sessions are paid. A session with status 'complete' and payment_status 'paid' is a sale, but there’s also 'no_payment_required', which happens when a 100% off promotion code is used. If you filter only by 'paid', your own launch coupon will fail to deliver, and you’ll find out from a customer. The verification function must include both cases.
Also, you need to match the session to the product. Sessions created via Payment Link have the payment_link field; server-created sessions don’t, so you have to fall back to price matching. That’s why the list call expands line_items.
Validating buyer input
The custom field contains untrusted text typed by a stranger. Before using it in an API call (e.g., inviting a GitHub collaborator), you must validate it rigorously. For GitHub usernames, the format is: 1 to 39 characters, alphanumeric and hyphens, no leading or trailing hyphen, no double hyphens. Any value that doesn’t comply must be rejected and flagged for human review, never sent to a URL.
Also useful: normalize by stripping a leading @, accepting a simple profile URL (github.com/user), and rejecting anything else like deep paths that could invite the wrong account.
The cursor and the 25-hour window
This is the problem that makes polling subtle. The obvious cursor is 'the newest session creation time I’ve seen', and the obvious query is created > cursor. To absorb clock skew and overlapping runs, you subtract a safety window. If you set that window to 6 hours, you lose sales. Why? Checkout Sessions can be completed up to 24 hours after creation (by default). A buyer can open checkout at 09:00, close the tab, return at 20:00, and pay. The creation time remains 09:00. Meanwhile, the cursor advances when an unrelated new session appears, say at 15:00. With a 6-hour window, the scan floor is at 09:00 and climbs. When the straggler completes at 20:00, its creation is permanently outside the window. It’s never seen. Solution: a 25-hour window (24-hour session lifetime plus 1 hour slack). Re-scanning already processed sessions costs one extra API page and a set lookup; missing a sale costs a customer. The asymmetry is key: size the window for the worst case and let the idempotency layer absorb the cost.
Two caveats: if you extend session lifetime (expires_at), widen the window accordingly. And keep the cursor monotonic: the next run should use the maximum between the previous cursor and the newest session seen.
Idempotency in two layers
A 25-hour window means the job rereads the same paid sessions roughly a hundred times. Each reread must be a no-op. The first layer is a set of processed session IDs stored in the committed state (a JSON file in the repo). The second layer handles concurrency: two simultaneous runs may read the same state and see the same session as new. To handle this, when writing to the ledger, use a hash of the session ID as a unique reference; if it already exists, ignore. Additionally, a concurrency lock at the workflow level prevents two runs from stepping on each other.
Transient vs permanent failure handling
Delivery is a call to the GitHub API. Errors like 429 (rate limit) or 500 are transient: retry in the next cycle without marking the session as processed. Errors like 404 (user doesn’t exist) are permanent: don’t retry, log for human intervention. Retry should be bounded by time (e.g., 6 hours) not by attempt count, because in a 15-minute cycle five attempts are consumed in an hour, but a provider incident can last longer.
Least-privilege credentials
The natural objection to this design is 'you’re going to put your Stripe secret key in a GitHub Action?' The answer is that no credential needs to be powerful. For Stripe, a restricted key (rk_...) with only read permission on Checkout Sessions (nothing else). For GitHub, a fine-grained PAT with only administration permissions on the product repository. The job’s own state is committed using the built-in GITHUB_TOKEN from the workflow, not the PAT. Verify permissions by running the job once and reading the log.
The GitHub Actions workflow
The YAML file defines a cron every 15 minutes with manual trigger. It uses concurrency to avoid races, SHA-pinned actions (not moving tags), and a final step that commits the state (cursor, processed sessions, ledger) to the repo. That commit makes everything work: state is durable, you have an audit trail and a diff per sale without a database.
It’s important to run in a private repository because it holds live keys and sales data. Also, the cron is best-effort: it can be delayed when the platform is busy. That’s why the confirmation message should promise minutes and commit to hours. The 25-hour window ensures a delay never loses a sale, only delays it.
When this pattern isn’t suitable
If your product needs instant delivery (seconds), use webhooks. If you sell to buyers without GitHub accounts (e.g., an ebook for non-developers), change delivery to another method (email a link). If you are a registered merchant, remember you are responsible for VAT and other taxes; Stripe Tax helps collect them, but you must know your jurisdiction’s rules. And it’s not a subscription engine; for recurring payments, webhooks are more appropriate due to their lifecycle (renewals, failures, dunning, cancellations).
Main loop summary
The job reads state, calculates the cursor with the 25-hour window, lists sessions, filters new paid ones that match grants, extracts username, validates, attempts delivery, logs result (success, transient failure, permanent failure), and updates state. In our implementation, that’s about 190 lines of I/O plus 164 lines of pure logic, zero dependencies, using Node.js native fetch and crypto. The separation allows unit-testing each rule without network.
What adds most value is testing edge cases: a session completing 23 hours after creation, a transient failure past the retry window becoming a flagged row, a second run over the same data doing nothing, and a grant configured with a URL instead of plink_ being reported loudly.
Before launch, create a 100% off promotion code with a redemption limit of one, buy your own product and watch the full flow. It costs nothing, exercises the no_payment_required branch, and is the only way to find out your grant is misconfigured.
At Q2BSTUDIO we apply similar patterns to automate delivery of custom software, integrating AI agents that handle fulfillment, and deploying on cloud environments like AWS or Azure with built-in cybersecurity. The philosophy is the same: simplify operations without sacrificing robustness.
This polling pattern with a 25-hour window and git-based state is a solid alternative for low-volume digital product sellers who want to avoid the complexity of a webhook and permanent server. With the right tools and a bit of code, you can have your own fulfillment system running in an afternoon.





