> ## 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.

# Verify callback signatures

> HMAC-SHA256 verification for inbound Runflow callbacks. Express + FastAPI.

## What you'll do

Reject spoofed POSTs to your webhook by verifying the `Runflow-Signature` HMAC header against a shared secret.

## Prerequisites

* A Runflow API key.
* A webhook handler (see [Handle async callbacks](/guides/handle-async-callbacks)).

## Steps

<Steps>
  <Step title="Create a callback secret">
    ```bash theme={"dark"}
    curl -X POST https://api.runflow.io/v1/callback-secrets \
      -H "Authorization: Bearer $RUNFLOW_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "label": "production" }'
    ```

    Response:

    ```json theme={"dark"}
    { "id": "01J0...", "label": "production", "plain_secret": "whsec_..." }
    ```

    Save `plain_secret` to your secret manager. It is shown once.
  </Step>

  <Step title="Verify the signature in your handler">
    Runflow sends `Runflow-Signature: <hex>` where the value is `HMAC-SHA256(secret, raw_body)`. The hex string is 64 chars (SHA-256 digest size).

    <CodeGroup>
      ```javascript Express theme={"dark"}
      import crypto from "crypto";
      import express from "express";
      const app = express();

      // Use raw body, not parsed JSON, for HMAC.
      app.use(express.raw({ type: "application/json" }));

      const SIG_LEN = 64; // hex chars in HMAC-SHA256

      app.post("/webhook/runflow", (req, res) => {
        const signature = req.header("Runflow-Signature") || "";
        if (!/^[0-9a-f]{64}$/.test(signature)) {
          return res.sendStatus(401);
        }
        const expected = crypto
          .createHmac("sha256", process.env.RUNFLOW_WEBHOOK_SECRET)
          .update(req.body)
          .digest("hex");
        const a = Buffer.from(signature, "hex");
        const b = Buffer.from(expected, "hex");
        if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
          return res.sendStatus(401);
        }

        const payload = JSON.parse(req.body.toString("utf8"));
        // ...persist payload, return 200...
        res.sendStatus(200);
      });
      ```

      ```python FastAPI theme={"dark"}
      import hmac, hashlib, os, re
      from fastapi import FastAPI, Request, HTTPException

      app = FastAPI()
      SECRET = os.environ["RUNFLOW_WEBHOOK_SECRET"].encode()
      HEX_64 = re.compile(r"^[0-9a-f]{64}$")

      @app.post("/webhook/runflow")
      async def runflow_webhook(req: Request):
          body = await req.body()
          signature = req.headers.get("Runflow-Signature", "")
          if not HEX_64.match(signature):
              raise HTTPException(401, "bad signature")
          expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
          if not hmac.compare_digest(signature, expected):
              raise HTTPException(401, "bad signature")
          payload = await req.json()
          # ...persist, return 200...
          return {"ok": True}
      ```
    </CodeGroup>
  </Step>

  <Step title="Use timing-safe comparison">
    Always compare with `crypto.timingSafeEqual` (Node) or `hmac.compare_digest` (Python). Plain `==` leaks timing info. Both inputs must be the same length, hence the regex pre-check above.
  </Step>
</Steps>

## Verify it worked

Send a test callback (re-deliver one):

```bash theme={"dark"}
curl -X POST https://api.runflow.io/v1/runs/{run_id}/callback-redeliveries \
  -H "Authorization: Bearer $RUNFLOW_API_KEY"
```

Your handler logs should show `200`. Now send a forged POST without the header. Your handler should return `401`.

## Troubleshooting

| Symptom                               | Likely cause                                    | Fix                                   |
| ------------------------------------- | ----------------------------------------------- | ------------------------------------- |
| Every callback fails verification     | Hashing parsed JSON instead of raw body         | Use the raw bytes Runflow sent.       |
| Signatures match in dev, fail in prod | Different secret per env                        | Rotate, redeploy.                     |
| `Runflow-Signature` header missing    | No callback secret registered for this endpoint | POST to `/v1/callback-secrets` first. |

## Rotation

Create a second secret. Both verify in parallel during cutover. Delete the old secret once traffic confirms the new one works.

## Related

<CardGroup cols={2}>
  <Card title="Handle callbacks" icon="webhook" href="/guides/handle-async-callbacks">Handler structure.</Card>
  <Card title="Callbacks concept" icon="webhook" href="/concepts/callbacks">Pattern, retries.</Card>
</CardGroup>
