Webhook secret rotation gets a lot of engineering attention. What gets less attention is what happens during the window between issuing a new secret and your customers deploying code that uses it. In that gap — which can be hours or days for a large enterprise customer — you have a problem: events signed with the new key will fail verification on the consumer side.
The standard mitigation is "send both signatures simultaneously, one per key." But this is awkward to communicate, awkward to implement consistently, and doesn't scale when you're operating webhook infrastructure for hundreds of customers with different deployment cadences.
Secret versioning is the cleaner answer. Instead of treating signing secrets as a single active credential, you maintain a versioned keyring where multiple keys can be valid simultaneously. Consumers verify against all current versions in order. Old keys expire on a schedule. You get smooth, gradual rotation without a hard cutover — and without a support ticket storm.
The Core Model
A versioned signing secret has three fields beyond the key material itself:
| Field | Type | Purpose |
|---|---|---|
version | integer or string | Monotonically increasing identifier |
active_from | timestamp | When this key becomes valid for signing |
expires_at | timestamp | When this key stops being accepted for verification |
is_primary | boolean | Which key to use when signing new events |
At any point in time, a destination has exactly one primary key (used for signing outbound events) and zero or more secondary keys (accepted for verification but not used for new signatures). Secondary keys are old primaries that haven't expired yet.
{
"destination_id": "dst_01HWXYZ...",
"signing_keys": [
{
"version": 3,
"key_prefix": "whsec_v3_...",
"active_from": "2026-04-15T00:00:00Z",
"expires_at": null,
"is_primary": true
},
{
"version": 2,
"key_prefix": "whsec_v2_...",
"active_from": "2026-03-01T00:00:00Z",
"expires_at": "2026-05-01T00:00:00Z",
"is_primary": false
}
]
}Both keys are currently valid for verification. Version 3 is used to sign new events. Version 2 will stop being accepted on May 1st — giving consumers six weeks to update.
Signing: Always Use the Primary Key
Signing is the easy part. When your delivery worker sends an event, it looks up the primary key for that destination and uses it. One key, one signature, no ambiguity.
type SigningKey struct {
Version int
Secret []byte
IsPrimary bool
ExpiresAt *time.Time
}
func (kr *KeyRing) PrimaryKey() (*SigningKey, error) {
for _, k := range kr.Keys {
if k.IsPrimary {
return &k, nil
}
}
return nil, fmt.Errorf("no primary signing key configured")
}
func SignPayload(payload []byte, key *SigningKey) string {
timestamp := time.Now().Unix()
mac := hmac.New(sha256.New, key.Secret)
fmt.Fprintf(mac, "%d.%s", timestamp, payload)
sig := hex.EncodeToString(mac.Sum(nil))
return fmt.Sprintf("t=%d,v%d=%s", timestamp, key.Version, sig)
}Notice the signature format: t=<unix>,v<version>=<hex>. Including the version number in the signature header is critical — it tells the consumer which key to use for verification without requiring them to try all active keys blindly.
The Stripe-compatible format uses v1= unconditionally. If you're building your own gateway, using v<N>= gives consumers a precise lookup instead of a linear scan.
Verification: Try the Indicated Version First, Fall Back to All Active Keys
On the consumer side, verification has two strategies depending on how much you trust the version tag in the header.
Strategy 1: Strict version lookup (preferred)
Parse the version from the signature header, look up exactly that key, verify. If the key doesn't exist or is expired, reject.
func VerifySignature(payload []byte, sigHeader string, keyring map[int][]byte) error {
// Parse: "t=1713830400,v3=abc123def456..."
parts := parseSignatureHeader(sigHeader)
ts, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp in signature")
}
// Reject replays older than 5 minutes
if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
return fmt.Errorf("signature timestamp too old")
}
// Find the versioned signature (v1, v2, v3, ...)
var version int
var receivedSig string
for k, v := range parts {
if strings.HasPrefix(k, "v") {
n, err := strconv.Atoi(strings.TrimPrefix(k, "v"))
if err == nil {
version = n
receivedSig = v
}
}
}
secret, ok := keyring[version]
if !ok {
return fmt.Errorf("unknown key version: %d", version)
}
mac := hmac.New(sha256.New, secret)
fmt.Fprintf(mac, "%d.%s", ts, payload)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(receivedSig), []byte(expected)) {
return fmt.Errorf("signature mismatch")
}
return nil
}Strategy 2: Try all active keys (fallback for legacy consumers)
For consumers that received events before you introduced versioning, you may have sent signatures without version tags. In that case, try each active key until one matches:
func VerifySignatureFallback(payload []byte, sigHeader string, keys [][]byte) error {
parts := parseSignatureHeader(sigHeader)
ts, _ := strconv.ParseInt(parts["t"], 10, 64)
receivedSig := parts["v1"] // legacy unversioned format
if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
return fmt.Errorf("signature timestamp too old")
}
toSign := fmt.Sprintf("%d.%s", ts, payload)
for _, secret := range keys {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(toSign))
expected := hex.EncodeToString(mac.Sum(nil))
if hmac.Equal([]byte(receivedSig), []byte(expected)) {
return nil
}
}
return fmt.Errorf("no valid key found for signature")
}The fallback approach works but has a subtle security property: you can't distinguish "wrong key" from "tampered payload" without also logging which key matched. Prefer the strict version lookup in new integrations.
Schema Design for a Versioned Keyring
If you're building the server side — the component that issues and rotates keys — you need a table that supports multiple active keys per destination.
CREATE TABLE destination_signing_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
destination_id UUID NOT NULL REFERENCES destinations(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
secret_ciphertext TEXT NOT NULL, -- encrypted at rest
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (destination_id, version)
);
-- Fetch all active keys for a destination (primary first)
CREATE INDEX idx_dsk_active ON destination_signing_keys (destination_id, expires_at)
WHERE expires_at IS NULL OR expires_at > NOW();The query to load a destination's active keyring at delivery time:
SELECT version, secret_ciphertext, is_primary
FROM destination_signing_keys
WHERE destination_id = $1
AND active_from <= NOW()
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY is_primary DESC, version DESC;ORDER BY is_primary DESC puts the primary key first so your delivery code can take rows[0] directly.
The Rotation Workflow
With this model, rotation becomes a four-step process instead of a hard cutover:
Step 1: Generate a new key, add it as non-primary
The new key has is_primary = false and active_from = NOW(). Both keys are now valid for verification. Nothing changes for consumers.
Step 2: Notify consumers Send a notification (email, dashboard banner, webhook) telling them a new key is available and when the old one expires. Give them the new key material and the version number.
Step 3: Promote the new key to primary
Set is_primary = true on the new key, is_primary = false on the old key. New events are now signed with the new key. Consumers still verifying against the old key continue to work — they'll just see events signed with a version number they don't have. This is the signal for them to deploy.
Step 4: Expire the old key
After your grace period (30 days is reasonable for most SaaS; 7 days is aggressive but acceptable for security-sensitive rotations), set expires_at = NOW() on the old key. Consumers still using the old key will now see verification failures.
| Step | Old Key State | New Key State | Consumer Impact |
|---|---|---|---|
| Before rotation | Primary, no expiry | Does not exist | None |
| After step 1 | Primary, no expiry | Secondary, no expiry | None |
| After step 3 | Secondary, expires in 30d | Primary, no expiry | Events now signed with new key — consumers need to update |
| After step 4 | Expired | Primary | Old key no longer accepted — consumers must be updated |
The window between steps 3 and 4 is your migration runway. Size it based on your customers' deployment velocity.
What to Expose in Your API
If you're building customer-facing webhook infrastructure (your customers are the ones configuring destinations), the key management API should expose:
GET /v1/destinations/{id}/signing-keys — list all active versions
POST /v1/destinations/{id}/signing-keys/rotate — generate new key, set expiry on current
DELETE /v1/destinations/{id}/signing-keys/{ver} — immediately revoke a specific versionThe rotate endpoint does steps 1–3 atomically. It should return both the old key's new expiry and the new key's version and secret (the secret is returned once, never again). The consumer gets exactly one chance to copy the key.
One-Time Secret Exposure
Signing secrets must follow the same rule as API keys: return the plaintext once at creation time, never again. Your database stores the encrypted form. The API returns the decrypted secret only in the response to the creation request.
{
"version": 3,
"secret": "whsec_v3_9f2e1a4b8c3d7f6e...",
"active_from": "2026-04-22T00:00:00Z",
"expires_at": null,
"is_primary": true,
"message": "Save this secret now. It will not be shown again."
}Subsequent GET requests return the version metadata and a prefix (whsec_v3_9f2e...) for identification, not the full secret.
GetHook's Approach
GetHook handles signing key management per destination. When you add a destination, a signing secret is generated and returned once. When you rotate via the dashboard or API, the old key enters a configurable grace period — events delivered during that window carry the version tag so your consumers know exactly which key to verify against. Expired keys are automatically removed from the acceptance list.
This means you can give enterprise customers a 60-day rotation window and aggressive security-conscious customers a 7-day window, all managed per destination without any infrastructure changes on your end.
The Practical Upside
Secret versioning shifts webhook secret rotation from a "coordinated cutover" — which requires your customer's engineering team to be ready at a specific moment — to a "rolling migration" that consumers can pick up at any point during a multi-week window.
For SaaS products serving enterprise customers with change management processes, this difference is enormous. A hard cutover that breaks a production integration at an enterprise customer is a support escalation, a potential SLA conversation, and erosion of trust. A versioned rotation that gives six weeks of overlap is a changelog entry.
If you're building webhook delivery infrastructure and aren't using versioned signing keys yet, the schema migration is small and the protocol change is backward-compatible. It's worth doing before your first enterprise customer asks why their webhook verification broke during a routine rotation.