Your own webhooks

Send signed webhooks from your app and receive them anywhere.

The named providers cover webhooks other services send you. This guide covers the other direction: your app emitting webhooks, to your own services or to your users. Both ends stay zero-dependency.

Sending

send() signs the payload in the Standard Webhooks format and POSTs it. Generate a secret once and share it with the receiver:

lib/notify.ts
import { send } from "next-webhooks";

export async function notifyPartner(reportId: string) {
  const res = await send({
    url: process.env.PARTNER_WEBHOOK_URL!,
    secret: process.env.OUTBOUND_WEBHOOK_SECRET!,
    payload: { type: "report.ready", data: { reportId } },
    retry: { attempts: 5 },
  });
  if (!res.ok) {
    // still failing after 5 attempts: queue it and try again later
  }
}

Any base64 value works as the secret. Generate one with: node -e "console.log('whsec_' + crypto.randomBytes(24).toString('base64'))"

Receiving

The receiver verifies with the svix() provider and the same secret, because send() speaks the Standard Webhooks format. If the receiver is not yours, they can use the svix SDK or any Standard Webhooks library instead:

app/api/webhooks/internal/route.ts
import { webhook, svix } from "next-webhooks";

export const POST = webhook({
  provider: svix({ name: "internal", secret: process.env.OUTBOUND_WEBHOOK_SECRET! }),
  handler: async (event) => {
    if (event.type === "report.ready") {
      // ...
    }
  },
});

Retries and deduplication

The retry option retries in process with exponential backoff and jitter: retry: 5 is shorthand for { attempts: 5 }, and delayMs, factor, maxDelayMs, and jitter tune the backoff. Retries cover network errors and transient statuses (408, 425, 429 honoring Retry-After, and 5xx); other non-2xx responses come back immediately, since resending an unauthorized delivery cannot succeed. Every attempt reuses the same webhook-id with a fresh signature, so the receiver dedupes an attempt that actually arrived.

For delivery that must survive a process crash, also check response.ok and call send() again later with the same id option. The receiver dedupes by that id, so a retry that crossed paths with a success is acknowledged instead of processed twice.

The lazy option: a shared token

For internal traffic where signing feels like overkill, token() checks a fixed secret header with a timing-safe comparison. A signature is still better (it also covers the body), but this beats the if (header === secret) everyone writes by hand:

app/api/webhooks/cron/route.ts
import { webhook, token } from "next-webhooks";

export const POST = webhook({
  provider: token({
    header: "x-webhook-token",
    secret: process.env.INTERNAL_WEBHOOK_TOKEN!,
  }),
  handler: async (event) => {
    // ...
  },
});

Need a scheme neither send() nor hmac() speaks? See Shopify and custom providers for implementing WebhookProvider yourself.