Developers · Webhooks

Webhooks.

Register an HTTPS endpoint and Alethia POSTs a signed JSON payload whenever a subscribed event happens: a deal moves stage, an entity is updated, a shareholding is created. Manage subscriptions with the manage_webhooks capability.

Subscribable

Events

Every event, grouped by resource. "ping" is reserved for the test endpoint and cannot be subscribed.

deal

deal.createddeal.updateddeal.stage_changeddeal.deleted

entity

entity.createdentity.updatedentity.deleted

person

person.createdperson.updatedperson.deleted

shareClass

shareClass.createdshareClass.updatedshareClass.deleted

shareholding

shareholding.createdshareholding.updatedshareholding.deleted

document

document.createddocument.updateddocument.deleted
  • "ping" is reserved for the test endpoint and cannot be subscribed.
  • Every delivery carries two signatures made with the same secret: X-Alethia-Signature (timestamped, recommended) and the legacy X-Tithe-Signature (body only). Verify the first; the second remains so existing receivers keep working.
  • The User-Agent stays Tithe-Webhooks/1 because it is documented here and a receiver may match on it.
  • deal.stage_changed fires when a stage change made in-app (the pipeline board or the deal forms) commits — never from the public API: PATCH /api/v1/deals/{dealId} silently strips stage on every write (by design — only the in-app service layer enforces the IC-gate, locked-deal, and forward-move rules a stage change must pass). data carries the full deal record plus previousStage and newStage.
  • Subscriptions with recordAccess: "open_only" (the default) never receive restricted records: a record flagged restricted, or one that belongs to or names a restricted entity or an entity that does not exist (a shareholding's issuer or entity holder, a share class's entity, a deal's buying company or party entities). Such an event is skipped for that subscription with no delivery attempt and no delivery record, and the same check is made again before every retry. Identity and compliance evidence documents are sent to no subscription.

Payload

Two envelope shapes

Most events use a simple standard envelope. entity.* events run through a durable outbox with a versioned envelope, so a subscriber can detect gaps and out-of-order delivery.

Standard envelope: deals, people, share classes, shareholdings, documents

Standard envelope (deals, people, share classes, shareholdings, documents, and ping): { event: string, workspaceId: string, data: <the affected record / test payload>, timestamp: ISO-8601 }.

{
  "event": "deal.created",
  "workspaceId": "ws_9f2c1a",
  "data": {
    "id": "deal_6f2a19",
    "name": "Example Deal",
    "stage": "identified",
    "...": "the rest of the deal record"
  },
  "timestamp": "2026-07-15T09:30:00.000Z"
}

Durable-outbox envelope: entity.*

entity.* events are produced through a durable outbox with a versioned envelope: { schemaVersion: 1, id: <sha256 event id>, event, workspaceId, entity: { id, data: <snapshot | tombstone> }, timestamp: ISO-8601, revision: number, sequence: number }. entity.deleted carries a tombstone whose revision matches the envelope.

{
  "schemaVersion": 1,
  "id": "3f9a2b7c1d4e5f60a1b2c3d4e5f60718",
  "event": "entity.updated",
  "workspaceId": "ws_9f2c1a",
  "entity": {
    "id": "ent_51ac02",
    "data": {
      "id": "ent_51ac02",
      "legalName": "Example Holdings Ltd",
      "...": "a full snapshot, or a tombstone for entity.deleted"
    }
  },
  "timestamp": "2026-07-15T09:30:00.000Z",
  "revision": 4,
  "sequence": 128
}

Verify every delivery

Signature verification

Every delivery carries an X-Alethia-Signature header, t=<unix seconds>,v1=<hex HMAC-SHA256>: HMAC-SHA256 over the string "<t>.<raw body>" (the header's own t, a full stop, then the raw body), keyed with the subscription's signingSecret, hex-encoded.

  1. Read the X-Alethia-Signature header and split it on commas into t=<unix seconds> and one or more v1=<hex> values.
  2. Take the RAW request body bytes exactly as received, and build the signed string: t, a full stop, then the body.
  3. Compute HMAC-SHA256 over that string using the subscription's signingSecret (whsec_…) as the key and hex-encode it.
  4. Compare against each v1 value in constant time; accept the delivery if any matches.
  5. Reject the delivery if t is more than five minutes (300 seconds) from your own clock, in either direction.
Node.js
const crypto = require("node:crypto");

const TOLERANCE_SECONDS = 300; // reject anything older (or newer) than 5 minutes

// X-Alethia-Signature: t=1760000000,v1=9f2c…
function isValidSignature(rawBody, signatureHeader, signingSecret) {
  const parts = Object.fromEntries(
    (signatureHeader || "").split(",").map((part) => part.trim().split("=")),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; // replay guard
  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(t + "." + rawBody) // the RAW bytes, before any JSON.parse
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Replay protection

  • The timestamp is part of the signed string, so an attacker cannot change t without invalidating the signature; checking t against a tolerance is what defeats a replayed capture. Five minutes is the recommended tolerance; keep your server clock synchronised (NTP).
  • t is the time of each delivery attempt. A retry is the same body with a fresh t and a fresh signature, so a tolerance window never rejects a legitimate retry.
  • A tolerance does not by itself stop a replay inside the window. To reject a replay, key on signed data only: for entity.* events store the envelope id and ignore repeats; for every other event, AFTER the signature verifies, compute a replay key from the verified values, not from the header text: SHA-256 of the exact signed string <t>.<raw body>, with t as the parsed number. Store it and reject a repeat. Never key on the raw X-Alethia-Signature string: the same valid signature can be resent with its fields reordered, extra spaces or upper-case hex, which still verifies but is a different string. Keep each key for at least the signature tolerance (five minutes if you use the recommended tolerance), since an older capture already fails the t check. entity.* events may use either their envelope id or this key.
  • X-Alethia-Delivery-Id is NOT authenticated and must never be your replay check: anyone holding a captured request can change it without invalidating the signature. Use it only to skip work for a legitimate retry you have already processed (a retry has a fresh t, so its signature differs, but its delivery id is the same).
  • Never log or echo the signing secret; compare signatures in constant time.

Legacy X-Tithe-Signature

Legacy, kept for compatibility: HMAC-SHA256 over the body alone, hex, prefixed: sha256=<hex digest>. It carries no timestamp, so it cannot detect replay; prefer X-Alethia-Signature.

Node.js (legacy)
// Legacy X-Tithe-Signature: sha256=<hex> — body only, no timestamp, no replay guard.
function isValidLegacySignature(rawBody, signatureHeader, signingSecret) {
  const expected = crypto.createHmac("sha256", signingSecret).update(rawBody).digest("hex");
  const received = (signatureHeader || "").replace(/^sha256=/, "");
  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Reliability

Delivery & retries

Delivery headers, transport rules and the retry/backoff schedule.

Headers on every delivery: Content-Type: application/json, Content-Length, X-Alethia-Signature, X-Tithe-Signature, X-Tithe-Event, X-Alethia-Delivery-Id (standard-envelope events), User-Agent: Tithe-Webhooks/1.

  • POST over HTTPS only; URLs resolving to private/loopback/link-local/metadata addresses are blocked at registration AND at delivery (DNS-pinned, rebinding-safe).
  • 5-second per-attempt timeout; redirects are not followed; any 2xx status counts as delivered.
  • Standard-envelope events: up to 5 attempts with backoff of 1, 5, 30, 120 and 360 minutes (cron-driven retry sweep).
  • entity.* outbox events: up to 8 attempts, exponential backoff from 60 s capped at 6 h; payloads capped at 128 KiB.
  • Delivery outcomes are denormalised onto the subscription (lastDeliveryAt / lastDeliveryStatus).
  • Standard-envelope events carry an X-Alethia-Delivery-Id header. It identifies the delivery, it is the same on the first attempt and on every retry of that delivery, and it is not part of the signed string, so it is not authenticated. Delivery is at least once, so a receiver can store the id to skip a retry it has already processed, but must not use it to reject replays: use the replay key described under replay guidance. entity.* events do not carry the header; use the id in their envelope.

Before you trust it

Send a test event

POST /api/v1/webhooks/{subId}/test — Fires a synthetic "ping" event at the subscription's URL, signed with its signingSecret, and reports the outcome. The subscription must be enabled and structurally deliverable. Payload data: { message: "Test delivery from Alethia", subscriptionId }.

curl -X POST "https://app.alethiahq.com/api/v1/webhooks/sub_123/test" \
  -H "Authorization: Bearer tk_{workspaceId}_{secret}" \
  -H "Idempotency-Key: 9f2d6b7a-9d61-4e3a-8c2a-5e7b1f0a6c9d"

Wire up your first subscription.

Register an endpoint, send yourself a test ping, and you are ready to receive live events.