Skip to content

Webhooks

Outbound webhooks let downstream systems react to record lifecycle events without polling. Subscriptions are registered at runtime (POST /api/v1/webhooks) — they’re per-tenant data, not schema config.

The framework emits a record event whenever a tracked schema’s auto-generated route mutates a document; the webhook dispatcher finds every active subscription whose events list matches the event type and POSTs an HMAC-signed payload to its URL.

Terminal window
curl -X POST https://api.example.com/api/v1/webhooks \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{
"events": ["order.created", "order.transitioned", "account.*"],
"url": "https://hooks.example.com/davepi"
}'

Response (the secret field is shown exactly once — subsequent reads omit it):

{
"_id": "<sub-id>",
"userId": "<tenant>",
"events": ["order.created", "order.transitioned", "account.*"],
"url": "https://hooks.example.com/davepi",
"active": true,
"failureCount": 0,
"secret": "<32-byte hex>",
"createdAt": "...",
"updatedAt": "..."
}

Stash the secret somewhere safe; you can’t recover it later. If you lose it, delete the subscription and create a new one.

PatternMatches
order.createdExact event type.
order.*Every order.<verb> event (created, updated, deleted, transitioned).
*Every event the tenant emits. Useful for catch-all integrations.

The framework emits these for every schema:

Event typeWhen
<path>.createdAuto-generated POST /api/v1/<path> succeeds, or a GraphQL create mutation does.
<path>.updatedPUT /api/v1/<path>/:id, bulk-update PUT, or GraphQL update mutation.
<path>.deletedDELETE /api/v1/<path>/:id, bulk-delete, or GraphQL delete mutation.
<path>.transitionedState-machine transition (REST PUT changing the state field, GraphQL <path>Transition<Field>, or MCP equivalent).

<path> is the schema’s path declaration. There’s no <path>.restored event today — soft-restore emits a .updated.

Each delivery is a POST with the headers:

X-davepi-Signature: sha256=<hex>
X-davepi-Event: order.created
X-davepi-Delivery: <uuid>
Content-Type: application/json

And the body:

{
"id": "<uuid>",
"type": "order.created",
"version": "v1",
"userId": "<tenant>",
"recordId": "<doc-id>",
"record": { /* the affected document */ },
"deliveredAt": "2026-05-11T12:00:00Z"
}

For bulk mutations (PUT against a query, bulk-delete), the payload swaps recordId + record for:

{
"id": "<uuid>",
"type": "order.updated",
"version": "v1",
"userId": "<tenant>",
"filter": { /* the query that matched */ },
"numAffected": 47,
"deliveredAt": "..."
}

No before document is delivered today. If the receiver needs the prior state, query the record’s audit log via GET /api/v1/<path>/:id/history.

const crypto = require('node:crypto');
function verify(req, secret) {
const sig = req.headers['x-davepi-signature'] || '';
if (!sig.startsWith('sha256=')) return false;
const provided = sig.slice('sha256='.length);
const expected = crypto.createHmac('sha256', secret)
.update(req.rawBody) // not the parsed JSON!
.digest('hex');
// timing-safe compare
const a = Buffer.from(provided, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

req.rawBody is the raw request body (use a middleware that preserves it, e.g. express.raw({ type: 'application/json' }) or bodyParser.json({ verify: (req, _, buf) => req.rawBody = buf })).

OutcomeWhat happens
HTTP 2xxSuccess — failureCount reset to 0, lastDeliveryAt updated.
Non-2xx, timeout (10s), network errorRetry on the backoff schedule: 1s, 5s, 30s, 5min, 1h. Each attempt counts.
10 consecutive failures across deliveriesThe subscription is auto-disabled (active: false). Re-enable manually after fixing the receiver.

Deliveries are at-least-once — a delivery may be retried even if your receiver eventually returned 2xx for a prior attempt. Receivers must be idempotent. Use the X-davepi-Delivery header (the delivery’s id) as a deduplication key.

POST /api/v1/webhooks/:id/test fires a synthetic webhook.test event to the subscription’s URL — useful for verifying the receiver’s signature check without waiting for a real mutation.

VerbPathNotes
POST/api/v1/webhooksCreate. Returns the secret exactly once.
GET/api/v1/webhooksList the caller’s subscriptions (secrets omitted).
GET/api/v1/webhooks/:idRead one (secret omitted).
DELETE/api/v1/webhooks/:idDelete.
POST/api/v1/webhooks/:id/testHand-fire a webhook.test event.

All routes are tenant-scoped — subscriptions belong to the creating user’s userId.

On create, the URL is validated against private / loopback / link-local ranges and against DNS resolutions that point at them. In NODE_ENV=test this check is relaxed so a local Express receiver bound to 127.0.0.1 can receive deliveries during the test suite; production rejects loopback URLs.

  • Audit rows themselves. Webhooks track schema events, not the audit log. If you want every audit row mirrored externally, write a custom route.
  • Search / aggregation reads. Reads don’t emit events.
  • Cross-tenant fan-out. A subscription created by tenant A only sees events that happened in tenant A’s scope.
  • State machines<path>.transitioned events.
  • ACL — projection applied to the record field on delivery.
  • Audit log — same per-record history that webhook receivers can look up via /history.