OAuth & Sign in with Sporty

Syncing bookings

Keep your booking system and a club's Sporty diary in agreement, in both directions.

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:

ScopeFor
club.courts.readListing the courts, to map them to yours
club.reservations.readReading the diary and its categories, and receiving webhooks
club.reservations.writeHolding, 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_idrequiredYour id for the booking, up to 191 characters.
court_idrequiredA court from GET /courts.
from, torequiredISO 8601 with an offset. to must be after from.
nameoptionalWhat the club sees in its diary, up to 255 characters.
category_idoptionalOne 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 PUT again changes the URL. GET /webhook shows the current settings and how delivery is going; DELETE /webhook stops 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 with 422.

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"
      }
    }
  ]
}
  • type is one of the following:
    • reservation.created
    • reservation.updated, when the booking was moved, renamed or otherwise changed;
    • reservation.cancelled.
  • events holds up to about 100 changes, oldest first.
  • Members' bookings arrive without names (name is null) 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))
}

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

StatuserrorMeaning
409slot_takenThe court is booked for some of that time. conflicting_reservation_id says by what.
422unknown_courtThe club has no such court.
422unknown_categoryThe club has no such reservation category.
422invalid_timesThe booking would end before it starts.
404not_foundNo such booking at this club; for PATCH, none your application pushed.
409not_cancelableThe booking could not be cancelled; error_description says why.
409no_acting_adminNo club administrator is on record as having granted your application. Ask the club to grant it again.
409no_courtsThe 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 /webhook answers 404;
  • your token still works;
  • a PUT sets 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.