Skip to content

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-Signature header
  • 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, and webhook-signature
  • It also sends identical Centrali-Id, Centrali-Timestamp, and Centrali-Signature aliases
  • 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

  1. Capture the raw request body before JSON parsing.
  2. Read webhook-id, webhook-timestamp, and webhook-signature.
  3. Derive the HMAC key by stripping whsec_ and base64url-decoding the remainder.
  4. Compute base64(HMAC_SHA256(secret, "${id}.${timestamp}.${body}")).
  5. Compare it against every space-separated v1,<base64> candidate in the signature header.
  6. 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 previousSecret and previousSecretValidUntil

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:

  • successRate1h
  • successRate24h
  • totalDeliveries1h
  • totalDeliveries24h
  • lastFailureAt
  • lastFailureError

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, and webhook-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

See Also