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#

  1. You call the rotate route. The gateway mints a new primary secret and returns it exactly once.
  2. The previous secret becomes the secondary and keeps signing for 24 hours. Every delivery in that window carries two v1= digests in HookChat-Signature, one per secret.
  3. Your receiver accepts a delivery when any digest matches, so it verifies with the old secret, the new one, or both.
  4. When secondary_expires_at passes, only the new secret signs. Deliveries go back to one v1=.
A delivery during the overlap
HookChat-Signature: t=1756900000,v1=<digest under the new secret>,v1=<digest under the old secret>
HookChat-Timestamp: 1756900000

Rotate#

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.

TypeScript
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)
Rotating twice inside 24 hours replaces the secondary. A receiver still holding the original secret then stops verifying. Roll the new secret out to every receiver before you rotate again.

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.

TypeScript
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.

TypeScript
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)

Next#