Plugins
Webhooks

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

payload.config.ts
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.

FieldTypeDescription
operations('create' | 'update' | 'delete')[]Which writes emit events. Default: all three.
includePreviousDatabooleanAdd a previousData key to the body on update events.
includeDraftsbooleanEmit for a deliberate save that leaves the document a draft. Default true. See Drafts and autosave.
transform({ doc, previousDoc, operation, req, target }) => unknownReshape 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 emits created. 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._status says 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: false on the collection to keep unpublished content from leaving at all. Only saves that leave the document published emit then, so an unpublish goes unannounced; deleted is unaffected.

Admin-managed subscriptions

The Webhook Subscriptions collection (slug webhook-subscriptions by default) is where operators register receivers:

  • name, url, and an enabled toggle. The URL must be absolute http: or https: 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 configure delivery.allowedHosts, so by default localhost and private addresses stay usable
  • events: a multi-select of every event the opted-in collections can emit
  • secret: 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 carries secret_set and secret_hint instead. 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 Signing
  • previousSecretExpiresAt: set while a rotation grace period is open, when deliveries carry two signatures
  • headers: 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), which fetch either 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 Security
  • description: 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:

payload.config.ts
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.

payload.config.ts
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.

On this page