Webhooks on secret change
Get a signed HTTPS call when a secret is created, changed, rotated or deleted, or an environment is promoted or restored. Payloads carry names, never values.
Updated 2026-10-21 · read this page as markdown
On this page
A webhook calls an HTTPS endpoint of yours when something changes in your organisation's secrets. Pro includes 3 endpoints, Team 25, Enterprise as many as you need. Set them up in Organisation settings → Webhooks (owners and admins).
Events
| Event | When |
|---|---|
secret.created | A secret is created — directly, by an import, a promotion or a restore |
secret.updated | A secret's value changes, or it is renamed |
secret.deleted | A secret is deleted — directly, or by a promotion or a restore |
secret.rotated | An earlier version is made current again |
environment.promoted | A promotion lands, with counts |
environment.restored | A point-in-time restore lands, with counts |
Writing a value identical to the current one changes nothing and sends nothing. Each endpoint subscribes to the events it wants.
One event per kind of change, not per secret
An import, a promotion or a restore can change hundreds of secrets in one go.
It sends one event of each kind it caused — one secret.created for
everything it created, one secret.updated, one secret.deleted — listing the
secrets in data.secrets, with data.counts saying how many:
{
"type": "secret.created",
"timestamp": "2026-10-20T09:30:00.000Z",
"data": {
"organization": { "id": "0193…", "slug": "acme" },
"project": { "id": "0193…", "slug": "checkout-api" },
"environment": { "id": "0193…", "slug": "staging" },
"secrets": [
{ "name": "SENTRY_DSN", "version": 1 },
{ "name": "STRIPE_WEBHOOK_SECRET", "version": 1 }
],
"counts": { "created": 2 },
"actor": { "type": "user", "id": "0193…", "label": "dana@acme.example" }
}
}An event about one secret carries data.secret; an event about several carries
data.secrets instead — never both — so handle both shapes. The list holds at
most 1,000 entries, which is also the most one batch can change.
The payload never contains a value
Names, slugs, version numbers, counts and who did it — the same vocabulary as the audit log. Anybody who wants the value fetches it with a credential, and is audited doing so.
{
"type": "secret.updated",
"timestamp": "2026-10-20T09:30:00.000Z",
"data": {
"organization": { "id": "0193…", "slug": "acme" },
"project": { "id": "0193…", "slug": "checkout-api" },
"environment": { "id": "0193…", "slug": "production" },
"secret": { "name": "STRIPE_SECRET_KEY", "version": 7 },
"actor": { "type": "user", "id": "0193…", "label": "dana@acme.example" }
}
}Verifying the signature
Every delivery is signed with the Standard Webhooks
scheme — the same one xecret consumes from its own payment provider — using the
endpoint's whsec_… secret. It is shown once, when the endpoint is created
or its secret rotated; xecret stores it encrypted and never shows it again.
Three headers arrive with each request: webhook-id, webhook-timestamp (Unix
seconds) and webhook-signature (v1, and a base64 HMAC-SHA256). Sign
id.timestamp.body with the base64-decoded part of the secret after whsec_,
compare in constant time, and reject a timestamp more than five minutes away
from your clock:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret: string, headers: Headers, body: string): boolean {
const id = headers.get('webhook-id') ?? '';
const timestamp = headers.get('webhook-timestamp') ?? '';
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest();
return (headers.get('webhook-signature') ?? '')
.split(' ')
// Only `v1,` signatures count: anything else is a scheme this code does
// not verify, not a signature to compare.
.filter((part) => part.startsWith('v1,'))
.map((part) => Buffer.from(part.slice('v1,'.length), 'base64'))
.some((given) => given.length === expected.length && timingSafeEqual(given, expected));
}Verify against the raw body, before parsing it. The official Standard Webhooks libraries do all of this for you.
Delivery and retries
A 2xx response is a success; anything else — including a redirect, which is
never followed — is a failure. Each attempt waits up to ten seconds. A failed
delivery is retried with backoff — 1 minute, 5 minutes, 30 minutes, 2 hours, 6
hours, 12 hours and 24 hours after each failure, give or take the five minutes
between delivery sweeps — then abandoned. A retry sends the same body,
re-signed with a fresh timestamp, under the same webhook-id, so use it to
discard duplicates.
The first few deliveries of a change are sent straight away; anything left over, and every retry, is sent by the next delivery sweep. A sweep takes organisations in turns — every organisation's oldest delivery before anybody's second — so another organisation's large backlog never holds yours up, and keeps sending until nothing is due or its time is up, when the rest waits for the next sweep.
An endpoint that keeps failing — 15 failures in a row spanning at least a day — is disabled automatically, and the organisation's owners and admins are emailed. Re-enable it from the dashboard once it is fixed.
Each endpoint has a delivery log — status, attempts, the response code, the
body sent — and a Send test button that sends one webhook.test delivery.
Endpoint URLs must be https://, and may not point at a private, loopback or
link-local address.
If your plan changes
Your endpoints are kept. On a plan with no webhooks they are paused — nothing is sent, and the dashboard says why — and resume when the plan includes them again.
Next
- Promotion and restore — the environment-wide events.
- The audit log — the same facts, kept forever.