Skip to content
Developer

Sync hooks

Let an external service participate in a write — validate it, enrich it, or reject it — instead of only being told about it afterwards.

Every other extension point in backlex runs after the fact. Outbound webhooks, flows and extension event hooks all fire once the row is committed. A sync hook runs before the write and its answer decides the outcome.

That difference is what lets somebody else own validation, enrichment, pricing or tax without backlex shipping an integration for each one. It is the same mechanism behind Saleor’s synchronous webhooks and Shopify Functions.

POST https://your-app.example/guard
{
"event": "orders.beforeCreate",
"collection": "orders",
"phase": "beforeCreate",
"id": null,
"data": { "total": 4200, "country": "DE" },
"actor": { "userId": "…", "email": "…", "roles": ["editor"] },
"at": "2026-07-30T09:00:00.000Z"
}

Your service answers one of:

{ "allow": true } // proceed
{ "allow": true, "data": { "tax": 798 } } // proceed, patched
{ "allow": false, "reason": "VAT id is required" } // reject the write

A rejection surfaces to the caller as 403 with your reason.

Phases

EventWhenNotes
<collection>.beforeCreatebefore the row is insertedid is null — it does not exist yet
<collection>.beforeUpdatebefore the patch is applieddata is the patch, not the whole row
<collection>.beforeDeletebefore the row is removeddata is the row being deleted; only the verdict is used

Patterns support <collection>.<phase>, <collection>.*, *.<phase> and *. Deliberately no prefix or regex matching — a hook sits on the write path, and a pattern language with surprises in it is a way to accidentally block every write in the workspace.

The decisions you have to make

onError — there is no safe default

When a hook cannot answer (timeout, non-2xx, unreadable body) backlex does what you configured:

onErrorBehaviourUse when
denythe write is blockedthe hook enforces something that must not be skipped — compliance, fraud, entitlement
allowthe write proceedsthe hook enriches, and stale data beats an outage

This field is required. allow silently drops the guarantee the hook exists to provide; deny converts your app’s outage into your customer’s. Only you can say which is worse for a given hook.

A 200 response whose body cannot be read, or omits allow, is not an approval — it is a failure, and onError applies. A broken app must not be able to quietly disable itself.

canMutate — mutation is opt-in

A hook’s data patch is ignored unless canMutate is set. A hook registered to validate must not be able to rewrite rows just by returning a data key.

A patch is a shallow merge, and the merged body is re-validated against the collection schema — the hook is external, so its output is trusted no more than the client’s.

Timeouts

BoundDefaultMax
One hook2000 ms10 000 ms
All hooks for one write, combined15 000 ms

The per-hook bound uses a real AbortController, so a hanging app is cut off rather than holding the request. The combined bound exists because several slow-but-not-timing-out hooks would otherwise add up to a hung write; exceeding it fails the write with 503.

Ordering

Hooks run sequentially, ordered by priority then age, and each one sees the previous hook’s patch. Running them in parallel would make two hooks patching the same field a coin flip. The first rejection stops the chain.

Signing

Give a hook a secret and every call carries:

x-backlex-timestamp: 1785400000
x-backlex-signature: <hex HMAC-SHA256 of `${timestamp}.${body}`>

The timestamp is in its own header and inside the signed string, so your app can reject a stale call without parsing the signature. Custom headers are applied first, so they can never override the signing headers.

When hooks do not run

  • Disabled hooks, and hooks belonging to another workspace. A hook with no workspace is instance-wide and runs for every workspace’s writes.
  • After the breaker trips. 15 consecutive failures disable the hook — a dead deny hook that stayed enabled would block every write forever. Any success resets the counter.

They do run on bulk paths (CSV import, batch, bulk update). That is a deliberate choice: skipping them there would make the batch endpoint a way to bypass a validation gate silently. If a hook makes an import too slow, disable the hook — do not rely on backlex quietly not calling it.

Placement in the write

Hooks run last in the validation phase: after schema validation, relation checks, field conditions, validation rules, and after hash fields have been digested — so a hook never sees a plaintext password.

Managing hooks

/api/admin/sync-hooks, admin-only:

MethodPath
GET/list (secrets never included)
POST/create
PATCH/{id}update — omit secret to keep the stored one; re-enabling clears the breaker
DELETE/{id}remove
POST/{id}/testfire one synthetic call and report the verdict

All five surfaces reach the same service, so a hook is configured identically whichever you use:

SurfaceEntry point
REST/api/admin/sync-hooks
SDKclient.syncHooks.*
GraphQLsyncHooks, createSyncHook, updateSyncHook, deleteSyncHook, testSyncHook
MCPsync_hooks.list / .create / .update / .test / .delete
CLIbacklex sync-hooks …

onError is required on every one of them, and none of them will pick it for you. A hook that is safe over REST and lax over GraphQL would be worse than no hook at all.

Use test before you rely on a hook. It sends a __test__.beforeCreate payload and shows you what came back, so a misconfiguration surfaces there rather than as a blocked write in production. It does not touch the failure counter.

A hook is bound to the workspace that created it, and the API has no way to create an instance-wide one. That is deliberate rather than an omission: a hook with no workspace receives the pending row data of every workspace on the instance, so it would be a read channel into everyone else’s writes. Instance operators who genuinely want one insert the row directly.