Digital products remove shipping and inventory and add three problems in their place: delivering a file without leaking it, enforcing what a licence permits, and charging tax in the customer's country rather than yours.
The third one is the one that catches people, because it is a legal obligation rather than a feature.
The module
import { model } from "@medusajs/framework/utils"
export const DigitalProduct = model.define("digital_product", {
id: model.id().primaryKey(),
name: model.text(),
file_key: model.text(), // object storage key, never a public URL
file_size_bytes: model.bigNumber().nullable(),
license_type: model.enum(["single", "multi", "unlimited"]).default("single"),
max_downloads: model.number().nullable(),
access_days: model.number().nullable(), // null = perpetual
})
export const Entitlement = model.define("digital_entitlement", {
id: model.id().primaryKey(),
customer_id: model.text().index(),
order_id: model.text().index(),
digital_product_id: model.text().index(),
license_key: model.text().nullable().unique(),
downloads_used: model.number().default(0),
expires_at: model.dateTime().nullable(),
revoked_at: model.dateTime().nullable(),
})The entitlement is the important entity. It answers "what does this customer own, and under what terms?" — a question you will ask constantly, from support tools to a customer's download page to a re-download three years later. A system that only records download links cannot answer it.
Link the digital product to the core product so a purchasable variant maps to a deliverable file.
Fulfilment on order
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { fulfillDigitalOrderWorkflow } from "../workflows/fulfill-digital-order"
export default async function fulfillDigitalOrder({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
await fulfillDigitalOrderWorkflow(container).run({ input: { order_id: data.id } })
}
export const config: SubscriberConfig = {
event: "order.placed",
}The workflow creates entitlements, generates licence keys where applicable, and emails access links. Doing it in a workflow rather than inline gives you compensation — a failure part-way does not leave half the order entitled.
For digital-only orders, capture payment immediately rather than at fulfilment. There is no shipping window in which cancelling means anything. Authorise versus capture.
Delivery
Never a permanent public URL. Files live in private object storage and are delivered through short-lived signed URLs generated per request:
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const digital = req.scope.resolve("digital_product")
const entitlement = await digital.retrieveEntitlement(req.params.entitlementId)
// Ownership, revocation, expiry and download count — all four, every time.
if (!entitlement || entitlement.customer_id !== req.auth_context?.actor_id) {
return res.status(404).json({ message: "Not found" })
}
if (entitlement.revoked_at) {
return res.status(403).json({ message: "Access revoked" })
}
if (entitlement.expires_at && entitlement.expires_at < new Date()) {
return res.status(403).json({ message: "Access expired" })
}
const product = await digital.retrieveDigitalProduct(entitlement.digital_product_id)
if (product.max_downloads && entitlement.downloads_used >= product.max_downloads) {
return res.status(403).json({ message: "Download limit reached" })
}
const url = await digital.createSignedUrl(product.file_key, { expiresInSeconds: 300 })
await digital.incrementDownloads(entitlement.id)
res.redirect(302, url)
}Five minutes is plenty for a download to start and short enough that a shared link is useless. Check ownership on every request — a guessable entitlement id with no ownership check is a free-file endpoint.
Licence keys
For software, generate a key per entitlement and give it a verification endpoint:
import { randomBytes } from "node:crypto"
export function generateLicenseKey(prefix = "ACME") {
const raw = randomBytes(10).toString("hex").toUpperCase()
const groups = raw.match(/.{1,5}/g) ?? []
return `${prefix}-${groups.join("-")}`
}Grouped, uppercase, prefixed — because customers type these by hand and read them over the phone. Add a rate-limited activation endpoint that records device or installation counts against the entitlement's licence type, and be sure revocation actually revokes: refunded orders should mark the entitlement revoked, and the verification endpoint should honour it.
Tax
The part with legal consequences. In most jurisdictions — the EU, UK and many others — digital services are taxed where the customer is, not where you are. Practically:
- Collect and retain evidence of the customer's location, typically two non-conflicting pieces such as billing address and IP country.
- Apply the destination country's rate.
- Register for the relevant scheme, such as EU OSS, and file accordingly.
Manual rates do not survive this. Assume a tax engine from the start. Tax configuration.
Refunds
Digital goods cannot be returned, which makes your refund policy a real decision rather than a formality. Whatever you choose, encode it: a refunded order should revoke the entitlement, invalidate the licence key, and record why. Refunding money while leaving a working licence in place is a leak that compounds.
Note that EU consumer law lets buyers waive the right of withdrawal for immediately delivered digital content, but only with explicit consent captured at checkout. If you sell to EU consumers, capture it.
Bundles and hybrid orders
An order containing both a physical and a digital item needs both paths: digital entitlements issued immediately, physical items following the normal fulfilment flow. The customer sees one order with two fulfilment states, which is fine as long as the emails do not claim the whole order has shipped.
Large files and streaming
Signed URLs work well up to a point. For files over a few hundred megabytes, two additional concerns:
Serve through a CDN, not directly from the bucket. A signed CloudFront URL gives you edge delivery and the same expiry semantics. Direct bucket downloads from another continent are slow and expensive.
Support resumable downloads. Object storage honours HTTP range requests, so a dropped connection resumes rather than restarting. Do not proxy the file through your application server — that breaks ranges, ties up a Node process for the duration, and turns one download into a capacity problem.
For video, a signed HLS manifest with per-segment expiry is better than a single large file: playback starts immediately, and a leaked URL expires within seconds rather than minutes.
Updates and versioning
Software and design assets get revised, and customers expect access to what they bought.
export const DigitalProductVersion = model.define("digital_product_version", {
id: model.id().primaryKey(),
digital_product_id: model.text().index(),
version: model.text(), // "2.1.0"
file_key: model.text(),
released_at: model.dateTime(),
notes: model.text().nullable(),
})Then decide the entitlement policy explicitly: perpetual access to all future versions, access for a fixed period, or major versions as separate purchases. Whichever you choose, state it on the product page — this is the single most common source of disputes in digital goods, and it is entirely preventable with a sentence.
Notify entitled customers when a new version ships. It is the cheapest goodwill available and it is the reason they will buy the next thing.
Fraud
Digital goods attract card testing and resale, because there is no shipping address to verify and delivery is instant.
Three cheap mitigations, in order of value:
Rate limit downloads per entitlement and per IP. A licence being downloaded from forty addresses in an hour is not one customer with several devices.
Delay delivery slightly on high-risk orders. Even fifteen minutes lets fraud signals arrive before the file does, and legitimate customers rarely notice.
Watermark where the format allows. Embedding the purchaser's details in a PDF or ebook makes casual resale traceable, which deters it more effectively than any licence text.
Do not over-rotate. Friction on legitimate buyers costs more than the fraud does in most catalogues — measure before hardening.
Digital products are simpler to fulfil and harder to get legally right. Ask us.
Frequently asked questions
How do I sell digital products with Medusa?
Create a digital product module holding files and licence terms, link it to core products, and issue entitlements in a workflow triggered by order placement. Deliver files through short-lived signed URLs rather than permanent links.
How should digital file delivery be secured?
Keep files in private object storage and generate signed URLs valid for a few minutes, issued per request after checking ownership, revocation, expiry and download count. Never expose a permanent public URL.
What is an entitlement and why store one?
A record of what a customer owns and on what terms — licence type, download count, expiry, revocation. It answers ownership questions long after purchase, which a bare download link cannot, and it is what makes revocation on refund possible.
How is VAT handled on digital products?
Digital services are generally taxed where the customer is located, so you must collect evidence of their location, apply the destination rate and register for schemes such as EU OSS. This effectively requires a tax engine rather than manual rates.
Should digital orders capture payment immediately?
Yes. There is no shipping window in which cancelling before capture is meaningful, so capture at checkout rather than at fulfilment.
How do I handle refunds for digital products?
Decide the policy explicitly and encode it: a refund should revoke the entitlement and invalidate any licence key. If you sell to EU consumers, capture explicit consent to waive the right of withdrawal for immediate delivery.
Tax in Medusa: Regions, Rates, Overrides and When to Use a Tax Engine
Tax-inclusive versus tax-exclusive pricing, product overrides, and the point at which manual rates stop being viable and you buy an engine.
Testing Medusa: What to Test, What to Skip, and How
Commerce code moves money, so untested checkout logic is a liability. A pragmatic test strategy — what earns its keep, and what is theatre.
Building a Marketplace on Medusa: Vendors, Splits and Payouts
Multi-vendor commerce means splitting one customer order into several vendor orders, and moving money to people who are not you. The model and the money mechanics.



