Sinkronizacija rezervacija
Ako terene kluba prodajete putem vlastitog sustava, primjerice tržnice, sustava za upravljanje objektima ili web-stranice kluba, dva se rasporeda moraju slagati. Ova stranica pokriva oba smjera:
- Vi javljate nama svaku rezervaciju koju primite, a mi zauzimamo taj teren kako ga nitko ne bi rezervirao u Sportyju.
- Mi javljamo vama svaku rezervaciju napravljenu, premještenu ili otkazanu u Sportyju, putem potpisanog webhooka.
Vaša integracija je servisni račun. Ima token dobiven putem client credentials i djeluje u ime kluba, a ne nekog člana.
Što je rezervacija koju vi pošaljete
Kad pošaljete rezervaciju, teren se zauzima točno kao kad administrator kluba blokira termin u Sportyju:
- Ima naziv (prikazan u klupskom rasporedu) i, po želji, kategoriju.
- Nema ni igrača ni cijene. Sporty za nju ne naplaćuje ništa, ne primjenjuje ograničenja članstva i ne obavještava nijednog člana.
- Teren mora biti slobodan. To je jedino pravilo koje provjeravamo.
Tko je igrao i tko je platio ostaje u vašem sustavu. Klubu javljate da je teren zauzet, a ne prodajete rezervaciju putem Sportyja.
Prije nego što počnete
Klub vašoj aplikaciji mora odobriti potrebne opsege. Za dvosmjernu sinkronizaciju to su:
| Opseg | Za |
|---|---|
club.courts.read | Popis terena, kako biste ih povezali sa svojima |
club.reservations.read | Čitanje rasporeda i njegovih kategorija te primanje webhookova |
club.reservations.write | Zauzimanje, premještanje i otkazivanje rezervacija |
club.reservations.write vašoj aplikaciji omogućuje otkazivanje bilo koje rezervacije u
klubu, uključujući rezervacije članova, kao što to može osoblje kluba. Klub ga odobrava znajući
to. Koristite ga za rezervacije koje morate povući, a ne za pospremanje rasporeda.
Dohvatite token:
curl -X POST https://api.sporty.plus/oauth2/token \
-d grant_type=client_credentials \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET \
-d scope="club.courts.read club.reservations.read club.reservations.write"
Ako vaša aplikacija djeluje za više klubova, uz svaki zahtjev u nastavku pošaljite
X-Club: <club id>. Webhookovi se postavljaju za svaki klub zasebno.
Kategorije
Rezervacija može nositi jednu od kategorija rezervacija kluba, oznaka koje njegovi administratori daju blokiranim terminima („Liga”, „Teniska škola”). Klupski je raspored prikazuje u boji kategorije.
curl https://api.sporty.plus/api/partner/v1/reservation-categories \
-H "Authorization: Bearer $ACCESS_TOKEN"
{ "data": [ { "id": 3, "name": "Liga", "color": "#ea47bc" } ] }
Za ovo je potreban club.reservations.read. Klub ih ne mora imati; category_id je neobavezan.
Zauzimanje terena
Da biste najprije pronašli slobodan termin, GET /availability?date=… navodi termine svakog
terena, slobodne ili zauzete; pogledajte partnerski API.
Zauzimanje terena to ponovno provjerava, pa termin koji je u međuvremenu zauzet i dalje dobiva
409 slot_taken.
curl -X POST https://api.sporty.plus/api/partner/v1/reservations \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"external_id": "bk_8f3a21",
"court_id": 412,
"from": "2026-10-06T17:00:00+02:00",
"to": "2026-10-06T18:30:00+02:00",
"name": "Online: Horvat"
}'
| Polje | ||
|---|---|---|
external_id | obavezno | Vaš id rezervacije, do 191 znaka. |
court_id | obavezno | Teren iz GET /courts. |
from, to | obavezno | ISO 8601 s pomakom vremenske zone. to mora biti nakon from. |
name | neobavezno | Ono što klub vidi u svojem rasporedu, do 255 znakova. |
category_id | neobavezno | Jedna od kategorija rezervacija kluba, iz GET /reservation-categories. |
Odgovor je 201 s rezervacijom:
{
"data": {
"id": 918273,
"court_id": 412,
"court_name": "Teren 3",
"from": "2026-10-06T15:00:00+00:00",
"to": "2026-10-06T16:30:00+00:00",
"category_id": null,
"approved": false,
"cancelled": false,
"source": "partner",
"external_id": "bk_8f3a21",
"name": "Online: Horvat",
"updated_at": "2026-09-29T11:20:04+00:00"
}
}
Sačuvajte id. Njime premještate ili otkazujete rezervaciju. Vremena se uvijek vraćaju u UTC-u.
Dvostruko slanje je sigurno
external_id čini zahtjev idempotentnim. Ponovno slanje istog external_id dok ta rezervacija
postoji odgovara 200 s postojećom rezervacijom, nepromijenjenom. Ponovni pokušaj nakon
isteka vremena stoga nikad ne zauzima drugi teren. Ponovno slanje s drugim vremenima ne premješta
rezervaciju; za premještanje koristite PATCH.
Ako je rezervacija u međuvremenu otkazana, s vaše strane ili od strane kluba, ponovno slanje
njezina external_id rezervira je iznova.
Kad je teren zauzet
{
"error": "slot_taken",
"error_description": "The court is already booked for some of that time.",
"conflicting_reservation_id": 918102
}
Status 409. Rezervacija smije početi točno kad druga završava; svako preklapanje dulje od nule
je sukob.
Premještanje rezervacije
curl -X PATCH https://api.sporty.plus/api/partner/v1/reservations/918273 \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"court_id": 413, "from": "2026-10-06T18:00:00+02:00", "to": "2026-10-06T19:30:00+02:00"}'
Pošaljite samo ono što se mijenja: court_id, name, category_id, from, to. Svaki kraj
vremenskog raspona može se promijeniti zasebno, pa {"to": "…"} produljuje ili skraćuje
rezervaciju. Novi termin provjerava se u odnosu na sve ostale rezervacije, a odgovor je ažurirana
rezervacija. Promjena zbog koje bi rezervacija završila prije nego što počne odbija se s
422 invalid_times.
Možete premještati samo rezervacije koje je poslala vaša aplikacija. Rezervacije napravljene u
Sportyju pripadaju članovima i klubu. Za njih, kao i za otkazane rezervacije, odgovor je 404.
Otkazivanje rezervacije
curl -X DELETE https://api.sporty.plus/api/partner/v1/reservations/918273 \
-H "Authorization: Bearer $ACCESS_TOKEN"
Odgovor je 204. Ovo radi za bilo koju rezervaciju u klubu i izvodi se kao otkazivanje od strane
administratora kluba. Ako je rezervacija bila članova, iznos mu se vraća u klupski novčanik i
dobiva obavijest da je rezervacija otkazana.
Obavijesti o rezervacijama napravljenima u Sportyju
Postavljanje webhooka
curl -X PUT https://api.sporty.plus/api/partner/v1/webhook \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://bookings.example.com/hooks/sporty"}'
{
"data": {
"url": "https://bookings.example.com/hooks/sporty",
"delivered_up_to": "2026-09-29T11:25:00+00:00",
"last_delivered_at": null,
"last_failed_at": null,
"last_error": null,
"secret": "whsec_…"
}
}
secret se prikazuje jednom, u ovom odgovoru. Pohranite ga. Da biste ga zamijenili, uz
sljedeći PUT pošaljite "rotate_secret": true, a odgovor će sadržavati novi.- Ponovno slanje
PUTmijenja URL.GET /webhookprikazuje trenutne postavke i kako ide isporuka;DELETE /webhookzaustavlja isporuku. - Postoji jedan webhook po aplikaciji i klubu.
- Novi webhook počinje od trenutka kad ga postavite. Za sve ranije pogledajte nadoknađivanje.
- URL mora biti
https, ne smije sadržavati vjerodajnice i mora se razriješiti u javnu adresu. Sve ostalo odbija se s422.
Što stiže
Otprilike jednom u minuti, dok god se nešto promijenilo, dobivate POST poput ovoga:
{
"id": "5d1c4e0a-4a59-4b8e-9a0e-2a7b8f6c9d12",
"club_id": 57,
"sent_at": "2026-09-29T11:31:07+00:00",
"events": [
{
"type": "reservation.created",
"reservation": {
"id": 918301,
"court_id": 412,
"court_name": "Teren 3",
"from": "2026-10-07T16:00:00+00:00",
"to": "2026-10-07T17:00:00+00:00",
"category_id": null,
"approved": true,
"cancelled": false,
"source": "sporty",
"external_id": null,
"name": null,
"updated_at": "2026-09-29T11:30:58+00:00"
}
}
]
}
typeje jedno od sljedećeg:reservation.createdreservation.updated, kad je rezervacija premještena, preimenovana ili na drugi način promijenjena;reservation.cancelled.
eventssadrži do otprilike 100 promjena, od najstarije.- Rezervacije članova stižu bez naziva (
namejenull) i bez osoba koje u njima sudjeluju. Vidite da je teren zauzet i kada.
Ne oslanjajte se na to da je type potpun. Promjene se skupljaju između isporuka, pa
rezervacija napravljena i otkazana unutar iste minute stiže samo kao reservation.cancelled.
Smatrajte reservation trenutnim stanjem rezervacije i primijenite ga: ako je cancelled true,
oslobodite teren; inače ga zauzmite na court_id od from do to.
Ne dobivate obavijesti o vlastitim promjenama. Rezervacija koju pošaljete ili premjestite ne
vraća vam se. Kad otkažete rezervaciju člana, ona vam se vraća kao reservation.cancelled. Taj je
događaj bezopasan jer je teren u vašoj kopiji već slobodan.
Odgovaranje
Odgovorite bilo kojim 2xx unutar 10 sekundi. Sve ostalo smatra se neuspjehom: drugi status,
istek vremena ili preusmjeravanje (preusmjeravanja ne slijedimo). Iste promjene, uz sve novije,
tada se ponovno šalju otprilike minutu kasnije, i tako dalje dok ih ne prihvatite. Ništa se ne
gubi, a redoslijed se čuva.
To znači da ćete ponekad istu promjenu primiti dvaput. Obradu napravite idempotentnom: kao
ključ koristite reservation.id i zanemarite kopiju čiji updated_at nije noviji od onoga što
već imate.
Isporuka prestaje u bilo kojem od ovih slučajeva:
- klub povuče vaše odobrenje;
- vaša je aplikacija opozvana;
- izbrišete webhook;
- klub zaustavi webhook.
GET /webhook prikazuje last_error ako isporuke ne uspijevaju.
Provjera potpisa
Svaka isporuka sadrži:
Sporty-Signature: t=1790680267,v1=6c4b1e9f…
v1 je heksadecimalni HMAC-SHA256 od {t}.{raw body}, s tajnim ključem vašeg webhooka kao
ključem. Provjerite ga prema izvornom tijelu zahtjeva prije parsiranja i odbijte stare
vremenske oznake kako se presretnuta isporuka ne bi mogla ponoviti.
import crypto from 'node:crypto'
export function verifySporty(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds
return fresh && parts.v1?.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
}
function verifySporty(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
parse_str(str_replace(',', '&', $header), $parts);
$expected = hash_hmac('sha256', $parts['t'].'.'.$rawBody, $secret);
return abs(time() - (int) $parts['t']) <= $tolerance
&& hash_equals($expected, $parts['v1'] ?? '');
}
Računajte s isporukom koja kasni nekoliko minuta: šalje se ponovno dok je ne prihvatite, ali se svaki put iznova potpisuje, pa tolerancija mora pokriti samo razliku u satovima.
Nadoknađivanje
Iste su promjene dostupne i na zahtjev. Koristite ovo nakon prekida rada na vašoj strani, pri prvom povezivanju ili kao noćnu provjeru slažu li se dva rasporeda:
curl "https://api.sporty.plus/api/partner/v1/reservations?updated_since=2026-09-28T00:00:00Z" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Uz updated_since popis sadrži svaku rezervaciju promijenjenu u tom trenutku ili nakon njega, od
najstarije promjene, uključujući otkazane. Stranicama se krećete pomoću per_page (do 100) i
page. Da biste nastavili kasnije, krenite od posljednjeg updated_at koji ste vidjeli; tu
rezervaciju možda ćete vidjeti ponovno, što je bezopasno.
GET /reservations/{id} čita jednu rezervaciju, otkazanu ili ne.
Pogreške
| Status | error | Značenje |
|---|---|---|
409 | slot_taken | Teren je rezerviran za dio tog vremena. conflicting_reservation_id kaže kojom rezervacijom. |
422 | unknown_court | Klub nema takav teren. |
422 | unknown_category | Klub nema takvu kategoriju rezervacija. |
422 | invalid_times | Rezervacija bi završila prije nego što počne. |
404 | not_found | U ovom klubu nema takve rezervacije; za PATCH, nema takve koju je poslala vaša aplikacija. |
409 | not_cancelable | Rezervacija se nije mogla otkazati; error_description kaže zašto. |
409 | no_acting_admin | Nije zabilježen nijedan administrator kluba koji je odobrio vašu aplikaciju. Zamolite klub da je ponovno odobri. |
409 | no_courts | Klub nema terena za rezervaciju. |
Opće pogreške (401, 403 insufficient_scope, 429 i ostale) navedene su na stranici
partnerskog API-ja.
Što klub vidi
Svaka rezervacija koju zauzmete, premjestite ili otkažete bilježi se na administratora kluba koji je vašoj aplikaciji odobrio pristup. Ako je klub sam registrirao aplikaciju, bilježi se na administratora koji ju je registrirao. Kad otkažete rezervaciju člana, ime tog administratora stoji u obavijesti koju član dobiva. Recite to klubu, kako nitko ne bi bio iznenađen.
Klub vidi i vaš webhook. Pod Postavke → API klijenti administrator kluba vidi:
- njegov URL;
- isporučuje li, ne uspijeva li ili još nije ništa poslao;
- kad je posljednji put isporučio te posljednju pogrešku dok isporuke ne uspijevaju.
Tajni ključ ondje se nikad ne prikazuje. Neka vaša krajnja točka odgovara. Webhook koji danima ne uspijeva upravo je ono što klub primijeti i zaustavi.
Administrator može zaustaviti vaš webhook bez povlačenja vašeg pristupa. Nakon toga:
GET /webhookodgovara404;- vaš token i dalje radi;
PUTpostavlja novi webhook, s novim tajnim ključem, koji dobiva obavijesti o promjenama od tog trenutka nadalje.
Pozovite GET /reservations?updated_since=… od trenutka kad je stigla vaša posljednja isporuka,
kako biste pokupili ono što se dogodilo dok je webhook bio zaustavljen.