payload-downloads

Digital products for Payload ecommerce: signed expiring download links, per order access and download limits.


Keywords
payload, payloadcms, payload-plugin, ecommerce, downloads, digital-products, signed-url, npm, typescript
License
MIT
Install
npm install payload-downloads@1.0.1

Documentation

payload-downloads

npm node license payload

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-ecommerce and with any collection that holds orders
  • Signed links use node:crypto HMAC-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

Install

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-downloads
import { 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.

What was measured

Why access is granted on completed and not on processing

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'] })

Why a grant records the customer twice

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 signature

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.

Options

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.

What it adds to your database

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 email 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.

Honest limits

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.

License

MIT. Copyright George Vasiliades, https://github.com/Poseidonas