Skip to content

Sell a digital product

Beluga knows what a download is. It does not yet hand one over. This tutorial sets up a digital product correctly, shows what the flag changes, and closes the gap with the tool that exists for it: an order.paid webhook into a small fulfilment script.

Admin → Products → New product, and set Type to Digital.

Three things follow, and the editor enforces them:

  • Stock is always unlimited. The API refuses a finite count on a digital variant. This is not tidiness: the inventory decrement would count it down, reach zero, and start flagging paid orders oversold for a file that cannot run out.
  • Weight is irrelevant and the field is hidden.
  • Tax code. Digital goods are taxed differently from tangible ones in many places. If you use Stripe Tax, set a code for it; the store default txcd_99999999 is general tangible goods.

Publish it.

A cart holding only downloads never asks for a destination country and reaches Stripe with no address collection and no shipping options. A mixed cart still collects an address, priced on its physical lines only.

Digital lines are excluded from the parcel, not zeroed. The difference matters: a zero-gram line still participates, so a cart of nothing but PDFs would report a 0 g parcel, match the store’s lightest weight band, and charge postage on a parcel that does not exist. The same rule applies to subtotal bands: a $40 download does not push a $10 box over a “free over $50” threshold, and on an upper bound it is worse, because it could push the cart past every band, match nothing, and ship free in silence.

physicalLines, requiresShipping and parcelFor in shared/shipping.ts are the API for this, and they are what checkout uses too, from the catalogue rather than the request, so a buyer cannot declare their order digital to skip the address.

There is no file upload for products, no entitlement record, and no download route. That is a known gap, and the approach has not been chosen, so do not build it into the statically served assets directory: anything under public/assets/ is downloadable by whoever guesses the path.

The supported route today is a webhook:

  1. Store the files somewhere private: object storage with signed URLs is the usual answer.
  2. Follow Connect a fulfilment webhook and subscribe to order.paid.
  3. In the receiver, look at each item’s productId and variantId, mint a time-limited signed URL for the matching file, and email it to data.email.
  4. Deduplicate on the event id, because at-least-once delivery would otherwise send the link twice.

The payload snapshots productName and variantLabel at purchase, so the email can name what was bought even if the product is renamed later.

The order status vocabulary is paid, processing, shipped, cancelled, refunded. None of those means “delivered”. Mark a download shipped once the link has gone out if you want the admin to show it as done, and know that the Shipped email talks about carriers. Adding a delivered status is one of the two collisions the gap document records, along with what a refund should do to an entitlement.

A refund of a download restocks nothing, since there is no stock. It should revoke access, which is your receiver’s job: subscribe to order.refunded and expire the signed URL, or mark the entitlement revoked in whatever you built in step 1.