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 (
localhostand 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 idwebhook-timestamp— unix seconds when we signedwebhook-signature—v1a,<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-idheader 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.