Rotate a webhook secret
Rotating an endpoint's signing secret never drops a delivery. The gateway signs with both the new and the old secret for 24 hours, so your receiver keeps verifying while you change the stored value at your own pace.
How rotation works#
- You call the rotate route. The gateway mints a new primary secret and returns it exactly once.
- The previous secret becomes the secondary and keeps signing for 24 hours. Every delivery in that window carries two
v1=digests inHookChat-Signature, one per secret. - Your receiver accepts a delivery when any digest matches, so it verifies with the old secret, the new one, or both.
- When
secondary_expires_atpasses, only the new secret signs. Deliveries go back to onev1=.
HookChat-Signature: t=1756900000,v1=<digest under the new secret>,v1=<digest under the old secret>
HookChat-Timestamp: 1756900000Rotate#
The response is the same shape as endpoint creation: the endpoint plus its new plaintext signing_secret. Store it straight away; no read returns it again. secondary_expires_at on the endpoint tells you when the old secret stops verifying.
import { HookChat } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
const rotated = await client.webhooks.rotate(process.env.WEBHOOK_ID!)
console.log('new secret starts with', rotated.signing_secret.slice(0, 4))
console.log('old secret verifies until', rotated.endpoint.secondary_expires_at)Verify with either secret during the overlap#
A receiver that reads one secret from its environment keeps working through the overlap, because the header carries a digest under the old secret too. A receiver that can hold both is more forgiving still: it verifies whichever secret matches, so a deploy that updates one instance at a time never rejects a delivery.
import { createServer } from 'node:http'
import { verifyWebhook, HookChatSignatureError, type WebhookEvent } from '@hookchat/node'
// The current secret, plus the previous one while a rotation is in flight.
const secrets = [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET_PREVIOUS].filter(
(s): s is string => typeof s === 'string' && s.length > 0,
)
function verifyAny(raw: Buffer, headers: Record<string, string | string[] | undefined>): WebhookEvent {
let lastError: unknown
for (const secret of secrets) {
try {
return verifyWebhook(raw, headers, secret)
} catch (error) {
lastError = error
}
}
throw lastError
}
createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => {
try {
const event = verifyAny(Buffer.concat(chunks), req.headers)
console.log('verified', event.type, event.id)
res.writeHead(200).end()
} catch (error) {
if (error instanceof HookChatSignatureError) {
res.writeHead(401).end()
return
}
throw error
}
})
}).listen(Number(process.env.PORT))Confirm the overlap has ended#
The endpoint resource carries secondary_active and secondary_expires_at. Once secondary_active is false you can drop the previous secret from your configuration.
import { HookChat } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
const endpoint = await client.webhooks.get(process.env.WEBHOOK_ID!)
console.log('secondary_active', endpoint.secondary_active, 'until', endpoint.secondary_expires_at)
console.log('current secret prefix', endpoint.signing_secret_prefix)