Skip to content
bynhDocs

Receive signed webhooks

Get every event as signed JSON at your own https endpoint. The payload, the signature and how to check it, retries and duplicates.

3 min readUpdated

Signed webhooks send each event as JSON to any public https endpoint: your own service, a data warehouse, Zapier, Make or n8n. Every request is signed with the endpoint’s own secret, so you can check it came from bynh.

Add an endpoint

  1. Step 1: Enter the address

    In Settings › Integrations, open Signed webhooks and choose Add an endpoint. Give it a name your team will recognise and paste the address. It must be public and start with https://.

  2. Step 2: Pick the events

    Choose the events to send. Webhooks can take every event, and rules narrow them like any other connection.

  3. Step 3: Save the signing secret

    After you save, bynh shows the signing secret once. It starts with whsec_. Store it on your server; if you lose it, rotate it.

  4. Step 4: Send a test

    Choose Send test. The test event has the type test. Any 2xx answer counts as delivered.

The request

bynh sends a POST with a JSON body. It waits up to 10 seconds for an answer and doesn’t follow redirects.

HeaderValue
Content-Typeapplication/json
User-Agentbynh-webhooks/1.0
X-Bynh-EventThe event type, such as incident.opened.
X-Bynh-DeliveryThe delivery id (dlv_…). The same on automatic retries, new when you redeliver by hand.
X-Bynh-TimestampWhen the request was signed, in Unix seconds.
X-Bynh-Signaturet=<timestamp>,v1=<signature>, explained below.

The payload

report.status_changed
{
  "version": 1,
  "id": "evt_01J9ZC4SXK3V6BT0Q2M8N4R7YH",
  "type": "report.status_changed",
  "created_at": "2026-10-04T14:02:11Z",
  "workspace": { "id": "acme", "name": "Acme" },
  "data": {
    "report": {
      "id": 412,
      "number": 218,
      "title": "IDOR in billing API",
      "status": "fixing",
      "severity": "high",
      "asset": "api.acme.com",
      "weakness": "CWE-639",
      "cvss": 7.5,
      "reporter": "mona",
      "url": "https://app.bynh.io/reports/218",
      "created_at": "2026-10-02T09:12:40Z"
    },
    "changes": { "status": { "from": "confirmed", "to": "fixing" } }
  },
  "text": {
    "language": "en",
    "title": "Report #218 moved to Fixing",
    "lines": ["IDOR in billing API"],
    "facts": [{ "label": "Status", "value": "Fixing" }],
    "action": { "label": "Open the report", "url": "https://app.bynh.io/reports/218" }
  }
}
FieldWhat it holds
versionThe payload version, currently 1.
idThe event id (evt_…). It never changes, including on retries and redeliveries.
typeThe event, such as monitor.down or report.received.
created_atWhen the event happened, in UTC.
workspaceYour workspace’s id and name.
dataThe record the event is about, under its type (report, incident, monitor, maintenance, certificate, access_request or policy), and changes with what moved from and to, when something did.
textReady-made words in your workspace’s language: a title, lines, facts and a link to open it in bynh.
Event types
monitor.down · monitor.recovered
incident.opened · incident.updated · incident.resolved
maintenance.scheduled · maintenance.started · maintenance.completed
certificate.expiring · certificate.expired
report.received · report.updated · report.status_changed
access_request.waiting · policy.published

Payloads never hold a secret, an email address or a researcher’s steps to reproduce.

Check the signature

v1 is the hex HMAC-SHA256 of the timestamp, a dot and the raw request body, keyed with your signing secret (the whole string, whsec_ included). To check a request:

  • Read t and every v1 from X-Bynh-Signature.
  • Compute the HMAC-SHA256 of t + . + the raw body, before any JSON parsing.
  • Compare it to each v1 in constant time. One match is enough.
  • Refuse a t more than 5 minutes from now, so an old request can’t be replayed.
import crypto from "node:crypto";
import express from "express";

const secret = process.env.BYNH_WEBHOOK_SECRET; // whsec_…
const app = express();

// Keep the raw body: the signature covers the exact bytes bynh sent.
app.post("/bynh", express.raw({ type: "application/json" }), (req, res) => {
  const parts = (req.get("X-Bynh-Signature") ?? "").split(",").map((p) => p.trim().split("="));
  const t = parts.find(([k]) => k === "t")?.[1];
  const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);

  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(req.body).digest("hex");
  const valid = signatures.some(
    (s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
  );
  if (!valid) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  // Skip event.id if you've handled it, queue the work, then answer quickly.
  res.sendStatus(204);
});

Retries and duplicates

A timeout, a 429 or a 5xx is tried again; any other answer outside 2xx fails at once. bynh tries 6 times in all, about 12 hours, waiting roughly 2 minutes, 8 minutes, 32 minutes, 2 hours and 8½ hours between tries, each give or take 20%. When you send Retry-After and it’s longer, bynh waits that long instead, up to a day.

  • Deduplicate on `id`. A retry, a redelivery from the log or a slow answer can bring the same event twice. Store the ids you’ve handled and skip repeats.
  • Answer fast. Return a 2xx as soon as the signature checks out, and do the work afterwards. Anything slower than 10 seconds counts as a timeout.
  • Don’t rely on order. Retries can arrive after newer events. Use created_at and changes to decide what’s current.
  • Each endpoint gets at most 30 events a minute, and a workspace 120 across its connections. Over that, events wait their turn; nothing is dropped.

After five deliveries fail in a row, bynh turns the endpoint off. See Read the delivery log for what’s kept, how to send an event again and how to turn it back on.

Was this helpful?

Edit this page on GitHub