Signing and secrets
Reveal-once secrets, request headers, and verifying signatures in your receiver.
When a subscription has a secret, every delivery is signed so your receiver can prove the request came from your Payload instance and was not replayed with a different body.
Secrets are reveal-once
Creating a subscription in the admin auto-generates a 48-character hex secret (24 random bytes). The create response shows it in full, exactly once. Every later read, through the admin, REST, or GraphQL, returns __redacted__, so the secret cannot leak through a screen share, an API response, or an export after that first look. The raw value is still used server-side to sign deliveries, but it never leaves the server again.
To rotate a secret, delete the subscription and create a new one. Code subscriptions supply their secret from your environment instead.
Secrets are currently stored unencrypted at rest. Encryption at rest is planned. Treat database access as secret access.
Request headers
Every delivery carries:
Content-Type: application/json
User-Agent: 10x-media-webhooks
X-Webhook-Id: <delivery-id>
X-Webhook-Event: posts.created
X-Webhook-Timestamp: <unix-seconds>
X-Webhook-Signature: v1=<hex> (only when the subscription has a secret)Plus any custom headers configured on the subscription.
The signature scheme
The signature is HMAC-SHA256 over ${timestamp}.${rawBody}, hex-encoded, where timestamp is the value of X-Webhook-Timestamp and rawBody is the exact request body string. Binding the timestamp into the MAC lets receivers reject stale replays.
Verifying in your receiver
Read the raw body before any JSON parsing, compute the expected signature, and compare in constant time:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyWebhook(args: {
secret: string
timestamp: string // X-Webhook-Timestamp
rawBody: string // the unparsed request body
signature: string // X-Webhook-Signature
toleranceSeconds?: number
}): boolean {
const { secret, timestamp, rawBody, signature, toleranceSeconds = 300 } = args
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!Number.isFinite(age) || age > toleranceSeconds) return false
const expected = `v1=${createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')}`
const a = Buffer.from(signature)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}This scheme is intentionally different from Form Builder's signedWebhook action, which signs the body alone under an X-Form-Signature header. A receiver cannot mistake one for the other.