Mediflo
Webhooki

Weryfikacja podpisu

Nagłówki dostarczenia, wzór HMAC-SHA256 i gotowy kod odbiornika w TypeScript i PHP.

Każde dostarczenie jest podpisane sekretem Twojego endpointu. Bez weryfikacji podpisu każdy, kto zna adres, może podrzucić Ci fałszywe zdarzenie — weryfikuj zawsze, także w środowisku testowym.

Nagłówki

NagłówekZnaczenie
Content-TypeZawsze application/json.
Mediflo-Event-IdIdentyfikator koperty powtórzony w nagłówku, żeby dało się odsiać duplikat bez parsowania ciała. To Twój klucz idempotencji — dostarczamy co najmniej raz.
Mediflo-Event-TypeTyp zdarzenia powtórzony w nagłówku, żeby dało się routować albo odrzucić nieznany typ bez parsowania ciała.
Mediflo-Signaturet=<sekundy-unix>,v1=<hex>. Podczas rotacji sekretu są dwa wpisy v1=.
Mediflo-Event-TestWystępuje z wartością true tylko wtedy, gdy sam wyślesz zdarzenie testowe. Przy prawdziwym zdarzeniu nagłówka nie ma (nie ma też wartości false).

Wzór

v1 = HMAC_SHA256(sekret, "<t>.<surowe ciało>")   → zapis szesnastkowy

Znacznik czasu t jest częścią podpisywanego ciągu — to on chroni przed powtórzeniem. Odrzucaj żądania, których t odbiega od Twojego zegara o więcej niż ±5 minut (300 s).

Podpisuj SUROWE bajty

Ponowna serializacja sparsowanego JSON-a nie odtworzy tego samego skrótu. Czytaj ciało jako bajty, zanim cokolwiek je sparsuje. Sprawdź też poprawność zapisu szesnastkowego zanim porównasz — w Node timingSafeEqual rzuca wyjątkiem przy różnej długości, więc bez tego zabezpieczenia zniekształcony nagłówek zwróci 500 zamiast odrzucenia.

Rotacja sekretu

Gdy wygenerujesz nowy sekret, przez 24 godziny każde żądanie ma dwa wpisy v1= — najpierw dla nowego sekretu, potem dla poprzedniego. Zaakceptuj żądanie, jeśli pasuje którykolwiek; nie przerywaj na pierwszym, który się nie zgadza. Pole previousSecretExpiresAt na endpoincie mówi, kiedy stary sekret przestaje działać.

Gotowy kod

Poniższe fragmenty to dokładnie te same funkcje, które nasz zestaw testów uruchamia przeciwko prawdziwemu modułowi podpisującemu. Wklej je zamiast pisać własne sprawdzenie HMAC z opisu.

import { createHmac, timingSafeEqual } from 'node:crypto'/** * Verify a Mediflo webhook signature. * * Header shape: `t=<unix-seconds>,v1=<hex>[,v1=<hex>]` * where `v1 = HMAC_SHA256(secret, "<t>.<rawBody>")`. * * `rawBody` MUST be the exact bytes we sent. Re-serializing parsed JSON does * not reproduce the digest — in Express, use * `express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })`. * * Pass BOTH secrets during a rotation window (or just the current one; the * header carries a `v1=` entry per active secret and any match is valid). */export function verifyMedifloSignature(  header: string | undefined,  rawBody: string | Buffer,  secrets: string[],  toleranceSeconds = 300): boolean {  if (!header || secrets.length === 0) return false  let timestamp: number | null = null  const presented: string[] = []  for (const part of header.split(',')) {    const trimmed = part.trim()    const eq = trimmed.indexOf('=')    if (eq <= 0) continue    const key = trimmed.slice(0, eq)    const value = trimmed.slice(eq + 1)    if (key === 't') {      if (!/^\d+$/.test(value)) return false      timestamp = Number(value)    } else if (key === 'v1') {      // More than one entry appears during a 24 h secret rotation.      presented.push(value)    }  }  if (timestamp === null || presented.length === 0) return false  // Replay protection: the timestamp is INSIDE the signed string, so an  // attacker cannot move it forward without invalidating every digest.  const now = Math.floor(Date.now() / 1000)  if (Math.abs(now - timestamp) > toleranceSeconds) return false  for (const secret of secrets) {    const expected = createHmac('sha256', secret)      .update(`${timestamp}.${rawBody}`)      .digest('hex')    for (const candidate of presented) {      // Validate the hex BEFORE comparing. Buffer.from('zz', 'hex') truncates      // silently, and timingSafeEqual throws RangeError on a length mismatch —      // so without these two guards a malformed header crashes the handler      // instead of returning false.      const normalized = candidate.trim().toLowerCase()      if (!/^[0-9a-f]+$/.test(normalized)) continue      if (normalized.length !== expected.length) continue      if (        timingSafeEqual(          Buffer.from(normalized, 'hex'),          Buffer.from(expected, 'hex')        )      ) {        return true      }    }  }  return false}

PHP jest tu nie bez powodu: integracje po stronie polskich firm najczęściej pisze ten sam zespół, który utrzymuje stronę i panel klienta na hostingu PHP (Laravel, Symfony, WordPress) — i to tam ląduje odbiornik webhooków.

Higiena sekretu

  • Sekret pokazujemy raz: przy tworzeniu webhooka i przy każdej rotacji. Jest zaszyfrowany i nie ma endpointu, który by go zwrócił.
  • Zgubiony sekret = wygeneruj nowy (przycisk „Nowy sekret" w aplikacji albo POST /webhook-endpoints/{endpointId}/rotate-secret). To jedyna droga odzyskania.
  • Nigdy nie umieszczaj sekretu w adresie URL, w kodzie przeglądarki ani w logach.

Na tej stronie