Skip to main content

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.

What you’ll do

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

Prerequisites

Steps

1

Create a callback secret

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:
{ "id": "01J0...", "label": "production", "plain_secret": "whsec_..." }
Save plain_secret to your secret manager. It is shown once.
2

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).
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);
});
3

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.

Verify it worked

Send a test callback (re-deliver one):
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

SymptomLikely causeFix
Every callback fails verificationHashing parsed JSON instead of raw bodyUse the raw bytes Runflow sent.
Signatures match in dev, fail in prodDifferent secret per envRotate, redeploy.
Runflow-Signature header missingNo callback secret registered for this endpointPOST 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.

Handle callbacks

Handler structure.

Callbacks concept

Pattern, retries.