Skip to main content

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_editor of the target workspace to create or delete. Listing is allowed for any workspace member (admin / editor / viewer).
  • event_filter must 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 with 422 at 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).
  • target is required for webhook and rejected for in_app; secret is rejected for in_app too. A request that breaks these rules — or one with an empty event_filter, or one listing an unsupported event type — is rejected with 422 before any config is created.
  • delete() is a soft-delete: the row is preserved on the server with deleted_at set, but is excluded from all subsequent reads — a second delete() raises NotFoundError.
  • Cross-workspace lookup by id (delete()) returns 404, not 403, so the existence of configs in other workspaces never leaks. list() with an explicit workspace_id you cannot access returns 403 — the workspace ID is something the caller already named, so there's no existence to protect.
  • get() (by id) and update() are intentionally not exposed. Rotate by delete() + 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:

ChannelWhat happens on a matching run completion
webhookThe platform POSTs the event envelope to your target URL, signed with the per-config secret (if set). Deliveries are retried — see Delivery semantics.
in_appNo 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"
}
}
FieldDescription
eventDotted event type. "run.completed" today. Filter on this without parsing data.
event_idUUID, stable across retries of the same notification — your idempotency key (see deduplication).
occurred_atISO-8601 UTC timestamp of when the event was produced.
workspace_idThe workspace the run belongs to.
data.run_idThe run that reached a terminal state.
data.project_idThe run's project.
data.pipelineThe pipeline that executed (e.g. evaluate_sut_hosted).
data.statusThe terminal run status (e.g. completed).
data.result_urlAPI 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>
  • t is the signing timestamp (Unix seconds).
  • v0 is HMAC-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 on event_id — treat a repeated event_id as already handled and return 2xx without reprocessing.
  • What counts as success. Any 2xx response acknowledges the delivery. Respond 2xx only 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 a 3xx redirect (redirects are not followed) — is treated as permanent and is not retried. Return 2xx to accept, a transient 5xx to ask for a retry, and a 4xx only when the delivery should be abandoned.
  • Dead-letter. Once the retry window is exhausted, the notification is marked failed and 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()