The partner API
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
| Method | Path | Scope |
|---|---|---|
GET | /me | me.read |
GET | /me/reservations | me.reservations.read |
POST | /me/reservations | me.reservations.write |
DELETE | /me/reservations/{id} | me.reservations.write |
GET | /me/results | me.results.read |
GET | /me/memberships | me.memberships.read |
GET | /clubs/me | club.read |
GET | /courts | club.courts.read |
GET | /availability | club.courts.read |
GET | /reservations | club.reservations.read |
GET | /reservations/{id} | club.reservations.read |
GET | /reservation-categories | club.reservations.read |
POST | /reservations | club.reservations.write |
PATCH | /reservations/{id} | club.reservations.write |
DELETE | /reservations/{id} | club.reservations.write |
GET PUT DELETE | /webhook | club.reservations.read |
GET | /members | club.members.read |
POST | /members | club.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"
}
fromandtomust fall on the court's slots, asGET /availabilitylists them. The booking must be a whole number ofreservation_durationslots, and every one of them must be open to this member.external_idis optional: your id for the request, up to 191 characters. Sending it again answers200with 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:
error | Meaning |
|---|---|
slot_taken | Someone else has part of that time. |
outside_opening_hours | The court is not open to this member for all of it. |
court_closed | The court is closed that day. |
in_past | That time has passed. |
too_soon | Too close to the start for their membership. |
too_far_ahead | Further ahead than their membership allows. |
leaves_a_gap | It would leave a half-hour gap the club does not allow. |
duration_not_offered | The club does not sell that length at that time. |
limit_reached | They already hold as many bookings as their membership allows. |
not_a_member | The club has not accepted them yet. |
not_bookable | Any 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:nullwhen there was no winner.kind:league,tournamentormatch.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_durationlong. Times are in UTC;timezoneis the club's. - Taken or free. A slot is taken (
"available": false) when any booking overlaps it, even partly. - Closed courts.
closedisinactiveorcourt_breakwhen the court cannot be booked at all that day, and itsslotsare 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, to | UTC |
category_id | The club's reservation category, if any |
approved | Whether the booking is confirmed |
cancelled | Only ever true with updated_since or on GET /reservations/{id} |
source | partner for a booking your application pushed, sporty for any other |
external_id, name | Your id and name for a booking you pushed; null on every other |
updated_at | When 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
| Status | Body | Meaning |
|---|---|---|
401 | invalid_token | Missing, expired, revoked, or not ours. |
403 | insufficient_scope | Valid token, scope not granted. WWW-Authenticate names what is needed. |
403 | access_denied | No club has granted your application, or not the club you named. |
400 | club_required | You act for several clubs; name one with X-Club. |
422 | Laravel validation errors | A 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.