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, an install's circuit opening, a save of an install waiting for approval, a module the operator blocked and the operator's review of one of the tenant's app versions. 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, NAT64 (64:ff9b::/96, 64:ff9b:1::/48) and 6to4 (2002::/16) ones included, which carry an IPv4 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, and the audit trail records an endpoint's URL as its origin with its path and query masked (https://hooks.example.com/••••?••••), since either can hold the receiver's token.

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 delivery, a test ping included, carries no trace context (traceparent, baggage), and Aletheia's traces and metrics never record an endpoint's URL, so a URL that holds a token in its path or its query (a Slack-style incoming webhook) keeps it out of them.

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: an install's circuit opened after its vendor's outages, and every worker refuses its calls for a while; data names the install, its app and the failuresapp_install
app.install.change.requestedapp.install.change.requested: a save of an install adds hosts, or its automatic upgrade adds hosts or capabilities, and waits for an administratorapp_install
app.install.upgradedapp.install.upgraded: an install that upgrades automatically moved, or moved back, to another version, or an administrator approved the move it asked forapp_install
app.module.blockedapp.module.blocked: the operator blocked the module an install runs, by its hash or its app; one event per install, data with the operator's reasonapp_install
app.quota.exceededapp.quota.exceeded: a call through an install was refused because the period's use reached one of the operator's quotas of the tenant or one of the install's budgets; once per limit and periodapp_install
app.review.decidedapp.review.decided: the operator approved (and so published), rejected (with a comment) or, by turning reviews off, withdrew a review of a versionapp_version
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.

app.module.blocked carries { name, appName, versionId, version, sha256, scope, reason }: the install, the version it runs, whether the operator blocked that module or every version of its app, and why. From then on the install's calls, test calls and vendor webhooks fail with app_blocked, and its sessions hand no applicant off, until the operator lifts the block, which raises no event: the audit trail records app.module.unblocked, and GET /apps/health stops listing the install. Only the installs that run the module are told: a session that a blocked version started on an install that has moved to another version waits until its timeout, and its webhooks fail meanwhile.

app.install.change.requested carries { name, appName, changeId, versionId, hosts, hostsAdded, hostsRemoved, newInstall, origin, needsAdded, handlesAdded, readsDocuments, receivesWebhooks, … }: origin is save for an administrator's save, or auto_upgrade for an automatic upgrade the system asked for, with the version it moves to, the fromVersion it leaves and what it adds beyond hosts as the install's config would use it (needsAdded, such as documents, and handlesAdded, webhook or handoff). readsDocuments and receivesWebhooks say whether calls through the state it asks for would read the tenant's documents and receive the vendor's webhooks.

app.install.upgraded carries { name, appName, fromVersionId, fromVersion, toVersionId, toVersion, automatic, rollback, hostsRemoved }: automatic is true for the platform's own move and false for the approval of a move it asked for, and rollback is true when the install moved back down its range because the version it ran was withdrawn or blocked (Apps: reference).

app.quota.exceeded carries { install, app, scope, metric, period, max, used, currency, periodStart, resetsAt }: scope tenant for one of the operator's quotas of the tenant (whatever install met it) or install for one of the install's budgets, the metric it counts (calls, http_requests or spend, in micros of currency), its period (a UTC day or month) and when it ends (resetsAt). The run's step that made the call failed with app_quota_exceeded, a test call answered 429, and the install's calls are refused until the period ends, or the limit is raised: the operator's quotas with pnpm ops tenants quota, the install's budgets with PUT /app-installs/:install/limits. GET /apps/metering shows what each period used.

app.review.decided carries { name, version, reviewId, decision, comment } for a tenant whose versions wait for the operator's review (appReview required): an approved version is published, a rejected one stays uploaded and can be published again.

The same events raise the console's notifications.

Released under the Apache-2.0 License.