InventDB
All articles API

Webhooks and a change feed from the database itself

InventDB records every committed write at the one point in the engine that all writes pass through, and serves those changes two ways: a change feed you read with a cursor, and signed webhooks that carry no record data. A receiver pulls what changed through the normal API, under its own permissions.

An application that has to react when data changes has three common options, and each has a cost. It can poll, which is slow or expensive. It can call hooks from its own write code, which misses every write made through another path. Or it can run separate change-capture software that reads the database's log and feeds a message broker.

InventDB captures changes inside the engine, at the step every committed write passes, and keeps them in a change log inside the instance. Two readers serve that log over HTTP: a change feed you pull with a cursor, and outbound webhooks that tell a receiver when to pull. In InventDB SOAR a third reader starts workflows. This article explains the capture, the readers, a worked example and the limits.

Capture where every write passes

Every committed write in the engine ends at the same step: after the write-ahead log assigns it a log sequence number (LSN), the record is placed in the engine's in-memory write table. The capture hook sits at that step. It pushes five values onto an in-memory channel: the LSN, the namespace, the type, the record id and whether the record was written or deleted. It runs no SQL, takes no lock and does not wait for the disk.

A background task drains the channel in batches of up to 512 and writes compact rows, about 150 bytes each, to a change log stored in the instance's system namespace. The rows hold ids and operations only, never record contents. Writes to the system namespace are not captured, so the log never records its own writes.

Write paths feed the engine's write table, where a hook records each change into a change log that three readers consume REST API Bulk loads Transactions MCP tools Workflow steps THE ENGINE Write table LSN assigned, hook pushes lsn, type, id and op no SQL, no lock Change log ids and ops only 7 days, 6M rows Change feed GET /api/changes Webhooks signed, no data Workflow triggers InventDB SOAR
Every write path ends at the engine's write table, where the hook records the change. The change log keeps ids and operations only, and each reader takes changes from it with its own cursor.

Capturing at that step gives three guarantees:

  • No write path can skip it. REST calls, bulk loads, transactions, MCP tools and workflow steps all reach the same step.
  • Only committed changes are captured. A transaction's writes reach the write table only after its log batch and commit marker are on disk, so a transaction that rolls back emits nothing.
  • A crash loses nothing. The hook is installed before the engine replays its log at start-up, so the replay emits again any change whose change-log row was lost. Rows are keyed by the zero-padded LSN, and a repeat overwrites the same row.

The LSN is a single counter per instance that only increases. Each customer has their own instance with one writer, so the LSN puts every change in one total order and works as a cursor for any reader. The log keeps 7 days of changes or 6 million rows, about 1 GB, whichever limit is reached first, and a sweep enforces both every five minutes. The row limit always applies, so a stalled subscriber cannot make the log grow without bound.

The change feed

GET /api/changes reads the log for one type from a cursor:

GET /api/changes?namespace=pms&type=maintenance_requests&since=5548&limit=100
Authorization: Bearer <token>

{ "changes": [
    { "lsn": 5673, "ns": "pms", "type": "maintenance_requests",
      "id": "9ff468eb-33da-4bb7-94b3-4e4463c0356e", "op": "update" }
  ],
  "nextCursor": 5673 }

Keep nextCursor and pass it as since on the next call. A namespace and a type are both required, and the limit defaults to 500 with a maximum of 5,000.

The caller needs read access on the type, and that is checked first. Access to a type does not mean access to every row in it, so before answering, the server re-reads the changed ids under the caller's identity with SELECT _id ... WHERE _id IN (...), and the caller's row rules decide which ids remain. A caller restricted by row rules receives no deletes, because a deleted row can no longer be checked against the rule. To read the records themselves, the client fetches them through /db or /sql, where the same rules apply again.

If since is older than the oldest row the log still holds, the feed answers 410 with cursor_expired and the oldest cursor it has, and the client resynchronises from a full read.

Webhooks that carry no data

A subscription asks the instance to call a URL when a type changes:

POST /api/webhooks/subscriptions
{ "target_url": "https://ops.example.com/hooks/inventdb",
  "namespace": "pms", "type": "maintenance_requests",
  "ops": ["insert", "update"], "debounce_ms": 1000 }

Creating one requires read access on the type. The response includes a signing secret of 32 random bytes in hex, returned once and never again; the instance stores it encrypted with the rest of its data. A new subscription starts at the current head of the log, so it hears about future changes only.

A delivery worker looks at every subscription every 2 seconds and sends a signal when all of these hold:

  • the log has moved past the subscription's cursor;
  • its debounce window has passed: 5,000 ms if you set none, and never less than the instance minimum of 1,000 ms;
  • at least one new change is in its type and matches its operations;
  • the person who created it still has read access on the type. If they have lost it, the subscription is marked degraded and stays silent until access is restored or the subscription is edited.

The worker then sends one POST, with a 10-second timeout, carrying the current head cursor. It sends one signal per window rather than one per change, so a bulk import of 500 requests produces a handful of signals and the receiver's next pull reads them all.

POST /hooks/inventdb
X-InventDB-Signature: sha256=<hex of HMAC-SHA256(secret, "<timestamp>.<body>")>
X-InventDB-Timestamp: 1784999533
X-InventDB-Delivery-Id: 49965c89bc1543b6bc33f3ec95a37475

{ "subscription_id": "webhook_sub:5482666c33ca435b9ed7757b447b0f2f",
  "cursor": 5673,
  "scope": { "ns": "pms", "type": "maintenance_requests" },
  "hint": { "changed": 1, "ops": ["update"] },
  "test": false, "ts": "2026-07-25T17:32:12Z" }

The body holds no record ids and no field values. The receiver checks the signature, then reads what changed through the API with its own credentials, so its grants and row rules govern the read. A leaked delivery reveals only that something changed in a type. The hint is advisory, counted from a sample of up to 50 changes.

A delivery fails when the receiver returns a status outside 2xx or does not answer in time. The worker then waits 30 seconds before trying again, doubling the wait each time up to one hour, and after 8 failed attempts marks the subscription dead. Editing the subscription clears that state. GET /api/webhooks/subscriptions/{id}/status compares the subscription's cursor with the head of the log, so a receiver that is falling behind is easy to spot.

Delivery is at least once. Every pull is a function of the receiver's own cursor, so a lost or repeated signal costs nothing: the next pull returns the same set. Administrators can restrict target URLs to an allowlist of domains, let only administrators create subscriptions, cap subscriptions per user (25 by default) and raise the minimum debounce.

Example: dispatching urgent maintenance

Our property-management example runs this end to end. A tenant app files an urgent maintenance request with an ordinary insert. A small Python service holds a subscription on pms.maintenance_requests. When the signal comes in, the service verifies it, pulls the new urgent requests, creates a work order for the on-call technician and marks each request as dispatched. The core of the handler uses only the standard library:

def do_POST(self):
    raw = self.rfile.read(int(self.headers.get("content-length", 0)))
    ts = self.headers.get("X-InventDB-Timestamp", "")
    mine = "sha256=" + hmac.new(SECRET.encode(), f"{ts}.".encode() + raw,
                                hashlib.sha256).hexdigest()
    if not hmac.compare_digest(mine, self.headers.get("X-InventDB-Signature", "")):
        self.send_response(401); self.end_headers(); return
    if json.loads(raw).get("test"):            # test pings: verify, answer 200, do nothing
        self.send_response(200); self.end_headers(); return
    rows = idb("POST", "/sql", {"sql": "SELECT * FROM pms.maintenance_requests "
               "WHERE status = 'new' AND priority = 'urgent' LIMIT 10"})
    for r in rows:
        idb("POST", "/db/pms/work_orders", {"request_id": r["_id"], "unit": r["unit"],
            "issue": r["issue"], "assigned_to": "tech-on-call", "sla_hours": 4,
            "status": "dispatched"})
        r["status"] = "dispatched"
        idb("PUT", "/db/pms/maintenance_requests", r)  # PUT sends the whole record
    self.send_response(200); self.end_headers()

hmac.compare_digest compares in constant time, and an answer outside 2xx makes the instance retry with backoff. In the recorded run, a tenant reported a jammed door lock on unit 9F. The first signal carried cursor 5673 and a hint of one changed record; the service pulled one request, created the work order with a four-hour SLA and flipped the request to dispatched. The next signal, after the debounce, pulled nothing. The service acts on the state of the data, status = 'new', rather than on the event, and that is what makes repeated or merged deliveries harmless.

Workflow triggers in InventDB SOAR

In InventDB SOAR the same log starts workflows. A workflow with the trigger kind record_event names a type and the operations it watches:

"trigger_kind": "record_event",
"trigger_spec": { "ns": "pms", "type": "maintenance_requests",
                  "ops": ["insert", "update"], "debounce_ms": 2000, "max_concurrency": 1 }

A dispatcher reads the log from its own stored cursor every 2 seconds, up to 10,000 changes per pass, groups them by type and operation, and matches active workflows. Each matching workflow gets at most one event per operation in each pass, carrying up to 200 changed ids. The debounce, none by default, limits how often a workflow fires. max_concurrency, 1 by default, limits how many of its runs can be in progress; changes committed while a run is in progress mark the workflow for one trailing run instead of queueing a run per change. The ids pass through the workflow owner's row rules first, and if none remain, nothing fires.

Automation load therefore follows configuration rather than write volume. A transaction of 100,000 rows is coalesced into at most one event per workflow and operation in each 2-second pass, so it starts a handful of runs rather than one run per row.

Limits

  • Capture records a write or a delete, without telling an insert from an update, so the feed reports both as update. A subscription or trigger that lists only insert also fires on updates.
  • A delete carries only the record id, and callers restricted by row rules do not see deletes at all.
  • Each subscription and each feed call covers one namespace and type.
  • The log holds 7 days or 6 million rows. A very large import can shorten that window to minutes, and a consumer that falls behind it must resynchronise.
  • Delivery is at least once, and a subscription receives at most one signal per debounce window, checked every 2 seconds. This is a notification channel for business events rather than a low-latency stream.

Setting it up

Webhooks and the change feed are part of both InventDB Serverless and InventDB SOAR; workflow triggers are part of InventDB SOAR. Create a service account for the receiver, so its credential belongs to the job rather than to a person, and grant it read access on the type. Subscribe with the request above and store the secret in the receiver's environment. Then call POST /api/webhooks/subscriptions/{id}/test: the delivery carries "test": true, and the receiver should verify the signature and answer 200 without acting. The API reference lists every webhook and feed endpoint.