Webhook subscriptions
Webhook subscriptions push events to your endpoint instead of making you poll for them. A webhook subscription points an endpoint at one or more event types for a client; when the platform emits a matching event, it delivers a POST to that endpoint. Reads need platform:read or webhooks:write and the operations module on read in one of your groups; writes need webhooks:write and are for an admin or Aleta only: any other user gets 403, in a group or not. Without the module the routes answer 403. Every write takes ?dry_run=true and an Idempotency-Key header and is logged in the change history.
A subscription you create with POST is stored but nothing delivers to it: v1 has no event sender of its own, which is also why v1's own signing key is pending (see the key list). Subscriptions moved from the legacy platform (origin: legacy) are delivered by the legacy platform, as before. They are Aleta's (owner: aleta), so your people can read them but not change them.
Data model
idstringStable identifier for the subscription. A UUID.
client_idstring or nullThe client whose events the subscription receives; null covers the whole tenant.
endpointstringThe http or https URL deliveries are POSTed to. Loopback, private, link-local and metadata addresses are refused.
event_typesarray of stringEvent types the subscription covers, as given and in order, e.g.
calculated_data.created: emitted when a day's holdings and performance numbers are ready. Each is one of the event types ofGET /v1/events. A subscription moved from the legacy platform keeps the names it was stored with.statusstringactiveordisabled. A disabled subscription receives nothing.disabled_at, disabled_reasontimestamp or null, string or nullWhen and why the subscription was disabled.
PATCHwithstatus: disabledsetsdisabled_atand leaves the reason as it is;status: activeclears both.originstringlegacy(moved from the legacy platform) orv1.ownerstringaleta(Aleta set it up for you) ortenant(your own). One Aleta set up is Aleta's to change: a PATCH or DELETE by your people, admins included, is a403with the textAleta set this up for you: only Aleta can change it.deliveryobjectThe last delivery to this subscription:
last_attempt_atandlast_success_at(timestamp or null),last_status_code(integer or null),last_error(the latest failed attempt's message, cut to 500 characters, or null) andconsecutive_failures(integer) since the last success. Never a secret.created_at, updated_attimestampRFC 3339, UTC, microseconds. A subscription moved from the legacy platform has the time legacy made it.
archived_at, archived_by, archive_reasontimestamp, string, string or nullSet when a DELETE archived a subscription that had been delivered to or was moved from the legacy platform: when, the id of the user who archived it, and the
?reason=given. Null while it is live.
List webhook subscriptions
Returns the subscriptions you can see, oldest first (created_at, then id), as a cursor list. An admin sees all of them; a user sees the subscriptions of the clients their access covers in full: a grant on the client (and its sub-clients when the grant includes them) or on the whole tenant. A grant on a party, an entity, a cash account or a custody account under a client does not show the client's subscription; a subscription for the whole tenant needs a grant on the tenant. An unknown query parameter is a 400.
Query parameters
client_idstringOnly this client's subscriptions.
include_archivedbooleanAlso archived subscriptions. Default
false.cursor, limitstring, integerThe page:
limitdefaults to 100; values above 1000 are capped at 1000. A cursor that does not decode is a400.
curl https://api.aleta.ai/v1/webhook-subscriptions \
-H "Authorization: Bearer {access_token}"
{
"data": [
{
"id": "019f95f0-2d40-7b58-8f6e-9a3c1d7e5b02",
"client_id": "019dfeb0-0511-77a7-a8be-a911ab988a0a",
"endpoint": "https://example.com/aleta/events",
"event_types": ["calculated_data.created"],
"status": "active",
"disabled_at": null,
"disabled_reason": null,
"origin": "v1",
"owner": "tenant",
"delivery": {
"last_attempt_at": null,
"last_success_at": null,
"last_status_code": null,
"last_error": null,
"consecutive_failures": 0
},
"created_at": "2026-10-17T06:12:00.000000Z",
"updated_at": "2026-10-17T06:12:00.000000Z",
"archived_at": null,
"archived_by": null,
"archive_reason": null
}
],
"next_cursor": null,
"limit": 100
}
Retrieve a webhook subscription
Returns one subscription, an archived one too. An unknown id, one you cannot see, or one that is not a UUID is a 404.
curl https://api.aleta.ai/v1/webhook-subscriptions/019f95f0-2d40-7b58-8f6e-9a3c1d7e5b02 \
-H "Authorization: Bearer {access_token}"
Create a webhook subscription
Subscribes an endpoint to events for a client. Returns 201 with the created subscription, status: active, origin: v1 and owner: tenant (aleta when Aleta creates it). A client you cannot see is a 404. Any other body field is a 400.
Body parameters
client_idstringThe client's UUID. Required.
endpointstringThe absolute http or https URL to deliver to, stored trimmed. Required. Anything else is a
400 bad_request, and so is a loopback, private, link-local or metadata address orlocalhost.event_typesarray of stringEvent types to subscribe to, stored as given and in order. Required. Each must be one of the event types, listed once. An empty list is a
400with the textevent_types must not be empty; a name that is no event type (a blank one included) is a400naming the allowed types, and a name listed twice is a400.
curl -X POST https://api.aleta.ai/v1/webhook-subscriptions \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2f0e-hook-1" \
-d '{
"client_id": "019dfeb0-0511-77a7-a8be-a911ab988a0a",
"endpoint": "https://example.com/aleta/events",
"event_types": ["calculated_data.created"]
}'
{
"id": "019f95f0-2d40-7b58-8f6e-9a3c1d7e5b02",
"client_id": "019dfeb0-0511-77a7-a8be-a911ab988a0a",
"endpoint": "https://example.com/aleta/events",
"event_types": ["calculated_data.created"],
"status": "active",
"disabled_at": null,
"disabled_reason": null,
"origin": "v1",
"owner": "tenant",
"delivery": {
"last_attempt_at": null,
"last_success_at": null,
"last_status_code": null,
"last_error": null,
"consecutive_failures": 0
},
"created_at": "2026-10-17T06:12:00.000000Z",
"updated_at": "2026-10-17T06:12:00.000000Z",
"archived_at": null,
"archived_by": null,
"archive_reason": null
}
Update a webhook subscription
Changes the endpoint, the event types or the status, and returns 200 with the subscription. Fields you leave out keep their value; any other field is a 400. status: active also brings an archived subscription back; on an archived subscription nothing else can change: 409 in_use whose next_action is this route with status. An unknown id, one you cannot see, or one that is not a UUID is a 404. One Aleta set up for you (owner: aleta) is Aleta's to change: your people, admins included, get 403 with the text Aleta set this up for you: only Aleta can change it. A subscription moved from the legacy platform is 409 legacy_managed until your tenant has moved; the 403 comes first.
Body parameters
endpointstringThe new URL, checked as on create.
event_typesarray of stringThe new event types, replacing the old ones, checked as on create: an empty list, a name that is no event type or a name listed twice is a
400.statusstringactiveordisabled; any other value is a400with the textstatus must be one of: active, disabled.
curl -X PATCH https://api.aleta.ai/v1/webhook-subscriptions/019f95f0-2d40-7b58-8f6e-9a3c1d7e5b02 \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{ "status": "disabled" }'
Delete a webhook subscription
Unsubscribes the endpoint. A subscription that was never delivered to is deleted: 204. One that was delivered to is archived and stays readable: 200 with the subscription; ?reason= is kept as its archive_reason. Any query parameter other than reason and dry_run is a 400. An archived subscription answers 200 as it is. Unknown or not visible: 404. One Aleta set up for you (owner: aleta) is Aleta's to change: your people, admins included, get 403 with the text Aleta set this up for you: only Aleta can change it. A subscription moved from the legacy platform is 409 legacy_managed until your tenant has moved, after the 403 check; after the move it is archived like a delivered one.
curl -X DELETE https://api.aleta.ai/v1/webhook-subscriptions/019f95f0-2d40-7b58-8f6e-9a3c1d7e5b02 \
-H "Authorization: Bearer {access_token}"
List webhook signing keys
The public keys a webhook signature verifies against, newest first. One key signs for every tenant, and Aleta rotates it with 30 days of overlap: the active key signs now, and an older key stays on the list as retiring until retires_at. A key made by a rotation for v1's own key ring is pending: nothing signs with it, because v1 has no event sender of its own. Verify a signature against every key on the list, and fetch the list again when a signature does not verify (at least daily), since a rotation signs with the new key at once. Needs platform:read or webhooks:write, and the operations module on read in one of your groups (403 without). Where no signing key source is set up, the route answers 503. The page has no cursor: next_cursor is null and limit is the number of keys.
Response fields
kid, kty, crv, alg, use, x, yJWKThe key as a JSON Web Key: base64url coordinates without padding.
algis null only for a curve other than P-256, P-384 or P-521.statusstringactive(signs now),pending(v1's key ring: made, nothing signs with it) orretiring.created_at, retires_atISO 8601 timestamp or nullWhen the key was made, and when a retiring key leaves (null for the active and pending keys).
curl https://api.aleta.ai/v1/webhook-keys \
-H "Authorization: Bearer {access_token}"
{
"data": [
{
"kid": "339c621783ded44d99033456fef772714427bf789b5a78e3aaccfd76026ae5c6",
"kty": "EC",
"crv": "P-256",
"alg": "ES256",
"use": "sig",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
"status": "active",
"created_at": "2026-10-06T00:00:00Z",
"retires_at": null
}
],
"next_cursor": null,
"limit": 1
}
Rotate the v1 signing key
Aleta only, with platform:write: everyone else, a tenant admin included, gets 403 forbidden. Makes a new signing key for v1's own key ring and answers 201 with it in active, the ring's current key, with status pending (nothing signs with the ring); the key it replaces is in retiring and keeps verifying for 30 days. The private key never leaves Aleta's key service. The key the legacy platform signs with is not part of the ring and does not change. Both appear on the list. Takes ?dry_run=true (no key is made: active is null and retiring shows the current key as it would retire) and Idempotency-Key (a replay makes no second key). A replay with another body is a 409. A rotation that cannot be stored is undone (the new key is scheduled for deletion) and answers 409. Where no signing key service is set up, the route answers 503.
Body
reasonstringOptional. Why the key is rotated; kept with the key. Any other field is a
400.
curl -X POST https://api.aleta.ai/v1/webhook-keys/rotate \
-H "Authorization: Bearer {access_token}" \
-H "Idempotency-Key: rotate-2026-10" \
-H "Content-Type: application/json" \
-d '{"reason": "scheduled rotation"}'
{
"active": {
"kid": "0199f2a0-5b1c-7e3d-9f40-1a2b3c4d5e6f",
"kty": "EC",
"crv": "P-256",
"alg": "ES256",
"use": "sig",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
"status": "pending",
"created_at": "2026-10-18T09:00:00Z",
"retires_at": null
},
"retiring": null
}
Related
- Holdings — what to fetch when a
calculated_data.createdevent arrives. - Performance — likewise ready when the event fires.