> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runflow.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Handle async callbacks

> Build a webhook handler for Runflow runs. Express + FastAPI examples.

## What you'll do

Stand up a webhook handler that takes a Runflow callback, persists the result, and returns `200` fast.

## Prerequisites

* A Runflow API key. [Create one.](https://app.runflow.io/settings/api-keys)
* A public URL for the handler. Use [ngrok](https://ngrok.com) for local dev.
* A database or queue to persist results.

## Steps

<Steps>
  <Step title="Stand up the handler">
    The handler should: parse the body, persist the relevant fields, return `200` within a few seconds.

    These examples parse JSON directly for a local prototype. In production, verify `Runflow-Signature` against the raw request body before parsing JSON; see [Verify callback signatures](/guides/verify-callback-signatures).

    <CodeGroup>
      ```javascript Express theme={"dark"}
      import express from "express";
      const app = express();
      app.use(express.json({ limit: "5mb" }));

      app.post("/webhook/runflow", async (req, res) => {
        const { event, run_id, status, output } = req.body;

        // Persist immediately. Output URLs are presigned; copy them to your storage.
        await db.runs.upsert({ run_id, event, status, output });

        res.sendStatus(200);
        // Heavy work (downloading outputs) goes in a background job.
      });

      app.listen(3000);
      ```

      ```python FastAPI theme={"dark"}
      from fastapi import FastAPI, Request
      app = FastAPI()

      @app.post("/webhook/runflow")
      async def runflow_webhook(req: Request):
          body = await req.json()
          await db.runs.upsert(
              run_id=body["run_id"],
              event=body["event"],
              status=body["status"],
              output=body.get("output"),
          )
          return {"ok": True}
      ```
    </CodeGroup>
  </Step>

  <Step title="Pass callback_url on every run">
    ```bash theme={"dark"}
    curl -X POST https://api.runflow.io/v1/models/runflow/background-replace/runs \
      -H "Authorization: Bearer $RUNFLOW_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "input": { "image_url": "https://...", "prompt": "..." },
        "callback_url": "https://your-server.com/webhook/runflow"
      }'
    ```
  </Step>

  <Step title="Be idempotent">
    Runflow retries failed callbacks. Match on `run_id` for run callbacks or `batch_id` for batch callbacks, and skip if you have already processed it.

    ```javascript theme={"dark"}
    const existing = await db.runs.findByRunId(run_id);
    if (existing?.status === "succeeded") return res.sendStatus(200);
    ```
  </Step>

  <Step title="Copy outputs to your storage">
    Output URLs are presigned and time-limited. Download or re-upload them to your own bucket on receipt. Do not store the raw URLs as your source of truth.
  </Step>
</Steps>

## Verify it worked

Trigger a run, watch your handler logs:

```
POST /webhook/runflow 200 1234ms
{ event: 'run.completed', run_id: '01J0...', status: 'succeeded' }
```

<Check>You have a row in your DB with `status: succeeded`. You're done.</Check>

## Troubleshooting

| Symptom                            | Likely cause                         | Fix                                                                                                                  |
| ---------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| Callback arrives but body is empty | Missing body parser                  | For unsigned prototypes, use `express.json()` or framework equivalent. For signed handlers, read the raw body first. |
| Handler times out                  | Heavy work in the request            | Return `200` first, defer heavy work.                                                                                |
| Same payload arrives twice         | Retry on a 5xx your handler returned | Be idempotent on `run_id`.                                                                                           |
| Nothing arrives                    | URL not public                       | Use ngrok or Cloudflare Tunnel for local dev.                                                                        |

## Production hardening

* [Verify the signature](/guides/verify-callback-signatures) so spoofed POSTs cannot reach your DB.
* Wrap your handler in a queue (SQS, Pub/Sub, BullMQ) so a slow downstream cannot drop callbacks.
* Monitor `4xx` and `5xx` from your endpoint. Runflow stops retrying after a few attempts.

## Related

<CardGroup cols={2}>
  <Card title="Verify signatures" icon="shield-check" href="/guides/verify-callback-signatures">HMAC verify in code.</Card>
  <Card title="Callbacks concept" icon="webhook" href="/concepts/callbacks">Pattern, retries, redelivery.</Card>
</CardGroup>
