Webhook setup

Register a webhook URL, verify deliveries, and handle retries.

This guide covers registering a webhook URL, verifying the signature on incoming deliveries, and handling delivery failures.

1. Register a URL

From the dashboard (Settings → Dev space), register your HTTPS endpoint. We generate an Ed25519 keypair server-side and expose the public_key in the dashboard — use it to verify signatures.

URL requirements (enforced):

  • HTTPS scheme
  • A domain-style hostname (localhost and bare single-label hosts are rejected)

The public key is retrievable from the dashboard at any time, so there's no one-time secret to store. Copy it into your handler's environment when you're ready to verify.

2. Verify incoming deliveries

Each delivery carries three headers:

  • webhook-id — unique event id
  • webhook-timestamp — unix seconds when we signed
  • webhook-signaturev1a,<base64> Ed25519 signature

The signature covers the exact string ${webhook-id}.${webhook-timestamp}.${rawBody}. Verify with the webhook's public key:

import crypto from "crypto";
import express from "express";

const app = express();

// Raw body parser — we need the exact bytes that were signed.
app.use("/webhooks/blacksheep", express.raw({ type: "application/json" }));

app.post("/webhooks/blacksheep", (req, res) => {
    const id = req.header("webhook-id") ?? "";
    const timestamp = req.header("webhook-timestamp") ?? "";
    const sigHeader = req.header("webhook-signature") ?? "";

    const [scheme, b64] = sigHeader.split(",");
    if (scheme !== "v1a") return res.status(401).end();

    // Replay protection: reject deliveries older than 5 minutes. One-directional —
    // a small forward allowance covers clock skew; a future-dated timestamp won't
    // otherwise pass. The timestamp is signed, so it can't be rewound.
    const now = Math.floor(Date.now() / 1000);
    const ts = Number(timestamp);
    if (!Number.isFinite(ts) || now - ts > 300 || ts - now > 5) return res.status(401).end();

    const signed = Buffer.from(`${id}.${timestamp}.${req.body.toString("utf8")}`);
    const key = crypto.createPublicKey(process.env.BS_WEBHOOK_PUBLIC_KEY!);
    const ok = crypto.verify(null, signed, key, Buffer.from(b64, "base64"));

    if (!ok) {
        return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString("utf8"));
    handleEvent(event); // your business logic
    res.status(200).end();
});

Reject (don't process) any request whose signature fails — it isn't from us.

3. Respond fast

Return 2xx within 20 seconds. If your handler does meaningful work, accept the delivery into a queue and ack immediately:

app.post("/webhooks/blacksheep", async (req, res) => {
    // ...verify signature...
    await queue.enqueue(JSON.parse(req.body.toString("utf8")));
    res.status(200).end();
});

4. Handle retries idempotently

Failed deliveries (non-2xx or timeout) are retried with exponential backoff. Treat handlers as idempotent — process the same event twice without side-effect duplication:

  • Index on the webhook-id header in your DB.
  • On retry, no-op if you've already processed that id.

Responding with 410 Gone disables the webhook on our side. 429, 502, and 504 are treated as throttling and retried.

5. Inspect delivery logs

Every delivery — success or failure — is logged. Inspect attempts, response codes, and bodies from the dashboard when debugging.

6. Pause, remove, or rotate

All managed from the dashboard:

  • Pause temporarily: toggle the webhook to disabled.
  • Remove: delete the webhook.
  • Rotate the keypair: delete and recreate the webhook, then update your handler's public key.