Send webhooks to your customers from a Node.js / TypeScript app
Add customer-facing webhooks to a Node.js or TypeScript backend with @webhookrelay/sdk: event types, consumers, endpoints, idempotent publishing, delivery inspection, retries and failing-endpoint reports.
To send webhooks to your customers from a Node.js or TypeScript app, install @webhookrelay/sdk, register each customer as a consumer with the HTTPS endpoint they give you, and call relay.outbound.messages.publish() with an idempotency key whenever an event happens. Webhook Relay's Outbound Gateway signs each delivery with Standard Webhooks headers, retries failures up to eight times within 48 hours, paces each endpoint separately and keeps 35 days of per-attempt history. You don't build a queue, a retry worker or signing code.
By the end of this tutorial you will have:
- an event catalog (
invoice.paid,invoice.refunded), - a customer with a registered endpoint and its signing secret,
- idempotent publishing from your business logic,
- code that inspects deliveries, retries one, recovers failures after an outage and reports failing endpoints.
your Node.js app ──publish once──▶ Webhook Relay Outbound Gateway
│ sign (whsec_ per endpoint)
│ retry 5s → 10h, up to 8 attempts / 48h
│ per-endpoint rate limit, 35-day log
├──▶ https://acme.example/webhooks
└──▶ https://hooks.globex.example/wr
Prerequisites
- A Webhook Relay account on the Basic plan or above. The Outbound Gateway is self-serve on Basic ($9.99/month), Business and Pro. It is not on the Free plan. Open my.webhookrelay.com/outbound to confirm it is on.
- An outbound-restricted access token. On the Access Tokens page create a standalone token and choose an Outbound restriction. Use Outbound: manage for onboarding code that creates consumers and endpoints, and Outbound: publish only (
"permissions": { "outbound": "publish" }) for the service that emits events. A restricted token can only reach/v1/outbound/*, so a leaked publish token cannot touch your buckets, tunnels or account. - Node.js 18 or newer (the SDK uses the built-in
fetch). - A test endpoint. Any public HTTPS URL works. A free Webhook Bin is the quickest way to watch deliveries arrive.
1. Install the SDK and set credentials
npm install @webhookrelay/sdk
npm install --save-dev tsx typescript
export RELAY_KEY=YOUR_TOKEN_KEY
export RELAY_SECRET=YOUR_TOKEN_SECRET
export CUSTOMER_WEBHOOK_URL=https://bin.webhookrelay.com/v1/webhooks/YOUR_BIN_ID
Create one client module and import it everywhere. With no arguments the client reads RELAY_KEY and RELAY_SECRET (or a RELAY_API_KEY account key) from the environment. Keep it server-side: never publish from a browser or mobile app.
// relay.ts
import { WebhookRelay } from "@webhookrelay/sdk";
export const relay = new WebhookRelay({
key: process.env.RELAY_KEY,
secret: process.env.RELAY_SECRET,
});
2. Define your event catalog
Event types are the webhooks your customers can subscribe to. upsert creates or updates them, so you can run this on every deploy. The example payload documents the event for your customers (up to 64 KiB).
// catalog.ts
import { relay } from "./relay.js";
export async function syncEventCatalog() {
await relay.outbound.eventTypes.upsert("invoice.paid", {
description: "An invoice was paid in full",
example: { invoice_id: "inv_123", customer_id: "customer_42", amount: 4900, currency: "usd" },
});
await relay.outbound.eventTypes.upsert("invoice.refunded", {
description: "An invoice was refunded, fully or partially",
example: { invoice_id: "inv_123", refund_id: "re_9", amount: 4900, currency: "usd" },
});
}
To retire an event later, upsert it with deprecated: true. Deprecated event types can't be published.
3. Onboard a customer and register their endpoint
A consumer is one of your customers. Use your own customer or organization ID so you never need a mapping table. An endpoint is an HTTPS URL the customer gives you, subscribed to some event types (or ["*"] for all).
// onboarding.ts
import { relay } from "./relay.js";
export async function addCustomerEndpoint(customer: { id: string; name: string }, url: string) {
// Creates the consumer, or updates its name if it already exists.
await relay.outbound.consumers.upsert(customer.id, { name: customer.name });
const endpoint = await relay.outbound.endpoints.create(customer.id, {
url,
eventTypes: ["invoice.paid", "invoice.refunded"],
description: "Production webhook receiver",
autoDisable: true, // disable after 100 failures in a row spanning 12+ hours
timeout: 15, // seconds per attempt, up to 60
});
// Store endpoint.id next to the customer. Show endpoint.secret to the customer
// once: it is the whsec_ key they verify signatures with.
return { endpointId: endpoint.id, signingSecret: endpoint.secret };
}
The URL must be public HTTPS. Hosts that resolve to private or loopback addresses are rejected, and each URL can be registered once per consumer. If the customer loses the secret, relay.outbound.endpoints.revealSecret(endpointId) returns it again.
4. Publish when the event happens
Call publish from the code path where the event actually occurs, after your database transaction commits. Derive the idempotency key from the event itself, so a retry after a timeout, a crash or a duplicate job can never deliver twice.
// billing.ts
import { WebhookRelayAPIError } from "@webhookrelay/sdk";
import { relay } from "./relay.js";
type Invoice = { id: string; customerId: string; amount: number; currency: string };
export async function onInvoicePaid(invoice: Invoice) {
try {
const receipt = await relay.outbound.messages.publish(
{
consumer: invoice.customerId,
eventType: "invoice.paid",
eventId: `evt_${invoice.id}`, // your own reference, stored on the receipt
payload: {
invoice_id: invoice.id,
customer_id: invoice.customerId,
amount: invoice.amount,
currency: invoice.currency,
},
},
{ idempotencyKey: `invoice-paid:${invoice.id}` },
);
// 202 Accepted: stored durably, delivery is asynchronous.
// receipt.id is sent to every endpoint as the webhook-id header.
return receipt;
} catch (err) {
if (err instanceof WebhookRelayAPIError) {
if (err.status === 404) return null; // customer has no consumer yet: nothing to send
if (err.status === 409) throw new Error(`idempotency key reused for a different payload: ${invoice.id}`);
if (err.status === 413) throw new Error("payload is over 256 KiB, send a reference and let the customer fetch it");
// 429 means 10,000 messages are already waiting to be prepared. Retry later with the same key.
}
throw err;
}
}
A few rules that keep this correct:
- Publish after commit. If you publish inside the transaction and it rolls back, the customer receives an event that never happened. If your process can die between commit and publish, write the event to an outbox table in the same transaction and publish from a worker. The idempotency key makes the worker's retries safe.
- Send IDs and state, not secrets. Payloads are logged for 35 days in your delivery history. Keep tokens, card data and passwords out of them.
- Keep payloads small. The limit is 256 KiB. For large objects, send an ID and a URL your customer can fetch.
5. Inspect what was delivered
messages.get returns the payload plus one delivery per endpoint, with every attempt's status code, duration and error. A message that was just accepted may still be in preparation and have no deliveries yet.
// inspect.ts
import { relay } from "./relay.js";
export async function showMessage(messageId: string) {
const message = await relay.outbound.messages.get(messageId);
for (const delivery of message.deliveries ?? []) {
console.log(`endpoint ${delivery.output_id}:`);
for (const attempt of delivery.attempts ?? []) {
console.log(` #${attempt.attempt_no} ${attempt.status_code ?? "-"} ${attempt.outcome ?? ""} ${attempt.error ?? ""} (${attempt.duration_ms ?? 0} ms)`);
}
}
}
// What did one endpoint receive? Filter by status, event type or message.
export async function failedDeliveries(endpointId: string) {
return relay.outbound.endpoints.deliveries(endpointId, { status: "failed", limit: 20 });
}
Delivery statuses are received (queued), stalled (a retry is scheduled), sent (the endpoint answered 2xx), failed (retries ran out or the endpoint answered 410 Gone) and rejected (skipped while the endpoint was paused or disabled).
6. Retry one delivery and recover after an outage
When a customer says "we missed an event", re-send it without publishing again. Every copy keeps the original webhook-id, so a receiver that deduplicates will not process it twice.
// recovery.ts
import { relay } from "./relay.js";
// One message to one endpoint, now.
export async function retryOne(endpointId: string, messageId: string) {
const task = await relay.outbound.endpoints.retry(endpointId, messageId);
return waitForTask(task.id);
}
// Everything that exhausted its retries since the customer's outage started.
export async function recoverSince(endpointId: string, since: Date) {
const task = await relay.outbound.endpoints.recover(endpointId, { since: since.toISOString() });
return waitForTask(task.id);
}
// Messages skipped while the endpoint was paused or disabled: resume first, then replay.
export async function resumeAndReplay(endpointId: string, since: Date) {
await relay.outbound.endpoints.resume(endpointId);
const task = await relay.outbound.endpoints.replayMissing(endpointId, { since: since.toISOString() });
return waitForTask(task.id);
}
async function waitForTask(taskId: string) {
for (;;) {
const task = await relay.outbound.recoveryTasks.get(taskId);
if (task.status === "completed" || task.status === "failed") return task;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
7. Report failing endpoints
With dozens of customers, some endpoint is always down. An endpoint becomes failing after 5 failed attempts in a row or one exhausted delivery. Run a report like this from a cron job or your admin dashboard instead of waiting for customers to notice.
// health.ts
import { relay } from "./relay.js";
export async function failingEndpointReport() {
const { endpoints, stats } = await relay.outbound.health();
console.log(`24h: ${stats.attempts} attempts, ${stats.failures} failures; failing endpoints: ${endpoints.failing}`);
if (endpoints.failing === 0) return [];
// Longest failing first, across all customers.
const failing = await relay.outbound.endpoints.listAll({ state: "failing", limit: 20 });
return failing.map((e) => ({
customer: e.consumer,
url: e.url,
failingSince: e.failing_since,
failures24h: e.stats?.failures ?? 0,
}));
}
8. Rotate a customer's signing secret
rotateSecret returns a new whsec_ secret. For the next 24 hours every delivery carries two signatures, one per secret, so the customer can switch keys without dropping a webhook.
const newSecret = await relay.outbound.endpoints.rotateSecret(endpointId);
Run it end to end
Put the pieces together in one script and run it against your Webhook Bin URL:
// main.ts
import { relay } from "./relay.js";
import { syncEventCatalog } from "./catalog.js";
import { addCustomerEndpoint } from "./onboarding.js";
import { onInvoicePaid } from "./billing.js";
import { showMessage } from "./inspect.js";
import { failingEndpointReport } from "./health.js";
const url = process.env.CUSTOMER_WEBHOOK_URL;
if (!url) throw new Error("set CUSTOMER_WEBHOOK_URL");
await syncEventCatalog();
const customer = { id: "customer_42", name: "Acme" };
const existing = await relay.outbound.endpoints.list(customer.id).catch(() => []);
if (!existing.some((e) => e.url === url)) {
const { endpointId, signingSecret } = await addCustomerEndpoint(customer, url);
console.log("endpoint", endpointId, "secret", signingSecret);
}
const receipt = await onInvoicePaid({ id: "inv_123", customerId: customer.id, amount: 4900, currency: "usd" });
console.log("published", receipt?.id, "to", receipt?.endpoint_ids.length, "endpoint(s)");
await new Promise((resolve) => setTimeout(resolve, 3000));
if (receipt) await showMessage(receipt.id);
console.log(await failingEndpointReport());
npx tsx main.ts
The bin shows a POST with webhook-id, webhook-timestamp, webhook-signature, webhook-event-type: invoice.paid and webhook-consumer: customer_42. Run the script again: the publish returns the same receipt id and the bin receives nothing new, because the idempotency key matched.
What your customers do
Send each customer their whsec_ secret and the receiving guide, which has drop-in verification code for Node.js and Go. Any Standard Webhooks library works too. You can test signatures by hand with the Standard Webhooks signature generator.
Next steps
- Set up the Outbound Gateway: the same flow with
curland the full REST reference. - Monitor, retry and recover deliveries: delivery statuses, health, Functions and limits.
- Send webhooks from a Go service and add webhooks to a Next.js SaaS app.
- Webhook best practices: a checklist for senders and receivers.
- Webhooks as a service pricing and plans.
Frequently asked questions
Do I need my own queue or retry worker to send webhooks from Node.js?
No. relay.outbound.messages.publish returns a 202 receipt once the message is stored durably, and Webhook Relay delivers it to every subscribed endpoint, retrying failures up to eight times within 48 hours. Your Node.js process can exit or restart right after the call returns.
What happens if my code calls publish twice for the same event?
Pass an idempotencyKey derived from the event, such as invoice-paid:inv_123. A repeat call with the same key and the same message returns the original receipt and nothing is delivered twice. Reusing the key for a different payload is rejected with a 409 error.
Which Webhook Relay plan do I need to send webhooks?
The Outbound Gateway is self-serve on the Basic plan ($9.99 per month) and every plan above it. It is not available on the Free plan. Basic covers 100 customers with up to 10 endpoints each, and there are no per-message fees.
How do my customers verify the webhooks?
Every delivery is signed with the Standard Webhooks scheme using the endpoint's whsec_ secret, in the webhook-id, webhook-timestamp and webhook-signature headers. Customers can use any Standard Webhooks library, including Svix's, or the short verification functions in the Webhook Relay receiving guide.
