Skip to content

Outbound Webhooks

Overview

Outbound webhooks are the send step in Centrali's core backend flow. When matching record events occur in your workspace, Centrali can send signed HTTP POST requests to your systems for downstream delivery, fanout, and synchronization.

Outbound webhooks are an at-least-once delivery surface. Design receivers to handle retries, manual replay, and occasional duplicate deliveries safely.

Note

Looking for incoming third-party webhooks from Stripe, Shopify, GitHub, Svix, or Clerk? See Webhook Ingestion. This page covers outbound webhooks that Centrali sends from your workspace.

Tip

Need code to run inside Centrali when the event happens? Use event-driven triggers or automations. Use outbound webhooks when another system needs to be notified outside Centrali. Many flows use both.

6.1.0 Breaking Change

X-Signature was removed in 6.1.0. Outbound deliveries now use the Standard Webhooks header set plus Centrali-* aliases. If you are upgrading an existing receiver, start with the Outbound Webhook Migration Guide.

What Centrali Sends

Create a webhook subscription with:

  • A destination URL
  • One or more record event types
  • An optional recordSlugs filter to scope deliveries to specific collections

When a matching event occurs, Centrali sends a signed JSON payload to your endpoint and records the delivery attempt for inspection, replay, and cancellation.

Creating a Webhook

curl -X POST https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order Notifications",
    "url": "https://your-app.com/webhooks/centrali",
    "events": ["record_created", "record_updated"],
    "recordSlugs": ["orders"]
  }'

For scripted provisioning, send an Idempotency-Key header so a network retry does not accidentally create a duplicate subscription.

The create response includes the signing secret once:

{
  "id": "6d6f4f59-9c85-4c9d-8a3d-4cfd4db2f6be",
  "name": "Order Notifications",
  "url": "https://your-app.com/webhooks/centrali",
  "events": ["record_created", "record_updated"],
  "recordSlugs": ["orders"],
  "active": true,
  "secret": "whsec_..."
}

Copy the secret immediately. Subsequent GET and LIST responses do not return it.

Webhook Events

The current documented outbound webhook subscription flow covers these record lifecycle events:

  • record_created - A new record was inserted
  • record_updated - An existing record was modified
  • record_deleted - A record was deleted
  • records_bulk_created - Multiple records were inserted in one bulk operation

Event Payload

Example payload for record_created:

{
  "event": "record_created",
  "workspaceSlug": "my-workspace",
  "recordSlug": "orders",
  "recordId": "rec_abc123",
  "data": {
    "id": "rec_abc123",
    "recordSlug": "orders",
    "data": {
      "customer": "John Doe",
      "total": 99.99,
      "status": "pending"
    }
  },
  "timestamp": "2026-04-25T10:30:00.000Z",
  "createdBy": "user_123"
}

Notes:

  • recordId is the affected record ID
  • data is the record snapshot for create/update events
  • Actor fields vary by event: createdBy, updatedBy, or deletedBy
  • Bulk events may differ slightly from single-record events

Delivery Model

Matching record event
  -> signed outbound POST attempt
  -> receiver returns quickly
  -> success, retry schedule, or manual replay/cancel

Keep this contract in mind:

  • Delivery is at least once, not exactly once
  • Automatic retries and manual replay can send the same payload more than once
  • Delivery logs are the source of truth for attempt status, receiver response, and replay history
  • If you need internal computation before notifying another system, do that work first with Triggers or Automations, then emit outbound webhooks from the resulting record events

Webhook Security

Signature Headers

Every outbound delivery includes the Standard Webhooks canonical headers and matching Centrali-* aliases:

Canonical header Alias Meaning
webhook-id Centrali-Id Unique delivery ID
webhook-timestamp Centrali-Timestamp Unix timestamp in seconds
webhook-signature Centrali-Signature One or more v1,<base64> signatures

Additional metadata headers:

  • Centrali-Event-Type on every delivery
  • Centrali-Retry-Attempt only when attemptCount > 1
  • Centrali-Test-Event: true only for synthetic test deliveries sent through POST /webhook-subscriptions/{id}/test

Verifying Signatures

The signed input is:

${webhook-id}.${webhook-timestamp}.${raw-body}

Important details:

  • Verify against the raw request body, not re-serialized JSON
  • Accept either webhook-* or Centrali-* header names
  • Enforce a timestamp tolerance of 5 minutes unless you have a strong reason to widen it
  • During secret rotation, webhook-signature may contain multiple space-separated signatures; accept the delivery if any of them verifies
  • Centrali webhook secrets use the whsec_ prefix and must be base64url-decoded before use as the HMAC key
const crypto = require('crypto');
const express = require('express');
const app = express();

function deriveKey(secret) {
  const b64 = secret.replace(/^whsec_/, '');
  return Buffer.from(b64, 'base64url');
}

function timingSafeEqualString(a, b) {
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}

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) => timingSafeEqualString(candidate, expectedV1));
}

app.post(
  '/webhooks/centrali',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    if (!verifyWebhook(req.headers, req.body, process.env.WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));
    console.log('Event:', event.event);

    res.status(200).send('OK');
  }
);

If you prefer an off-the-shelf verifier, any library that implements the Standard Webhooks model, including standardwebhooks or Svix, works with the canonical webhook-* headers directly. If your framework only exposes the branded aliases, map them onto webhook-id, webhook-timestamp, and webhook-signature first.

Need the full migration checklist, side-by-side examples, or a header mapping table? Read Outbound Webhook Migration (6.1).

Rotating the Signing Secret

Webhook secrets are server-managed. You do not set or replace them with a normal update call.

To generate a new secret:

curl -X POST https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/WEBHOOK_ID/rotate-secret \
  -H "Authorization: Bearer YOUR_TOKEN"

The response includes the new secret once. Rotation is not an immediate cutover in 6.1:

  • The new secret becomes active immediately
  • The previous secret stays valid for 24 hours
  • During that grace window, webhook-signature contains one signature for the new secret and one for the previous secret
  • The subscription stores this server-managed grace state as previousSecret and previousSecretValidUntil

Scoping by Collection

Use recordSlugs to limit deliveries to specific collections:

{
  "recordSlugs": ["orders"]
}

Omit recordSlugs to receive matching events across all collections. On update, pass [] to clear the restriction.

Delivery Behavior

Each event is delivered with a 10-second HTTP timeout per attempt. Design your endpoint to be idempotent because the same event may be delivered more than once.

Receiver contract

Your receiver should:

  1. Verify the signature against webhook-id, webhook-timestamp, and the raw request body.
  2. Return a 2xx response quickly after durably accepting or queueing the work.
  3. Process the event idempotently so retries and replay do not create duplicate side effects.
  4. Log enough context to correlate deliveries, such as webhook-id, event, recordSlug, recordId, and timestamp.

Automatic retries

Failed deliveries are retried automatically up to 5 attempts total with this backoff schedule:

Attempt Delay after previous attempt
1 - (immediate on event)
2 30 seconds
3 2 minutes
4 10 minutes
5 30 minutes

A delivery stays in retrying until it succeeds, is cancelled, or exhausts all 5 attempts. At that point it terminates as failed.

Circuit breaker

To protect endpoints that are erroring in bulk, Centrali applies a per-subscription circuit breaker:

  • Opens when 50% of requests fail over a rolling 10-second window, with a minimum of 5 requests
  • Rejects attempts immediately while open and records them in the delivery log
  • Resets automatically after 30 seconds of no traffic

A circuit-open rejection still spends a retry attempt.

Test a Receiver

Send a synthetic delivery through the production pipeline:

curl -X POST https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/WEBHOOK_ID/test \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "record_updated",
    "payload": {
      "source": "docs-smoke-test"
    }
  }'

The test delivery:

  • Uses the same signing, retry, timeout, and circuit-breaker path as a real delivery
  • Adds Centrali-Test-Event: true to the outbound request
  • Stores isTest: true at the top level of the recorded request payload
  • Returns the created delivery row, including the final status, httpStatus, and any lastError

Use this endpoint before cutover, after secret rotation, or whenever you change receiver code.

Check Endpoint Health

Fetch the customer-facing health summary for one subscription:

curl -X GET https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/WEBHOOK_ID/health \
  -H "Authorization: Bearer YOUR_TOKEN"

Example response:

{
  "successRate1h": 100,
  "successRate24h": 99.2,
  "totalDeliveries1h": 6,
  "totalDeliveries24h": 243,
  "lastFailureAt": "2026-05-14T09:48:21.000Z",
  "lastFailureError": "Request failed with status code 500"
}

This endpoint intentionally reports windowed success rates rather than the in-process circuit-breaker state. Use successRate1h for healthy/degraded indicators and lastFailureError for operator context.

View Delivery History

List deliveries for a subscription:

curl -X GET "https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/WEBHOOK_ID/deliveries?limit=50&offset=0" \
  -H "Authorization: Bearer YOUR_TOKEN"

Supported query parameters:

Parameter Description
limit Rows per page (default 50)
offset Offset into the result set (default 0)
status success, failed, or retrying
since ISO 8601 timestamp; only deliveries created at or after this time
until ISO 8601 timestamp; only deliveries created at or before this time

List rows omit requestPayload and responseBody. Fetch a single delivery for the full payload and receiver response:

curl -X GET https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/WEBHOOK_ID/deliveries/DELIVERY_ID \
  -H "Authorization: Bearer YOUR_TOKEN"

Workspace-wide helpers:

curl -X GET https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/deliveries/recent \
  -H "Authorization: Bearer YOUR_TOKEN"

curl -X GET https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/deliveries/failed \
  -H "Authorization: Bearer YOUR_TOKEN"

Manual Replay

Replay is for deliveries that already failed, or for cases where your receiver processed a delivery incorrectly and you need to send the same payload again:

curl -X POST https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/deliveries/DELIVERY_ID/retry \
  -H "Authorization: Bearer YOUR_TOKEN"

Replay behavior:

  • Reuses the original stored payload
  • Recomputes signed delivery headers as a fresh dispatch
  • Runs through the same retry schedule as a fresh delivery
  • Populates replayedFrom on the new delivery row
  • Requires the webhooks:replay OAuth scope

Replay returns 409 Conflict when the original delivery is still retrying or when the owning subscription has been deleted.

Cancel a Pending Retry

If a delivery is currently retrying, you can stop the automatic retry schedule:

curl -X POST https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/deliveries/DELIVERY_ID/cancel \
  -H "Authorization: Bearer YOUR_TOKEN"

Cancel flips the delivery to failed with lastError: "Cancelled by user" and requires the webhooks:cancel OAuth scope.

Managing Webhooks

List Webhooks

curl -X GET https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions \
  -H "Authorization: Bearer YOUR_TOKEN"

Update Webhook

curl -X PATCH https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/WEBHOOK_ID \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://new-endpoint.com/webhooks",
    "active": true,
    "recordSlugs": ["orders"]
  }'

Delete Webhook

curl -X DELETE https://api.centrali.io/data/workspace/my-workspace/api/v1/webhook-subscriptions/WEBHOOK_ID \
  -H "Authorization: Bearer YOUR_TOKEN"

Best Practices

  1. Respond quickly and do downstream work asynchronously.
  2. Preserve the raw request body until signature verification succeeds.
  3. Accept either canonical webhook-* headers or the identical Centrali-* aliases.
  4. Make handlers idempotent because retries and replays can send the same event more than once.
  5. Keep the receiver small: verify, acknowledge, enqueue, and return.
  6. Use webhook-id as your primary correlation and deduplication key.
  7. Run /test after receiver changes or secret rotation.

Example Implementation

const express = require('express');
const app = express();

app.post(
  '/webhooks/centrali',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    if (!verifyWebhook(req.headers, req.body, process.env.WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));

    res.status(200).send('OK');

    try {
      await processWebhook(event);
    } catch (error) {
      console.error('Webhook processing failed:', error);
    }
  }
);

async function processWebhook(event) {
  switch (event.event) {
    case 'record_created':
      await handleRecordCreated(event.data);
      break;
    case 'record_updated':
      await handleRecordUpdated(event.data);
      break;
  }
}

Common Use Cases

Slack Notifications

async function handleRecordCreated(record) {
  await fetch('https://hooks.slack.com/services/YOUR/WEBHOOK/URL', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `New ${record.recordSlug} record created: ${record.id}`
    })
  });
}

External System Sync

async function handleRecordUpdated(record) {
  await crm.updateContact({
    id: record.id,
    ...record.data
  });
}

Troubleshooting

Webhook Not Firing

  • Verify the subscription is active
  • Confirm the configured event names match the events you expect
  • Check whether recordSlugs is narrowing the subscription more than intended
  • Review the delivery log for recent failures and retrying rows

Signature Validation Failing

  • Verify against the raw request body, not re-serialized JSON
  • Read webhook-id, webhook-timestamp, and webhook-signature (or the matching Centrali-* aliases)
  • Recompute ${id}.${timestamp}.${body} and compare against v1,<base64>
  • Split the signature header on spaces and accept any matching candidate during secret rotation
  • Reject timestamps older than your tolerance window (5 minutes is the default)
  • Strip the optional whsec_ prefix and base64url-decode the remainder before computing the HMAC key
  • Use crypto.timingSafeEqual or an equivalent constant-time comparison

High Failure Rate

  • Confirm the endpoint is publicly reachable
  • Verify the receiver returns a response within 10 seconds
  • Check TLS/SSL certificate validity
  • Inspect your receiver logs alongside Centrali delivery logs