Webhooks
The server tells your system about changes itself.
Why
The SDK reads secrets at service startup. That is enough while values change rarely: restart — pick up the new one.
A webhook is needed when you must react immediately: re-read the config without a restart, raise an alert on a failed rotation, learn that a one-time token was presented a second time.
How to set one up
The project's Webhooks section — or a request:
curl -s $BASE/projects/$PROJECT/webhooks \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"url":"https://ops.example.com/envmaster","eventTypes":["entry.updated","rotation.failed","enrollment.reuse-detected"]}'
You need an address, a list of events and, if you want, a list of
environments (environments) — empty means "all".
The signing secret is returned in the response once, at creation. In
the database it is stored encrypted and is never shown again: if it is
lost, the webhook has to be created anew. The required permission is the
project admin; the last 50 deliveries with statuses are visible in the
dashboard or via GET /projects/:id/webhooks/:id/deliveries.
What arrives
A POST with a body:
{
"event": "entry.updated",
"projectId": "prj_...",
"environment": "prod",
"occurredAt": "2026-08-27T10:15:00.000Z",
"metadata": { "key": "DB_PASSWORD", "kind": "secret", "version": 4 }
}
There are no secret values in the body, and there never will be. You get the key name, its type, and the version number — enough to decide whether to re-read the config, and not enough for a leak into the receiver's log to become a secret leak.
Verifying the signature
The x-signature header contains sha256=<hex> — an HMAC-SHA256 of the
request body under your secret.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header ?? "");
const b = Buffer.from(expected);
// Длины сравниваются отдельно: timingSafeEqual бросает на разных длинах.
return a.length === b.length && timingSafeEqual(a, b);
}
Compute the HMAC over the raw body, before parsing the JSON. JSON reassembled from an object almost certainly differs in whitespace or field order, and the signature will not match.
Compare in constant time. Plain === on strings exits early at the first
mismatched byte, and the signature can be recovered from response timing.
Not verifying the signature means accepting such a request from anyone. The webhook address will sooner or later end up in logs, in browser history, or in someone's screenshot.
Events
| Event | When it arrives |
|---|---|
entry.created | a new key appeared in the environment |
entry.updated | the value changed, including a rollback to a past version |
entry.deleted | the key was deleted |
entry.rotated | rotation completed successfully |
rotation.failed | rotation failed; the value stayed the same |
secret.near-expiry | a secret is close to expiring |
enrollment.reuse-detected | an enrollment token was presented a second time |
validation.failed | the environment failed schema validation |
The identity.login type is registered in the list of allowed events but
is currently not sent — watch identity logins in the audit log
(identity.login, with a newIp flag on a new address). Subscribing to
it is pointless; there will be no deliveries.
Two events are worth wiring up even if you need none of the others.
rotation.failed means the automatic replacement failed and the service
is running on the old value. Without a notification this comes out on the
day the old value stops working.
enrollment.reuse-detected means the one-time token was used twice. One
of the two is not you. The server has already refused the second exchange
by then, but it does not revoke the identity itself — that is your
action: revoke the identity and issue a new one. The full analysis is on
the Services and identities page.
Delivery and retries
The first attempt is immediate. On failure (a network error or a non-2xx response) the server retries with growing pauses: 1 s, 10 s, 1 min, 5 min, up to five attempts in total.
Respond with 2xx as early as possible, before any heavy work. A receiver
that rebuilds a container first and only then responds will rebuild it
five times.
The handler must be idempotent: a retry is the same delivery, not a new event.
Where webhooks can go
HTTPS only. Internal, loopback, and link-local addresses are rejected in production: a server that can be pointed at any address becomes a scanner of your network in someone else's hands.
The address is checked by IP after name resolution, not by text —
otherwise the ban would be bypassed with a domain pointing at 127.0.0.1.
The check is repeated before every delivery attempt, not only at save
time: DNS that changed after setup does not open the door.
Shared secrets
If an environment reads another project's keys via a reference, a change
to a shared value is delivered to its webhooks too — with a fromProjectId
field in the metadata, so it is visible that the key came from outside.
If the key is overridden by a local value, the event is not delivered: for this environment nothing has changed. Details — in the Shared secrets section.
Disabling without deleting
A webhook has a switch (PATCH /webhooks/:id, field enabled). A
disabled webhook keeps its address, secret, and subscriptions but receives
no deliveries — handy when the receiver is under maintenance and there is
no point flooding it with retries.