Payment Is Not Delivery: A Simple Model for Digital Goods


For digital goods, payment and delivery should be separate state transitions. The provider can say money moved, but your system still needs an explicit database record that says access was granted.

The model is simple: the provider handles the payment, your server verifies the provider status, and your database records access.

The parts that usually confuse people are capture, webhooks/callbacks, idempotency keys, and what to store in your database.

Provider APIs differ. Always check your provider’s docs for exact webhook/callback, status, and amount formats.

Payment is not access

A paid order and product access are related, but they are not the same thing.

The payment says money moved. Delivery is the database record that says the user can access the digital product.

Some apps call that record an entitlement. Others call it a purchase, license, subscription, download grant, or user product. The name matters less than the idea.

That distinction keeps the rest of the system simple. Payment status answers whether money moved. The access record answers whether the product is unlocked.

Terminology

Payment provider

Stripe, Adyen, Mollie, Paddle, Lemon Squeezy, etc.

They handle card details and bank/payment network stuff so your app does not touch raw card numbers.

Authorization

The bank says: “This card can pay this amount.”

Think of it like placing a hold on the money.

The amount is reserved, but it has not necessarily been captured yet.

No digital goods should be delivered yet if the payment is only authorized, unless your provider explicitly says that is your final success state.

Capture

Capture means: “Actually take the money.”

For physical goods, shops often authorize first, then capture when they ship. For digital goods, you usually want immediate capture, because delivery is instant.

Simple rule:

  • Digital product paid and captured: grant access.
  • Only authorized: do not grant access yet.
  • Failed or cancelled: do not grant access.

Refund

Refund means the money was already captured, and now you send it back.

If you refund a digital product, your app needs a product decision:

  • Keep access forever?
  • Remove access immediately?
  • Remove access after the current billing period?

Store that decision in your database. Do not leave it implicit.

Webhooks, callbacks, and return URLs

The browser redirect is not enough.

You usually give the payment provider a URL on your server. After the payment changes, the provider sends a request to that URL. Some docs call this a webhook, some call it a callback.

Sometimes that request includes a status. Sometimes it is basically just an id, and your server has to check the provider status.

For digital goods, your database should not grant access only because the browser returns to your site.

A safe pattern is to treat callbacks and return URLs as prompts to verify status server-side. The provider calls your URL, your server fetches the current payment status, and access is granted only if the payment is paid or captured.

Why?

  • The user can close the tab.
  • The redirect can fail.
  • Some payment methods finish later.
  • Webhooks/callbacks can be retried by the provider.
  • Some providers may give you an id and expect your server to check the current payment status.

In short: grant access only after the provider API confirms paid or captured.

What to store

You do not need a complicated payment system at the start.

You need enough data to answer:

  • What did the user try to buy?
  • Did we verify that the provider got paid?
  • Did we grant access?
  • Is the webhook/callback safe to process more than once?

Products

This is what you sell.

Store the basic fields: id, slug, name, price, currency, and whether it is active.

Orders

This is the user’s attempt to buy something.

Store: order id, user id, product id, amount, currency, status, and timestamps.

Store the amount on the order. Do not only read it from Product.

Why? Prices change. The order should remember what this user paid at that time. Send amounts to the payment provider in whatever format that provider expects.

If one checkout can contain multiple products, use OrderLines. For a one-product digital checkout, keeping the product id directly on the order is fine.

Payments

This is the provider payment attached to the order.

Store: payment id, order id, provider name, idempotency key, provider payment id, status, and timestamps.

The important fields are the idempotency key, the provider payment id, and the status.

For a very simple app, storing the provider payment id directly on the order can be enough. A separate payment row becomes useful when you need retries, failed attempts, refunds, or better debugging.

Access / delivery

This is the actual digital access.

Do not use “paid order exists” as your only access check. Record the delivery or access grant in your database.

Store: user id, product id, order id, granted time, and maybe revoked time.

That is the row that says:

This user owns this digital thing.

In a small app, the access table might be as plain as:

user_id | product_id | order_id | granted_at          | revoked_at
7       | course-1   | 123      | 2026-06-01 10:04    | null

Then the authorization check is not a payment query. It is a product-access query for the current user and product.

Repeated webhooks/callbacks

Webhooks/callbacks can arrive more than once. That should be safe.

The strongest protection is your own database state:

  • If the order is already paid, return success.
  • If access is already granted, return success.

If the provider gives you a stable event, callback, or transaction id, you can store that too. It helps with logs and debugging. But it is not the main safety mechanism.

It is better if the whole callback can run again and still end in the same state. Keep the local checks separate from the payment provider check.

For digital goods, the safe default is: confirm or capture payment first, then grant access. If you use automatic capture, the provider success state is the proof. If you use manual capture, call capture first and only grant access after the provider reports that capture succeeded.

The shape is usually:

load local payment by provider_payment_id

local database checks:
  if local payment is already captured, return success
  if access row already exists, return success

payment provider / PSP check:
  fetch current payment status from provider
  if provider does not say captured, return without granting access

local database transaction:
  mark payment captured
  insert access row if missing

return success

When the same webhook arrives a second time, it sees the completed local state and exits without asking the provider again.

The opposite failure is still possible: capture succeeds, then your access write fails. That is why the callback should be retryable and why your database should make the access insert idempotent.

Idempotency keys

An idempotency key is a “do not create this twice” token for calls to the payment provider.

It matters when your server asks the provider to create a payment, the provider does it, but your server times out before seeing the response. If your server retries with a new key, it may create a second payment. If it retries with the same key, the provider can return the first result.

Keep it simple: create the idempotency key in your database before calling the provider, then reuse it for retries of that same purchase.

Concurrency control

Repeated webhooks/callbacks are not the only issue. Two different requests can process the same payment at the same time, for example a callback and a return-page check.

Use the database as the final guard:

  • Update payment/order/access in one transaction.
  • Put a unique constraint on the access/delivery row.
  • Treat “already paid” as success, not an error.
  • Use a row version/concurrency token on payment or order rows if multiple flows can update them.

The reason is simple: app-level “check then insert” logic can race. A database constraint or row version still holds when two requests hit at once.

The simple flow

1. User clicks buy

Create an order and payment locally. If your provider supports idempotency keys, save the key before calling the provider.

2. Provider redirects or confirms

Store the provider payment id. Do not grant access yet unless the provider already says the money is captured.

3. Webhook/callback or return tells you to check status

Your server asks the provider for the current payment status. If the provider confirms paid/captured, mark the order paid and record access.

The access record is the output that matters.

Avoid these

The common mistakes are:

  • Unlocking the product because the frontend says payment completed.
  • Trusting a success=true query string.
  • Creating a new idempotency key on every retry.
  • Writing callback code that breaks if the same callback is sent twice.

The tiny checklist

For a basic digital goods app, store:

  • Product
  • Order
  • Payment
  • Access/delivery record
  • Provider event/callback id if available

Grant access only when:

  • Your server received a trusted provider callback or return.
  • Your server has checked the provider status.
  • The payment is captured or paid.
  • The access record does not already exist, or your unique constraint prevents duplicates.

Also make the callback handler safe to run more than once.

Takeaway

Keep payments predictable.

The payment provider handles the card. Your app stores the order, payment, idempotency key, provider payment id, provider callback/event id if available, and access/delivery record.

For digital goods, the important final step is not “show success page.” It is recording access in your database after the provider status says paid.

That access record unlocks the product.