Relayna logo Relayna

Webhook Best Practices for AI Workflow Callbacks

Relayna Team 2 min read

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.