Runqora UI
Webhooks

Connect a backend

Write the WebhookSource the webhook components call, with your own code or a service such as Svix.

The webhook components don't send webhooks and don't keep any data. Every call goes through a WebhookSource, an object of async functions that you write for whatever sends your webhooks: your own code and database, or a service such as Svix.

npx shadcn@latest add @runqora/webhooks

That adds lib/webhooks/types.ts with WebhookSource and the types it uses. The components install it too.

The contract

Each function returns a WebhookResult: { ok: true, data } or { ok: false, error }. The error is shown to people as it is, so write it for them. Errors are values rather than exceptions because Next.js hides the message of an error thrown by a server action in production.

CallUsed byNeeded
listEndpointsEndpoints, and the log's endpoint filterFor the endpoint manager
createEndpointAdd endpointNo
updateEndpointEdit, on/off switchNo
deleteEndpointDeleteNo
revealSecretSigning secretNo
rotateSecretSigning secretNo
sendTestEventSend test eventNo
listDeliveriesDelivery logFor the delivery log
getDeliveryDelivery detailsFor the details
resendDeliveryResendNo

Leave a call out and the components hide what depends on it.

Keep the source the same object between renders, such as a module-level constant or useState(() => createSource()). The components load data when the source's functions change, so an object rebuilt on every render of a client component would load forever.

With Next.js server actions

Write the calls as server actions, then pass them to the components as an object. Every server action is a public endpoint that anyone can call, so each one starts by checking who is asking:

lib/webhooks/actions.ts
"use server";

import type { WebhookEndpoint, WebhookResult } from "@/lib/webhooks/types";

/** The account the signed-in person may manage webhooks for. */
async function authorize(): Promise<{ accountId: string }> {
  // Read the session, check the person's role, and return their account.
  throw new Error("Write the authorization check first.");
}

export async function listEndpoints(): Promise<WebhookResult<WebhookEndpoint[]>> {
  try {
    const { accountId } = await authorize();
    return { ok: true, data: await yourBackend.listEndpoints(accountId) };
  } catch (error) {
    console.error(error);
    return { ok: false, error: "The endpoints couldn't be loaded." };
  }
}
lib/webhooks/source.ts
import {
  createEndpoint,
  getDelivery,
  listDeliveries,
  listEndpoints,
  resendDelivery,
} from "@/lib/webhooks/actions";
import type { WebhookSource } from "@/lib/webhooks/types";

export const webhookSource: WebhookSource = {
  createEndpoint,
  getDelivery,
  listDeliveries,
  listEndpoints,
  resendDelivery,
};

A server component can pass webhookSource to the components as it is.

Security checklist

These are your backend's job. A service such as Svix does the sending part for you.

  • Check who is asking in every call, and only return or change that account's endpoints and deliveries, whatever ids the browser sends. Answer "doesn't exist" for ids outside the account, so their existence doesn't leak.
  • Keep signing secrets out of listEndpoints. Return them only from createEndpoint, revealSecret and rotateSecret, to people allowed to manage webhooks.
  • Check every input on the server: URLs (https://, a sensible length, no user name or password), event types against your own list, and ids.
  • Don't let a URL reach your own network. Customers choose the URLs your server sends requests to, and see what they answer. Refuse private, loopback and link-local addresses, including cloud metadata addresses such as 169.254.169.254, and your own servers' public addresses. Check the address when the request connects, not only when the URL is saved, as DNS can change in between. Don't follow redirects. See Standard Webhooks on SSRF.
  • Limit each request, such as 15 seconds for the whole request, and keep only the start of each response.
  • Sign webhooks with a secret per endpoint, for example as Standard Webhooks do, and store the secrets encrypted.
  • Limit resends and test events per account, so they can't be used to flood someone else's server.

With Svix

In Svix, each of your customers is an application, and the components map onto its API one call each. appId is the customer's Svix application, from authorize().

CallSvix SDK
listEndpointssvix.endpoint.list(appId), following its iterator until done. enabled is !disabled, and eventTypes is filterTypes ?? [].
createEndpointsvix.endpoint.create(appId, { url, description, filterTypes }), then svix.endpoint.getSecret(appId, id) for its secret. Leave filterTypes out for every event: Svix refuses an empty list.
updateEndpointsvix.endpoint.patch(appId, id, { url, description, filterTypes, disabled }), with filterTypes: null for every event.
deleteEndpointsvix.endpoint.delete(appId, id)
revealSecretsvix.endpoint.getSecret(appId, id), returning its key
rotateSecretsvix.endpoint.rotateSecret(appId, id). The old secret keeps working for 24 hours. Return null and let people reveal the new one.
sendTestEventsvix.endpoint.sendExample(appId, endpointId, { eventType })
listDeliveriessvix.messageAttempt.listAttemptedMessages(appId, endpointId, { status, eventTypes, iterator }). Status 0 is succeeded, 1 and 3 pending, 2 failed. The cursor is the iterator, and the last page has done.
getDeliverysvix.message.get(appId, msgId) for the payload, and svix.messageAttempt.listByMsg(appId, msgId, { endpointId, withContent: true }) for the attempts. The status comes from the newest attempt, as a message has no status of its own.
resendDeliverysvix.messageAttempt.resend(appId, msgId, endpointId)

Svix lists deliveries one endpoint at a time, so give the delivery log requireEndpoint: it opens on the first endpoint and always keeps one in its filter. A delivery is a message sent to an endpoint, so build its id from both, such as `${endpointId}:${msgId}`.

<WebhookDeliveryLog requireEndpoint source={webhookSource} />

Without Next.js

The components only call functions, so a source can just as well call your API with fetch:

import type { WebhookSource } from "@/lib/webhooks/types";

export const webhookSource: WebhookSource = {
  async listDeliveries({ cursor, filter }) {
    const response = await fetch("/api/webhooks/deliveries", {
      body: JSON.stringify({ cursor, filter }),
      method: "POST",
    });
    return response.ok
      ? { ok: true, data: await response.json() }
      : { ok: false, error: "The deliveries couldn't be loaded." };
  },
  async listEndpoints() {
    const response = await fetch("/api/webhooks/endpoints");
    return response.ok
      ? { ok: true, data: await response.json() }
      : { ok: false, error: "The endpoints couldn't be loaded." };
  },
};

WebhookSource

Prop

Type

On this page