Webhooks
Asynchronous event notifications delivered over HTTPS with constant-time HMAC-SHA256 signatures and a 5-minute replay window.
How delivery works
- You register an endpoint URL + webhook secret via the dashboard or
client.create_webhook(). - When an event fires, we POST the payload to your URL with a signed timestamp.
- Your receiver verifies the signature + timestamp before trusting the payload.
- You respond with a 2xx status within 10 seconds; anything else triggers retry.
- Delivery is retried with exponential backoff up to 24 hours (5 attempts) before the delivery is marked failed.
Signature verification
Two headers accompany every delivery:
| Header | Format |
|---|---|
| X-RadMah AI-Signature | hex(HMAC-SHA256(secret, timestamp + "." + body)). Optional sha256= prefix accepted by the SDK helper. |
| X-RadMah AI-Timestamp | Unix epoch seconds when the server signed the payload. |
Always verify using a constant-time comparator + a replay-window check. A plain
== on the hex strings is a timing side-channel.Verify in your framework
from fastapi import FastAPI, HTTPException, Request
from radmah_sdk import (
verify_webhook_signature,
WebhookVerificationError,
)
import os
app = FastAPI()
@app.post("/hooks/radmah")
async def inbound(request: Request):
body = await request.body()
try:
verify_webhook_signature(
body=body,
signature=request.headers.get("X-RadMah AI-Signature", ""),
timestamp=request.headers.get("X-RadMah AI-Timestamp", ""),
secret=os.environ["RADMAH_WEBHOOK_SECRET"],
)
except WebhookVerificationError as exc:
raise HTTPException(status_code=401, detail=str(exc))
# ... route by payload["type"]
return {"ok": True}
Event catalog
| Event name | When it fires |
|---|---|
| job.created | New job persisted. Fired once per submission. |
| job.started | Worker picked up the job and began execution. |
| job.succeeded | Job completed successfully, evidence bundle sealed. Delivery carries { job_id, bundle_url, chain_root }. |
| job.failed | Job terminated unrecoverably. Payload carries { error_code, message, detail } — same shape as the API error envelope. |
| job.cancelled | Operator cancelled the job before completion. No evidence bundle. |
| agent.plan_ready | ADS project produced a plan and is awaiting_approval. Payload carries { project_id, plan_steps, estimated_credits }. |
| agent.step_succeeded | An individual tool step inside an ADS project completed. High-volume; subscribe only if streaming. |
| agent.heal_fired | Self-heal path triggered because a quality gate failed mid-plan. Payload carries { trigger, replan_spec }. |
| agent.finished | ADS project reached a terminal state (succeeded, failed, cancelled). Always the last event for a project_id. |
| dataset.uploaded | A new dataset finished the upload + profile stage. Payload carries { dataset_id, row_count, column_count }. |
| seal.created | A sealed contract seal was minted. Payload carries { seal_id, seal_hash, contract_hash }. |
Delivery semantics
- At-least-once: the same event may be redelivered after a 5xx or timeout. Make your handler idempotent — dedupe by
event_idin the payload. - Order is not guaranteed:
agent.finishedmay arrive before a trailingagent.step_succeededon a very fast run. Ignore step events for projects already in a terminal state. - Retry schedule: 30s, 2min, 10min, 1h, 6h. After the 5th failure the delivery is marked
failedand visible in the dashboard. - Secret rotation: call
client.rotate_webhook_secret(webhook_id). The old secret remains valid for 24 h to give your receiver a rollover window.
Common pitfalls
- Re-parsing the body: middleware that decodes + re-encodes JSON changes byte ordering and breaks the HMAC. Always hash the raw request body.
- Clock skew: make sure your server is NTP-synced. A drifting clock looks identical to a stale-timestamp rejection.
- Signature comparison: use
hmac.compare_digest(Python) orcrypto.timingSafeEqual(Node). Plain===leaks bits via timing.