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.
Zdarzenia
Dział zatytułowany „Zdarzenia”| 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.
Rejestracja endpointu
Dział zatytułowany „Rejestracja endpointu”- Wywołaj
POST /v1/webhook-endpointsz adresemurli niepustą listąevents. - Odpowiedź
201zawiera polesecret(zaczyna się odwhsec_). To jedyny raz, kiedy widzisz sekret.GET, lista iPATCHgo nie zwracają. Zapisz go w menedżerze sekretów. - 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.
Wymagania wobec adresu
Dział zatytułowany „Wymagania wobec adresu”- Tylko
https. Adres bez danych logowania (user:hasło@), bez białych znaków i znaków sterujących, najwyżej 2048 znaków. Inaczej422 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ź
3xxto 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.
Format dostawy
Dział zatytułowany „Format dostawy”Każda dostawa to POST z nagłówkami:
Content-Type: application/jsonUser-Agent: Rezeri-Webhooks/1.0Rezeri-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 samymid.type— typ zdarzenia z tabeli wyżej.created_at— chwila zdarzenia, ISO 8601 z offsetem strefyEurope/Warsaw.api_version— wersja API, w której opisany jestdata.object.test—falsew każdej prawdziwej dostawie. Zdarzenie wysłane z panelu do sprawdzenia połączenia matrue. Odbiornik nie zmienia stanu po zdarzeniu ztest: true: nie oznacza rezerwacji jako opłaconej ani nie wysyła klientowi wiadomości.data.object— rezerwacja w tym samym kształcie, co wGET /v1/bookings/{booking_id}(w przykładzie skrócona). Pola opisuje referencja API.data.previous_attributes— tylko wbooking.rescheduled, patrz niżej. W pozostałych zdarzeniach tego klucza nie ma.
Poprzedni termin (previous_attributes)
Dział zatytułowany „Poprzedni termin (previous_attributes)”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 strefyEurope/Warsaw, jakdata.object.startiend. 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.objectbudujemy od nowa przy każdej próbie: przy dwóch szybkich przełożeniach obie dostawy mogą mieć wobjectnajnowszy termin, a każda swójprevious_attributes. - Zdarzenie testowe
booking.rescheduledwysłane z panelu ma wprevious_attributesbieżący termin przykładowej rezerwacji.
Minimalny payload (payload_format: minimal)
Dział zatytułowany „Minimalny payload (payload_format: minimal)”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, cobookingIdw zdarzeniurezeri:bookingSuccessfulwidgetu).status— publiczny status rezerwacji w chwili wysyłki.occurred_at— chwila zdarzenia, równacreated_atkoperty.
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.
Jak odbierać zdarzenia
Dział zatytułowany „Jak odbierać zdarzenia”- Deduplikuj po
id. To samo zdarzenie może przyjść więcej niż raz (dostawa „co najmniej raz”). Zapisz przetworzoneidi 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,typeicreated_atzostają te same. - Odpowiadaj szybko. Sukces to odpowiedź
2xxw ciągu 10 sekund od rozpoczęcia próby. Ciała odpowiedzi nie czytamy. Zapisz zdarzenie, odpowiedz200i przetwarzaj je w tle. - Ignoruj nieznane pola i typy zdarzeń. W
v1mogą dojść nowe pola, wartości enumów i typy zdarzeń — patrz Wersjonowanie.
Ponowienia
Dział zatytułowany „Ponowienia”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.
Wyłączenie endpointu
Dział zatytułowany „Wyłączenie endpointu”Gdy dostawa wyczerpie wszystkie próby, a endpoint od jej powstania nie przyjął z sukcesem żadnej innej dostawy, wyłączamy go:
enabledzmienia się nafalse, adisabled_reasonna"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.
Weryfikacja podpisu
Dział zatytułowany „Weryfikacja podpisu”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ę:
- Weź surowe body — dokładnie te bajty, które przyszły, przed parsowaniem JSON. Ponowna serializacja zmienia bajty i psuje podpis.
- Rozbij nagłówek po
,na paryklucz=wartość. Zbierzti wszystkie wartościv1. - Odrzuć dostawę, gdy
tróżni się od Twojego zegara o więcej niż 5 minut. To chroni przed powtórzeniem przechwyconej dostawy. - Policz HMAC-SHA256 z
<t>.+ surowe body i porównaj go z każdymv1w stałym czasie. Przyjmij dostawę, gdy pasuje którykolwiek. - Po weryfikacji deduplikuj po
idzdarzenia.
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)// })import hashlibimport hmacimport time
def verify_rezeri_signature(header: str, body: bytes, secret: str, tolerance: int = 300) -> bool: timestamp = None signatures = [] for part in (header or "").split(","): key, _, value = part.partition("=") if key == "t" and value.isascii() and value.isdigit(): timestamp = int(value) elif key == "v1": signatures.append(value) if timestamp is None or abs(int(time.time()) - timestamp) > tolerance: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest() return any(sig.isascii() and hmac.compare_digest(expected, sig) for sig in signatures)
# Flask: request.get_data() returns the raw body.# @app.post("/webhooks/rezeri")# def rezeri_webhook():# if not verify_rezeri_signature(request.headers.get("Rezeri-Signature", ""), request.get_data(), SECRET):# return "", 400# event = request.get_json()# return "", 200<?php
function verifyRezeriSignature(string $header, string $rawBody, string $secret, int $tolerance = 300): bool{ $timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', $part, 2), 2, ''); if ($key === 't' && ctype_digit($value)) { $timestamp = (int) $value; } elseif ($key === 'v1') { $signatures[] = $value; } } if ($timestamp === null || abs(time() - $timestamp) > $tolerance) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false;}
// $rawBody = file_get_contents('php://input');// $header = $_SERVER['HTTP_REZERI_SIGNATURE'] ?? '';// if (!verifyRezeriSignature($header, $rawBody, getenv('REZERI_WEBHOOK_SECRET'))) {// http_response_code(400);// exit;// }// $event = json_decode($rawBody, true);// http_response_code(200);Rotacja sekretu
Dział zatytułowany „Rotacja sekretu”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.