About five minutes, start to replay

Catch your first webhook — verified, stored, replayable

Stand up an endpoint that rejects anything unsigned, point a signed request at it, and watch the payload become a record you can query and re-dispatch.

You need a Centrali workspace. You do not need a Stripe account — you will play the sender until the real secret arrives.

Before you start

Two minutes of setup, and it is the only part that happens in the console. Create a service account under Access → Service Accounts — it hands you a client id and secret, which is the one thing the SDK cannot create for itself.

npm install @centrali-io/centrali-sdk
js
import { CentraliSDK } from '@centrali-io/centrali-sdk';
const centrali = new CentraliSDK({
baseUrl: 'https://centrali.io',
workspaceId: process.env.CENTRALI_WORKSPACE_ID,
clientId: process.env.CENTRALI_CLIENT_ID,
clientSecret: process.env.CENTRALI_CLIENT_SECRET,
});

Client credentials are server-side only — never ship them to a browser. For frontend code use a publishable key instead.

1

Make somewhere for events to land

A webhook payload arrives in whatever shape the sender decides, and that shape changes without asking you. Let the collection evolve with it instead of guessing the schema up front.

js
await centrali.collections.create({
name: 'Webhook Events',
recordSlug: 'webhook-events',
schemaDiscoveryMode: 'auto-evolving',
});

A collection that accepts today’s payload and tomorrow’s extra field.

Pick ‘strict’ instead if you would rather reject anything you have not declared.

Prefer clicking? Console → Data → Collections.

2

Write the function that runs when one lands

No imports, no handler signature, no request object. A function gets three globals — api, executionParams, and triggerParams — and executionParams.payload is the parsed body.

js
async function run() {
const event = executionParams.payload;
// Senders retry. Make arrival idempotent.
const existing = await api.queryRecords('webhook-events', {
where: { 'data.eventId': { eq: event.id } },
page: { limit: 1 },
});
if (existing.data.length > 0) {
return { ok: true, duplicate: true, recordId: existing.data[0].id };
}
const record = await api.createRecord('webhook-events', {
eventId: event.id,
eventType: event.type,
raw: event,
});
return { ok: true, recordId: record.id };
}

Idempotent on arrival, so a sender that retries the same event twice stores it once.

record.id, not record.data.id — data holds your fields, so record.data.id is undefined unless you declared a field literally called id.

3

Give it a URL that rejects anything unsigned

One field carries the entire wire format. Setting provider tells Centrali which header to read, how the signed value is built, and how the digest is encoded — no header names, no extraction regexes, no algorithm selection.

js
const fn = await centrali.functions.create({
name: 'Store webhook events',
code: source, // the function from step 2, as a string
});
const trigger = await centrali.triggers.create({
name: 'Stripe webhook',
functionId: fn.data.id,
executionType: 'http-trigger',
triggerMetadata: {
path: 'stripe-webhook',
validateSignature: true,
provider: 'stripe', // <- the whole wire format, one field
signingSecret: 'whsec_local_test',
},
});

Your endpoint is live at:

https://<api-host>/data/workspace/<your-slug>/api/v1/http-trigger/stripe-webhook

The URL is not returned by the API — you build it from your workspace slug and the path you chose. It is stable from this moment on, which is what makes step 7 painless.

Prefer clicking? Console → Logic → Functions to author the code, then Logic → Triggers to wire it up.

4

Send it a signed request

Nothing has arrived yet. Until a provider issues you a real secret you can play the sender yourself — the same scheme, signed with the key you just set.

js
// send-test-event.js — stands in for Stripe until the real secret arrives
const crypto = require('crypto');
const SECRET = 'whsec_local_test';
const URL = 'https://<api-host>/data/workspace/<your-slug>/api/v1/http-trigger/stripe-webhook';
const body = JSON.stringify({ id: 'evt_test_1', type: 'payment_intent.succeeded' });
const ts = Math.floor(Date.now() / 1000);
const sig = crypto.createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex');
await fetch(URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Stripe-Signature': `t=${ts},v1=${sig}`,
},
body,
});

The signature verifies, your function runs, and the event is in the durable buffer.

Verification happens at the trigger, before your code runs — an invalid signature is rejected and your function never sees it. Timestamps older than five minutes are rejected too, so sign a fresh one.

5

It is a record now, not a log line

This is the part that is hard to bolt on later. The payload is a row in a collection, so you query it with the same language you use for everything else.

js
const result = await centrali.queryRecords('webhook-events', {
resource: 'webhook-events',
where: { 'data.eventType': { eq: 'payment_intent.succeeded' } },
sort: [{ field: 'createdAt', direction: 'desc' }],
page: { limit: 50 },
});

Every event you have ever received, filterable by any field inside it.

Operators carry no $ prefix, and meta.total is omitted unless you pass includeTotal: true.

6

Replay it when something downstream breaks

Verified arrivals are buffered at the HTTP edge — after the signature check, before your function is invoked. So when the thing your function calls was down for an hour, the original request is still there to re-dispatch.

json
{
"sourceTable": "inbound_events",
"id": "b7c1…",
"replayedFrom": "a3f9…", // the original arrival
"dispatchStatus": "dispatched"
}

The raw request runs again through the same trigger, recorded as a new event linked back to the original.

Replay lives in the console Event Log and in the replay_event MCP tool. It is per-event, and the stored payload is re-sent as-is.

7

Point the real sender at it

Providers will not issue a signing secret until you give them a URL, which is the standard chicken-and-egg. Yours has been stable since step 3, so registering it is the only thing left.

js
await centrali.triggers.update(trigger.data.id, {
triggerMetadata: {
path: 'stripe-webhook',
validateSignature: true,
provider: 'stripe',
signingSecret: 'whsec_…', // the real one, from the provider dashboard
},
});

Delete your test script. Nothing else changes.

Same shape for Shopify or GitHub — change provider. Clerk, Resend, Loops, OpenAI and Brex all sign with the Svix scheme, so they use provider: ‘svix’.

That is the whole loop

Verified before your code runs, kept as a queryable record, and replayable when something downstream breaks — the parts of a backend that have to keep working when you are not looking.

Free to start. No credit card.