Skip to content

Automate npm download keys after a sale

After a verified payment, your server creates the recipient, issues a download key for the purchased packages and sends the install instructions.

Overview

This guide tells you how to issue a download key automatically after a customer pays for a package. Your server receives a verified payment event, creates the recipient, issues the download key with POST /api/v1/download-keys/ensure and sends the install instructions by email. A refund suspends the recipient or revokes the key.

The guide is for developers who sell private npm packages, for example an SDK or a component library. The examples use Stripe Checkout, but the flow is the same with other payment providers. The package is @acme/sdk on the shared registry at registry.privatenpm.com.

PrivateNPM does not keep orders, prices or licences. Your store and your database own the sale. The registry gives you read-only download keys (pdk_…) for exact packages, and recipient records that group the keys of one customer.

Before you start

  • Your organization has the Growth, Scale or Platform plan. API access starts on Growth.
  • You are an organization admin, so you can create API keys.
  • You published the packages that you sell in a registry of your organization.
  • You have the webhook secret of your payment provider.
  • You have a transactional email service that your server can call.
  • Your server has a database to keep the relation between orders, recipients and keys.

The plan also limits the number of active download keys: 500 on Growth, with no limit on Scale and Platform. The server example uses Node.js 18 or later, Express and the official stripe package.

How automated issuance works

The end-to-end flow

  1. The customer pays in your checkout.
  2. The payment provider sends a signed event to your server.
  3. Your server verifies the signature and the payment status.
  4. Your server finds the package IDs that the purchase grants.
  5. Your server calls POST /api/v1/recipients with your stable customer reference.
  6. Your server calls POST /api/v1/download-keys/ensure with the order reference and the package IDs.
  7. Your server stores the order, the recipient ID and the key ID.
  8. Your server sends the key and the .npmrc instructions to the customer.

Why a retry is safe

Payment providers send an event again when your server does not answer with success. Your server can also fail between two steps. Thus, each step must give the same result when it runs a second time. The two registry calls have this property.

POST /api/v1/recipients creates or updates a recipient by its externalRef. If a recipient with that reference exists, the call returns the same recipient. If the name changed, the call updates the name.

POST /api/v1/download-keys/ensure keeps one key for each pair of recipient and key externalRef. The first call issues the key. A later call with the same pair returns the same key and the same secret. If the access in the request is different, the call replaces the access of the key. Thus, a retry never issues a second key, and a lost email never forces a rotation.

CAUTION: Do not automate with POST /api/v1/download-keys. That operation issues a new key for each call, so a retry makes a second key.

The two references

FieldBelongs toRecommended value
Recipient externalRefOne customer accountThe customer ID of your payment provider or of your own system, for example stripe:cus_Q8x2….
Key externalRefOne grant of accessThe order ID, for example order:cs_live_a1B2…, or one ID for each subscription.

Both references must be 255 characters or fewer. The recipient reference is unique in your organization. If you use one key reference for each order, each order gets its own key. If you use one reference for each customer and registry, the customer keeps one key, and each ensure call sends the complete list of packages.

Map each product to package IDs

A download key grants access in one registry. Find the registry ID and the package IDs one time, and keep them in the configuration of your server.

  1. List the registries with GET /api/v1/registries. Record the id of the registry, for example reg_01J8X3A2B7.
  2. List the packages of that registry with GET /api/v1/packages?registryId=reg_01J8X3A2B7.
  3. Record the id of each package that you sell, for example rpkg_01J9Y8S3D2.
  4. In your server configuration, map each price or product of your store to its list of package IDs.

Both list operations need npm:package:read on the API key. Use package IDs, not package names, in the ensure call.

Selected packages access rule on the download key form
For a purchase, grant only the packages that the order includes.

Create the API key for your server

  1. Open Organization → API keys.
  2. In Name, type Fulfilment server.
  3. Select Owned by the organization. The key then stays valid after you leave the organization.
  4. Under Permissions, select registry:download_key:manage and npm:package:read.
  5. Select Create key. The page shows the gsk_… secret one time.
  6. Store the secret in the secret store of your server as PRIVATENPM_API_KEY.

The server sends the key in the Authorization: Bearer header to https://privatenpm.com/api/v1. Each request body must have the header Content-Type: application/json.

Handle the payment event

Configure your payment provider to send the paid event to your server. With Stripe, send these events: checkout.session.completed and checkout.session.async_payment_succeeded to fulfil orders, and charge.refunded to handle refunds.

  1. Read the raw request body.
  2. Verify the signature with the library of your provider. With Stripe, use stripe.webhooks.constructEvent.
  3. If the signature is not valid, return 400 and stop.
  4. Make sure that the payment status is paid.
  5. Read the purchased prices and find the package IDs in your map.

Return a 2xx status only after all steps succeed. If a step fails, return 500 so that the provider sends the event again.

Create or update the recipient

Send the customer name and your stable customer reference:

POST /api/v1/recipients
Authorization: Bearer gsk_...
Content-Type: application/json

{"name": "Acme Ltd", "externalRef": "stripe:cus_Q8x2Lm4Nw1"}

The registry returns 200 with the recipient. Store the id:

{
  "id": "rrec_01J9Z3K8Q4",
  "name": "Acme Ltd",
  "externalRef": "stripe:cus_Q8x2Lm4Nw1",
  "createdAt": "2026-10-05T09:14:27Z",
  "updatedAt": "2026-10-05T09:14:27Z"
}

A second call with the same externalRef returns the same id. The name is required and must be 200 characters or fewer.

Ensure the download key

Send the registry, the recipient, the order reference and the package IDs:

POST /api/v1/download-keys/ensure
Authorization: Bearer gsk_...
Content-Type: application/json

{
  "registryId": "reg_01J8X3A2B7",
  "recipientId": "rrec_01J9Z3K8Q4",
  "name": "Order cs_live_a1B2",
  "issuedTo": "[email protected]",
  "externalRef": "order:cs_live_a1B2",
  "packageIds": ["rpkg_01J9Y8S3D2"]
}

The registry returns 200 with the key and its secret:

{
  "key": {
    "id": "rdk_01J9Z3R5V8",
    "registryId": "reg_01J8X3A2B7",
    "name": "Order cs_live_a1B2",
    "issuedTo": "Acme Ltd",
    "externalRef": "order:cs_live_a1B2",
    "recipientId": "rrec_01J9Z3K8Q4",
    "packageIds": ["rpkg_01J9Y8S3D2"],
    "displayPrefix": "pdk_Xr4k9TqZ",
    "createdAt": "2026-10-05T09:14:28Z",
    "credentialVersion": "1"
  },
  "secret": "pdk_..."
}

The fields registryId, name, issuedTo, recipientId and externalRef are required. When you send recipientId, the registry stores the recipient name as issuedTo. The name must be 120 characters or fewer.

Send exactly one access rule: packageIds, scopeId or allPackages. For a sale, send packageIds. The packages must belong to the registry in registryId. You can also send expiresAt in RFC 3339 format to end the access on a date.

NOTE: Each ensure call that returns a key that already exists writes an accessed entry for the key in the audit log. The audit log thus shows each time that your server read the secret again.

Send the install instructions

Send the email from your own email service after you store the order and the key ID. Send the email to the address that the payment provider verified. This example is for a scoped package on the shared registry:

Subject: Your Acme SDK download key

Your download key: pdk_...

Store the key in an environment variable named ACME_NPM_TOKEN.
Do not commit the key to a repository.

Add these lines to the .npmrc file of your project:
@acme:registry=https://registry.privatenpm.com/
//registry.privatenpm.com/:_authToken=${ACME_NPM_TOKEN}

Then run: npm install @acme/sdk

If your registry has a verified custom domain, for example npm.acme.com, use that host name in both .npmrc lines. The .npmrc guide gives the instructions for pnpm, Yarn and CI.

Record in your database that your server sent the email. If the email fails, send it again later. The ensure call returns the same secret, so you do not need a new key.

Download key form showing recipient, key name, and external reference fields
The dashboard shows the recipient and the external reference of each automated key.

Handle refunds and cancellations

Choose the action that matches the event:

  • To end the access of one order, revoke its key with DELETE /api/v1/download-keys/{id}.
  • To stop all keys of a customer, suspend the recipient.
  • To give the access back after a dispute, resume the recipient.

Suspend the recipient with this request. Send false to resume it:

PATCH /api/v1/recipients/rrec_01J9Z3K8Q4
Authorization: Bearer gsk_...
Content-Type: application/json

{"suspended": true}

A suspended recipient keeps its keys, but the registry refuses them until you resume the recipient. The registry also refuses to issue new keys for a suspended recipient. A revocation is permanent. It also revokes the keys that the recipient made from that key in the download portal.

The change takes effect within a short interval. Copies of the package that the customer downloaded before the change stay on the computers of the customer.

CAUTION: Do not use the order reference of a revoked key again. An ensure call with that reference returns 409 download key is revoked. For a new purchase, use a new order reference.

Complete server example

This Express server receives Stripe events, issues keys and revokes them after a full refund. The db object and sendInstallEmail belong to your application.

import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
const API = "https://privatenpm.com/api/v1";
const API_KEY = process.env.PRIVATENPM_API_KEY; // gsk_...
const REGISTRY_ID = process.env.PRIVATENPM_REGISTRY_ID; // reg_...

// Your catalog: Stripe price ID -> package IDs that the purchase grants.
const GRANTS = {
  price_AcmeSdkPro: ["rpkg_01J9Y8S3D2"],
  price_AcmeSdkSuite: ["rpkg_01J9Y8S3D2", "rpkg_01J9Y8T7F4"],
};

async function api(method, path, body) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: "Bearer " + API_KEY, "Content-Type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok) {
    const err = new Error(method + " " + path + ": " + res.status + " " + data.code + " " + data.message);
    err.status = res.status;
    throw err;
  }
  return data;
}

async function fulfill(session) {
  if (session.payment_status !== "paid") return; // async payments arrive later
  const items = await stripe.checkout.sessions.listLineItems(session.id);
  const packageIds = [...new Set(items.data.flatMap((item) => GRANTS[item.price.id] ?? []))];
  if (packageIds.length === 0) return;

  const email = session.customer_details.email;
  const recipient = await api("POST", "/recipients", {
    name: session.customer_details.name || email,
    externalRef: "stripe:" + (session.customer || session.id),
  });
  const ensured = await api("POST", "/download-keys/ensure", {
    registryId: REGISTRY_ID,
    recipientId: recipient.id,
    name: "Order " + session.id.slice(-24),
    issuedTo: email,
    externalRef: "order:" + session.id,
    packageIds,
  });

  const order = await db.upsertOrder({
    sessionId: session.id,
    paymentIntent: session.payment_intent,
    recipientId: recipient.id,
    keyId: ensured.key.id,
  });
  if (!order.emailedAt) {
    await sendInstallEmail(email, ensured.secret);
    await db.markEmailed(session.id);
  }
}

async function refund(charge) {
  if (!charge.refunded) return; // partial refund: keep the access
  const order = await db.findOrderByPaymentIntent(charge.payment_intent);
  if (!order) return;
  try {
    await api("DELETE", "/download-keys/" + order.keyId);
  } catch (err) {
    if (err.status !== 409) throw err; // 409: the key is already revoked
  }
}

const app = express();

app.post("/stripe/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.get("stripe-signature"), STRIPE_WEBHOOK_SECRET);
  } catch {
    return res.sendStatus(400);
  }
  try {
    switch (event.type) {
      case "checkout.session.completed":
      case "checkout.session.async_payment_succeeded":
        await fulfill(event.data.object);
        break;
      case "charge.refunded":
        await refund(event.data.object);
        break;
    }
    res.sendStatus(200);
  } catch (err) {
    console.error(err);
    res.sendStatus(500); // Stripe sends the event again
  }
});

app.listen(3000);

Each step of fulfill gives the same result when Stripe sends the event again. The recipient and the key stay the same, and emailedAt prevents a second email.

Verify the result

Use the test mode of your payment provider and a test package. With Stripe, forward events to your server with stripe listen --forward-to localhost:3000/stripe/webhook.

  1. Complete a test checkout with the Stripe test card 4242 4242 4242 4242. Your server log shows the two registry calls.
  2. Open Registry → Recipients. The new recipient shows with its reference.
  3. Open the recipient. The key shows with the order reference.
  4. Send the same event again with stripe events resend and the event ID. The recipient still has one key.
  5. In a new, empty directory, configure .npmrc with the secret from the email.
  6. Run npm install @acme/sdk. The install succeeds.
  7. Refund the test payment in full. The key status changes to revoked.
  8. Run the install again in another new directory with an empty cache. The install fails with 401.

Troubleshoot

The API returns errors as JSON with a code and a message.

ResponseCauseCorrection
401 unauthenticatedThe API key is absent, wrong, revoked or expired.Send Authorization: Bearer gsk_… with a valid key.
403 api access is not enabled for this organizationThe plan does not include API access.Change to Growth or a higher plan.
403 permission_deniedThe API key does not have registry:download_key:manage.Create a key with that permission.
403 registry is not available to this publisherThe key reference exists for a key in a different registry.Use a key reference that includes the registry.
400 a recipient and key external reference are requiredThe ensure call has no recipientId or no externalRef.Create the recipient first and send both fields.
400 choose all packages, one npm scope, or selected packagesThe request has no access rule or more than one.Send exactly one of the three access fields.
400 package is not available to this publisherA package ID is not in the registry of the request.Examine the map of package IDs.
404 not_foundThe recipient ID or the key ID does not exist in the organization.Use the IDs that the API returned.
409 download key is revokedA person or your server revoked the key that has this reference.Use a new key reference.
409 registry recipient is suspendedYou cannot issue a new key for a suspended recipient.Resume the recipient first.
409 plan limit reachedThe organization has the maximum number of active download keys.Revoke unused keys or change to a higher plan.
429The server sent more requests than the plan permits in one minute.Wait, then try again. Make the interval longer after each failure.

If a customer reports that the key does not work, open the recipient page and examine Rejected requests. For install failures, read Fix npm E401: private registry authentication.

Security and maintenance

  • Keep the gsk_… API key only on the server. Never send it to a browser.
  • Give the API key only the two permissions that this guide uses.
  • Set an expiry on the API key and rotate it before the expiry date.
  • Verify each payment event with the library of your provider before you act on it.
  • Grant only the packages of the order. Do not send allPackages in a sale.
  • Examine the audit log for accessed entries that your server did not make.

WARNING: Do not write the download key secret to a log or an analytics tool. Anyone with the secret can install the packages of the order.

Email is not a secure channel. If you do not want to send secrets by email, invite the recipient to the download portal. The recipient then creates and rotates its own keys within the access that you delegate. To send key and recipient changes to other systems, configure registry webhooks.

READY WHEN YOU ARE

Put private packages to work.