Skip to content

Outbound webhooks ​

Aletheia sends events to a tenant's own systems as they happen: a decision, a case created, assigned, decided, closed or past its due date, the end of a run, an approval request and its outcome, and an app's circuit opening. An admin adds an endpoint in Connect › Webhooks (or through the API), picks its events and gets a signing secret; every delivery is signed with it.

Endpoints ​

RoutePermissionWhat it does
GET /webhook-eventswebhooks:readThe event catalogue, with a sample body per event.
GET /webhook-endpointswebhooks:readThe tenant's endpoints with lastDeliveryAt and failing.
POST /webhook-endpointswebhooks:write{ url, description?, events, enabled? }; answers the endpoint and its secret, once.
GET, PUT, DELETE /webhook-endpoints/:idas aboveRead, replace (the secret is kept) or delete an endpoint with its deliveries.
POST /webhook-endpoints/:id/testwebhooks:writeSends a signed ping now and answers { delivered, responseCode, latencyMs, error? }.
POST /webhook-endpoints/:id/rotate-secretwebhooks:writeA new secret, once; the previous one keeps signing for 24 hours.
GET /webhook-deliverieswebhooks:readDeliveries newest first, filtered by endpointId, status, event, workflowRunId, time.
POST /webhook-deliveries/:id/retrywebhooks:writeMakes a failed or given-up delivery due now; 409 for a delivered one.

Admins hold both permissions. URLs must be https and must not name localhost or a private, loopback or link-local address; a name that resolves to one is refused when a delivery connects, so DNS cannot turn a delivery towards the deployment's own network. A tenant has at most 20 endpoints. Secrets are sealed with SECRET_STORE_KEY: without it on the API endpoints cannot be created (503 secret_store_unavailable), and without it on the worker deliveries wait. WEBHOOK_ALLOW_PRIVATE_NETWORKS=true lets endpoints use http and private addresses, for development stacks only. Endpoint changes, tests, rotations and retries are audited (webhook_endpoint.*, webhook_delivery.retried); secrets never are.

Deliveries ​

Each delivery is a POST with a JSON body:

json
{
  "id": "7f1c2a9e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
  "type": "decision.created",
  "occurredAt": "2026-10-06T10:00:00.123Z",
  "tenantId": "11111111-1111-4111-8111-111111111111",
  "resource": { "type": "decision", "id": "0c9e6a1b-2d3f-4a5b-8c6d-7e8f9a0b1c2d" },
  "data": { "outcome": "approve", "riskScore": 12, "source": "automated", "workflowRunId": "…" }
}

id is the event's id: the same on every attempt and on every endpoint that receives the event, so a receiver deduplicates on it. data is the payload the change wrote to the audit trail. The headers are:

HeaderValue
Content-Typeapplication/json
User-AgentAletheia-Webhooks/1
X-Aletheia-EventThe event type (ping for a test).
X-Aletheia-DeliveryThe delivery's id, the same on every attempt of it.
X-Aletheia-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">, one v1 per secret.

A 2xx answer within 10 seconds counts as delivered. Anything else (another status, a timeout, a refused connection) is retried 1 minute, 5 minutes, 30 minutes and 2 hours later; after the fifth failed attempt the delivery is given up (exhausted), the endpoint shows as failing, Home lists it under Needs your attention for a day and the admins get a notification. Redirects are not followed. Deliveries are at least once and unordered: a delivery can arrive twice, and two events can arrive in either order, so compare occurredAt when order matters.

Events go out a few seconds after they happen: the worker reads the audit trail every two seconds and leaves events younger than ten seconds for the next pass, so a change written by a transaction still open is not skipped. An endpoint receives the events that happen after it is created; a disabled endpoint receives nothing, and its queued deliveries wait until it is enabled again. Every worker sends deliveries; one at a time reads the audit trail.

Verifying a delivery ​

Recompute the HMAC over the raw body as received (before any JSON parsing) and compare it with each v1, in constant time, and refuse a timestamp more than five minutes away from now:

js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyAletheiaSignature(header, rawBody, secret, toleranceSeconds = 300) {
  const parts = header.split(',').map((part) => part.trim().split('='));
  const t = Number(parts.find(([key]) => key === 't')?.[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  return parts.some(
    ([key, value]) =>
      key === 'v1' && value?.length === 64 && timingSafeEqual(Buffer.from(value, 'hex'), expected),
  );
}

After Rotate secret deliveries carry two v1 signatures for 24 hours, one with the new secret and one with the old, so the receiver can switch to the new secret at its own pace.

Events ​

EventRaised by (audit action)Resource
decision.createddecision.emitted: a run emitted a decisiondecision
case.createdcase.created: a run opened a casecase
case.assignedcase.assigned: a case was assigned or claimedcase
case.decidedcase.decided: a reviewer decided a casecase
case.closedcase.closedcase
case.sla.breachedcase.sla.breached: a case passed its due date while opencase
run.completedworkflow.run.completedworkflow_run
run.failedworkflow.run.failed, with the errorworkflow_run
approval.requesteddefinition.approval.requesteddefinition_approval
approval.decideddefinition.approval.approved, .rejected or .withdrawndefinition_approval
app.circuit.openedapp.circuit.opened: the workers stopped calling an app after failuresapp_install
submission.link_requestedcollection.submission.link_requested: an applicant whose link expired asked for a new one; send a fresh link with POST /collection-submissions/{id}/linkcollection_submission

GET /webhook-events returns each with a sample body. A delivery about a run, its decision or its cases carries the run's id, so GET /workflow-runs/:id/related lists the deliveries of a run (webhooks:read) and the run page shows them.

The same events raise the console's notifications.

Released under the Apache-2.0 License.