Sells digital products from a Payload store: a product or variant carries files, a paid order earns access to them, and the customer receives a signed link that expires, counts its uses and stops working the moment the order is refunded.
- Works with
@payloadcms/plugin-ecommerceand with any collection that holds orders - Signed links use
node:cryptoHMAC-SHA256, no runtime dependencies - The file is streamed by the plugin; no storage path, URL or bucket key ever reaches the client
- No admin components, so it survives minor releases
Requires Payload 3.88 or newer and @payloadcms/plugin-ecommerce 3.88 or newer. Verified against Payload 3.88.0 with the official plugin installed.
pnpm add payload-downloadsimport { downloadsPlugin } from 'payload-downloads'
export default buildConfig({
plugins: [
downloadsPlugin({
downloadLimit: 5,
expiryDays: 30,
linkTtlSeconds: 300,
}),
],
})Mark a product or variant downloadable, attach one or more files, and the plugin does the rest.
A signed in customer reads their own downloads from GET /api/downloads:
{
"downloads": [
{
"id": "6a85...",
"downloadCount": 1,
"downloadLimit": 5,
"expiresAt": "2026-09-18T10:00:00.000Z",
"file": { "id": "3", "filename": "guide.pdf", "filesize": 918273, "mimeType": "application/pdf" },
"remaining": 4,
"url": "/api/downloads/file?expires=1800000300&file=3&grant=12&order=8&signature=Xk3...",
"usable": true
}
]
}To put the same links in your own order email, call downloadLinksForOrder on the server.
Read from @payloadcms/plugin-ecommerce@3.88.0. The order status field is declared with defaultValue: 'processing', and confirmOrder writes status: 'processing' on every order it creates. processing is therefore the value an order carries because it exists, not because money settled: an order typed into the admin by hand, an import, or a second payment adapter all produce the same value without a payment having happened.
Handing over a file cannot be undone. The default grants on completed, which is a deliberate act by the shop. One line moves it earlier if that suits your trade:
downloadsPlugin({ grantOnStatuses: ['processing', 'completed'] })Read from stripe/confirmOrder.ts, the order is written with
...(req.user ? { customer: req.user.id } : { customerEmail })
A logged in customer's order carries a customer and no customerEmail; a guest order carries an email and no customer. A grant records whichever the order has, and GET /api/downloads matches on both, so a customer who checked out as a guest and later registered with the same address still sees the download.
The link carries grant, order, file, expires and signature. The signature is
HMAC-SHA256( SHA256("payload-downloads:v1:" + secret), "v1\ngrant\norder\nfile\nexpires" )
base64url encoded, 43 characters. The key is derived from the secret rather than being the secret, so the same Payload secret used elsewhere is not the HMAC key. All four values are inside the signed message, separated by a character none of them may contain, so a signature cannot be moved to another file, another order, another grant or a later expiry. Verification compares with timingSafeEqual, and each of those five substitutions is refused by a test of its own.
A valid signature is not by itself permission to download. Before a byte is written the plugin also reloads the grant and refuses it if it is revoked, expired or at its limit, so a refund stops links that were already handed out, not only future ones.
| Option | Default | Meaning |
|---|---|---|
baseUrl |
'' |
Text placed before a generated link. Empty gives a root relative link |
customersSlug |
'users' |
Slug of the customers collection |
disabled |
false |
Stops granting, revoking and serving but keeps the fields and the collection, so the database keeps its shape |
downloadableFieldName |
'downloadable' |
Checkbox marking a product or variant as downloadable |
downloadLimit |
5 |
Successful deliveries one grant allows. 0 means no limit |
enableVariants |
true |
Adds the fields to variants and the variant column to a grant |
endpointPath |
'/downloads' |
Path below the Payload API route |
expiryDays |
0 |
Days a grant stays usable. 0 means no expiry |
filesFieldName |
'downloadFiles' |
Upload field holding the files |
fileResolver |
none | Reads the bytes when the upload collection does not use local storage |
grantOnStatuses |
['completed'] |
Statuses that grant access on the way in |
grantsSlug |
'download-grants' |
Collection holding one grant per order and file |
isAdmin |
'admin' in req.user.roles
|
Who may read every grant. Customers always read their own |
linkTtlSeconds |
300 |
Seconds a signed link stays valid |
ordersSlug |
'orders' |
Slug of the orders collection |
productsSlug |
'products' |
Slug of the products collection |
revokeOnStatuses |
['refunded', 'cancelled'] |
Statuses that revoke access on the way in. [] never revokes |
secret |
the Payload secret | Key the signature is derived from |
uploadsSlug |
'media' |
Upload collection holding the files |
variantsSlug |
'variants' |
Slug of the variants collection |
A value that cannot be used is replaced by its default rather than being applied. A negative downloadLimit becomes 5, a linkTtlSeconds of 0 becomes 300, and 3.9 becomes 3. Nothing is silently reinterpreted: -5 never becomes 5. A status that appears in both lists revokes.
| Collection | Field | Type | Notes |
|---|---|---|---|
| your products collection | downloadable |
checkbox | indexed, defaults to false |
| your products collection | downloadFiles |
upload, hasMany | shown when downloadable is on |
| your variants collection | downloadable |
checkbox | omitted when enableVariants is false |
| your variants collection | downloadFiles |
upload, hasMany | a downloadable variant overrides its product |
download-grants |
order |
relationship | indexed, required |
download-grants |
file |
upload | indexed, required |
download-grants |
product, variant
|
relationship | what the grant came from |
download-grants |
customer |
relationship | indexed, set for a signed in buyer |
download-grants |
customerEmail |
indexed, set for a guest buyer | |
download-grants |
downloadCount |
number | successful deliveries so far |
download-grants |
downloadLimit |
number | the limit in force when the grant was made |
download-grants |
expiresAt |
date | empty when expiryDays is 0 |
download-grants |
lastDownloadedAt |
date | |
download-grants |
revoked |
checkbox | indexed |
The grants collection refuses create, update and delete through the API; it is written only by the plugin. Read is allowed to a customer for their own rows and to whoever isAdmin accepts.
Two endpoints are added: GET /api/downloads and GET /api/downloads/file.
Exported for server side use: downloadsPlugin, createDownloadLink, downloadLinksForOrder, grantAccess, revokeAccess, downloadableFilesForOrder, grantState, decideTransition, signDownload, verifyDownloadSignature, parseClaim, resolveConfig.
Local storage works out of the box, a storage adapter needs six lines. Payload has no stable public API for reading the bytes behind an upload document when a cloud adapter owns them. Rather than guess at an internal one, the plugin reads from the collection's staticDir and, when that is not available, asks you:
downloadsPlugin({
fileResolver: async ({ filename }) => {
const object = await s3.send(new GetObjectCommand({ Bucket: 'files', Key: filename }))
return { body: object.Body as ReadableStream<Uint8Array>, size: object.ContentLength }
},
})Without one, a request for a file the plugin cannot read returns 501 and logs. It never redirects to the storage, because a redirect is the storage path.
A signed link is a bearer token. Anyone holding the URL inside its lifetime can download. It is not bound to an IP address, a session or a browser. linkTtlSeconds defaults to 300 for that reason; the customer gets a fresh link from GET /api/downloads whenever they need one.
Rotating the Payload secret invalidates every outstanding link. Set secret explicitly if you need the two to move independently.
A delivery is counted when the response starts, not when it finishes. A customer whose connection drops halfway has still used one of their five. There is no way to know from the server that a stream completed.
The count is atomic where the adapter allows it. The increment is written with $inc through payload.db.updateOne, which was measured at 50 concurrent increments on both the PostgreSQL and MongoDB adapters with none lost. If that call fails the plugin falls back to a read and a write, and in that fallback a burst of simultaneous requests using the same link can exceed the limit by the size of the burst. If the delivery cannot be recorded at all the request is refused with 500 rather than served unmetered.
Range requests are ignored. The whole file is sent from the beginning every time. Resuming an interrupted download starts again and, in the worst case, costs another unit of the limit.
Orders completed before you install get nothing. Grants are created when an order crosses into a granting status. Existing completed orders stay without grants until they are saved again.
Files are matched per order, not per line. Buying the same product twice on one order produces one grant with one limit, not two.
MIT. Copyright George Vasiliades, https://github.com/Poseidonas