Skip to main content
Ablo owns the ordered transaction log — the source of truth. Webhooks are how that log reaches your database. Ablo streams every committed change to an endpoint in your app as a signed event; your handler writes your own database. It’s the same two-sided shape as Stripe: you call Ablo to make changes (the client), and Ablo calls you to persist them (this webhook). Ablo never holds your database credentials.

The loop

There are two ways data flows out of Ablo, and they’re for different jobs:
reachesuse it for
Realtime (useAblo, WSS)your live UIinstant, optimistic rendering
Webhooks (this page)your databasea durable copy you own — analytics, backups, server logic
Most apps use both: the realtime stream for the UI, the webhook stream to keep their database in sync. This page is the webhook stream. If you know Stripe, you already know the shape:
StripeAblo
stripe.x.create(...) — make the changethe Ablo client — make the change (+ live sync)
/stripe-webhook — confirm and persist/api/ablo/[...all] — persist into your database
Stripe owns the chargesAblo owns the transaction log
you mirror charges into your databaseyou mirror the log into your database
The difference in Ablo’s favor: every event carries syncId, a monotonic log position, so you can both dedupe and apply in order — Ablo guarantees the order because it owns the log.

The event object

Every delivery is a batch of events. Each event:
fieldmeaning
type"<model>.<verb>", e.g. task.updated
modelthe model name (task) — the table to write
objectIdthe changed row’s id
datathe post-change row, or null on delete (like Stripe’s event.data.object)
syncIdmonotonic log position — dedupe and order by this
idString(syncId) — the event id
createdAtISO commit timestamp
import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks';

1. Create a handler

npx ablo init scaffolds this for you at app/api/ablo/[...all]/route.ts. If your project uses Prisma it’s a working generic mirror — one upsert/delete for every model, no per-model code. Otherwise it’s a neutral route with a single place to plug your database in.
app/api/ablo/[...all]/route.ts
import { Webhook } from 'svix'; // any Standard Webhooks library
import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks';
import { PrismaClient } from '@prisma/client';

const wh = new Webhook(process.env.ABLO_WEBHOOK_SECRET!);
const prisma = new PrismaClient();

type ModelDelegate = {
  upsert(a: { where: { id: string }; create: Record<string, unknown>; update: Record<string, unknown> }): Promise<unknown>;
  delete(a: { where: { id: string } }): Promise<unknown>;
};

export async function POST(req: Request): Promise<Response> {
  const body = await req.text(); // RAW body — required to verify
  let batch: { data: AbloWebhookEvent[] };
  try {
    batch = wh.verify(body, Object.fromEntries(req.headers)) as { data: AbloWebhookEvent[] };
  } catch {
    return new Response('invalid signature', { status: 400 });
  }

  const delegates = prisma as unknown as Record<string, ModelDelegate | undefined>;
  for (const event of [...batch.data].sort((a, b) => a.syncId - b.syncId)) {
    const model = delegates[event.model];
    if (!model) continue; // a model you don't mirror — skip
    if (event.data === null) {
      await model.delete({ where: { id: event.objectId } }).catch(() => {});
    } else {
      await model.upsert({ where: { id: event.objectId }, create: event.data, update: event.data });
    }
  }

  return new Response(null, { status: 200 }); // 2xx = delivered
}
You only edit this if your tables diverge from Ablo’s schema (renamed columns, extra side effects) — add a case for that model before the generic mirror. If your tables match Ablo’s, you never touch it.
Return a 2xx quickly. Any other status is a failure and is retried. Do heavy work asynchronously after responding.

2. Test locally

You don’t register a localhost URL. npx ablo dev forwards committed changes to your machine with a local signing secret — the same idea as Stripe’s stripe listen. Run your app, run ablo dev, and writes flow into your local handler.

3. Register your endpoint

For a deployed URL, register it once. Ablo mints the signing secret and returns it a single time — the CLI writes it straight into your .env.local.
npx ablo webhooks create https://yourapp.com/api/ablo/[...all]
# ✓ Registered we_… → https://yourapp.com/api/ablo/[...all]
# ✓ Wrote ABLO_WEBHOOK_SECRET to .env.local (shown once)
Manage and inspect endpoints:
npx ablo webhooks list          # endpoints + delivery health (status, cursor, last error)
npx ablo webhooks roll <id>     # mint a fresh signing secret
npx ablo webhooks enable <id>   # re-enable a disabled endpoint
Or call the API directly — the org is derived from your secret key:
curl https://api.abloatai.com/api/v1/webhook_endpoints \
  -H "authorization: Bearer $ABLO_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "url": "https://yourapp.com/api/ablo/[...all]" }'
# → { "id": "we_…", "secret": "whsec_…", "status": "enabled", ... }

4. Verify the signature

Ablo signs every request with the Standard Webhooks scheme (the spec Svix authored). Verify with any compatible library — svix or standardwebhooks — using the secret from registration. Ablo ships no verification code of its own; you use the open library.
const wh = new Webhook(process.env.ABLO_WEBHOOK_SECRET!);
const event = wh.verify(rawBody, Object.fromEntries(req.headers));
Verification checks three headers — webhook-id, webhook-timestamp, webhook-signature — and rejects a timestamp outside a 5-minute window (replay protection). Always verify against the raw request body.

Event delivery

Ablo delivers from a per-endpoint cursor over the log, advancing only on a 2xx. A failed delivery leaves the cursor in place, so the same events are re-sent until they land — at-least-once, in order.
behaviorhow it works
OrderingEvery event carries syncId, a monotonic log position. Apply in syncId order — Ablo guarantees the order because it owns the log.
RetriesA non-2xx (or no response within the timeout) is retried with backoff: immediate, 5s, 5m, 30m, 2h, 5h, 10h, 10h — 8 attempts over ~32h.
Auto-disableAfter the retries exhaust, the endpoint is marked disabled and delivery stops until you ablo webhooks enable <id>.
ReplayNothing is lost on failure: the log is the durable buffer, and delivery resumes from the endpoint’s cursor once it’s healthy.

Best practices

  • Dedupe by syncId. Skip any syncId you’ve already stored. Delivery is at-least-once, so the same event can arrive twice after a retry.
  • Apply in syncId order. It’s the log position; sort each batch by it.
  • Return 2xx fast. Acknowledge first, then do slow work asynchronously.
  • Subscribe to only what you need. Set enabledEvents to the models you mirror (['*'] is the default = all).
  • Roll secrets periodically. ablo webhooks roll <id> mints a new secret; Standard Webhooks supports a rotation window so in-flight events still verify.
  • Verify the raw body. Frameworks that re-serialize JSON will break the signature — verify the bytes you received.

Event types

The verb is derived from the change:
typewhen
<model>.createda row was inserted
<model>.updateda row was updated
<model>.deleteda row was deleted (data is null)
<model>.archiveda row was soft-archived
<model>.unarchiveda soft-archived row was restored
Internal coordination changes (permissions, sync groups) carry no webhook — only your data models produce events.