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:
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:
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.