Outbound Webhook Migration (6.1)¶
6.1.0 changes Centrali outbound webhook signing from the legacy body-only X-Signature model to the Standard Webhooks header set.
If your receiver already verifies Centrali webhooks, you must update it before rolling 6.1 into production.
What Changed¶
Before 6.1:
- Centrali sent a single
X-Signatureheader - The signature covered the raw body only
- There was no built-in replay protection input
As of 6.1:
- Centrali sends
webhook-id,webhook-timestamp, andwebhook-signature - It also sends identical
Centrali-Id,Centrali-Timestamp, andCentrali-Signaturealiases - The signed input is
${id}.${timestamp}.${rawBody} - Signatures use the Standard Webhooks
v1,<base64>format - Secret rotation now has a 24 hour dual-signature grace window
Why this changed:
- Replay protection now ships in the signed input by default
- Standard Webhooks-compatible libraries can verify Centrali deliveries directly
- Secret rotation no longer requires a hard cutover that risks dropped deliveries
Header Mapping¶
| Pre-6.1 | 6.1+ | Notes |
|---|---|---|
X-Signature | webhook-signature | X-Signature is removed |
| None | webhook-id | Unique delivery ID |
| None | webhook-timestamp | Unix timestamp in seconds |
| None | Centrali-Id / Centrali-Timestamp / Centrali-Signature | Branded aliases with identical values |
| None | Centrali-Event-Type | Event name on every delivery |
| None | Centrali-Retry-Attempt | Present only on retries |
| None | Centrali-Test-Event: true | Present only on synthetic test deliveries |
Verification Checklist¶
- Capture the raw request body before JSON parsing.
- Read
webhook-id,webhook-timestamp, andwebhook-signature. - Derive the HMAC key by stripping
whsec_and base64url-decoding the remainder. - Compute
base64(HMAC_SHA256(secret, "${id}.${timestamp}.${body}")). - Compare it against every space-separated
v1,<base64>candidate in the signature header. - Reject timestamps outside your tolerance window. A 5 minute window is the Standard Webhooks default.
Node.js¶
Before¶
const expected = crypto
.createHmac('sha256', deriveKey(secret))
.update(rawBody)
.digest('base64');
if (req.headers['x-signature'] !== expected) {
return res.status(401).send('Invalid signature');
}
After¶
function verifyWebhook(headers, rawBody, secret) {
const id = headers['webhook-id'] ?? headers['centrali-id'];
const timestamp = headers['webhook-timestamp'] ?? headers['centrali-timestamp'];
const signatureHeader = headers['webhook-signature'] ?? headers['centrali-signature'];
if (!id || !timestamp || !signatureHeader) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto
.createHmac('sha256', deriveKey(secret))
.update(`${id}.${timestamp}.${rawBody.toString('utf8')}`)
.digest('base64');
const expectedV1 = `v1,${expected}`;
return String(signatureHeader)
.split(/\s+/)
.filter(Boolean)
.some((candidate) => crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expectedV1)));
}
Python¶
Before¶
expected = base64.b64encode(
hmac.new(derive_key(secret), raw_body, hashlib.sha256).digest()
).decode("utf-8")
if request.headers["X-Signature"] != expected:
raise HTTPException(status_code=401, detail="Invalid signature")
After¶
def verify_webhook(headers: dict[str, str], raw_body: bytes, secret: str) -> bool:
webhook_id = headers.get("webhook-id") or headers.get("centrali-id")
timestamp = headers.get("webhook-timestamp") or headers.get("centrali-timestamp")
signature_header = headers.get("webhook-signature") or headers.get("centrali-signature")
if not webhook_id or not timestamp or not signature_header:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
signed_input = f"{webhook_id}.{timestamp}.{raw_body.decode('utf-8')}"
digest = hmac.new(derive_key(secret), signed_input.encode("utf-8"), hashlib.sha256).digest()
expected_v1 = "v1," + base64.b64encode(digest).decode("utf-8")
return any(
hmac.compare_digest(candidate, expected_v1)
for candidate in signature_header.split()
)
Go¶
Before¶
mac := hmac.New(sha256.New, deriveKey(secret))
mac.Write(rawBody)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if r.Header.Get("X-Signature") != expected {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
After¶
func verifyWebhook(h http.Header, rawBody []byte, secret string) bool {
webhookID := h.Get("webhook-id")
if webhookID == "" {
webhookID = h.Get("Centrali-Id")
}
timestamp := h.Get("webhook-timestamp")
if timestamp == "" {
timestamp = h.Get("Centrali-Timestamp")
}
signatureHeader := h.Get("webhook-signature")
if signatureHeader == "" {
signatureHeader = h.Get("Centrali-Signature")
}
if webhookID == "" || timestamp == "" || signatureHeader == "" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, deriveKey(secret))
mac.Write([]byte(webhookID + "." + timestamp + "." + string(rawBody)))
expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, candidate := range strings.Fields(signatureHeader) {
if hmac.Equal([]byte(candidate), []byte(expected)) {
return true
}
}
return false
}
Secret Rotation Grace Window¶
POST /webhook-subscriptions/{id}/rotate-secret no longer forces an immediate receiver cutover.
Rotation behavior in 6.1:
- The newly returned secret becomes active immediately
- The previous secret remains valid for 24 hours
- Deliveries during that window include two space-separated signatures in
webhook-signature - The current subscription state tracks
previousSecretandpreviousSecretValidUntil
Receiver guidance:
- Keep both secrets available during rollout
- Accept the delivery if either secret verifies any
v1,...candidate - Once the 24 hour window expires, remove the old secret from your receiver
Validate the Migration¶
Use the new webhook endpoints to test your receiver before cutting production traffic over.
Send a Synthetic Delivery¶
curl -X POST "https://api.centrali.io/data/workspace/{workspace}/api/v1/webhook-subscriptions/{id}/test" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event": "record_updated",
"payload": {
"source": "migration-check"
}
}'
This dispatches through the real delivery pipeline and adds Centrali-Test-Event: true. The stored delivery payload also carries top-level isTest: true.
Check Endpoint Health¶
curl "https://api.centrali.io/data/workspace/{workspace}/api/v1/webhook-subscriptions/{id}/health" \
-H "Authorization: Bearer YOUR_TOKEN"
This returns:
successRate1hsuccessRate24htotalDeliveries1htotalDeliveries24hlastFailureAtlastFailureError
It does not expose the raw in-process circuit-breaker state. Use successRate1h for health badges and lastFailureError for operator context.
Libraries¶
If you do not want to maintain verification code yourself:
- Any Standard Webhooks-compatible verifier works with
webhook-id,webhook-timestamp, andwebhook-signature - Svix helpers work as long as you feed them the canonical headers
- If your framework only surfaces
Centrali-*aliases, map them onto the canonical header names first