Add webhooks to a Next.js SaaS app
Let customers register webhook endpoints from your Next.js settings page, publish events from route handlers and show delivery history, with Webhook Relay's Outbound Gateway and @webhookrelay/sdk. App Router, server actions, complete code.
To add webhooks to a Next.js SaaS app, use Webhook Relay's Outbound Gateway as the delivery backend: a settings page lets each customer register HTTPS endpoints through a server action, your route handlers call relay.outbound.messages.publish() when something happens, and the same page reads delivery history back from the API. Webhook Relay signs every delivery with Standard Webhooks headers, retries failures up to eight times within 48 hours, paces each endpoint separately and keeps 35 days of attempts. Your app stores nothing but an organization ID.
What you'll build (App Router, React 19 / Next.js 15):
app/
settings/webhooks/page.tsx endpoints, state, recent deliveries
settings/webhooks/actions.ts add / delete / retry / rotate (server actions)
settings/webhooks/add-endpoint.tsx form that shows the signing secret once
api/invoices/[id]/pay/route.ts business logic that publishes invoice.paid
lib/
webhooks.ts server-only Webhook Relay client + ownership checks
auth.ts your existing auth (stubbed here)
Prerequisites
- A Webhook Relay account on the Basic plan or above ($9.99/month covers 100 customers with up to 10 endpoints each; the Outbound Gateway is not on the Free plan).
- An access token restricted to Outbound: manage from the Access Tokens page. The settings page creates endpoints, so it needs
manage. If events are published by a separate worker, give that worker its own Outbound: publish only token ("permissions": { "outbound": "publish" }). - A Next.js 15 app using the App Router.
npm install @webhookrelay/sdk server-only
# .env.local
RELAY_KEY=YOUR_TOKEN_KEY
RELAY_SECRET=YOUR_TOKEN_SECRET
Create your event catalog once, from a script or the dashboard. See step 2 of the Node.js tutorial for the eventTypes.upsert calls; this tutorial assumes invoice.paid and invoice.refunded exist.
1. A server-only client with ownership checks
Every customer organization is one Webhook Relay consumer, keyed by your own org ID. The helper that loads an endpoint also checks it belongs to the caller's org: that check is what keeps one tenant from touching another tenant's endpoints.
// lib/webhooks.ts
import "server-only";
import { WebhookRelay, WebhookRelayAPIError } from "@webhookrelay/sdk";
export const relay = new WebhookRelay({
key: process.env.RELAY_KEY,
secret: process.env.RELAY_SECRET,
});
/** Ensure the org exists as a consumer. Safe to call repeatedly. */
export async function ensureConsumer(org: { id: string; name: string }) {
await relay.outbound.consumers.upsert(org.id, { name: org.name });
}
/** Load an endpoint only if it belongs to this org. */
export async function getOwnedEndpoint(orgId: string, endpointId: string) {
const endpoint = await relay.outbound.endpoints.get(endpointId);
if (endpoint.consumer !== orgId) throw new Error("endpoint not found");
return endpoint;
}
/** An org's endpoints; an org that never added one has no consumer yet. */
export async function listEndpoints(orgId: string) {
try {
return await relay.outbound.endpoints.list(orgId);
} catch (err) {
if (err instanceof WebhookRelayAPIError && err.status === 404) return [];
throw err;
}
}
/** Publish an event for one org. Returns null if the org has no consumer. */
export async function publishEvent(orgId: string, eventType: string, payload: unknown, idempotencyKey: string) {
try {
return await relay.outbound.messages.publish({ consumer: orgId, eventType, payload }, { idempotencyKey });
} catch (err) {
if (err instanceof WebhookRelayAPIError && err.status === 404) return null;
throw err;
}
}
// lib/auth.ts — replace with your auth (Auth.js, Clerk, WorkOS, Lucia...)
export async function getCurrentOrg(): Promise<{ id: string; name: string }> {
return { id: "org_42", name: "Acme" };
}
2. Server actions for the settings page
Server actions run on the server, so the token never reaches the browser. Each one resolves the org from the session, never from the form.
// app/settings/webhooks/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { getCurrentOrg } from "@/lib/auth";
import { ensureConsumer, getOwnedEndpoint, relay } from "@/lib/webhooks";
export type AddEndpointState = { error?: string; secret?: string };
export async function addEndpoint(_prev: AddEndpointState, form: FormData): Promise<AddEndpointState> {
const org = await getCurrentOrg();
const url = String(form.get("url") ?? "").trim();
const eventTypes = form.getAll("eventTypes").map(String);
if (!url.startsWith("https://")) return { error: "The URL must start with https://" };
if (eventTypes.length === 0) return { error: "Pick at least one event" };
try {
await ensureConsumer(org);
const endpoint = await relay.outbound.endpoints.create(org.id, {
url,
eventTypes,
autoDisable: true,
});
revalidatePath("/settings/webhooks");
// Shown once. The customer verifies signatures with it.
return { secret: endpoint.secret };
} catch (err) {
// Private/loopback hosts, duplicate URLs and plan limits are rejected here.
return { error: err instanceof Error ? err.message : "Could not add the endpoint" };
}
}
export async function deleteEndpoint(form: FormData) {
const org = await getCurrentOrg();
const endpoint = await getOwnedEndpoint(org.id, String(form.get("endpointId")));
await relay.outbound.endpoints.delete(endpoint.id);
revalidatePath("/settings/webhooks");
}
export async function retryDelivery(form: FormData) {
const org = await getCurrentOrg();
const endpoint = await getOwnedEndpoint(org.id, String(form.get("endpointId")));
await relay.outbound.endpoints.retry(endpoint.id, String(form.get("messageId")));
revalidatePath("/settings/webhooks");
}
export async function resendFailed(form: FormData) {
const org = await getCurrentOrg();
const endpoint = await getOwnedEndpoint(org.id, String(form.get("endpointId")));
// Re-send everything that exhausted its retries in the last 24 hours.
await relay.outbound.endpoints.recover(endpoint.id);
revalidatePath("/settings/webhooks");
}
export async function rotateSecret(_prev: AddEndpointState, form: FormData): Promise<AddEndpointState> {
const org = await getCurrentOrg();
const endpoint = await getOwnedEndpoint(org.id, String(form.get("endpointId")));
// The old secret keeps signing for 24 hours, so the customer can switch without downtime.
return { secret: await relay.outbound.endpoints.rotateSecret(endpoint.id) };
}
3. The add-endpoint form
A small client component posts to the server action and shows the signing secret exactly once.
// app/settings/webhooks/add-endpoint.tsx
"use client";
import { useActionState } from "react";
import { addEndpoint, type AddEndpointState } from "./actions";
export function AddEndpointForm({ eventTypes }: { eventTypes: { name: string; description?: string }[] }) {
const [state, action, pending] = useActionState<AddEndpointState, FormData>(addEndpoint, {});
return (
<form action={action}>
<label>
Endpoint URL
<input name="url" type="url" placeholder="https://example.com/webhooks" required />
</label>
<fieldset>
<legend>Events</legend>
{eventTypes.map((t) => (
<label key={t.name}>
<input type="checkbox" name="eventTypes" value={t.name} defaultChecked /> {t.name}
{t.description ? ` (${t.description})` : null}
</label>
))}
</fieldset>
<button disabled={pending}>{pending ? "Adding..." : "Add endpoint"}</button>
{state.error ? <p role="alert">{state.error}</p> : null}
{state.secret ? (
<p>
Signing secret (copy it now, it is shown once): <code>{state.secret}</code>
</p>
) : null}
</form>
);
}
4. The settings page
A server component lists the org's endpoints with their state and 24-hour failure count, plus the latest deliveries for each, with buttons to retry one delivery or re-send everything that failed.
// app/settings/webhooks/page.tsx
import { getCurrentOrg } from "@/lib/auth";
import { listEndpoints, relay } from "@/lib/webhooks";
import { deleteEndpoint, resendFailed, retryDelivery } from "./actions";
import { AddEndpointForm } from "./add-endpoint";
export const dynamic = "force-dynamic";
export default async function WebhooksSettingsPage() {
const org = await getCurrentOrg();
const [eventTypes, endpoints] = await Promise.all([
relay.outbound.eventTypes.list(),
listEndpoints(org.id),
]);
const deliveries = await Promise.all(
endpoints.map((e) => relay.outbound.endpoints.deliveries(e.id, { limit: 10 })),
);
return (
<main>
<h1>Webhooks</h1>
<p>
We sign every webhook with the Standard Webhooks scheme (webhook-id, webhook-timestamp,
webhook-signature) and retry failures for up to 48 hours.
</p>
<AddEndpointForm eventTypes={eventTypes.filter((t) => !t.deprecated)} />
{endpoints.map((endpoint, i) => (
<section key={endpoint.id}>
<h2>{endpoint.url}</h2>
<p>
{endpoint.state} · events: {endpoint.event_types.join(", ")} · last 24h:{" "}
{endpoint.stats?.attempts ?? 0} attempts, {endpoint.stats?.failures ?? 0} failed
</p>
<table>
<thead>
<tr><th>Event</th><th>Message</th><th>Last response</th><th /></tr>
</thead>
<tbody>
{deliveries[i].map((d) => (
<tr key={d.id}>
<td>{d.event_type}</td>
<td><code>{d.message_id}</code></td>
<td>{d.status_code ?? "pending"}</td>
<td>
<form action={retryDelivery}>
<input type="hidden" name="endpointId" value={endpoint.id} />
<input type="hidden" name="messageId" value={d.message_id} />
<button>Retry</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
<form action={resendFailed}>
<input type="hidden" name="endpointId" value={endpoint.id} />
<button>Re-send failed deliveries (last 24h)</button>
</form>
<form action={deleteEndpoint}>
<input type="hidden" name="endpointId" value={endpoint.id} />
<button>Delete endpoint</button>
</form>
</section>
))}
</main>
);
}
Add a "Rotate secret" button the same way as AddEndpointForm, wiring useActionState to rotateSecret so the new secret is displayed once.
5. Publish events from your business logic
Publish after the database change commits, with an idempotency key derived from the event. If the request is retried, or the handler runs twice, the second publish returns the original receipt and nothing is delivered twice.
// app/api/invoices/[id]/pay/route.ts
import { NextResponse } from "next/server";
import { getCurrentOrg } from "@/lib/auth";
import { publishEvent } from "@/lib/webhooks";
// Your own data layer.
async function markInvoicePaid(orgId: string, invoiceId: string) {
return { id: invoiceId, orgId, amount: 4900, currency: "usd", paidAt: new Date().toISOString() };
}
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const org = await getCurrentOrg();
const invoice = await markInvoicePaid(org.id, id); // commits first
await publishEvent(
org.id,
"invoice.paid",
{ invoice_id: invoice.id, amount: invoice.amount, currency: invoice.currency, paid_at: invoice.paidAt },
`invoice-paid:${invoice.id}`,
);
return NextResponse.json({ ok: true });
}
publish returns as soon as Webhook Relay has stored the message (a 202 with a durable receipt), so it adds one fast API call to the request, not a delivery. If you can't afford even a lost event between commit and publish, write the event to an outbox table in the same transaction and publish from a worker; the Go tutorial shows the pattern.
6. Tell customers how to verify
Link your customers to a page that explains the headers and gives them verification code. The receiving guide is written for that audience and has Node.js and Go verifiers; any Standard Webhooks library works, including Svix's. Customers can test their verifier with the Standard Webhooks signature generator.
Next steps
- Monitor, retry and recover deliveries: statuses, health, replay after a pause and Functions that reshape a payload per customer.
- Outbound Gateway docs and plans and pricing.
- Webhook best practices: the checklist this setup implements.
- Migrating from another provider? See migrate to the Outbound Gateway.
Frequently asked questions
Can I call the Webhook Relay SDK from a Next.js client component?
No. The access token must stay on the server. Call the SDK only from server components, server actions and route handlers, and import the client module from a file marked server-only so a build fails if it is ever bundled for the browser.
How do I stop one customer from editing another customer's webhook endpoints?
Use your organization ID as the Webhook Relay consumer ID, and before every update, delete, retry or secret operation fetch the endpoint and check that its consumer field equals the signed-in user's organization ID. Never trust an endpoint ID from a form on its own.
Does Webhook Relay provide an embeddable webhook portal for my customers?
Not yet. You build the settings page yourself with the SDK, as in this tutorial, which keeps it in your own design system and auth. Everything a portal needs is in the API: event types, endpoints, secrets, delivery history, retry and health.
What should happen when a customer's endpoint keeps failing?
Webhook Relay marks the endpoint failing after 5 failed attempts in a row and keeps retrying each delivery for up to 48 hours. Show the endpoint state and recent failures on the settings page, let the customer retry or re-send failed deliveries after they fix their server, and enable auto-disable so an endpoint that fails 100 times in a row over at least 12 hours stops receiving traffic.
