Plugins
Webhooks

Quick start

Install the plugin, opt a collection in, and deliver your first webhook.

Install

pnpm add @10x-media/webhooks @10x-media/fields

@10x-media/fields is a peer dependency: signing secrets are stored through its encrypted field, whose admin components resolve through your import map, and under pnpm an app cannot import a transitive dependency. You do not have to register the fields() plugin.

Configure

Opt collections in. That is the whole minimum config:

payload.config.ts
import { buildConfig } from 'payload'
import { webhooks } from '@10x-media/webhooks'

export default buildConfig({
  // ...
  plugins: [
    webhooks({
      collections: {
        posts: true, // create, update, and delete
      },
    }),
  ],
})

The plugin adds a Webhooks group to the admin with two collections: Subscriptions (endpoint records) and Deliveries (the log).

Create a subscription

In the admin, create a Subscription: a name, the receiver URL, and which events it listens to (posts.created, posts.updated, posts.deleted). Use Generate for the signing secret, or paste your own in whsec_... form; either way, copy it to your receiver before you save. It is encrypted at rest and never shown again. Save without one and a secret is generated for you that the admin never shows; Rotate secret then issues one you can copy. An API create with no secret gets one generated and returned once, as generatedSecret on the create response. See Signing.

Verify

Create a posts document. The plugin's afterChange hook records a delivery and POSTs a JSON body to your URL:

{
  "id": "msg_<delivery-id>",
  "event": "posts.created",
  "collection": "posts",
  "operation": "create",
  "occurredAt": "2026-01-01T00:00:00.000Z",
  "data": { "...": "the document" }
}

Open Webhook Deliveries in the admin: the row shows the event, status (success, or failed/dead on errors), HTTP response code, and duration. The Redeliver button replays the payload as a new delivery.

Verify the signature

Deliveries are signed with Standard Webhooks, so your receiver can use an off-the-shelf verifier:

receiver.ts
import { Webhook } from 'standardwebhooks'

const wh = new Webhook(process.env.WEBHOOK_SECRET!) // the whsec_... value

export async function POST(req: Request) {
  const raw = await req.text() // the raw body, before any JSON parsing
  try {
    const payload = wh.verify(raw, {
      'webhook-id': req.headers.get('webhook-id') ?? '',
      'webhook-timestamp': req.headers.get('webhook-timestamp') ?? '',
      'webhook-signature': req.headers.get('webhook-signature') ?? '',
    })
    // payload is the parsed body, already verified
    return Response.json({ ok: true })
  } catch {
    return new Response('bad signature', { status: 400 })
  }
}

verify throws on a bad signature or a timestamp outside its five-minute tolerance, so catch it and answer 400. Without the try, a forged or stale request becomes an unhandled 500, which is harder to tell apart from your own bugs. See Signing for the scheme itself and for verifying without a library.

If nothing is delivered

With no job runner configured, delivery: 'auto' runs deliveries inline inside the hook, so they should fire immediately. If you set delivery: 'queue' (or installed Jobs, which switches auto to queue), deliveries wait as pending until a worker or payload.jobs.run() processes them. See Deliveries.

On this page