Skip to content
AbloAblo
Esc
navigateopen⌘Jpreview
On this page

Webhooks

Stream the committed transaction log to your own systems as signed events.

Ablo keeps an ordered transaction log of every committed change and coordinates the writers — agents, and the people alongside them — that produce it. Your rows live in your own database; Ablo holds only the log. Webhooks stream that log to your systems as signed events: every committed change is POSTed to an endpoint in your app, and your handler decides what to do with it.

It’s the same two-sided shape as Stripe: you call Ablo to make changes (the client), and Ablo calls you with each change (this webhook). Webhooks are the push way to keep a store in step with the log — your own database, a warehouse, a search index, a background job. The direct alternative is ablo connect, where Ablo reads your write-ahead log and writes back through a scoped role. Either way your handler owns the write: the webhook path gives Ablo no database credentials at all.

The loop

Your app writes to Ablo’s ordered log — the source of truth, which holds the log and coordination only, never your rows. The log fans out two ways: realtime sync to live clients for an instant, optimistic UI, and a signed event to your webhook route, which writes the durable copy into your own database.

There are two ways data flows out of Ablo, and they’re for different jobs:

reaches use it for
Realtime (useAblo, WSS) your live UI instant, optimistic rendering
Webhooks (this page) your database a 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:

Stripe Ablo
stripe.x.create(...): make the change the Ablo client: make the change (+ live sync)
/stripe-webhook: confirm and persist /api/ablo/[...all]: persist into your database
Stripe owns the charges Ablo owns the transaction log
you mirror charges into your database you 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:

field meaning
type "<model>.<verb>" with the model name lowercased, e.g. task.updated
model the model name exactly as declared in your schema: the table to write
objectId the changed row’s id
data the post-change row, or null on delete (like Stripe’s event.data.object)
syncId monotonic log position: dedupe and order by this
id String(syncId): the event id
createdAt ISO 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.

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.

2. Test locally

npx ablo dev prepares an isolated schema branch; it does not forward webhook deliveries. Register an HTTPS endpoint that Ablo can reach. For local handler development, expose your app with the HTTPS tunnel your team already trusts, then register that temporary URL and remove the endpoint when you finish.

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)

Scope which models fire and label the endpoint at creation with --events and --description (both optional; events default to * = every model):

npx ablo webhooks create https://yourapp.com/api/ablo/[...all] \
  --events task,project --description "prod mirror"

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
npx ablo webhooks rm <id>       # remove an 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.

behavior how it works
Ordering Every event carries syncId, a monotonic log position. Apply in syncId order: Ablo guarantees the order because it owns the log.
Retries A 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-disable After the retries exhaust, the endpoint is marked disabled and delivery stops until you ablo webhooks enable <id>.
Replay Nothing 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:

type when
<model>.created a row was inserted
<model>.updated a row was updated
<model>.deleted a row was deleted (data is null)
<model>.archived a row was soft-archived
<model>.unarchived a soft-archived row was restored

Internal coordination changes (permissions, sync groups) carry no webhook — only your data models produce events.

Was this page helpful?