BIO.RE
Notifications

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

ParamTypeNotes
idstring (UUID)WebhookEndpoint.id

No body.

Headers

HeaderRequiredNotes
Cookie: admin_session=...Admin session cookie
X-CSRF-TokenCSRF guard (admin write)

Throttle / permission

LayerLimit
Permissionconfig:write
Throttle10 req / hour
HTTP code201

Response

201 CreatedApiResponseOf<WebhookSecretRotatedDto>

{
  "success": true,
  "data": {
    "secret": "a1b2c3d4e5f6...",
    "rotatedAt": "2026-06-22T14:00:00.000Z"
  }
}
FieldTypeNotes
secretstringThe new HMAC signing secret. Returned ONCE — save it now; subsequent GET /admin/webhooks responses redact it.
rotatedAtstring (ISO 8601)WebhookEndpoint.updatedAt after the rotation write

Errors

HTTPReason / i18nKey
401Missing / invalid admin session
403Permission denied (config:write required)
404webhook.endpoint_not_found — no WebhookEndpoint row with the supplied ID
429Throttle exceeded (10 req / hour)

Side effects

  1. webhookEndpoint.findUnique({ where: { id } })404 if missing.
  2. Generate a new secret via generateSecret() (cryptographically random hex).
  3. webhookEndpoint.update({ where: { id }, data: { secret } }) — old secret overwritten in place.
  4. Return { secret, rotatedAt: updated.updatedAt.toISOString() }.
  5. 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-string

The 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

SourcePathLines
Controllerapps/api-core/src/modules/webhook/webhook.controller.ts127–142 (rotateSecret)
Response DTOapps/api-core/src/modules/webhook/dto/webhook-response.dto.ts154–163 (WebhookSecretRotatedDto)
Serviceapps/api-core/src/modules/webhook/webhook.service.ts153–164 (rotateSecret)
Secret generatorapps/api-core/src/modules/webhook/webhook.service.tsgenerateSecret()randomBytes(32).toString('hex')
HMAC signerapps/api-core/src/modules/webhook/webhook.service.tssignPayload(payload, secret)createHmac('sha256', secret).update(payload).digest('hex')
Prisma modelpackages/prisma/prisma/schema.prismaWebhookEndpoint.secret (2952), WebhookDelivery (2965)
Audit interceptorapps/api-core/src/modules/admin/middleware/audit.interceptor.tsAuto-logs rotation calls (with body redaction)
Live responseNOT verified — sourced from DTO at dto/webhook-response.dto.ts:154–163

On this page