Sign In

Webhooks

Asynchronous event notifications delivered over HTTPS with constant-time HMAC-SHA256 signatures and a 5-minute replay window.

How delivery works

  1. You register an endpoint URL + webhook secret via the dashboard or client.create_webhook().
  2. When an event fires, we POST the payload to your URL with a signed timestamp.
  3. Your receiver verifies the signature + timestamp before trusting the payload.
  4. You respond with a 2xx status within 10 seconds; anything else triggers retry.
  5. 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:

HeaderFormat
X-RadMah AI-Signaturehex(HMAC-SHA256(secret, timestamp + "." + body)). Optional sha256= prefix accepted by the SDK helper.
X-RadMah AI-TimestampUnix 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 nameWhen it fires
job.createdNew job persisted. Fired once per submission.
job.startedWorker picked up the job and began execution.
job.succeededJob completed successfully, evidence bundle sealed. Delivery carries { job_id, bundle_url, chain_root }.
job.failedJob terminated unrecoverably. Payload carries { error_code, message, detail } — same shape as the API error envelope.
job.cancelledOperator cancelled the job before completion. No evidence bundle.
agent.plan_readyADS project produced a plan and is awaiting_approval. Payload carries { project_id, plan_steps, estimated_credits }.
agent.step_succeededAn individual tool step inside an ADS project completed. High-volume; subscribe only if streaming.
agent.heal_firedSelf-heal path triggered because a quality gate failed mid-plan. Payload carries { trigger, replan_spec }.
agent.finishedADS project reached a terminal state (succeeded, failed, cancelled). Always the last event for a project_id.
dataset.uploadedA new dataset finished the upload + profile stage. Payload carries { dataset_id, row_count, column_count }.
seal.createdA 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_id in the payload.
  • Order is not guaranteed: agent.finished may arrive before a trailing agent.step_succeeded on 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 failed and 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) or crypto.timingSafeEqual (Node). Plain === leaks bits via timing.