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
recordSlugsfilter 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 insertedrecord_updated- An existing record was modifiedrecord_deleted- A record was deletedrecords_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:
recordIdis the affected record IDdatais the record snapshot for create/update events- Actor fields vary by event:
createdBy,updatedBy, ordeletedBy - 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-Typeon every deliveryCentrali-Retry-Attemptonly whenattemptCount > 1Centrali-Test-Event: trueonly for synthetic test deliveries sent throughPOST /webhook-subscriptions/{id}/test
Verifying Signatures¶
The signed input is:
Important details:
- Verify against the raw request body, not re-serialized JSON
- Accept either
webhook-*orCentrali-*header names - Enforce a timestamp tolerance of 5 minutes unless you have a strong reason to widen it
- During secret rotation,
webhook-signaturemay 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-signaturecontains one signature for the new secret and one for the previous secret - The subscription stores this server-managed grace state as
previousSecretandpreviousSecretValidUntil
Scoping by Collection¶
Use recordSlugs to limit deliveries to specific collections:
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:
- Verify the signature against
webhook-id,webhook-timestamp, and the raw request body. - Return a
2xxresponse quickly after durably accepting or queueing the work. - Process the event idempotently so retries and replay do not create duplicate side effects.
- Log enough context to correlate deliveries, such as
webhook-id,event,recordSlug,recordId, andtimestamp.
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: trueto the outbound request - Stores
isTest: trueat the top level of the recorded request payload - Returns the created delivery row, including the final
status,httpStatus, and anylastError
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
replayedFromon the new delivery row - Requires the
webhooks:replayOAuth 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¶
- Respond quickly and do downstream work asynchronously.
- Preserve the raw request body until signature verification succeeds.
- Accept either canonical
webhook-*headers or the identicalCentrali-*aliases. - Make handlers idempotent because retries and replays can send the same event more than once.
- Keep the receiver small: verify, acknowledge, enqueue, and return.
- Use
webhook-idas your primary correlation and deduplication key. - Run
/testafter 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
recordSlugsis 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, andwebhook-signature(or the matchingCentrali-*aliases) - Recompute
${id}.${timestamp}.${body}and compare againstv1,<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.timingSafeEqualor 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
What to Read Next¶
- Need the full request and response contracts? Read Webhooks API Reference.
- Upgrading an existing receiver from
X-Signature? Read Outbound Webhook Migration (6.1). - Need the exact payload shapes? Read Event Payloads.
- Need to fan work out after receipt? Read Automations and Writing Functions.
- Need to inspect live record streams inside the platform? Read Realtime.
-
Need to receive third-party webhooks into Centrali? Read Webhook Ingestion.
-
Need the inbound async webhook path instead of outbound delivery? Read Webhook Ingestion.
- Need code to run inside Centrali on the same underlying events? Read Triggers and Functions.
- Need multi-step internal workflows before or after delivery? Read Automations.
- Need exact record-event payload shapes? Read Trigger Parameters and Event Payloads Reference.