Webhooki
Dostarczenia to żądania HTTPS POST podpisane algorytmem HMAC-SHA256 na timestamp.raw_body z użyciem jednorazowego sekretu pokazanego przy tworzeniu webhooka. Podpis przychodzi w nagłówku X-RiskWatch-Signature jako t=<unix>,v1=<hex>; odrzucaj podpisy starsze niż 5 minut.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyRiskWatch(header, rawBody, secret, toleranceSec = 300) {
const entries = String(header ?? "").split(",").map((p) => p.trim()).filter(Boolean);
const tPart = entries.find((p) => p.startsWith("t="));
const t = tPart ? tPart.slice(2) : "";
const age = Math.abs(Date.now() / 1000 - Number(t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const expected = Buffer.from(
createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"),
"hex"
);
return entries
.filter((p) => p.startsWith("v1="))
.slice(0, 8)
.some((p) => {
const actual = Buffer.from(p.slice(3), "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
});
}Ponawianie
Po pierwszej próbie kolejne są planowane z opóźnieniem 1, 5, 30 i 120 minut od poprzedniej porażki (łącznie około 156 minut plus czas żądań i kolejki), po czym dostarczenie jest oznaczane jako nieudane. Dostarczenia są unikalne dla pary webhook–wystąpienie zdarzenia (ponowne wystąpienie tego samego zdarzenia to nowe dostarczenie), więc powtórzenia można bezpiecznie przetwarzać idempotentnie po data.occurrence_id.
Typy zdarzeń
Subskrybuj * lub filtruj według risk_event.info, risk_event.warning, risk_event.critical.
Weryfikacja webhooka (Node.js; wskazówki dla n8n)
Poniższa funkcja Node.js weryfikuje podpis i kopertę, ale sama nie wykonuje operacji biznesowej ani nie wysyła odpowiedzi HTTP. W n8n ustaw Webhook: POST, Raw Body, odpowiedź przez Respond to Webhook. W Code użyj await this.helpers.getBinaryDataBuffer(0, "data") do odczytu bajtów oraz $input.first().json.headers do nagłówków. Nie odtwarzaj rawBody przez JSON.stringify. Te wskazówki nie zastępują testu uruchomionego workflow n8n; integracja Make wymaga osobno zweryfikowanego odbiornika. Reguły:
- Odczytaj dokładne bajty surowego ciała żądania (rawBody) przed jakimkolwiek parsowaniem JSON.
- Przechowuj occurrence_id w trwałej bazie z unikalnym indeksem, nie w pamięci procesu. TTL co najmniej 3h obejmuje nominalne 156 minut; kolejka, awarie i ręczny replay mogą wykraczać poza to okno. Dobierz retencję do tych opóźnień albo przechowuj klucze bezterminowo. W n8n self-hosted moduł crypto wymaga zezwolenia NODE_FUNCTION_ALLOW_BUILTIN=crypto na runnerze; w innych środowiskach użyj zweryfikowanego odbiornika Node.js.
- Sprawdź, że t= z nagłówka X-RiskWatch-Signature zgadza się z X-RiskWatch-Timestamp i mieści w oknie 300 sekund.
- Zaakceptuj dostarczenie, gdy którykolwiek wpis v1= pasuje do HMAC-SHA256(secret, "${t}.${rawBody}") — pozwala to na bezprzestojową rotację sekretu (POST /v1/webhooks/{id}/rotate-secret).
- Zweryfikuj zgodność X-RiskWatch-Delivery-ID z body.id oraz X-RiskWatch-Event z body.type. seenOccurrences zawiera tylko trwale zakończone operacje. Dla action=verified wykonaj atomowo operację i zapis data.occurrence_id albo zapis do trwałej kolejki z unikalnym occurrence_id; dopiero po commit zwróć 2xx. Błąd zapisu/przetwarzania: 5xx, aby RiskWatch ponowił żądanie. Duplikat: 200; odrzucenie weryfikacji: 401. Użyj statusu jako kodu odpowiedzi HTTP, nie tylko pola JSON.
import { createHmac, timingSafeEqual } from "node:crypto";
export function handleRiskWatchWebhook({ rawBody, headers, secret, seenOccurrences, nowSec = Math.floor(Date.now() / 1000) }) {
const sigHeader = String(headers["x-riskwatch-signature"] ?? "");
const headerTs = Number(headers["x-riskwatch-timestamp"]);
const parts = sigHeader.split(",").map((p) => p.trim()).filter(Boolean);
const signedTs = Number((parts.find((p) => p.startsWith("t=")) ?? "").slice(2));
if (!Number.isFinite(signedTs) || signedTs !== headerTs || Math.abs(nowSec - signedTs) > 300) {
return { status: 401, action: "rejected_timestamp" };
}
const expected = Buffer.from(createHmac("sha256", secret).update(`${signedTs}.${rawBody}`).digest("hex"), "hex");
const validSig = parts
.filter((p) => p.startsWith("v1="))
.slice(0, 8)
.some((p) => {
const actual = Buffer.from(p.slice(3), "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
});
if (!validSig) return { status: 401, action: "rejected_signature" };
const payload = JSON.parse(rawBody);
if (headers["x-riskwatch-delivery-id"] !== payload.id || headers["x-riskwatch-event"] !== payload.type) {
return { status: 401, action: "rejected_envelope_mismatch" };
}
const occurrenceId = String(payload.data?.occurrence_id ?? payload.id);
if (seenOccurrences.has(occurrenceId)) {
return { status: 200, action: "duplicate", occurrenceId };
}
// Verification alone must not acknowledge unfinished business work.
// The caller commits its work + occurrence marker atomically before 2xx.
return { status: 200, action: "verified", occurrenceId };
}