Dokumentacja

v1https://api.riskwatch.pl/v1

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.

verify.mjs
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:

  1. Odczytaj dokładne bajty surowego ciała żądania (rawBody) przed jakimkolwiek parsowaniem JSON.
  2. 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.
  3. Sprawdź, że t= z nagłówka X-RiskWatch-Signature zgadza się z X-RiskWatch-Timestamp i mieści w oknie 300 sekund.
  4. 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).
  5. 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.
webhook-handler-recipe.mjs (Node.js / n8n)
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 };
}