First package? Follow the publish and install quickstart →
Overview
This guide tells you how to send registry changes to your own HTTPS server with webhooks. A webhook is an HTTP POST request that PrivateNPM sends to your server when something changes in your organization. For example, the registry sends npm_package.published when someone publishes a new version, and registry_recipient_portal.revoked when you revoke the portal access of a recipient.
At the end, you have an endpoint that receives the events that you select. The endpoint verifies each signature and processes each event one time. The guide is for developers who connect the registry to a CRM, a support tool, a release dashboard or a chat channel.
Webhooks send changes out of PrivateNPM. They do not receive payment events from Stripe or from other providers. For that flow, read Automate npm download keys after a sale.
Before you start
- Your organization has the Growth, Scale or Platform plan. The Starter plan does not include webhooks.
- Your role includes
webhook:manage. The built-in admin role includes this permission. - You have an HTTPS URL on the public internet that can receive POST requests.
- Your server can read the raw bytes of the request body before it parses the JSON.
- Your server has a durable store, for example a database table, to record the event IDs that it processed.
The code example uses Node.js 18 or later with Express. The same algorithm works in all languages that have HMAC-SHA256 and Base64 functions.
How webhook delivery works
From a change to a delivery
Each change in the registry writes an audit entry. In the same database transaction, the registry writes a domain event with a stable event ID. If the transaction fails, neither the change nor the event exists. Thus, you never receive an event for a change that did not occur.
After the commit, a background job compares the event type with the subscriptions of each endpoint. For each endpoint that subscribes to the type, the job stores one delivery record and queues one send job. The stored record holds the exact payload bytes. All later attempts send the same bytes.
The request that your server receives
Each delivery is a POST request with a JSON body and these headers. The headers follow the Standard Webhooks specification.
| Header | Value |
|---|---|
Content-Type | application/json |
webhook-id | The delivery ID, for example msg_01J9Z4C6H2. It stays the same for all attempts of one delivery. |
webhook-timestamp | The time of this attempt, in seconds since 1 January 1970 UTC. |
webhook-signature | The text v1, followed by the Base64 HMAC-SHA256 signature of this attempt. |
The signature algorithm
Each endpoint has a signing secret, in the format whsec_…. The text after whsec_ is a Base64 value. Decode that value to get the key bytes. The signed content is the webhook-id value, a period, the webhook-timestamp value, a period and the raw body.
signed content = webhook-id + "." + webhook-timestamp + "." + raw body
signature = Base64(HMAC-SHA256(key = Base64Decode(secret without "whsec_"), signed content))
header value = "v1," + signatureThe registry calculates a new timestamp and a new signature for each attempt. Thus, a retry has the same webhook-id and the same body, but a different timestamp and signature.
Retries and the attempt limit
The registry accepts any 2xx status as a successful delivery. Any other status, a timeout or a connection failure is a failed attempt. Each attempt waits a maximum of 10 seconds for the response. The registry does not follow redirects, so a 3xx response is a failed attempt.
After a failed attempt, the registry tries again. The interval increases after each failure, from seconds to tens of minutes. After 8 failed attempts, the delivery status changes to failed and the registry stops. The eighth attempt occurs more than one hour after the first attempt.
Order and duplicates
Delivery is at least once. Your server can receive the same event more than one time, for example after a timeout or a manual redelivery. The registry also does not guarantee the order of events. A registry_download_key.updated event can arrive before the registry_download_key.created event of the same key.
The webhook-id header identifies a delivery. The id field in the body identifies the event. Use the pair tenant_id and id as the key to remove duplicates. An operator replay of old events makes new deliveries, but the body keeps the original event ID.
The payload: schema version 1
All deliveries use the same envelope:
{
"schema_version": 1,
"id": "evt_01J9Z4B8N5",
"type": "registry_recipient_portal.revoked",
"tenant_id": "org_01J8X2V1R9",
"created_at": "2026-10-05T09:14:27Z",
"data": {
"resource_type": "registry_recipient_portal",
"resource_id": "rpor_01J9Z3P6D1",
"action": "revoked",
"actor_id": "user_01J8X2W4K3"
}
}| Field | Description |
|---|---|
schema_version | The integer 1. It is not related to the v1 text of the signature. |
id | The stable event ID. Treat it as an opaque value. |
type | The event name, in the format resource_type.action. |
tenant_id | The organization where the change occurred. |
created_at | The time of the change in RFC 3339 UTC. It is not the time of the delivery. |
data.resource_type, data.resource_id | The kind and the ID of the record that changed. |
data.action | What occurred, for example created, published or revoked. |
data.actor_id | The user that made the change. The field is absent for a change that the system made. |
The payload is a change notification, not a copy of the record. It contains no names, package content, email addresses, credentials, key secrets or before and after values. To get the current state, request the record from the management API with its ID. A deleted record can be absent, and the API can return a state that is newer than the event.
Your receiver must accept fields that it does not know, because a later release can add fields to version 1. If schema_version is not 1, record the event and do not apply business effects.
The registry event catalogue
The endpoint form shows these events in groups. Select only the events that your receiver handles.
| Group | Events | When the registry sends them |
|---|---|---|
| Package security | npm_security.created, npm_security.updated | A release watch, finding, suppression or deployment evidence changed. Only its version ID is sent; review details in the security page. |
| Inspection reports | npm_inspection_report.created | An administrator saved a report. The payload contains its ID, not the inventory or findings. Read retained reports from the package UI. |
| Public PyPI policy | pypi_upstream_policy.updated | A registry manager changed the public-name policy. The payload contains the registry ID, not the allowlist. |
| Packages | npm_package.created, npm_package.updated, npm_package.deleted, npm_package.published, npm_package.unpublished | Created: a new package record. Published: a new version. Unpublished: a version tombstone. Updated: a change to settings, dist-tags or deprecation. |
| Registries | npm_registry.created, npm_registry.updated, npm_registry.deleted | A change to a private registry. |
| Scopes | npm_scope.created, npm_scope.deleted | You reserve or release a scope. |
| Publisher keys | npm_token.created, npm_token.revoked | You issue or revoke a publisher key. A rotation sends both events. |
| Domains | registry_domain.created, registry_domain.updated, registry_domain.revoked | Updated: for example, the DNS verification completes. The system makes that change, so the event has no actor. |
| Download keys | registry_download_key.created, registry_download_key.updated, registry_download_key.revoked | Updated: the access, the recipient link or the secret changes. |
| Recipients | registry_recipient.created, registry_recipient.updated, registry_recipient_portal.created, registry_recipient_portal.revoked | Updated: the name changes, or you suspend or resume the recipient. |
| PyPI projects | pypi_project.created, pypi_project.updated, pypi_project.deleted, pypi_project.published, pypi_project.unpublished | A change to a Python project or release. |
| Files packages | files_package.created, files_package.updated, files_package.deleted, files_package.published, files_package.unpublished | A change to a Files package or version. |
For a package event, resource_id is the package ID (rpkg_…), not the version. To find the new version after npm_package.published, request GET /api/v1/packages/{packageId}/versions.
NOTE: An endpoint receives only the events that you selected. When a later release adds a new event type, your endpoint does not receive the new type until you add it to the endpoint.
Add an endpoint
Make sure that your receiver URL answers on HTTPS before you start.
- Open Organization → Webhooks.
- Open Add endpoint.
- In Endpoint URL, type the HTTPS URL of your receiver, for example
https://hooks.acme.com/privatenpm. - In Description, type a short name, for example
CRM sync. - Under Events, select the events that your receiver handles.
- Select Add endpoint. The page shows the signing secret one time.
- Copy the signing secret into the secret store of your server, for example as
PRIVATENPM_WEBHOOK_SECRET.

The URL must use HTTPS, must be 2,048 characters or fewer and must not contain a user name, a password or a fragment. The registry connects only to public IP addresses. If the host name resolves to a private, loopback or link-local address, each attempt fails.
WARNING: Do not put the signing secret in source code or in a log. Anyone with the secret can send requests that your receiver accepts as genuine.
Verify each signature
Your receiver must verify the signature before it parses the body or acts on the event. Do these checks in this order:
- Read the raw body bytes. Do not parse the JSON and serialize it again.
- Read the three
webhook-headers. If a header is absent, return 400. - Compare
webhook-timestampwith your clock. If the difference is more than 5 minutes, return 400. - Calculate the expected signature with the algorithm above.
- Compare the expected value with each
v1,value of the header in constant time. - If no value matches, return 401 and do not process the event.
- Parse the JSON and process the event.
The webhook-signature header can contain more than one value, separated by spaces. Accept the request when one value matches. This example is a complete receiver in Node.js:
import crypto from "node:crypto";
import express from "express";
const secret = process.env.PRIVATENPM_WEBHOOK_SECRET; // whsec_...
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const app = express();
function validSignature(id, timestamp, header, body) {
const signed = Buffer.concat([Buffer.from(id + "." + timestamp + "."), body]);
const expected = crypto.createHmac("sha256", key).update(signed).digest();
return header.split(" ").some((part) => {
const [version, value] = part.split(",");
if (version !== "v1" || !value) return false;
const given = Buffer.from(value, "base64");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
app.post("/privatenpm", express.raw({ type: "application/json" }), async (req, res) => {
const id = req.get("webhook-id");
const timestamp = req.get("webhook-timestamp");
const header = req.get("webhook-signature");
if (!id || !timestamp || !header) return res.sendStatus(400);
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(400);
if (!validSignature(id, timestamp, header, req.body)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
if (event.schema_version !== 1) return res.sendStatus(204);
// recordOnce stores (tenant_id, id) with a unique constraint and
// returns false when the event is already stored.
const isNew = await recordOnce(event.tenant_id, event.id, event);
res.sendStatus(204);
if (isNew) queueWork(event); // handle the event outside the request
});
app.listen(3000);The functions recordOnce and queueWork belong to your application. The receiver returns 204 after it stores the event and before it does slow work, because each attempt waits a maximum of 10 seconds. The Standard Webhooks project also publishes verification libraries for many languages.
CAUTION: Do not use a JSON body parser before the signature check. A parser changes the bytes, and the signature check then fails for all requests.
Send a test event and read the delivery history
- Open Organization → Webhooks and select the endpoint. The endpoint page opens.
- Select Send test event.
- Select Refresh in the Recent deliveries section. A delivery with the type
webhook.testappears. - Open the delivery. The page shows the status, the HTTP status, the duration, the number of attempts and the payload.
The test event uses the version 1 envelope. Its resource_type is webhook, its resource_id is the endpoint ID (whep_…) and its action is test. The registry sends the test event to the endpoint even when the endpoint does not subscribe to other events.
Each delivery has one of three statuses. pending means that the registry queued an attempt or that a retry waits. succeeded means that your server returned 2xx. failed means that all attempts failed.
Select Redeliver to send a delivery again. A redelivery sends the same body with the same webhook-id. The registry keeps delivery records for 30 days.
Verify the result
Do this test after each change to your receiver:
- Send a test event. The delivery status changes to
succeededwith HTTP 204. - Select Redeliver on the same delivery. Your log shows that
recordOncereturned false. - Send a request with a changed body and the old headers to your receiver. The receiver returns 401.
- Make a real change, for example create a test download key. A
registry_download_key.createddelivery appears if the endpoint subscribes to it. - Request the key from the management API with the
resource_idof the event. The API returns the key.
Troubleshoot
Open the delivery in Recent deliveries and read the error text. Then use this table:
| Symptom | Cause | Correction |
|---|---|---|
| The receiver returns 401 for all deliveries. | A body parser changed the raw body, or the key is wrong. | Calculate the signature over the raw bytes. Decode the text after whsec_ from Base64. |
| 401 starts after a secret rotation. | The receiver still uses the old secret. | Install the new secret on the receiver. |
destination not allowed: the URL resolves to a private or internal address | The host name resolves to a private network address. | Use a host name that resolves to a public address. |
the receiver did not answer in time | The receiver did not respond in 10 seconds. | Store the event, return 2xx and do the work after the response. |
receiver answered 301 or 302 | The registry does not follow redirects. | Use the final URL in the endpoint. |
the receiver could not be reached | DNS, TLS or the connection failed. | Examine the DNS record, the certificate and the firewall of the receiver. |
| No deliveries appear after a change. | The endpoint does not subscribe to the event, or the endpoint is paused. | Examine Events and Status on the endpoint page. |
| Deliveries stop after a plan change. | The new plan does not include webhooks. | Change to Growth or a higher plan. |
| A business action occurs two times. | The receiver does not remove duplicates. | Store tenant_id and id with a unique constraint before the action. |
NOTE: The registry does not queue events for a paused endpoint. After you select Resume, the registry does not deliver the events that occurred during the pause.
Security and maintenance
- Rotate the signing secret at a regular interval and after each person with access to it leaves.
- Give
webhook:manageonly to the people who configure integrations. - Subscribe each endpoint only to the events that it uses.
- Delete endpoints that you no longer use. The deletion also removes their delivery history.
- Never trust a tenant ID or a resource ID from a body that has no valid signature.
- Examine the delivery history for
faileddeliveries, and redeliver them after you correct the cause.
To rotate the secret, select Rotate secret on the endpoint page. The page shows the new secret one time. The old secret stops at once, and retries that wait use the new secret.
CAUTION: Configure your receiver to accept the old and the new secret before you rotate. If the receiver knows only the old secret, all deliveries fail until you install the new secret.
Use Pause during maintenance of the receiver only when you can accept lost events. To keep the events during a maintenance of less than one hour, leave the endpoint active. The retries deliver the events after the maintenance.
Related guides
- Automate npm download keys after a sale receives payment events and issues keys.
- See private package downloads explains the download counters.
- Recipient npm download portal explains the portal events.
- The management API reference lists the envelope and the resource endpoints.