Webhook — Rotate HMAC Secret (admin)
Generate a new cryptographically random HMAC signing secret for a webhook endpoint. The plaintext value is returned EXACTLY ONCE — subsequent GETs redact it. Subscribers must update their verification config before the next delivery.
POST /api/v1/admin/webhooks/:id/rotate-secret — 👤 Admin · permission config:write · Throttle: 10 req / hour
Generates a new cryptographically random HMAC signing secret for a WebhookEndpoint, persists it, and returns the plaintext value exactly once. The old secret is invalidated immediately — in-flight retries after rotation will use the new secret and fail subscriber-side verification until the subscriber updates their config.
One-time read. The plaintext secret is returned only in the rotation response. Subsequent GET /admin/webhooks calls redact it. If the admin closes the rotation panel without copying the secret, only another rotation recovers it — there is no retrieval endpoint.
Rotation is destructive. In-flight deliveries already signed with the previous secret will continue to use it. Retries after rotation re-read the WebhookEndpoint row, so they use the NEW secret and will fail subscriber-side HMAC verification until the subscriber updates their config. Coordinate the rotation with subscriber owners.
Tighter throttle than other writes. 10 req / hour — matches the test-ping budget. Rotation is destructive (old secret invalidated immediately), so a runaway script can't grind through endpoints before someone intervenes.
Request
Path parameters
| Param | Type | Notes |
|---|---|---|
id | string (UUID) | WebhookEndpoint.id |
No body.
Headers
| Header | Required | Notes |
|---|---|---|
Cookie: admin_session=... | ✓ | Admin session cookie |
X-CSRF-Token | ✓ | CSRF guard (admin write) |
Throttle / permission
| Layer | Limit |
|---|---|
| Permission | config:write |
| Throttle | 10 req / hour |
| HTTP code | 201 |
Response
201 Created — ApiResponseOf<WebhookSecretRotatedDto>
{
"success": true,
"data": {
"secret": "a1b2c3d4e5f6...",
"rotatedAt": "2026-06-22T14:00:00.000Z"
}
}| Field | Type | Notes |
|---|---|---|
secret | string | The new HMAC signing secret. Returned ONCE — save it now; subsequent GET /admin/webhooks responses redact it. |
rotatedAt | string (ISO 8601) | WebhookEndpoint.updatedAt after the rotation write |
Errors
| HTTP | Reason / i18nKey |
|---|---|
401 | Missing / invalid admin session |
403 | Permission denied (config:write required) |
404 | webhook.endpoint_not_found — no WebhookEndpoint row with the supplied ID |
429 | Throttle exceeded (10 req / hour) |
Side effects
webhookEndpoint.findUnique({ where: { id } })—404if missing.- Generate a new secret via
generateSecret()(cryptographically random hex). webhookEndpoint.update({ where: { id }, data: { secret } })— old secret overwritten in place.- Return
{ secret, rotatedAt: updated.updatedAt.toISOString() }. - Audit log row via the controller-level
AdminAuditInterceptor(request body redaction applies).
In-flight WebhookDelivery retries will read the new secret on next dispatch (the endpoint row is re-read per attempt), so a retry signed pre-rotation that hasn't fired yet will sign with the new value and fail subscriber verification until the subscriber updates their config.
Subscriber HMAC verification
Subscribers verify the X-Webhook-Signature header against the raw POST body using the secret distributed at endpoint creation / rotation:
X-Webhook-Signature: hmac-sha256-hex-stringThe signature is computed server-side as signPayload(JSON.stringify(payload), endpoint.secret). Subscribers should compute the same and compare in constant time. A pre-rotation signature against a post-rotation secret will not match.
Code samples
curl -X POST 'https://api.bio.re/api/v1/admin/webhooks/wh-uuid/rotate-secret' \
-H 'Cookie: admin_session=...' \
-H 'X-CSRF-Token: ...'type RotatedSecret = {
secret: string;
rotatedAt: string;
};
async function rotateWebhookSecret(endpointId: string, csrfToken: string): Promise<RotatedSecret> {
const res = await fetch(`https://api.bio.re/api/v1/admin/webhooks/${endpointId}/rotate-secret`, {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': csrfToken },
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Rotate failed'), {
code: json?.error?.code,
});
}
return json.data;
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/webhook/webhook.controller.ts | 127–142 (rotateSecret) |
| Response DTO | apps/api-core/src/modules/webhook/dto/webhook-response.dto.ts | 154–163 (WebhookSecretRotatedDto) |
| Service | apps/api-core/src/modules/webhook/webhook.service.ts | 153–164 (rotateSecret) |
| Secret generator | apps/api-core/src/modules/webhook/webhook.service.ts | generateSecret() — randomBytes(32).toString('hex') |
| HMAC signer | apps/api-core/src/modules/webhook/webhook.service.ts | signPayload(payload, secret) — createHmac('sha256', secret).update(payload).digest('hex') |
| Prisma model | packages/prisma/prisma/schema.prisma | WebhookEndpoint.secret (2952), WebhookDelivery (2965) |
| Audit interceptor | apps/api-core/src/modules/admin/middleware/audit.interceptor.ts | Auto-logs rotation calls (with body redaction) |
| Live response | NOT verified — sourced from DTO at dto/webhook-response.dto.ts:154–163 |
SMS Cost Log + Email Cost Estimate (admin)
Three finance-facing endpoints for tracking outbound notification spend. SmsCostLog provides per-message Twilio/Vonage rows; the summary endpoint groups them by country / provider / event / day. Email cost is computed analytically from NotificationEvent counts × per-1k contract rate.
Admin Tools — Health, Failure Trend, Job Detail
Three observability endpoints powering the admin notification operations view. Health = subsystem checks (stuck jobs, queue depth, rollup freshness, dead-job watchdog). Failure trend = hourly fail counts for the last N days. Job detail = full row for one NotificationJob.