Syncing bookings
If you sell a club's courts through your own system, such as a marketplace, a venue manager or the club's website, two diaries have to agree. This page covers both directions:
- You tell us about each booking you take, and we hold that court so nobody books it in Sporty.
- We tell you about each booking taken, moved or cancelled in Sporty, by signed webhook.
Your integration is a service account. It holds a client credentials token and acts for the club, not for any member.
What a booking from you is
When you push a booking, the court is held exactly as when a club administrator blocks a slot in Sporty:
- It has a name (shown in the club's diary) and, optionally, a category.
- It has no players and no price. Sporty takes no payment for it, applies no membership limits and notifies no member about it.
- The court must be free. That is the only rule we check.
Who played and who paid stays in your system. You are telling the club that the court is taken, not selling the booking through Sporty.
Before you start
The club must grant your application the scopes it needs. For a two-way sync that is:
| Scope | For |
|---|---|
club.courts.read | Listing the courts, to map them to yours |
club.reservations.read | Reading the diary and its categories, and receiving webhooks |
club.reservations.write | Holding, moving and cancelling bookings |
club.reservations.write lets your application cancel any booking at the club, including
members' bookings, as the club's staff can. The club grants it knowing that. Use it for
bookings you must take back, not to tidy the diary.
Get a 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"
If your application acts for several clubs, send X-Club: <club id> on every request below.
Webhooks are set per club.
Categories
A booking may carry one of the club's reservation categories, the labels its administrators give held slots ("League", "Tennis school"). The club's diary shows it in the category's colour.
curl https://api.sporty.plus/api/partner/v1/reservation-categories \
-H "Authorization: Bearer $ACCESS_TOKEN"
{ "data": [ { "id": 3, "name": "Liga", "color": "#ea47bc" } ] }
This needs club.reservations.read. A club may have none; category_id is optional.
Holding a court
To find a free slot first, GET /availability?date=… lists each court's slots, free or taken;
see the partner API. Holding a court checks it again,
so a slot taken in the meantime still gets 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"
}'
| Field | ||
|---|---|---|
external_id | required | Your id for the booking, up to 191 characters. |
court_id | required | A court from GET /courts. |
from, to | required | ISO 8601 with an offset. to must be after from. |
name | optional | What the club sees in its diary, up to 255 characters. |
category_id | optional | One of the club's reservation categories, from GET /reservation-categories. |
The answer is 201 with the booking:
{
"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"
}
}
Keep id. It is how you move or cancel the booking. Times always come back in UTC.
Sending it twice is safe
external_id makes the request idempotent. Sending the same external_id again while that
booking stands answers 200 with the existing booking, unchanged. A retry after a timeout
therefore never holds a second court. A re-send with different times does not move the booking;
use PATCH to move it.
If the booking has since been cancelled, by you or by the club, sending its external_id again
books it afresh.
When the court is taken
{
"error": "slot_taken",
"error_description": "The court is already booked for some of that time.",
"conflicting_reservation_id": 918102
}
Status 409. A booking may start exactly when another ends; any overlap longer than zero is a
clash.
Moving a booking
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"}'
Send only what changes: court_id, name, category_id, from, to. Either end of the time
may change on its own, so {"to": "…"} extends or shortens a booking. The new slot is checked
against every other booking, and the answer is the updated booking. A change that would end the
booking before it starts is refused with 422 invalid_times.
You can only move bookings your application pushed. Bookings made in Sporty belong to the
members and the club. For those, and for bookings that have been cancelled, the answer is 404.
Cancelling a booking
curl -X DELETE https://api.sporty.plus/api/partner/v1/reservations/918273 \
-H "Authorization: Bearer $ACCESS_TOKEN"
The answer is 204. This works for any booking at the club, and it runs as a club
administrator's cancellation would. If the booking was a member's, they are refunded to their
club wallet and notified that it was cancelled.
Hearing about bookings made in Sporty
Setting the webhook
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 is shown once, in this response. Store it. To replace it, send
"rotate_secret": true with the next PUT, and the response carries the new one.- Sending
PUTagain changes the URL.GET /webhookshows the current settings and how delivery is going;DELETE /webhookstops delivery. - There is one webhook per application and club.
- A new webhook starts from the moment you set it. For anything earlier, see catching up.
- The URL must be
https, carry no credentials, and resolve to a public address. Anything else is refused with422.
What arrives
About once a minute, while anything has changed, you get a POST like this one:
{
"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"
}
}
]
}
typeis one of the following:reservation.createdreservation.updated, when the booking was moved, renamed or otherwise changed;reservation.cancelled.
eventsholds up to about 100 changes, oldest first.- Members' bookings arrive without names (
nameisnull) and without the people in them. You see that the court is taken, and when.
Do not rely on type to be complete. Changes are collected between deliveries, so a
booking made and cancelled within the same minute arrives only as reservation.cancelled.
Treat reservation as the booking's current state and apply that: if cancelled is true,
free the court; otherwise, hold it on court_id from from to to.
You do not hear about your own changes. A booking you push or move is not sent back to you.
When you cancel a member's booking, it does come back as reservation.cancelled. That event is
harmless, since the court is already free in your copy.
Answering
Answer with any 2xx within 10 seconds. Anything else counts as a failure: another status, a
timeout, or a redirect (we do not follow redirects). The same changes, plus anything newer, are
then sent again about a minute later, and so on until you accept them. Nothing is dropped, and
the order is kept.
This means you will sometimes receive a change twice. Make handling idempotent: key on
reservation.id, and ignore a copy whose updated_at is not newer than what you already have.
Delivery stops in any of these cases:
- the club withdraws your grant;
- your application is revoked;
- you delete the webhook;
- the club stops the webhook.
GET /webhook shows last_error if deliveries are failing.
Verifying the signature
Every delivery carries:
Sporty-Signature: t=1790680267,v1=6c4b1e9f…
v1 is the hex HMAC-SHA256 of {t}.{raw body}, keyed with your webhook secret. Check it
against the raw request body before parsing it, and reject old timestamps so a captured
delivery cannot be replayed.
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'] ?? '');
}
Allow for a delivery arriving several minutes late: it is resent until you accept it, but it is signed afresh each time, so the tolerance only has to cover clock skew.
Catching up
The same changes are available on request. Use this after an outage on your side, when you first connect, or as a nightly check that the two diaries agree:
curl "https://api.sporty.plus/api/partner/v1/reservations?updated_since=2026-09-28T00:00:00Z" \
-H "Authorization: Bearer $ACCESS_TOKEN"
With updated_since, the list holds every booking changed at or after that instant, oldest
change first, cancellations included. Page through with per_page (up to 100) and page.
To continue later, start from the last updated_at you saw; you may see that booking again,
which is harmless.
GET /reservations/{id} reads a single booking, cancelled or not.
Errors
| Status | error | Meaning |
|---|---|---|
409 | slot_taken | The court is booked for some of that time. conflicting_reservation_id says by what. |
422 | unknown_court | The club has no such court. |
422 | unknown_category | The club has no such reservation category. |
422 | invalid_times | The booking would end before it starts. |
404 | not_found | No such booking at this club; for PATCH, none your application pushed. |
409 | not_cancelable | The booking could not be cancelled; error_description says why. |
409 | no_acting_admin | No club administrator is on record as having granted your application. Ask the club to grant it again. |
409 | no_courts | The club has no courts to book. |
The general errors (401, 403 insufficient_scope, 429 and so on) are listed on the
partner API page.
Who the club sees
Every booking you hold, move or cancel is recorded against the club administrator who granted your application access. If the club registered the application itself, it is recorded against the administrator who registered it. When you cancel a member's booking, that administrator is the name in the member's notification. Tell the club, so nobody is surprised.
The club also sees your webhook. Under Settings → API clients, a club administrator sees:
- its URL;
- whether it is delivering, failing, or has sent nothing yet;
- when it last delivered, and the last error while it is failing.
The secret is never shown there. Keep your endpoint answering. A webhook that fails for days is what a club notices, and what it stops.
The administrator can stop your webhook without withdrawing your access. After that:
GET /webhookanswers404;- your token still works;
- a
PUTsets a new webhook, with a new secret, which hears about changes from that moment on.
Call GET /reservations?updated_since=… from when your last delivery arrived, to pick up what
happened while the webhook was stopped.