Dokumentacja API
Ta funkcja wciąż ma oznaczenie Wkrótce w aplikacji: w tej chwili nie możesz tworzyć kluczy API ani webhooków. Jeśli otworzysz Ustawienia > API i webhooki, zobaczysz tylko informację, że funkcja jest w przygotowaniu. Ta strona opisuje, jak będzie działać, gdy funkcja stanie się dostępna.
Ta strona jest technicznym opisem dla programistów budujących integrację z API Salonnare. Zakłada, że już wiesz, czym jest klucz API i webhook, jeśli szukasz wyjaśnienia w zwykłym języku i ekranów, na których je tworzysz, zobacz API i webhooki.
Każde stwierdzenie poniżej pochodzi bezpośrednio z kodu źródłowego serwera Salonnare. Gdziekolwiek kod i ta strona się rozmijają, ufaj aktualnemu kodowi, ten dokument może się zdezaktualizować, kod to to, co faktycznie działa.
Autoryzacja
Wysyłaj swój klucz API w każdym żądaniu w nagłówku X-API-Key:
curl https://<twoj-salon>.salonnare.com/api/bookings \
-H "X-API-Key: sk_live_a1b2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Format klucza
Klucz wygląda tak: sk_live_<4 znaki hex>_<48 znaków hex>, na przykład:
sk_live_a1b2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Tylko pierwsze 12 znaków (sk_live_ plus 4-znakowy segment) są zapisywane i widoczne ponownie po utworzeniu. Pozostałe 48 znaków to właściwy sekret. Salonnare przechowuje hash SHA-256 całego klucza, nigdy sam klucz.
Uzyskiwanie klucza
Kluczami i webhookami zarządza administrator salonu w aplikacji, w Ustawienia -> API i webhooki. Przy tworzeniu klucza wybierasz jeden z trzech poziomów uprawnień:
| Uprawnienie | Co obecnie umożliwia |
|---|---|
read (domyślne) | Tylko GET/HEAD/OPTIONS. Każda inna metoda na każdym endpoincie zwraca 403. |
write | W praktyce to samo co read: żaden endpoint obecnie nie przyjmuje żądania zapisu przez klucz API (patrz Endpointy do odczytu poniżej, wszystkie sześć tras to GET). |
full | Obecnie to samo co read/write, z tego samego powodu. |
Innymi słowy: dostęp do zapisu jeszcze nie istnieje, niezależnie od poziomu uprawnień klucza. Nie buduj integracji, która zakłada tworzenie lub zmianę wizyt, klientów, usług czy pracowników przez API, każda trasa przyjmująca klucz API jest tylko do odczytu, a żądanie próbujące czegokolwiek innego niż GET na jakikolwiek endpoint jest odrzucane przed dotarciem do logiki trasy.
Pełny klucz jest pokazywany tylko raz, w odpowiedzi wywołania tworzącego klucz, w momencie jego utworzenia. Zapisz go od razu (w menedżerze haseł, nie w wiadomości czatu czy arkuszu), Salonnare nie może go pokazać ponownie. Jeśli go zgubisz, usuń ten klucz i utwórz nowy.
Do jakiego salonu należy klucz
To klucz, nie nazwa hosta, który wywołujesz, decyduje o salonie (tenancie). Możesz wysyłać żądania na subdomenę swojego salonu (https://<twoj-salon>.salonnare.com/api/...), co jest zalecanym, jednoznacznym sposobem, ale salon w odpowiedzi jest zawsze tym, do którego należy klucz, niezależnie od tego, na jaki poprawny host Salonnare trafisz.
Błędy autoryzacji
| Sytuacja | Status | Treść |
|---|---|---|
Brak nagłówka X-API-Key i brak tokenu Authorization: Bearer | 401 | {"error": "Token ontbreekt.", "code": "unauthorized", "error_key": "server.errors.token_missing"} |
| Nieznany, nieprawidłowy lub wygasły klucz | 401 | {"error": "Invalid API key.", "code": "invalid_api_key"} |
| Salon klucza nie jest aktywny (np. zawieszony) | 403 | {"error": "Salon is not active.", "code": "tenant_inactive"} |
Żądanie inne niż GET/HEAD/OPTIONS z kluczem read | 403 | {"error": "This API key is read-only.", "code": "read_only_key"} |
Żądanie z kluczem API otrzymuje komunikaty błędów po angielsku: trzy błędy z warstwy klucza w tabeli powyżej są zawsze po angielsku, a błędy przechodzące przez mechanizm tłumaczeń (większość tych z polem error_key) są dla ruchu z kluczem zwracane po angielsku, niezależnie od wysłanego nagłówka Accept-Language. Żądanie bez klucza (pierwszy wiersz) uwzględnia swój nagłówek Accept-Language dla dziewięciu języków obsługiwanych przez Salonnare (nl, en, de, fr, es, it, pl, pt, tr), a w pozostałych przypadkach jest po niderlandzku. Niektóre komunikaty na innych endpointach to nadal ustalone holenderskie ciągi tekstowe. Nie opieraj się na żadnym z tych zachowań: rozróżniaj w swojej integracji na podstawie kodu statusu HTTP (oraz code/error_key, jeśli są dostępne), nigdy na treści komunikatu. Treść odpowiedzi zawsze zawiera error, kilka tras dodaje code, a niektóre dodają do tego error_key/error_params, parsuj elastycznie i nie zakładaj dokładnego, ustalonego zestawu pól.
Endpointy do odczytu
Klucz API daje dostęp do odczytu dla dokładnie sześciu endpointów. Żadna inna trasa nie przyjmuje klucza API, niezależnie od poziomu jego uprawnień:
| Metoda | Ścieżka | Zwraca |
|---|---|---|
GET | /api/bookings | Listę wizyt |
GET | /api/bookings/:id | Jedną wizytę |
GET | /api/crm/clients | Listę klientów |
GET | /api/crm/clients/:id | Jednego klienta |
GET | /api/services | Listę usług |
GET | /api/staff | Listę pracowników |
W przeciwieństwie do reszty aplikacji administracyjnej Salonnare, te sześć tras nie zwracają kluczowi API surowego wiersza bazy danych. Każdy typ obiektu przechodzi przez ustaloną funkcję formatującą, toApiClient, toApiStaff, toApiBooking, toApiService, która zawsze emituje tylko stałą, wersjonowaną listę pól, w formacie snake_case. Nic poza tą listą nigdy nie jest zawarte, niezależnie od tego, co jeszcze ma lub w przyszłości zyska dany rekord. Dwa z czterech formatów są celowo identyczne z ich odpowiednikami webhookowymi, pole po polu: toApiClient odpowiada payloadowi client.created/client.updated, a toApiBooking odpowiada payloadowi wizyty, łącznie z tablicą services (oba używają tej samej listy dopuszczonych pól dla wierszy usług). Jeśli już parsujesz webhooki Salonnare, odpowiedzi REST dla klientów i wizyt nie wymagają osobnego parsera.
To filtrowanie dotyczy wyłącznie ruchu z kluczem API. Zalogowany pracownik salonu, wywołując te same adresy URL z tokenem Bearer zamiast nagłówka X-API-Key, wciąż otrzymuje pełny, nieprzefiltrowany rekord, na którym działa sama aplikacja (który zawiera pola wewnętrzne, celowo pomijane przez te formatujące funkcje). Różnica dotyczy wyłącznie kanału dostępu, nie endpointu czy danych. Ten dokument opisuje tylko format X-API-Key, to ten, który zobaczy Twoja integracja.
GET /api/bookings
Parametry zapytania (wszystkie opcjonalne):
| Parametr | Format | Działanie |
|---|---|---|
from, to | YYYY-MM-DD | Pół-otwarty zakres dat na czasie rozpoczęcia wizyty. |
date | YYYY-MM-DD | Jeden dzień (taki sam efekt jak from=to=ten dzień). |
source | tekst | Filtruje według sposobu utworzenia wizyty (manual, online, google_reserve, ...). |
location_id | liczba całkowita | Filtruje do jednej lokalizacji. |
client_id | liczba całkowita | Filtruje do wizyt jednego klienta. |
page | liczba całkowita | Włącza paginację (patrz poniżej). Bez tego otrzymujesz pełną tablicę. |
limit | liczba całkowita | Rozmiar strony, gdy ustawiono page. Domyślnie 50, maksymalnie 200. |
Bez ?page= odpowiedź to zwykła tablica JSON, ograniczona do 2000 wierszy, domyślnie sortowana od najnowszych, chyba że ustawisz jednocześnie from i to, albo date, wtedy sortuje od najstarszych (chronologicznie). Z ?page=N odpowiedź wygląda tak:
{
"items": [ /* takie same obiekty jak poniżej */ ],
"total": 134,
"page": 1,
"limit": 50,
"pages": 3
}
Każda pozycja, w obu trybach, jest wynikiem funkcji toApiBooking():
{
"id": 4821,
"client_id": 312,
"staff_id": 7,
"start_at": "2026-08-25T09:00:00.000Z",
"end_at": "2026-08-25T09:45:00.000Z",
"status": "scheduled",
"notes": "",
"services": [
{
"service_id": 41,
"staff_id": 7,
"duration_min": 45,
"price": "45.00"
}
]
}
To kompletny zestaw pól dla tego endpointu, bez imienia klienta, bez szczegółów sali, bez całkowitej kwoty wizyty. status to jedno z: scheduled, provisional, completed, cancelled, no-show. Wizyty odwołane i niestawienia się są uwzględniane na liście, odfiltruj je po stronie klienta na podstawie status, jeśli potrzebujesz tylko aktywnych wizyt. notes jest zawsze tekstem (pusty ciąg, nie null, gdy nie ma czego pokazać).
services to usługi wizyty, w kolejności wykonania, i to ta sama tablica, którą wysyłają webhooki wizyt. Dla każdego wiersza: service_id (nazwę rozwiąż przez GET /api/services), staff_id (pracownik przypisany do tej konkretnej usługi, który może być inny niż staff_id wizyty), duration_min i price, czyli cena jako ciąg dziesiętny, zapisana w momencie utworzenia wizyty, więc późniejsza zmiana ceny usługi nie zmienia historii. Wizyta zawsze ma co najmniej jeden wiersz.
GET /api/bookings/:id
Brak parametrów zapytania. Zwraca pojedynczy obiekt w takim samym formacie jak pozycja listy powyżej:
{
"id": 4821,
"client_id": 312,
"staff_id": 7,
"start_at": "2026-08-25T09:00:00.000Z",
"end_at": "2026-08-25T09:45:00.000Z",
"status": "scheduled",
"notes": "",
"services": [
{
"service_id": 41,
"staff_id": 7,
"duration_min": 45,
"price": "45.00"
}
]
}
Nie ma tu imienia klienta, sali czy informacji o kuracji (cure), odpowiedź z kluczem API celowo zatrzymuje się na ośmiu polach powyżej, mimo że wersja tego endpointu dla zalogowanego użytkownika zwraca znacznie bogatszy obiekt ze szczegółami sali dla każdej pozycji, dodatkowymi pracownikami przy usłudze i kontekstem kuracji wizyty. services zawiera tylko rzeczywiste usługi: wiersze przejściowe, które aplikacja wykorzystuje na czas przetwarzania (np. działanie koloru), są pomijane, więc tablica odpowiada dokładnie webhookowi wizyty. Dodanie pól w przyszłości nie jest zmianą łamiącą kompatybilność, ale zakładaj, że dzisiejsza odpowiedź ma dokładnie ten format.
Niepoprawne liczbowo lub niedodatnie :id zwraca 400 z {"error": "Invalid appointment ID.", "code": "bad_request", "error_key": "server.errors.invalid_booking_id_upper"}. Prawidłowe, ale nieistniejące :id zwraca 404 z {"error": "Appointment not found.", "code": "not_found", "error_key": "server.errors.booking_not_found"}.
GET /api/crm/clients
Parametry zapytania (wszystkie opcjonalne):
| Parametr | Format | Działanie |
|---|---|---|
search | tekst | Dopasowuje do imienia, preferowanego imienia, e-maila i telefonu. |
label_id | liczba całkowita | Filtruje do klientów z przypisaną tą etykietą. |
page | liczba całkowita | Włącza paginację, ta sama struktura jak przy wizytach powyżej. |
limit | liczba całkowita | Rozmiar strony, gdy ustawiono page. Domyślnie 50, maksymalnie 200. |
Bez ?page= odpowiedź to zwykła tablica JSON, sortowana alfabetycznie (albo według trafności wyszukiwania, gdy ustawiono search). Każda pozycja jest wynikiem funkcji toApiClient():
{
"id": 312,
"name": "Anna Voorbeeld",
"email": "anna@example.com",
"phone": null,
"mobile": "+31612345678",
"first_name": "Anna",
"last_name": "Voorbeeld",
"preferred_name": null,
"city": "Waalwijk",
"country": "NL",
"created_at": "2026-01-14T10:22:00.000Z"
}
To kompletny zestaw pól, bez adresu, daty urodzenia, etykiet, danych lojalnościowych czy o niestawieniu się, i bez żadnych pól konta/dostawcy płatności (passwordHash, stripeCustomerId i podobnych), które kiedyś się tu przedostawały. search/label_id wciąż filtrują, którzy klienci się kwalifikują, nie dodają pól do tego, co jest zwracane o każdym z nich.
GET /api/crm/clients/:id
Brak parametrów zapytania. Zwraca pojedynczy obiekt w takim samym formacie jak pozycja listy powyżej:
{
"id": 312,
"name": "Anna Voorbeeld",
"email": "anna@example.com",
"phone": null,
"mobile": "+31612345678",
"first_name": "Anna",
"last_name": "Voorbeeld",
"preferred_name": null,
"city": "Waalwijk",
"country": "NL",
"created_at": "2026-01-14T10:22:00.000Z"
}
Odpowiedzi z formularza wywiadu (intake_responses) nigdy nie są częścią tej odpowiedzi dla klucza API, niezależnie od tego, kto ma do nich dostęp w aplikacji, to pole po prostu nie znajduje się na stałej liście powyżej.
Niepoprawne liczbowo lub zerowe/NaN :id zwraca 400 z {"error": "Invalid client ID.", "code": "bad_request", "error_key": "server.errors.invalid_client_id"}. Prawidłowe, ale nieistniejące :id zwraca 404 z {"error": "Client not found.", "code": "not_found", "error_key": "server.errors.not_found", "error_params": {"entity": "Client"}}.
GET /api/services
Parametry zapytania:
| Parametr | Format | Działanie |
|---|---|---|
bookable | 1 lub true | Tylko aktywne (niezarchiwizowane) usługi. Bez tego uwzględniane są też usługi zarchiwizowane. |
Odpowiedź jest zawsze zwykłą tablicą JSON (ten endpoint nie wspiera paginacji). Każda pozycja jest wynikiem funkcji toApiService():
[
{
"id": 41,
"name": "Knipbeurt",
"duration_min": 45,
"price": "45.00",
"category_id": 3,
"active": true
}
]
To kompletny zestaw pól, bez opisu, numeru artykułu, stawki VAT, ograniczeń harmonogramu i bez nazwy/koloru kategorii (tylko surowe category_id, jeśli potrzebujesz szczegółów kategorii, pobierz je osobno lub prowadź własne mapowanie).
GET /api/staff
Brak parametrów zapytania. Odpowiedź jest zawsze zwykłą tablicą JSON (bez wsparcia paginacji). Każda pozycja jest wynikiem funkcji toApiStaff():
[
{
"id": 7,
"name": "Chris Medewerker",
"email": "chris@example.com",
"role": "staff"
}
]
To kompletny zestaw pól, bez iban/bic, bez statusu Stripe Connect, bez danych o blokadzie/próbach logowania, bez flag uprawnień do stron. Jeśli Twoja integracja potrzebuje danych do wypłat, nie jest to coś, co może odczytać klucz API, musi pochodzić bezpośrednio od salonu.
Celowo nie ma pola active: ten endpoint zawsze zwraca tylko aktywnych pracowników salonu, więc to pole nigdy nie mogłoby mieć innej wartości niż true. Nie czekaj na active: false, by uznać kogoś za nieaktywnego, pracownik, który odchodzi, po prostu przestaje pojawiać się na tej liście, więc traktuj "brak w odpowiedzi" jako sygnał.
Limit zapytań
Każde żądanie /api/*, włącznie z żądaniami z kluczem API, jest ograniczone do 200 żądań na minutę na kombinację salonu i adresu IP. Żaden z sześciu endpointów do odczytu nie ma dodatkowego, ostrzejszego limitu poza tym.
Po przekroczeniu limitu otrzymujesz:
- Status
429 - Treść:
{"error": "Too many requests, please try again later", "code": "rate_limited", "error_key": "server.errors.rate_limited_comma"} - Nagłówki w każdej odpowiedzi (nie tylko przy przekroczeniu limitu):
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset(znacznik czasu Unix, w sekundach) - W samej odpowiedzi
429dodatkowy nagłówekRetry-Afterz liczbą sekund do odczekania przed kolejną próbą
Przestrzegaj Retry-After zamiast odpytywać w ustalonym interwale, odzwierciedla on rzeczywisty moment resetu okna, które przekroczyłeś.
Webhooki
Zamiast odpytywania możesz zarejestrować adres URL i pozwolić Salonnare wysyłać do niego zdarzenia w momencie, gdy się dzieją. Webhooki konfiguruje się w aplikacji w Ustawienia -> API i webhooki -> Webhooki, w tym samym miejscu, gdzie znajdują się klucze API.
Struktura przesyłki
Każda dostawa to żądanie POST z taką treścią JSON:
{
"event": "booking.created",
"data": { /* dane specyficzne dla zdarzenia, patrz poniżej */ },
"timestamp": "2026-08-25T09:00:03.512Z"
}
Oprócz treści każda dostawa zawiera dwa nagłówki: X-Webhook-Signature (patrz Weryfikacja podpisów) i X-Webhook-Event (ta sama wartość co event w treści).
Zdarzenia
Możesz zapisać się na dowolną kombinację sześciu zdarzeń. Czytaj kolumnę "Wywoływane, gdy" dosłownie, każde z tych zdarzeń jest wywoływane wyłącznie przez działanie pracownika salonu w aplikacji, i każde ma dokładnie jeden punkt wywołania w serwerze:
| Zdarzenie | Wywoływane, gdy |
|---|---|
booking.created | Pracownik tworzy wizytę w aplikacji (POST /api/bookings) |
booking.updated | Pracownik edytuje wizytę w aplikacji (PATCH/PUT /api/bookings/:id), łącznie ze zmianą statusu na zakończona lub niestawienie się, ponieważ aplikacja wykonuje to przez to samo wywołanie |
booking.cancelled | Pracownik odwołuje wizytę w aplikacji (DELETE /api/bookings/:id) |
payment.received | Pracownik kończy rozliczenie w kasie (POST /api/pos/orders) |
client.created | Pracownik tworzy klienta w aplikacji (POST /api/crm/clients) |
client.updated | Pracownik edytuje klienta w aplikacji (PATCH/PUT /api/crm/clients/:id) |
Co nie wywołuje zdarzenia
To jest część, która zaskakuje integracje, więc jest wyjaśniona dosłownie, nie domyślnie. Wizyty, klienci i płatności powstające w jakikolwiek inny sposób nie generują żadnego webhooka. Konkretnie, nic nie jest wysyłane, gdy:
- klient rezerwuje online przez stronę rezerwacji salonu, albo sam odwołuje lub potwierdza wizytę na tej stronie;
- wizyta przychodzi przez Google Reserve;
- wpis z listy rezerwowej zostaje przekształcony w wizytę;
- powtarzająca się seria generuje swoje wizyty;
- tworzona jest seria kuracji (
cure), albo odwoływana jest jedna z jej sesji; - wizyta tymczasowa zostaje automatycznie odwołana, bo minął termin zaliczki, albo zostaje potwierdzona, bo zaliczka została zapłacona;
- wizyta jest oznaczana jako zakończona jako efekt rozliczenia w kasie;
- klient sam się rejestruje na stronie rezerwacji, zapisuje się przez Google/Microsoft/Apple/Facebook, albo jest tworzony przez import;
- rekord klienta zmienia się przez coś innego niż ekran edycji klienta: przeliczenie poziomu lojalności, saldo klienta, odpowiedzi w formularzu wywiadu, odbity e-mail, albo jest anonimizowany przez żądanie usunięcia.
Praktyczny skutek: nie możesz używać webhooków jako kompletnego kanału zmian. Dla salonu przyjmującego rezerwacje online większość nowych wizyt nigdy nie dotrze do Twojego endpointu. Synchronizuj okresowo z GET /api/bookings z zakresem from/to i traktuj webhooki jako wskazówkę o niskim opóźnieniu, nie jako źródło prawdy.
Wszystkie sześć payloadów przechodzi przez ustaloną listę dopuszczonych pól na serwerze, tylko pola wymienione poniżej są kiedykolwiek wysyłane, w formacie snake_case, niezależnie od tego, co jeszcze zawiera dany rekord. To celowy kontrakt: payloady webhooków nie zyskają po kryjomu nowych pól z bazy danych.
booking.created / booking.updated / booking.cancelled:
{
"event": "booking.created",
"data": {
"id": 4821,
"client_id": 312,
"staff_id": 7,
"start_at": "2026-08-25T09:00:00.000Z",
"end_at": "2026-08-25T09:45:00.000Z",
"status": "scheduled",
"notes": "",
"services": [
{
"service_id": 41,
"staff_id": 7,
"duration_min": 45,
"price": "45.00"
}
]
},
"timestamp": "2026-08-25T09:00:03.512Z"
}
Wszystkie trzy zdarzenia wizyt wysyłają dokładnie ten format, zbudowany z zapisanej wizyty: te same pola, te same typy, start_at/end_at zawsze w UTC. Dwa szczegóły, które warto znać:
statusto status w momencie wysłania zdarzenia.booking.createddla wizyty wymagającej zaliczki zatem zgłaszaprovisional, niescheduled, abooking.cancelledzgłaszacancelled, a nie status, który wizyta miała wcześniej.- To ten sam zestaw pól, co
GET /api/bookings/:id, łącznie z tablicąservices, więc jeden parser obsługuje oba kanały.
payment.received:
{
"event": "payment.received",
"data": {
"order_id": 9931,
"order_number": "2026-000482",
"total": 45,
"payment_method": "card",
"client_id": 312
},
"timestamp": "2026-08-25T09:12:47.001Z"
}
client.created / client.updated:
{
"event": "client.created",
"data": {
"id": 312,
"name": "Anna Voorbeeld",
"email": "anna@example.com",
"phone": null,
"mobile": "+31612345678",
"first_name": "Anna",
"last_name": "Voorbeeld",
"preferred_name": null,
"city": "Waalwijk",
"country": "NL",
"created_at": "2026-01-14T10:22:00.000Z"
},
"timestamp": "2026-08-25T09:00:03.512Z"
}
Zauważ, że to dokładnie ten sam zestaw pól co odpowiedź GET /api/crm/clients powyżej, toWebhookClient() i toApiClient() korzystają z tej samej listy dopuszczonych pól, więc klient wygląda identycznie, niezależnie od tego, czy otrzymujesz go przez webhook, czy pobierasz przez API.
Gdy właściciel salonu wysyła ręczną testową dostawę z ekranu Webhooki, otrzymasz przesyłkę z "event": "test" i niewielkim przykładowym payloadem. To nie jedno z sześciu zdarzeń, na które można się zapisać, niech Twój handler ignoruje (nie zwraca błędu dla) nazw zdarzeń, których nie rozpoznaje.
Weryfikacja podpisów
Każda dostawa jest podpisana za pomocą HMAC-SHA256 na surowej treści żądania, przy użyciu sekretu webhooka. Podobnie jak klucz API, ten sekret jest zaszyfrowany w stanie spoczynku i pokazywany w całości tylko raz: w odpowiedzi wywołania tworzącego, i ponownie, jeśli salon wygeneruje go na nowo przez POST /api/developer/webhooks/:id/rotate-secret. GET/PUT na webhooku nigdy nie zawiera sekretu, tylko wartość logiczną hasSecret, więc jeśli salon go zgubił, musi (albo ty, w jego imieniu) wygenerować go na nowo i zaktualizować wszystko, co weryfikuje podpis po Twojej stronie. Skrót hex jest wysyłany w nagłówku X-Webhook-Signature.
const crypto = require('crypto');
function verify(rawBody, signatureHeader, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
rawBody musi być dokładnie tymi bajtami, które Salonnare wysłało przez sieć, nie JSON.stringify(JSON.parse(rawBody)) i nie żadnym obiektem, który od tego czasu ponownie zserializowałeś. Ponowna serializacja zmienia kolejność kluczy i białe znaki, co zmienia bajty, co zmienia podpis. To najczęstszy błąd integracyjny i prowadzi do podpisu, który nigdy się nie zgadza, bez żadnego wyraźnego komunikatu błędu wskazującego przyczynę. Wczytaj treść jako surowy tekst/bufor przed tym, jak Twój framework przetworzy ją jako JSON, i oblicz skrót na tej wartości.
crypto.timingSafeEqual zgłasza wyjątek (a nie zwraca false), gdy dwa bufory różnią się długością w bajtach, więc brakujący albo nieprawidłowy nagłówek X-Webhook-Signature spowoduje wyjątek w funkcji verify() powyżej, a nie tylko niepowodzenie sprawdzenia. Zabezpiecz się przed tym, sprawdź, czy nagłówek jest obecny i jest 64-znakowym ciągiem hex, przed wywołaniem timingSafeEqual, albo otocz wywołanie blokiem try/catch.
Zachowanie dostawy
- Jedna próba, bez ponownych prób. Jeśli Twój endpoint jest niedostępny albo zwraca błąd, Salonnare nie kolejkuje ani nie powtarza tej dostawy. Zaprojektuj swoją integrację, by tolerowała sporadyczne nieodebrane zdarzenie (na przykład okresowo synchronizując z
GET /api/bookingsz zakresemfrom/to), a nie zakładała, że każde zdarzenie dotrze. - Limit czasu 10 sekund. Jeśli Twój endpoint nie odpowie w ciągu 10 sekund, dostawa jest traktowana jako nieudana.
- Przekierowania nie są śledzone. Odpowiedź
3xxliczy się jako nieudana dostawa, tak samo jak4xx/5xx. Odpowiadaj2xxz dokładnie tego adresu URL, który zarejestrowałeś. - Automatyczne wyłączenie po 10 kolejnych niepowodzeniach. Gdy webhook zawiedzie 10 razy pod rząd (liczą się przekroczenia czasu, odpowiedzi inne niż 2xx oraz zablokowane/nieprawidłowe adresy URL), Salonnare automatycznie go deaktywuje. Właściciel salonu musi go ponownie włączyć z ekranu Webhooki, ponowne włączenie resetuje licznik niepowodzeń.
- Logi dostaw są dostępne dla salonu, nie dla Twojego endpointu. Administrator salonu może sprawdzić ostatnie dostawy (kod statusu, treść odpowiedzi, czas trwania) dla webhooka przez
GET /api/developer/webhooks/:id/logsw aplikacji, to wywołanie wymaga logowania administratora (nie klucza API), więc służy salonowi do debugowania własnej integracji, nie jest to coś, co wywołuje Twój odbierający serwis.
Wymagania dotyczące adresu URL
Adres URL webhooka, który rejestrujesz, musi być publicznie dostępny w internecie. Salonnare weryfikuje go zarówno podczas zapisu, jak i ponownie, na bieżąco, bezpośrednio przed każdą pojedynczą dostawą (żeby zabezpieczyć się przed domeną, która dziś wskazuje na coś bezpiecznego, a jutro na coś wewnętrznego). Adres URL jest odrzucany, przy zapisie z kodem 400, albo przy dostawie jako nieudana próba, gdy jest lub wskazuje na:
- Coś innego niż
http:/https:(w produkcji tylkohttps:) localhost,127.0.0.1,0.0.0.0,::1,::albo jakikolwiek równoważny adres IPv6 w nawiasach- Wewnętrzną nazwę hosta (
server,db,mariadb,mysql,redis,docs,worker) - Dosłowny prywatny/loopback/link-local adres IP (zakresy RFC 1918,
169.254.0.0/16,127.0.0.0/8, IPv6 ULAfc00::/7, IPv6 link-localfe80::/10) - Adres IPv6 w jednej z notacji przejściowych, która może kryć adres IPv4 w swoich niższych bitach: zmapowany IPv4 (
::ffff:0:0/96), kompatybilny z IPv4 (::/96), NAT64 (64:ff9b::/96) i 6to4 (2002::/16). Są blokowane na poziomie prefiksu, więc każdy zapis osadzonego adresu jest odrzucany,[::ffff:127.0.0.1],[::127.0.0.1]i[::7f00:1]jednakowo. Zwykłe adresy IPv6 typu global-unicast nie są tym objęte - Publiczną nazwę hosta, której wpis DNS wskazuje na coś z powyższego, sprawdzane w momencie dostawy, nie tylko jednorazowo przy rejestracji, właśnie dlatego, żeby domena, którą później przekierujesz na adres wewnętrzny, wciąż była blokowana
Skieruj adres URL na domenę, którą kontrolujesz od samego początku, nie rejestruj placeholdera, planując później przekierować DNS na adres wewnętrzny, i nie opieraj się tylko na sprawdzeniu przy zapisie, dostawa weryfikuje przy każdej próbie na nowo.