Webhook Best Practices for AI Workflow Callbacks
When a reviewer approves or rejects a checkpoint in Relayna, we fire a webhook to the URL you specified when creating the checkpoint. Getting webhook delivery right is critical — a missed callback means your agent stalls and your workflow breaks.
This post covers what you need to know to build a rock-solid webhook receiver.
Respond Fast, Process Later
The most important rule: respond with a 200 OK immediately, then process the payload asynchronously.
Relayna waits up to 10 seconds for a response before marking a delivery as failed and scheduling a retry. If your handler does anything slow — database writes, downstream API calls, sending emails — you’ll hit that timeout regularly.
The pattern looks like this in Python with FastAPI:
from fastapi import FastAPI, BackgroundTasks, Request
app = FastAPI()
@app.post("/webhooks/relayna")
async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
payload = await request.json()
background_tasks.add_task(process_decision, payload)
return {"ok": True}
async def process_decision(payload: dict):
# Do the actual work here — update your DB, resume the agent, etc.
checkpoint_id = payload["checkpoint_id"]
decision = payload["decision"]
# ...
Make Your Handler Idempotent
Relayna retries failed deliveries up to 5 times with exponential backoff. That means your handler will receive duplicate events. Design it to handle the same event multiple times without side effects.
The simplest approach: store a processed_event_ids set and check it before doing work.
async def process_decision(payload: dict):
event_id = payload["event_id"]
if await already_processed(event_id):
return # Duplicate delivery — skip it
await mark_as_processing(event_id)
# ... do the actual work ...
await mark_as_processed(event_id)
Verify the Signature
Every Relayna webhook includes an X-Relayna-Signature header — an HMAC-SHA256 signature of the raw request body using your webhook secret. Always verify it before processing.
import hmac
import hashlib
def verify_signature(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
Use hmac.compare_digest — never a plain == comparison — to prevent timing attacks.
Handle All Decision Types
A checkpoint decision can be one of four states:
| Decision | Meaning |
|---|---|
approved |
Reviewer accepted — continue the workflow |
rejected |
Reviewer declined — stop or reroute |
needs_changes |
Reviewer wants modifications — loop back |
expired |
TTL elapsed before a decision was made |
Make sure your handler has a branch for each. Treating expired the same as rejected is a common source of confusing bugs.
Test With Real Payloads
Use our webhook inspector in the dashboard to replay any past delivery. It shows the full request headers and body, which is invaluable when debugging a handler that’s silently dropping events.
For local development, tools like ngrok or cloudflared tunnel let you expose your local server so Relayna can reach it directly during testing.