Przejdź do głównej zawartości

Webhooki

Webhook to żądanie POST, które Rezeri wysyła na Twój adres https, gdy zmienia się rezerwacja. Nie musisz odpytywać GET /v1/bookings, żeby wiedzieć, że klient zapłacił albo odwołał wizytę.

Endpointami webhooków zarządzasz przez /v1/webhook-endpoints. Klucz API potrzebuje scope webhooks:manage. Pola żądań i odpowiedzi opisuje referencja API.

Typ Kiedy powstaje
booking.created Powstała nowa rezerwacja — z dowolnego kanału: widżetu, panelu albo API.
booking.accepted Rezerwacja przeszła w status accepted: usługodawca ją zaakceptował albo klient opłacił rezerwację z płatnością z góry.
booking.rescheduled Rezerwacja dostała inny termin: zmienił się początek albo koniec wizyty. Powstaje niezależnie od tego, kto ją przełożył — usługodawca w panelu (także przeciągnięciem w kalendarzu), klient przez link z wiadomości albo stronę rezerwacji, czy zamiana terminu z listy oczekujących. Poprzedni termin jest w data.previous_attributes.
booking.cancelled Rezerwacja została anulowana — przez klienta, usługodawcę albo API.
booking.payment_succeeded Płatność rezerwacji zakończyła się sukcesem.
booking.payment_failed Płatność rezerwacji się nie udała. Rezerwacja zostaje w awaiting_payment, więc klient może zapłacić ponownie.

Rezerwacja utworzona od razu jako zaakceptowana daje dwa zdarzenia: booking.created i booking.accepted. Rezerwacja, która po akceptacji czeka na płatność (awaiting_payment), nie daje booking.accepted — przyjdzie ono dopiero po opłaceniu.

booking.rescheduled nie powstaje przy tworzeniu rezerwacji ani przy zapisie, który nie zmienia terminu. Rezerwacja przełożona i anulowana w jednym zapisie daje tylko booking.cancelled. Każde przełożenie to osobne zdarzenie z własnym id.

Endpoint dostaje tylko zdarzenia z listy events, którą podajesz przy rejestracji.

  1. Wywołaj POST /v1/webhook-endpoints z adresem url i niepustą listą events.
  2. Odpowiedź 201 zawiera pole secret (zaczyna się od whsec_). To jedyny raz, kiedy widzisz sekret. GET, lista i PATCH go nie zwracają. Zapisz go w menedżerze sekretów.
  3. Sekretem weryfikujesz podpis każdej dostawy — patrz Weryfikacja podpisu.

PATCH /v1/webhook-endpoints/{id} zmienia url, events, enabled albo payload_format. DELETE usuwa endpoint razem z jego oczekującymi dostawami. POST /v1/webhook-endpoints/{id}/test wysyła na endpoint zdarzenie testowe — patrz Testowanie integracji.

Organizacja może mieć najwyżej 16 endpointów. Kolejny POST dostaje 409 webhook_endpoint_limit.

  • Tylko https. Adres bez danych logowania (user:hasło@), bez białych znaków i znaków sterujących, najwyżej 2048 znaków. Inaczej 422 webhook_url_invalid.
  • Każdy adres IP, na który rozwiązuje się host, musi być publiczny. Odrzucamy adresy prywatne, loopback, link-local, metadane chmury (169.254.169.254) i CGNAT. Wystarczy jeden taki adres w DNS, żeby odrzucić cały host: 422 webhook_url_not_allowed.
  • DNS sprawdzamy przy rejestracji i przy każdej dostawie. Host, który później zacznie wskazywać adres prywatny, nie dostanie zdarzenia.
  • Nie podążamy za przekierowaniami. Odpowiedź 3xx to nieudana dostawa.

Do testów na własnym komputerze potrzebujesz więc publicznego adresu https, który przekazuje ruch do Twojej maszyny — localhost nie zadziała.

Każda dostawa to POST z nagłówkami:

Content-Type: application/json
User-Agent: Rezeri-Webhooks/1.0
Rezeri-Signature: t=1792411392,v1=5f2b…

i body:

{
"id": "evt_8c1d4e0f2a3b4c5d6e7f8a9b0c1d2e3f",
"type": "booking.accepted",
"created_at": "2026-10-19T14:03:12.481203+02:00",
"api_version": "v1",
"test": false,
"data": {
"object": {
"id": "3f1c2a9e-0b7d-4e8f-9a3b-5c1d2e4f6a8b",
"status": "accepted"
}
}
}
  • id — identyfikator zdarzenia. Każdy endpoint organizacji dostaje to samo zdarzenie z tym samym id.
  • type — typ zdarzenia z tabeli wyżej.
  • created_at — chwila zdarzenia, ISO 8601 z offsetem strefy Europe/Warsaw.
  • api_version — wersja API, w której opisany jest data.object.
  • test — false w każdej prawdziwej dostawie. Zdarzenie wysłane z panelu do sprawdzenia połączenia ma true. Odbiornik nie zmienia stanu po zdarzeniu z test: true: nie oznacza rezerwacji jako opłaconej ani nie wysyła klientowi wiadomości.
  • data.object — rezerwacja w tym samym kształcie, co w GET /v1/bookings/{booking_id} (w przykładzie skrócona). Pola opisuje referencja API.
  • data.previous_attributes — tylko w booking.rescheduled, patrz niżej. W pozostałych zdarzeniach tego klucza nie ma.

Zdarzenie booking.rescheduled niesie obok data.object termin sprzed zmiany:

{
"id": "evt_2b7e9c41d0a84f6e8c3a5b1d9e0f7a62",
"type": "booking.rescheduled",
"created_at": "2026-10-21T09:15:40.112934+02:00",
"api_version": "v1",
"test": false,
"data": {
"object": {
"id": "3f1c2a9e-0b7d-4e8f-9a3b-5c1d2e4f6a8b",
"status": "accepted",
"start": "2026-10-28T10:00:00+01:00",
"end": "2026-10-28T11:00:00+01:00"
},
"previous_attributes": {
"start": "2026-10-23T14:00:00+02:00",
"end": "2026-10-23T15:00:00+02:00"
}
}
}
  • previous_attributes.start, previous_attributes.end — początek i koniec wizyty przed przełożeniem, ISO 8601 z offsetem strefy Europe/Warsaw, jak data.object.start i end. Po nich znajdziesz i przesuniesz stary wpis w swoim systemie.
  • Poprzedni termin zapisujemy w chwili zdarzenia, więc ponowienie (także ręczne z panelu) niesie te same wartości. data.object budujemy od nowa przy każdej próbie: przy dwóch szybkich przełożeniach obie dostawy mogą mieć w object najnowszy termin, a każda swój previous_attributes.
  • Zdarzenie testowe booking.rescheduled wysłane z panelu ma w previous_attributes bieżący termin przykładowej rezerwacji.

Endpoint ma pole payload_format: full (domyślnie) albo minimal. Ustawiasz je przy POST albo zmieniasz PATCH-em — sekret się nie zmienia.

W trybie minimal data.object niesie tylko:

{
"id": "3f1c2a9e-0b7d-4e8f-9a3b-5c1d2e4f6a8b",
"status": "accepted",
"occurred_at": "2026-10-19T14:03:12.481203+02:00"
}
  • id — identyfikator rezerwacji (ten sam, co bookingId w zdarzeniu rezeri:bookingSuccessful widgetu).
  • status — publiczny status rezerwacji w chwili wysyłki.
  • occurred_at — chwila zdarzenia, równa created_at koperty.

Danych klienta, metadata, usługi, ceny ani terminów w nim nie ma. Wyjątek: booking.rescheduled niesie też data.previous_attributes z poprzednim terminem, jak w full — po GET znasz już tylko nowy termin. Szczegóły pobierzesz przez GET /v1/bookings/{booking_id}. Koperta, podpis, nagłówki i ponowienia są takie same jak w full. Format czytamy przy każdej próbie, więc zmiana dotyczy też dostaw czekających na ponowienie.

  • Deduplikuj po id. To samo zdarzenie może przyjść więcej niż raz (dostawa „co najmniej raz”). Zapisz przetworzone id i pomiń powtórkę.
  • Nie zakładaj kolejności. Ponowienie starego zdarzenia może przyjść po nowszym.
  • Czytaj stan z data.object. Budujemy go od nowa przy każdej próbie, więc niesie aktualny stan rezerwacji w chwili wysyłki, nie w chwili zdarzenia. id, type i created_at zostają te same.
  • Odpowiadaj szybko. Sukces to odpowiedź 2xx w ciągu 10 sekund od rozpoczęcia próby. Ciała odpowiedzi nie czytamy. Zapisz zdarzenie, odpowiedz 200 i przetwarzaj je w tle.
  • Ignoruj nieznane pola i typy zdarzeń. W v1 mogą dojść nowe pola, wartości enumów i typy zdarzeń — patrz Wersjonowanie.

Każda odpowiedź inna niż 2xx, przekroczenie 10 sekund, błąd sieci i zablokowany adres to nieudana próba. Po nieudanej próbie planujemy kolejną:

Po próbie Następna za Od zdarzenia
1 1 min ≈ 1 min
2 5 min ≈ 6 min
3 30 min ≈ 36 min
4 2 h ≈ 2 h 36 min
5 6 h ≈ 8 h 36 min
6 12 h ≈ 20 h 36 min
7 3 h 24 min ≈ 24 h
8 — (porażka ostateczna) —

Razem to najwyżej 8 prób w ciągu około 24 godzin. Rzeczywiste opóźnienie bywa dłuższe o około minutę. Każda próba ma nowe t i nowy podpis.

Gdy dostawa wyczerpie wszystkie próby, a endpoint od jej powstania nie przyjął z sukcesem żadnej innej dostawy, wyłączamy go:

  • enabled zmienia się na false, a disabled_reason na "delivery_failures",
  • pozostałe oczekujące dostawy tego endpointu przepadają,
  • właściciel organizacji dostaje jeden e-mail o wyłączeniu.

Włączysz endpoint ponownie przez PATCH z "enabled": true — disabled_reason wraca wtedy do null. Zdarzeń z okresu wyłączenia nie wysyłamy ponownie. Stan z tego czasu odczytaj przez GET /v1/bookings.

Dostaw nie wysyłamy też, gdy organizacja traci dostęp do API — na przykład jej plan nie obejmuje już API.

Każda dostawa niesie nagłówek:

Rezeri-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<surowe body>")>
  • t — czas próby w sekundach Unix.
  • v1 — podpis HMAC-SHA256 w zapisie szesnastkowym, liczony sekretem endpointu z tekstu <t>. i surowego body.

Żeby zweryfikować dostawę:

  1. Weź surowe body — dokładnie te bajty, które przyszły, przed parsowaniem JSON. Ponowna serializacja zmienia bajty i psuje podpis.
  2. Rozbij nagłówek po , na pary klucz=wartość. Zbierz t i wszystkie wartości v1.
  3. Odrzuć dostawę, gdy t różni się od Twojego zegara o więcej niż 5 minut. To chroni przed powtórzeniem przechwyconej dostawy.
  4. Policz HMAC-SHA256 z <t>. + surowe body i porównaj go z każdym v1 w stałym czasie. Przyjmij dostawę, gdy pasuje którykolwiek.
  5. Po weryfikacji deduplikuj po id zdarzenia.

Nieprawidłowy podpis zbywaj odpowiedzią 400 i nie przetwarzaj zdarzenia.

import crypto from 'node:crypto'
export function verifyRezeriSignature(header, rawBody, secret, toleranceSeconds = 300) {
let timestamp = null
const signatures = []
for (const part of (header || '').split(',')) {
const i = part.indexOf('=')
if (i === -1) continue
const key = part.slice(0, i)
const value = part.slice(i + 1)
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value)
else if (key === 'v1') signatures.push(value)
}
if (timestamp === null || Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
return false
}
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
)
return signatures.some((sig) => {
const candidate = Buffer.from(sig)
return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected)
})
}
// Express: express.raw() keeps the body as a Buffer.
// app.post('/webhooks/rezeri', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyRezeriSignature(req.get('Rezeri-Signature'), req.body, process.env.REZERI_WEBHOOK_SECRET)) {
// return res.sendStatus(400)
// }
// const event = JSON.parse(req.body)
// res.sendStatus(200)
// })

Rotacja sekretu będzie dostępna w panelu Rezeri. Po rotacji przez 24 godziny podpisujemy każdą dostawę dwoma sekretami. Nagłówek ma wtedy dwa wpisy v1 — pierwszy nowym sekretem, drugi poprzednim:

Rezeri-Signature: t=1792411392,v1=<podpis nowym sekretem>,v1=<podpis poprzednim sekretem>

Weryfikator, który sprawdza wszystkie wpisy v1, przyjmie dostawę niezależnie od tego, który sekret ma u siebie. Podmień sekret w swojej aplikacji w ciągu tych 24 godzin. Potem dostawy niosą już tylko podpis nowym sekretem.