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

@nxgt/janus-webhooks

v0.7.7

Published

Signed webhooks for @nxgt/janus user events: Standard Webhooks signatures, retries with backoff through a pluggable durable queue, rotating secrets, and deliveries that run out reported — never dropped in silence

Readme

@nxgt/janus-webhooks

@nxgt/janus user events, delivered as signed webhooks by the Standard Webhooks specification: each user event is posted to your endpoints, signed with HMAC-SHA256, retried on failure — through a queue that can outlive the process — and every delivery given up is reported, never dropped without a word. The receiving side checks the signature with verifyWebhook, or with any Standard Webhooks library.

import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
import { webhooks } from '@nxgt/janus-webhooks';
import { z } from 'zod';

const secret = process.env.WEBHOOK_SECRET; // whsec_…, from mintWebhookSecret()
if (!secret) throw new Error('WEBHOOK_SECRET is not set');

const listener = webhooks({
	endpoints: [{ url: 'https://crm.example.com/hooks/janus', secrets: [secret] }],
});

export const auth = janus({
	user: z.object({ email: z.email() }),
	password: { login: 'email' },
	store: createMemoryStores(),
	hasher: scryptHasher(),
	events: listener, // every user.created, user.emailVerified, user.passwordReset, user.passwordChanged, user.emailChanged, user.secondFactorEnabled, user.secondFactorDisabled, user.recoveryCodesRegenerated, user.recoveryCodeUsed, user.newDeviceSignedIn, user.deleted
});

process.on('SIGTERM', async () => {
	await listener.close(); // waits for requests in flight, gives up the deliveries waiting for a retry
	process.exit(0);
});

0.x. A minor version may still change the surface; the changelog says how.

Install

bun add @nxgt/janus-webhooks @nxgt/janus
bun add zod # the schema of the examples; any Standard Schema library will do
bun add -d typescript

Both peers are required: @nxgt/janus 0.17 — the exact range is in peerDependencies — and typescript (6 or 7). @nxgt/janus is a peer, so one copy of it defines UserEvent. Like @nxgt/janus, it expects "moduleResolution": "bundler". It uses node:crypto, the global fetch and process.emitWarning: Bun or Node.

Subpaths

| Import | What it holds | | --- | --- | | @nxgt/janus-webhooks | Sending and receiving: webhooks(), verifyWebhook(), mintWebhookSecret(), and the WebhookQueue port with its in-memory reference, createMemoryWebhookQueue() | | @nxgt/janus-webhooks/conformance | For queue adapters: the suite a WebhookQueue runs — see API |

An adapter's spec runs the suite against a fresh, empty queue per case:

import { describe, it } from 'bun:test';
import { describeWebhookQueues } from '@nxgt/janus-webhooks/conformance';

describeWebhookQueues({
	name: 'my queue',
	runner: { describe, it }, // under bun test, pass them: they are not on globalThis
	harness: {
		async open() {
			const db = await openEmptyDatabase();
			return {
				queue: createMyWebhookQueue(db),
				faults: { fail: async (method) => db.failEvery(method) }, // leave out, and the outage cases pass with a JANUS_CONFORMANCE_SKIPPED warning
				close: () => db.drop(),
			};
		},
	},
});

The queues guide has every case and what fails it.

Usage

Sending — webhooks()

webhooks({ endpoints }) answers the listener janus({ events }) takes, with a close() for shutdown. It posts each user event to every endpoint whose types include it — all eleven types when types is absent:

import { webhooks } from '@nxgt/janus-webhooks';

const listener = webhooks({
	endpoints: [
		{ url: 'https://crm.example.com/hooks/janus', secrets: [crmSecret] },
		{ url: 'https://search.example.com/hooks', secrets: [searchSecret], types: ['user.deleted'] },
	],
	retries: ['5s', '5m', '30m', '2h', '5h', '10h', '10h'], // the default: at once, then these
	timeout: '10s', // the default, per request
	onGivingUp: async (delivery, reason) => {
		// reason.why: 'retriesRanOut' | 'closed' | 'endpointRemoved'; delivery.attempts: how many requests were sent
		await deadLetters.insert({ eventId: delivery.event.id, endpoint: delivery.endpoint, ...reason });
	},
});

The sending guide has every option, the retry schedule, when a delivery is given up and why, the wire format, and a test.

Surviving a restart — queue

Without a queue, deliveries wait in the process's memory: a crash loses the retries still waiting. Pass a WebhookQueue that every process shares, and a retry failed by one process is sent by the next:

import { createMemoryWebhookQueue, webhooks } from '@nxgt/janus-webhooks';

const secret = process.env.WEBHOOK_SECRET; // whsec_…, from mintWebhookSecret()
if (!secret) throw new Error('WEBHOOK_SECRET is not set');

// The reference, in memory: swap in a durable adapter of the port — such as
// createRedisWebhookQueue(redis) from @nxgt/janus-webhooks-redis — for a
// queue that survives a restart.
const queue = createMemoryWebhookQueue();

const listener = webhooks({
	endpoints: [{ id: 'crm', url: 'https://crm.example.com/hooks/janus', secrets: [secret] }],
	queue, // the listener now awaits the insert — one call to the queue, never an endpoint
});

process.on('SIGTERM', async () => {
	await listener.close(); // gives nothing up: what waits is sent by the next process
	process.exit(0);
});

createMemoryWebhookQueue() is the port's reference, and @nxgt/janus-webhooks/conformance the suite an adapter runs. The queues guide has the contract, leases, endpoint ids, and how to write and test an adapter.

Receiving — verifyWebhook()

verifyWebhook answers the UserEvent a request carries, or null when it is not one the endpoint's secrets signed. In a fetch-style handler:

import { verifyWebhook } from '@nxgt/janus-webhooks';

export async function receive(request: Request): Promise<Response> {
	const event = verifyWebhook({
		secrets: [secret],
		headers: request.headers,
		body: await request.text(), // the raw body: never request.json()
	});
	if (event === null) return new Response(null, { status: 401 }); // forged, altered, replayed late, or not a user event
	if (await handled.has(event.id)) return new Response(null, { status: 204 }); // a retry of one already handled

	await onUserEvent(event); // { id, type, occurredAt, userId, userType }
	await handled.add(event.id);
	return new Response(null, { status: 204 });
}

The receiving guide has the options, every reason for null, Hono and Node handlers, handling an event once, and a test.

Secrets — mintWebhookSecret()

import { mintWebhookSecret } from '@nxgt/janus-webhooks';

mintWebhookSecret(); // 'whsec_…': 32 random bytes, base64 — give the same one to the sender and the receiver

A secret from another Standard Webhooks library is accepted as it stands if it holds 24 to 64 bytes after whsec_, the range the specification sets.

API

| Export | What it is | | --- | --- | | webhooks(options) | The listener for janus({ events }), and close(). Options: endpoints (each { url, secrets, types?, id? }), retries, timeout, onGivingUp, fetch, queue, concurrency, lease, poll, orphanGrace. With a queue the listener answers the insert, a Promise; without one, or for an event no endpoint takes, undefined. Wiring mistakes are a TypeError when it is called, and so is an event rebuilt by hand that is not one, when the listener is | | verifyWebhook(options) | { secrets, headers, body, toleranceSeconds?, now? } → UserEvent \| null. Headers as a fetch Headers or a Node header record. No secret, a malformed one, a toleranceSeconds that is not a finite number of seconds, or a now that is not a valid Date is a TypeError | | mintWebhookSecret() | A new whsec_ secret for an endpoint | | createMemoryWebhookQueue() | The reference WebhookQueue, in memory: what webhooks() uses without a queue, and one to share between two webhooks() in a test | | WebhookQueue, QueuedDelivery | The port a durable queue implements, and one delivery as it holds it: the event and the endpoint's id, never the URL or a secret | | WebhooksOptions, WebhookEndpoint, Webhooks | What webhooks() takes and answers | | Delivery, GivingUp, Failure | What onGivingUp receives: { event, url, endpoint, attempts } — url is null for endpointRemoved — and { why, status, error }: a GivingUp is a Failure, what the last attempt got, with the reason | | VerifyOptions, HeadersLike, WebhookBody | What verifyWebhook takes, and the JSON body on the wire | | @nxgt/janus-webhooks/conformance | The suite a queue adapter runs: describeWebhookQueues({ name, harness, runner }), runWebhookQueueCase, the cases as data (webhookQueueCases, webhookQueueOutageCases, allWebhookQueueCases), referenceWebhookQueueHarness(), SKIP_REASONS, and their types. It imports no test framework |

Traps

Retries wait in memory, unless you pass a queue. Without one, a crash, or an exit without close(), loses every retry still waiting, and nothing sends it later. With a queue every process shares, what one process leaves waiting — a retry, a request cut short by a crash — the next one sends.

At least once, even across restarts. A delivery is claimed under a lease, and a process that dies mid-request loses nothing: once the lease lapses, another process sends it again — so a receiver may see one twice, and a crash costs one attempt of the schedule. Receivers deduplicate on webhook-id, as they already must for a retry. A lease is at least timeout plus one second: the worker extends it before reporting a failure, so a slow onGivingUp does not hand the delivery to another process.

Call close() on shutdown. Without a queue, it first sends every delivery due at close() — concurrency at a time, so an event taken while every slot was busy still gets its attempt — waits for the requests in flight, and gives up the deliveries waiting for a retry, each reported as closed — without it they vanish without a word. With one, it waits for the requests in flight and gives nothing up. process.on('SIGTERM', () => listener.close()).

The listener waits for the insert only. Every delivery goes through a queue; the listener awaits the insert — a Map in memory, one call with a queue — then returns, and the first request starts at once, so signUp never waits for an endpoint. With a queue, an insert that fails rejects, and janus reports it as JANUS_EVENT_FAILED with the event's id. A retry's timer does not hold the process open — end a script with await listener.close().

An endpoint's id is what a queue knows it by. Without an id, it is a hash of the URL: changing the URL of an endpoint with deliveries waiting leaves them to no endpoint, and after orphanGrace (24 hours) they are given up as endpointRemoved, with delivery.url null. Give each endpoint an id when you first pass a queue: adding one later changes the id too.

A redirect is a failure, and only a 2xx is a success. Redirects are not followed: point url at the final address, https:// — http:// only to localhost.

Verify the raw body, before any parsing. The signature covers the bytes as sent: await request.text(), never request.json() nor a body parser that re-serialises it.

A replay inside the tolerance verifies. A request copied and sent again within toleranceSeconds (300) passes, and so does a retry of one you already handled: keep the ids handled — event.id, the webhook-id header — and answer a second with a 2xx, doing nothing.

Rotate a secret in four steps. Sign with both (secrets: [old, next]), let the receiver accept both, then drop the old one from the sender, and last from the receiver.

A secret holds 24 to 64 bytes. Past 64, webhooks() and verifyWebhook() throw a TypeError — since 0.4.0; earlier versions took any length from 24. A longer secret from before is refused on upgrade, so rotate away from it first, on the version you run: mint one with mintWebhookSecret(), rotate to it in the four steps above, then upgrade.

Upgrade the receiver before the sender. A receiver's verifyWebhook answers null for a type it does not know, so the delivery fails until it is given up — @nxgt/janus-webhooks before 0.3.0 knows neither user.secondFactorEnabled nor user.secondFactorDisabled, and before 0.4.0 neither user.recoveryCodesRegenerated nor user.recoveryCodeUsed, and before 0.6.0 neither user.passwordChanged nor user.emailChanged, and before 0.7.0 not user.newDeviceSignedIn. Until every receiver is upgraded, give its endpoint the types it knows:

webhooks({
	endpoints: [{ url, secrets: [secret], types: ['user.created', 'user.emailVerified', 'user.passwordReset', 'user.deleted'] }],
});

That list is for a receiver before 0.3.0. For one on 0.3.x, which knows the second factor's events but not the recovery codes', add those two:

types: ['user.created', 'user.emailVerified', 'user.passwordReset', 'user.secondFactorEnabled', 'user.secondFactorDisabled', 'user.deleted'],

For one on 0.4.x or 0.5.x, which knows the recovery codes' events but not the change events', add those two:

types: ['user.created', 'user.emailVerified', 'user.passwordReset', 'user.secondFactorEnabled', 'user.secondFactorDisabled', 'user.recoveryCodesRegenerated', 'user.recoveryCodeUsed', 'user.deleted'],

For one on 0.6.x, which knows the change events but not the new device's, add those two:

types: ['user.created', 'user.emailVerified', 'user.passwordReset', 'user.passwordChanged', 'user.emailChanged', 'user.secondFactorEnabled', 'user.secondFactorDisabled', 'user.recoveryCodesRegenerated', 'user.recoveryCodeUsed', 'user.deleted'],

user.emailChanged is posted without its formerEmail. @nxgt/janus hands the listener the address the user had before, so a notice can reach that inbox; webhooks() drops it before the queue, so no endpoint, queue or onGivingUp report ever holds an address. A receiver reads the user by userId as for any type; send the notice to the former address from the janus({ events }) listener instead, beside webhooks():

const auth = janus({
	...config,
	async events(event) {
		await listener(event); // webhooks(): every type, formerEmail dropped
		if (event.type === 'user.emailChanged' && event.formerEmail != null) {
			const user = await auth.get(event.userId);
			await mail.emailChanged({ name: user.name, formerEmail: event.formerEmail, newEmail: user.email });
		}
	},
});

user.newDeviceSignedIn is posted without its sessionId, for the same reason: a session id stays in the process. Send the new-sign-in notice from the janus({ events }) listener, or from the sign-in's own answer, whose newDevice says so.

The warnings name the URL's origin, never the URL. JANUS_WEBHOOK_GAVE_UP writes https://crm.example.com because a path or query may hold a token of the receiver's; onGivingUp receives the full delivery.url, so do not log it as it stands. A queue never holds the URL at all — only the endpoint's id.

The symptoms and fixes are in troubleshooting.

Type safety, counted

Eleven plausible mistakes, eleven refused at compile time. test/types/webhooks.ts holds one @ts-expect-error per mistake, beside the wiring that must keep compiling (janus({ events: webhooks(…) }), with and without a queue): an endpoint with no secret, a secret read from the environment and not checked (string | undefined), a type janus never sends, a retry that is not a Duration, a receiver with no secret, reading a verified event before checking it for null, a queue missing a method, a concurrency written as a string, an endpoint id written as a number, a switch over reason.why that forgets endpointRemoved, and an onGivingUp that reads delivery.url without checking it for null — the URL of an endpoint no longer configured.

Documentation

  • Guides — sending, receiving and queues, every option with an example
  • Troubleshooting — by the warning or error you see
  • Roadmap — what is next, and what is not planned

Licence

MIT