Przejdź do głównej zawartości

Signature verification

PanDoktorek podpisuje każdy webhook sekretem subskrypcji (HMAC-SHA256). Twój system MUSI zweryfikować podpis i timestamp przed przetworzeniem — inaczej ktoś może wysłać fałszywy booking.cancelled albo powtórzyć stary.

Nagłówki​

NagłówekPrzykładZnaczenie
X-Pandoktorek-Eventbooking.createdtyp zdarzenia
X-Pandoktorek-Event-Id9f0c…identyfikator zdarzenia — deduplikuj po nim
X-Pandoktorek-Timestamp1762336800czas podpisu (Unix, sekundy)
X-Pandoktorek-Signaturev1=5d41…podpis

Algorytm (v1)​

signed_payload = timestamp + "." + raw_body
signature = "v1=" + hex(HMAC_SHA256(secret, signed_payload))
  1. Odrzuć żądanie, gdy |now - timestamp| > 300 sekund (ochrona przed replay).
  2. Policz podpis z surowych bajtów body — nie z json.loads + json.dumps (zmiana whitespace = inny podpis).
  3. Porównaj w czasie stałym (hmac.compare_digest, crypto.timingSafeEqual).
  4. Zdeduplikuj po X-Pandoktorek-Event-Id (dostarczenie at-least-once).

Python (FastAPI)​

import hashlib, hmac, os, time
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
SECRET = os.environ["PANDOKTOREK_WEBHOOK_SECRET"].encode()


@app.post("/webhooks/pandoktorek")
async def pandoktorek_webhook(request: Request) -> dict[str, bool]:
body = await request.body()
timestamp = request.headers.get("X-Pandoktorek-Timestamp", "")
signature = request.headers.get("X-Pandoktorek-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
raise HTTPException(status_code=400, detail="stale timestamp")
expected = "v1=" + hmac.new(
SECRET, f"{timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="invalid signature")
# ... deduplikacja po X-Pandoktorek-Event-Id, potem przetwarzanie
return {"ok": True}

Node.js (Express)​

import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.PANDOKTOREK_WEBHOOK_SECRET;

app.post("/webhooks/pandoktorek", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Pandoktorek-Timestamp") ?? "";
const signature = req.get("X-Pandoktorek-Signature") ?? "";
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send("stale timestamp");
}
const expected =
"v1=" +
crypto
.createHmac("sha256", SECRET)
.update(Buffer.concat([Buffer.from(`${timestamp}.`), req.body]))
.digest("hex");
const ok =
expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!ok) return res.status(401).send("invalid signature");
// ... deduplikacja po X-Pandoktorek-Event-Id, potem przetwarzanie
res.json({ ok: true });
});

Częste błędy​

  • Parsowanie JSON przed weryfikacją (framework zmienia bajty body).
  • Pominięcie timestampu w podpisie — stary format sha256=… nie jest już używany.
  • Zwykłe == zamiast porównania w czasie stałym.
  • Brak deduplikacji po Event-Id — ten sam event może przyjść więcej niż raz.