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
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://.Step 2: Pick the events
Choose the events to send. Webhooks can take every event, and rules narrow them like any other connection.
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.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.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | bynh-webhooks/1.0 |
X-Bynh-Event | The event type, such as incident.opened. |
X-Bynh-Delivery | The delivery id (dlv_…). The same on automatic retries, new when you redeliver by hand. |
X-Bynh-Timestamp | When the request was signed, in Unix seconds. |
X-Bynh-Signature | t=<timestamp>,v1=<signature>, explained below. |
The payload
{
"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" }
}
}| Field | What it holds |
|---|---|
version | The payload version, currently 1. |
id | The event id (evt_…). It never changes, including on retries and redeliveries. |
type | The event, such as monitor.down or report.received. |
created_at | When the event happened, in UTC. |
workspace | Your workspace’s id and name. |
data | The 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. |
text | Ready-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
tand everyv1fromX-Bynh-Signature. - Compute the HMAC-SHA256 of
t+.+ the raw body, before any JSON parsing. - Compare it to each
v1in constant time. One match is enough. - Refuse a
tmore 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_atandchangesto 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?