OAuth i prijava putem Sportyja

Sinkronizacija rezervacija

Održavajte svoj sustav rezervacija i klupski raspored u Sportyju usklađenima, u oba smjera.

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:

OpsegZa
club.courts.readPopis terena, kako biste ih povezali sa svojima
club.reservations.readČitanje rasporeda i njegovih kategorija te primanje webhookova
club.reservations.writeZauzimanje, 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_idobaveznoVaš id rezervacije, do 191 znaka.
court_idobaveznoTeren iz GET /courts.
from, toobaveznoISO 8601 s pomakom vremenske zone. to mora biti nakon from.
nameneobaveznoOno što klub vidi u svojem rasporedu, do 255 znakova.
category_idneobaveznoJedna 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 PUT mijenja URL. GET /webhook prikazuje trenutne postavke i kako ide isporuka; DELETE /webhook zaustavlja 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 s 422.

Š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"
      }
    }
  ]
}
  • type je jedno od sljedećeg:
    • reservation.created
    • reservation.updated, kad je rezervacija premještena, preimenovana ili na drugi način promijenjena;
    • reservation.cancelled.
  • events sadrži do otprilike 100 promjena, od najstarije.
  • Rezervacije članova stižu bez naziva (name je null) 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))
}

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

StatuserrorZnačenje
409slot_takenTeren je rezerviran za dio tog vremena. conflicting_reservation_id kaže kojom rezervacijom.
422unknown_courtKlub nema takav teren.
422unknown_categoryKlub nema takvu kategoriju rezervacija.
422invalid_timesRezervacija bi završila prije nego što počne.
404not_foundU ovom klubu nema takve rezervacije; za PATCH, nema takve koju je poslala vaša aplikacija.
409not_cancelableRezervacija se nije mogla otkazati; error_description kaže zašto.
409no_acting_adminNije zabilježen nijedan administrator kluba koji je odobrio vašu aplikaciju. Zamolite klub da je ponovno odobri.
409no_courtsKlub 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 /webhook odgovara 404;
  • vaš token i dalje radi;
  • PUT postavlja 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.