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.

This post covers capture, webhooks/callbacks, idempotency keys, and the data your app needs to store.

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

Model access explicitly

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

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.

Keep enough data to answer what the user bought, whether the provider confirmed payment, and whether access was granted:

  • Product: id, slug, name, current price, currency, and active status.
  • Order: id, user, product or order lines, amount, currency, status, and timestamps. Store the amount on the order: prices can change later.
  • Payment: id, order, provider, provider payment id, status, timestamps, and an idempotency key if the provider supports one. In a very small app, these fields can live on the order; a separate payment row is useful for failed attempts, retries, refunds, and debugging.
  • Access/delivery: user, product, order, granted time, and optionally revoked time. This is the record your authorization check should read. A paid order is evidence of payment, not by itself proof that access is currently allowed.

Idempotency, retries, and concurrent requests

When the user starts checkout, create the local order and payment first. When the provider creates the payment, save its provider payment id locally. If the provider supports idempotency keys, create one in your database before the first request and reuse it for retries of that same purchase. Otherwise, a timeout after the provider created the payment could produce a second payment attempt.

For payment-creation retries, reuse the payment’s idempotency key. For callback retries, use the local payment status and access record to see whether the work already completed.

Callbacks can be repeated, and a callback and return-page check can run at the same time. Treat both as safe to retry. The database is the final guard:

  • Update payment, order, and access in one transaction.
  • Put a unique constraint on the access/delivery row.
  • Treat an already-paid order or existing access row as success.
  • Use a row version/concurrency token when multiple flows can update the same payment or order.

Without those database guards, two requests can both see that access is missing and then both try to create it.

The processing shape is usually:

load local payment by provider_payment_id

if local payment is already captured or access already exists:
  return success

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

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

return success

If the provider gives you a stable event, callback, or transaction id, store it for troubleshooting. More importantly, make the whole flow safe to run again: if capture succeeds but the local access write fails, a retry should complete the access record without creating another payment.

Takeaway

For digital goods, payment confirmation is not the final step. Retrieve the provider status, record the payment and access grant transactionally, and make the flow safe to retry. The access record is what unlocks the product.