npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

payload-downloads

v1.0.1

Published

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

Readme

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/[email protected]. 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