Subscriptions
Opt collections in, manage subscribers in the admin, or register them in code.
A webhook fires when three things line up: a collection is opted in for the operation, and at least one enabled subscription listens for the resulting event.
Opting collections in
webhooks({
collections: {
posts: true, // shorthand: all three operations
orders: {
operations: ['create', 'delete'],
includePreviousData: true,
transform: ({ doc, operation }) => ({
id: doc.id,
status: doc.status,
operation,
}),
},
},
})The keys are typed against your generated CollectionSlug, so a typo or a global's slug is a type error instead of a source that compiles and never emits. Without generated types the key is a plain string, so the plugin also checks at startup: a slug that matches no registered collection throws rather than silently never emitting, and so does one of the plugin's own collections or a Payload internal one. A collection added by another plugin has to be registered before webhooks() runs.
| Field | Type | Description |
|---|---|---|
operations | ('create' | 'update' | 'delete')[] | Which writes emit events. Default: all three. |
includePreviousData | boolean | Add a previousData key to the body on update events. |
includeDrafts | boolean | Emit for a deliberate save that leaves the document a draft. Default true. See Drafts and autosave. |
transform | ({ doc, previousDoc, operation, req, target }) => unknown | Reshape or redact before sending. Applied to the body's data and, with includePreviousData, to previousData as well, so redaction covers both. target ('data' or 'previousData') names the slot; on the previousData call doc is the prior document. |
Events are named <collection>.<created|updated|deleted>: a create in posts emits posts.created. The hooks run afterChange and afterDelete, and a webhook is treated as a side effect of the write: if dispatching throws, a transform of yours included, the error is logged and the write still succeeds. transform runs once per event, before any delivery row is created, so a throw there leaves nothing half-written.
Drafts and autosave
On a collection with drafts enabled:
- Autosaves never emit. The admin autosaves every time an editor pauses, so emitting would send a stream of half-typed documents and fill the delivery log. The deliberate save that follows emits as usual. The admin also creates an empty draft the moment "Create new" is opened on an autosave collection, and that create does not emit either: the document's first deliberate save arrives as
updated. It is recognised as a draft create on an autosave collection made through the Local API on a signed-in user's request, so a create over REST or GraphQL, or a Local API create with no user (an import, a seed), still emitscreated. Your own code creating such a draft through the Local API on a user's behalf is indistinguishable from the admin's and is skipped with it. - A deliberate save that leaves the document a draft emits by default, and the body's
data._statussays which it was. That default exists because an unpublish is such a save, and a receiver that mirrors published content has to hear about it. - Set
includeDrafts: falseon the collection to keep unpublished content from leaving at all. Only saves that leave the document published emit then, so an unpublish goes unannounced;deletedis unaffected.
Admin-managed subscriptions
The Webhook Subscriptions collection (slug webhook-subscriptions by default) is where operators register receivers:
name,url, and anenabledtoggle. The URL must be absolutehttp:orhttps:with no embedded credentials (put a receiver token in a custom header instead); anything else is rejected on save rather than surfacing later as a dead delivery. The host is not judged unless you configuredelivery.allowedHosts, so by default localhost and private addresses stay usableevents: a multi-select of every event the opted-in collections can emitsecret: set on the create form only (generate one or supply your own), then write-only. It is encrypted at rest and stripped from every read; a read carriessecret_setandsecret_hintinstead. On an existing subscription the form shows the hint, read-only, so you can tell which key it holds, and the Rotate secret button beside Save is the only way to change it. See SigningpreviousSecretExpiresAt: set while a rotation grace period is open, when deliveries carry two signaturesheaders: extra key/value headers injected on every delivery to this subscription. The names the plugin sets (webhook-id,webhook-timestamp,webhook-signature,X-Webhook-Event,Content-Type,User-Agent) are reserved and rejected here, and so are the ones that belong to the HTTP transport (Host,Content-Length,Transfer-Encoding,Keep-Alive,Upgrade,Expect), whichfetcheither refuses or silently replaces. A name or URL that a save is not changing is not re-checked, so a rule introduced after a subscription was stored never stops that subscription being saved, rotated or switched off; the delivery path applies the rule when it fires. Values are encrypted at rest, since this is where a receiver's own credential goes (Authorization: Bearer ...), and masked in the admin with a reveal toggle; see Securitydescription: free-form notes
All operations on the collection require a user of the admin collection (admin.user), not just any logged-in user. See Access defaults.
Code subscriptions
For receivers that belong in code (your own services, environment-dependent URLs), register them on the plugin. No admin record is created:
webhooks({
collections: { orders: true },
subscriptions: [
{
id: 'my-crm',
url: 'https://crm.example.com/hooks/orders',
events: ['orders.created', 'orders.updated'],
secret: process.env.WEBHOOK_SECRET,
headers: { 'x-source': 'payload' },
},
],
})CodeSubscription fields: id, url, events, plus optional secret, headers, and enabled (default on). Code and admin subscriptions are merged at delivery time; each matching subscription gets its own delivery row, which records whether it came from code or from the collection, so an id that happens to equal a database id cannot be confused with that row.
secret must be in whsec_<base64> form, the same as an admin-managed one. A malformed value fails at startup, naming the subscription, rather than silently producing signatures no receiver can verify. Code subscriptions are not stored, so they are neither encrypted nor rotatable: change the environment variable and redeploy.
Renaming or hiding the collections
webhooks({
collections: { posts: true },
subscriptionsCollection: { slug: 'endpoints', hidden: false },
deliveriesLog: { slug: 'endpoint-deliveries', hidden: true },
})hidden: true keeps a collection out of the admin nav while it continues to function.
The subscriptions slug can be renamed freely: the stored secrets are bound to a pinned scope rather than to the slug, so a rename does not make them unreadable.
Collection overrides
Both collections take an overrides slot, the same shape the other plugins in this family use. Top-level keys spread over the plugin's defaults, access and admin merge key by key, your hooks append after the plugin's, and fields receives the defaults to compose with. The slug is not overridable here (it has its own option, and the plugin wires it into the delivery task and the endpoints).
Two parts of the plugin's own wiring are added after your override, so it cannot remove them: endpoints and admin.components.edit.beforeDocumentControls both add to the plugin's entries (the rotate-secret and redeliver endpoints and their buttons) rather than replacing them. The exception is endpoints: false, which turns the collection's REST surface off as it does on any collection, the plugin's endpoint and its button included. The write-only response strip also runs after your override, so an encryptedField you add through fields is stripped from reads like the plugin's own.
defaultFields contains unnamed layout rows (Name and URL share one), so compose by appending or by matching on a field's name rather than by position.
webhooks({
collections: { posts: true },
subscriptionsCollection: {
overrides: {
access: { update: ({ req }) => req.user?.role === 'admin' },
admin: { group: 'Integrations' },
fields: ({ defaultFields }) => [...defaultFields, { name: 'tenant', type: 'text' }],
},
},
deliveriesLog: {
overrides: { admin: { defaultColumns: ['event', 'status', 'createdAt'] } },
},
})Overriding access.update on the subscriptions collection also governs the rotate-secret endpoint, which defers to it.