OAuth & Sign in with Sporty

The partner API

What an access token can actually read and write, and what it deliberately cannot.

https://api.sporty.plus/api/partner/v1

This is the only place an OAuth access token works. Plain JSON — not JSON:API — with a deliberately small surface: every field is one somebody chose to expose.

curl https://api.sporty.plus/api/partner/v1/courts \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Endpoints

MethodPathScope
GET/meme.read
GET/me/reservationsme.reservations.read
POST/me/reservationsme.reservations.write
DELETE/me/reservations/{id}me.reservations.write
GET/me/resultsme.results.read
GET/me/membershipsme.memberships.read
GET/clubs/meclub.read
GET/courtsclub.courts.read
GET/availabilityclub.courts.read
GET/reservationsclub.reservations.read
GET/reservations/{id}club.reservations.read
GET/reservation-categoriesclub.reservations.read
POST/reservationsclub.reservations.write
PATCH/reservations/{id}club.reservations.write
DELETE/reservations/{id}club.reservations.write
GET PUT DELETE/webhookclub.reservations.read
GET/membersclub.members.read
POST/membersclub.members.write

GET /me

The member the token was issued for: id, first_name, last_name, name, picture and locale. Their email address is not part of it; ask for the email scope and read it from /oauth2/userinfo.

This and /me/reservations need a token issued for a member (authorization code), and they are the member's own data rather than a club's, so no club has to grant your application anything. A client-credentials token gets 401. If the member authorised your application for one club only, you see that club's part of their data.

GET /me/reservations

The bookings the member plays or coaches in, upcoming unless you say otherwise. from and to (ISO 8601) set the window; per_page goes up to 100.

{
  "data": [
    {
      "id": 918301,
      "club": { "id": 57, "name": "TK Test" },
      "court_id": 412,
      "court_name": "Teren 3",
      "from": "2026-10-07T16:00:00+00:00",
      "to": "2026-10-07T17:00:00+00:00",
      "approved": true
    }
  ]
}

The other players are not included: whom a member plays with is the other players' to share.

POST /me/reservations

Books a court as the member. Every rule the club applies to them in the Sporty app applies here too:

  • their membership's opening hours and price;
  • how far ahead they may book, and how close to the start;
  • how many bookings they may hold;
  • whether they must pay first.
{
  "court_id": 412,
  "from": "2026-10-07T18:00:00+02:00",
  "to": "2026-10-07T19:30:00+02:00",
  "external_id": "agent-7f3a"
}
  • from and to must fall on the court's slots, as GET /availability lists them. The booking must be a whole number of reservation_duration slots, and every one of them must be open to this member.
  • external_id is optional: your id for the request, up to 191 characters. Sending it again answers 200 with the booking already made, so a retry never books twice.

The answer is 201:

{
  "data": {
    "id": 918402,
    "club": { "id": 57, "name": "TK Test" },
    "court_id": 412,
    "court_name": "Teren 3",
    "from": "2026-10-07T16:00:00+00:00",
    "to": "2026-10-07T17:30:00+00:00",
    "approved": false,
    "price": 20,
    "needs_payment": true,
    "pay_by": "2026-10-01T10:20:00+00:00"
  }
}

Payment is never taken through the API. When needs_payment is true, the member must pay in the Sporty app before pay_by, or the club takes the slot back. Tell them so. A club that does not require payment up front answers needs_payment: false and pay_by: null. pay_by is given only on the answer that made the booking, and is null on a repeat.

The member is notified as for any booking they make themselves, and the club sees it in its diary as theirs.

When the member cannot book, the answer is 409 with a code, plus the rule's own message, translated for the member and meant to be shown to them:

errorMeaning
slot_takenSomeone else has part of that time.
outside_opening_hoursThe court is not open to this member for all of it.
court_closedThe court is closed that day.
in_pastThat time has passed.
too_soonToo close to the start for their membership.
too_far_aheadFurther ahead than their membership allows.
leaves_a_gapIt would leave a half-hour gap the club does not allow.
duration_not_offeredThe club does not sell that length at that time.
limit_reachedThey already hold as many bookings as their membership allows.
not_a_memberThe club has not accepted them yet.
not_bookableAny other rule; the message says which.

invalid_times (422) means the times are not whole slots, or run past midnight. 403 access_denied means the member authorised your application for a different club.

DELETE /me/reservations/{id}

Cancels one of the member's own bookings as the member, so their cancellation deadline applies. Anything they paid goes back to their club wallet, and the other players are told. The answer is 204. It is 409 not_cancelable, with the reason, when it is too late or the booking is not theirs to cancel (someone else booked them in). It is 404 for a booking they are not in.

GET /me/results

The member's match results, newest first. from and to (ISO 8601) narrow it; per_page goes up to 100.

{
  "data": [
    {
      "id": 22875,
      "club": { "id": 57, "name": "TK Test" },
      "played_at": "2026-09-27T18:00:00+00:00",
      "sport": "tennis",
      "kind": "league",
      "sets": [
        { "games": [2, 6], "tie_break": null },
        { "games": [6, 3], "tie_break": null },
        { "games": [7, 6], "tie_break": [10, 8] }
      ],
      "won": true,
      "official": true,
      "verified": true
    }
  ]
}
  • Scores are from the member's side. Every pair is [the member's, the opponents'], so you never need to know which side of the match they were on.
  • Sets: only sets that were played are listed.
  • won: null when there was no winner.
  • kind: league, tournament or match.
  • verified: the opponents confirmed the score.
  • Opponents are not included: whom a member played is the opponents' to share.

GET /me/memberships

The clubs the member belongs to, and on which plan: every membership that has not ended.

{
  "data": [
    {
      "club": { "id": 57, "name": "TK Test" },
      "membership": { "id": 12, "name": "Seniori", "basic": false },
      "start": "2026-01-01T00:00:00+00:00",
      "end": "2026-12-31T00:00:00+00:00",
      "renews": false
    }
  ]
}

end is null for a membership with no end date. renews says whether it renews on its own. basic marks the plan every member of the club has before they buy another.

GET /clubs/me

Which club your token acts for. Worth calling first, especially if you hold grants for several clubs.

GET /courts

per_page up to 100. Each court has id, name, active, indoor, lights, working_from, working_to, reservation_duration (minutes per slot) and sports, what is played on it:

"sports": [ { "id": 2, "slug": "padel", "name": "Padel" } ]

Most courts have one sport; a few have two, and a court nobody has set one for has none.

GET /availability

Which slots are free. date (YYYY-MM-DD, the club's local day) is required; days covers up to 7 days from it; court_id narrows it to one court.

{
  "data": [
    {
      "court_id": 412,
      "date": "2026-10-07",
      "timezone": "Europe/Zagreb",
      "closed": null,
      "slots": [
        { "from": "2026-10-07T06:00:00+00:00", "to": "2026-10-07T07:00:00+00:00", "available": false },
        { "from": "2026-10-07T07:00:00+00:00", "to": "2026-10-07T08:00:00+00:00", "available": true }
      ]
    }
  ]
}
  • One entry per court per day. The slots follow the club's timetable for that day, each reservation_duration long. Times are in UTC; timezone is the club's.
  • Taken or free. A slot is taken ("available": false) when any booking overlaps it, even partly.
  • Closed courts. closed is inactive or court_break when the court cannot be booked at all that day, and its slots are then empty.
  • Whose timetable. This is the club's standard timetable, the one a visitor who is not signed in sees. A member's own membership can open or close some hours, and set the price. Neither is shown here.

GET /reservations

from and to (ISO 8601, UTC) narrow the window; per_page up to 100. Bookings are returned without the people in them. Reading those needs club.members.read and the members endpoint, so the club makes the two decisions separately.

updated_since returns every booking changed at or after that instant instead, oldest change first, cancellations included. It is for keeping a copy in sync.

Each booking has:

Field
id, court_id, court_name
from, toUTC
category_idThe club's reservation category, if any
approvedWhether the booking is confirmed
cancelledOnly ever true with updated_since or on GET /reservations/{id}
sourcepartner for a booking your application pushed, sporty for any other
external_id, nameYour id and name for a booking you pushed; null on every other
updated_atWhen the booking last changed

Writing bookings and webhooks

POST, PATCH and DELETE /reservations and /webhook are for a system that keeps its own diary in step with the club's. They are described, with examples, in Syncing bookings.

GET /members

search matches name or email. per_page up to 100.

POST /members

{
  "email": "new@example.com",
  "firstName": "New",
  "lastName": "Member",
  "language": "hr",
  "countryCode": "HR"
}

language and countryCode are optional and default to the club's.

Idempotent on the email address. A member we already know is joined to the club and returned with 200; a new one is created and returned with 201. Re-sending the same person is safe, which matters when two systems are syncing people.

What is not here

Taking payment. A member booking made through the API is paid for in the Sporty app, never through the API.

Errors

StatusBodyMeaning
401invalid_tokenMissing, expired, revoked, or not ours.
403insufficient_scopeValid token, scope not granted. WWW-Authenticate names what is needed.
403access_deniedNo club has granted your application, or not the club you named.
400club_requiredYou act for several clubs; name one with X-Club.
422Laravel validation errorsA malformed request body.
429—120 requests per minute, per client.

Versioning

v1 is in the path and will not change shape under you. New fields and new endpoints may be added; existing fields will not be removed or change meaning. A breaking change means v2, and we will tell you before it arrives.