First package? Follow the publish and install quickstart →
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
- The customer pays in your checkout.
- The payment provider sends a signed event to your server.
- Your server verifies the signature and the payment status.
- Your server finds the package IDs that the purchase grants.
- Your server calls
POST /api/v1/recipientswith your stable customer reference. - Your server calls
POST /api/v1/download-keys/ensurewith the order reference and the package IDs. - Your server stores the order, the recipient ID and the key ID.
- Your server sends the key and the
.npmrcinstructions 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
| Field | Belongs to | Recommended value |
|---|---|---|
Recipient externalRef | One customer account | The customer ID of your payment provider or of your own system, for example stripe:cus_Q8x2…. |
Key externalRef | One grant of access | The 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.
- List the registries with
GET /api/v1/registries. Record theidof the registry, for examplereg_01J8X3A2B7. - List the packages of that registry with
GET /api/v1/packages?registryId=reg_01J8X3A2B7. - Record the
idof each package that you sell, for examplerpkg_01J9Y8S3D2. - 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.

Create the API key for your server
- Open Organization → API keys.
- In Name, type
Fulfilment server. - Select Owned by the organization. The key then stays valid after you leave the organization.
- Under Permissions, select
registry:download_key:manageandnpm:package:read. - Select Create key. The page shows the
gsk_…secret one time. - 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.
- Read the raw request body.
- Verify the signature with the library of your provider. With Stripe, use
stripe.webhooks.constructEvent. - If the signature is not valid, return 400 and stop.
- Make sure that the payment status is
paid. - 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/sdkIf 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.

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.
- Complete a test checkout with the Stripe test card
4242 4242 4242 4242. Your server log shows the two registry calls. - Open Registry → Recipients. The new recipient shows with its reference.
- Open the recipient. The key shows with the order reference.
- Send the same event again with
stripe events resendand the event ID. The recipient still has one key. - In a new, empty directory, configure
.npmrcwith the secret from the email. - Run
npm install @acme/sdk. The install succeeds. - Refund the test payment in full. The key status changes to revoked.
- 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.
| Response | Cause | Correction |
|---|---|---|
401 unauthenticated | The API key is absent, wrong, revoked or expired. | Send Authorization: Bearer gsk_… with a valid key. |
403 api access is not enabled for this organization | The plan does not include API access. | Change to Growth or a higher plan. |
403 permission_denied | The API key does not have registry:download_key:manage. | Create a key with that permission. |
403 registry is not available to this publisher | The 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 required | The 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 packages | The request has no access rule or more than one. | Send exactly one of the three access fields. |
400 package is not available to this publisher | A package ID is not in the registry of the request. | Examine the map of package IDs. |
404 not_found | The recipient ID or the key ID does not exist in the organization. | Use the IDs that the API returned. |
409 download key is revoked | A person or your server revoked the key that has this reference. | Use a new key reference. |
409 registry recipient is suspended | You cannot issue a new key for a suspended recipient. | Resume the recipient first. |
409 plan limit reached | The organization has the maximum number of active download keys. | Revoke unused keys or change to a higher plan. |
| 429 | The 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
allPackagesin a sale. - Examine the audit log for
accessedentries 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.
Related guides
- Recipient npm download portal lets recipients manage their own keys.
- Configure private registry webhooks sends registry changes to your server.
- See private package downloads shows what each recipient installs.
- Authenticate npm with .npmrc gives the client instructions.
- The management API reference describes the recipients and download keys operations.