Configure notifications
Configure where run-completion notifications get delivered for a workspace. One config row = one delivery destination (channel is singular), so a workspace wanting both webhook and in-app delivery creates two configs.
SDK
Create a webhook config — it delivers to a target URL, optionally signed with a secret (used for HMAC signing, and never returned by the API):
import aip_sdk as aip
webhook_cfg = aip.NotificationConfig.create(
workspace_id=ws.id,
channel="webhook",
target="https://hooks.example.com/aip/run-completed",
secret="shared-hmac-secret",
event_filter=["run.completed"],
)
print(webhook_cfg.id, webhook_cfg.channel, webhook_cfg.enabled)
An in-app config takes neither a target nor a secret:
in_app_cfg = aip.NotificationConfig.create(
workspace_id=ws.id,
channel="in_app",
event_filter=["run.completed"],
)
List the configs visible to you in a workspace. Soft-deleted configs are always excluded, and results paginate with page / per_page (defaulting to page=1, per_page=100):
configs = aip.NotificationConfig.list(workspace_id=ws.id)
for cfg in configs:
print(cfg.id, cfg.channel, cfg.target, cfg.enabled)
later_page = aip.NotificationConfig.list(workspace_id=ws.id, page=2, per_page=50)
There is no update endpoint — rotate a secret by deleting the old config and creating a new one:
webhook_cfg.delete()
new_webhook_cfg = aip.NotificationConfig.create(
workspace_id=ws.id,
channel="webhook",
target="https://hooks.example.com/aip/run-completed",
secret="rotated-hmac-secret",
event_filter=["run.completed"],
)
Attributes — id, workspace_id, channel ("webhook" or "in_app"), target, event_filter, enabled, created_at, updated_at. secret is write-only — sent in the request body and never returned by the API.
Notes
- Caller must be
workspace_admin/workspace_editorof the target workspace to create or delete. Listing is allowed for any workspace member (admin / editor / viewer). event_filtermust contain at least one event type, and every entry must be a supported event type. Use"run.completed"today; more event types will land as the dispatcher grows. An unsupported type (e.g."run.failed") is rejected with422at creation time rather than silently accepted and never delivered. Matching is exact — there are no wildcards, so a config only receives the event types it lists verbatim (see Which events reach a config).targetis required forwebhookand rejected forin_app;secretis rejected forin_apptoo. A request that breaks these rules — or one with an emptyevent_filter, or one listing an unsupported event type — is rejected with422before any config is created.delete()is a soft-delete: the row is preserved on the server withdeleted_atset, but is excluded from all subsequent reads — a seconddelete()raisesNotFoundError.- Cross-workspace lookup by id (
delete()) returns 404, not 403, so the existence of configs in other workspaces never leaks.list()with an explicitworkspace_idyou cannot access returns 403 — the workspace ID is something the caller already named, so there's no existence to protect. get()(by id) andupdate()are intentionally not exposed. Rotate bydelete()+create().
API
curl -sS -X POST "$AIP_API_URL/notifications/config" \
-H "Authorization: Bearer $AIP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"workspace_id": "<ws-id>",
"channel": "webhook",
"target": "https://hooks.example.com/aip/run-completed",
"secret": "shared-hmac-secret",
"event_filter": ["run.completed"],
"enabled": true
}'
curl -sS "$AIP_API_URL/notifications/config?workspace_id=<ws-id>" \
-H "Authorization: Bearer $AIP_TOKEN"
curl -sS -X DELETE "$AIP_API_URL/notifications/config/<config-id>" \
-H "Authorization: Bearer $AIP_TOKEN"
Delivery channels
A config's channel decides how a matching run-completion event is delivered:
| Channel | What happens on a matching run completion |
|---|---|
webhook | The platform POSTs the event envelope to your target URL, signed with the per-config secret (if set). Deliveries are retried — see Delivery semantics. |
in_app | No outbound call is made. The notification is recorded server-side for the in-app feed; there is nothing to receive or verify. |
The rest of this section is about the webhook channel — the envelope you receive, how to verify it, and how delivery behaves under failure.
Webhook payload envelope
Every webhook delivery is a single JSON object with this shape. The body is sent as compact JSON (no spaces between tokens); the form below is expanded only for readability.
{
"event": "run.completed",
"event_id": "f1f1c0de-0000-4000-8000-000000000000",
"occurred_at": "2026-05-27T10:00:00Z",
"workspace_id": "ws_abc123",
"data": {
"run_id": "run_def456",
"project_id": "proj_ghi789",
"pipeline": "evaluate_sut_hosted",
"status": "completed",
"result_url": "/runs/run_def456"
}
}
| Field | Description |
|---|---|
event | Dotted event type. "run.completed" today. Filter on this without parsing data. |
event_id | UUID, stable across retries of the same notification — your idempotency key (see deduplication). |
occurred_at | ISO-8601 UTC timestamp of when the event was produced. |
workspace_id | The workspace the run belongs to. |
data.run_id | The run that reached a terminal state. |
data.project_id | The run's project. |
data.pipeline | The pipeline that executed (e.g. evaluate_sut_hosted). |
data.status | The terminal run status (e.g. completed). |
data.result_url | API path to the run's results, relative to your API base URL. |
The same envelope shape serves every channel and every event type, so a receiver written against it keeps working as new event types are added.
Verifying webhook authenticity
When a config has a secret, every delivery carries an HMAC signature so you can confirm the request genuinely came from the platform and was not tampered with or replayed.
The signature is sent in the X-Resaro-Signature header, formatted as:
X-Resaro-Signature: t=1716804000,v0=<hex-hmac-sha256>
tis the signing timestamp (Unix seconds).v0isHMAC-SHA256(secret, "v0:{t}:{raw_body}"), hex-encoded.
Recompute the digest over the exact raw request body (do not re-serialise the parsed JSON — key order and spacing must match what was signed) and compare in constant time.
The helper below takes the per-config secret, the raw X-Resaro-Signature header value, and the exact request-body bytes (captured before JSON parsing); max_age_seconds rejects deliveries whose timestamp is too old, so a captured request can't be replayed later. It signs the raw body bytes directly — byte-for-byte what the server signed, with no UTF-8 round-trip to misencode — and it fails closed: a missing, malformed, stale, or mismatched signature returns False rather than raising, so it is safe to call on untrusted input.
import hashlib
import hmac
import time
def verify_webhook(secret: str, signature_header: str, raw_body: bytes, *, max_age_seconds: int = 300) -> bool:
try:
parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
timestamp, received = parts["t"], parts["v0"]
if abs(time.time() - int(timestamp)) > max_age_seconds:
return False
signed = b"v0:" + timestamp.encode() + b":" + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
except (KeyError, ValueError):
return False
A config created without a secret is delivered unsigned (no X-Resaro-Signature header); set a secret if you want to verify authenticity, which you should for any internet-facing endpoint.
Delivery semantics: retries, dead-letter, and deduplication
- At-least-once. A delivery is retried until it is acknowledged, so your endpoint can receive the same notification more than once. The retries carry the same
event_id, so dedupe onevent_id— treat a repeatedevent_idas already handled and return2xxwithout reprocessing. - What counts as success. Any
2xxresponse acknowledges the delivery. Respond2xxonly once you've durably accepted the payload. - Retries. A transient failure — a connection error, timeout, or a
429/500/502/503/504— is retried with exponential backoff and full jitter, up to ~8 attempts spread across roughly 24 hours. Keep your endpoint idempotent; don't assume the first attempt is the only one. - Fail-fast. A non-transient response — any other
4xx, or a3xxredirect (redirects are not followed) — is treated as permanent and is not retried. Return2xxto accept, a transient5xxto ask for a retry, and a4xxonly when the delivery should be abandoned. - Dead-letter. Once the retry window is exhausted, the notification is marked
failedand retained as the dead-letter record. There is no automatic replay today — replaying a failed delivery is a manual operation.
When notifications are not sent
These are silent no-ops by design — no delivery, and no error:
- Event-filter miss. A config is delivered only the event types listed verbatim in its
event_filter. A config filtering["run.failed"]receives nothing when a run completes; matching is exact, with no wildcards. - Run with no workspace. A run whose project resolves to no workspace (e.g. a run with no associated project) matches no config, so nothing is enqueued or sent.
- No enabled config. A workspace with no enabled, matching config for the event simply produces no notification.
End-to-end example
Stand up a receiver, point a config at it, run an evaluation, and verify the delivery you get back.
First, a minimal signed-webhook receiver (Flask). It rejects any call whose signature is missing, tampered, or replayed, then dedupes on event_id — delivery is at-least-once, so the same event_id can arrive more than once — and returns 2xx to acknowledge. Use a database rather than an in-memory set for the dedupe store in production.
import os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"]
seen: set[str] = set()
@app.post("/aip/run-completed")
def receive():
sig = request.headers.get("X-Resaro-Signature", "")
if not sig or not verify_webhook(SECRET, sig, request.get_data()):
abort(401)
event = request.get_json()
if event["event_id"] in seen:
return "", 200
seen.add(event["event_id"])
print(f"{event['event']}: run {event['data']['run_id']} -> {event['data']['status']}")
return "", 200
Then register the webhook and start a run. When the run reaches a terminal state, the platform POSTs the signed envelope to target — one POST per matching config, retried on failure — and the receiver above verifies it and dedupes on event_id. Scoring and uploading results work exactly as in Runs & Results; tear the config down when you're done.
import aip_sdk as aip
cfg = aip.NotificationConfig.create(
workspace_id=ws.id,
channel="webhook",
target="https://hooks.example.com/aip/run-completed",
secret=os.environ["WEBHOOK_SECRET"],
event_filter=["run.completed"],
)
with aip.run(project=project.id, dataset=f"{dataset.id}@v1") as run:
...
cfg.delete()