OAuth et Se connecter avec Sporty

Synchroniser les réservations

Gardez votre système de réservation et le planning Sporty d'un club en accord, dans les deux sens.

Si vous vendez les courts d'un club via votre propre système — une place de marché, un logiciel de gestion de site ou le site web du club —, deux plannings doivent concorder. Cette page couvre les deux sens :

  • Vous nous informez de chaque réservation que vous prenez, et nous bloquons ce court pour que personne ne le réserve dans Sporty.
  • Nous vous informons de chaque réservation prise, déplacée ou annulée dans Sporty, par webhook signé.

Votre intégration est un compte de service. Elle détient un jeton obtenu par identifiants client et agit pour le club, pas pour un membre.

Ce qu'est une réservation venant de vous

Lorsque vous envoyez une réservation, le court est bloqué exactement comme lorsqu'un administrateur du club bloque un créneau dans Sporty :

  • Elle a un nom (affiché dans le planning du club) et, éventuellement, une catégorie.
  • Elle n'a ni joueurs ni prix. Sporty n'encaisse aucun paiement pour elle, n'applique aucune limite d'adhésion et ne notifie aucun membre à son sujet.
  • Le court doit être libre. C'est la seule règle que nous vérifions.

Qui a joué et qui a payé reste dans votre système. Vous indiquez au club que le court est pris, vous ne vendez pas la réservation via Sporty.

Avant de commencer

Le club doit accorder à votre application les scopes dont elle a besoin. Pour une synchronisation bidirectionnelle, ce sont :

ScopePour
club.courts.readLister les courts, pour les faire correspondre aux vôtres
club.reservations.readLire le planning et ses catégories, et recevoir les webhooks
club.reservations.writeBloquer, déplacer et annuler des réservations

club.reservations.write permet à votre application d'annuler n'importe quelle réservation du club, y compris celles des membres, comme peut le faire le personnel du club. Le club l'accorde en connaissance de cause. Utilisez-le pour les réservations que vous devez reprendre, pas pour faire le ménage dans le planning.

Obtenir un jeton :

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"

Si votre application agit pour plusieurs clubs, envoyez X-Club: <club id> avec chacune des requêtes ci-dessous. Les webhooks se configurent club par club.

Catégories

Une réservation peut porter l'une des catégories de réservation du club, les étiquettes que ses administrateurs donnent aux créneaux bloqués (« Ligue », « École de tennis »). Le planning du club l'affiche dans la couleur de la catégorie.

curl https://api.sporty.plus/api/partner/v1/reservation-categories \
  -H "Authorization: Bearer $ACCESS_TOKEN"
{ "data": [ { "id": 3, "name": "Liga", "color": "#ea47bc" } ] }

Cela nécessite club.reservations.read. Un club peut n'en avoir aucune ; category_id est facultatif.

Bloquer un court

Pour trouver d'abord un créneau libre, GET /availability?date=… liste les créneaux de chaque court, libres ou pris ; voir l'API partenaire. Bloquer un court revérifie sa disponibilité, si bien qu'un créneau pris entre-temps renvoie quand même 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"
  }'
Champ
external_idobligatoireVotre identifiant pour la réservation, jusqu'à 191 caractères.
court_idobligatoireUn court issu de GET /courts.
from, toobligatoireISO 8601 avec un décalage horaire. to doit être postérieur à from.
namefacultatifCe que le club voit dans son planning, jusqu'à 255 caractères.
category_idfacultatifL'une des catégories de réservation du club, issue de GET /reservation-categories.

La réponse est 201 avec la réservation :

{
  "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"
  }
}

Conservez id. C'est ce qui vous permet de déplacer ou d'annuler la réservation. Les heures sont toujours renvoyées en UTC.

L'envoyer deux fois est sans risque

external_id rend la requête idempotente. Renvoyer le même external_id tant que cette réservation existe répond 200 avec la réservation existante, inchangée. Une nouvelle tentative après un délai dépassé ne bloque donc jamais un second court. Un nouvel envoi avec d'autres horaires ne déplace pas la réservation ; utilisez PATCH pour la déplacer.

Si la réservation a été annulée entre-temps, par vous ou par le club, renvoyer son external_id la crée à nouveau.

Lorsque le court est pris

{
  "error": "slot_taken",
  "error_description": "The court is already booked for some of that time.",
  "conflicting_reservation_id": 918102
}

Statut 409. Une réservation peut commencer exactement quand une autre se termine ; tout chevauchement supérieur à zéro est un conflit.

Déplacer une réservation

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"}'

N'envoyez que ce qui change : court_id, name, category_id, from, to. Chaque extrémité de l'horaire peut changer indépendamment, donc {"to": "…"} prolonge ou raccourcit une réservation. Le nouveau créneau est vérifié par rapport à toutes les autres réservations, et la réponse est la réservation mise à jour. Une modification qui ferait finir la réservation avant son début est refusée avec 422 invalid_times.

Vous ne pouvez déplacer que les réservations envoyées par votre application. Les réservations faites dans Sporty appartiennent aux membres et au club. Pour celles-ci, ainsi que pour les réservations annulées, la réponse est 404.

Annuler une réservation

curl -X DELETE https://api.sporty.plus/api/partner/v1/reservations/918273 \
  -H "Authorization: Bearer $ACCESS_TOKEN"

La réponse est 204. Cela fonctionne pour n'importe quelle réservation du club, et se déroule comme l'annulation par un administrateur du club. S'il s'agissait de la réservation d'un membre, il est remboursé sur le portefeuille de son club et averti de l'annulation.

Être informé des réservations faites dans Sporty

Configurer le 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 n'est affiché qu'une fois, dans cette réponse. Conservez-le. Pour le remplacer, envoyez "rotate_secret": true avec le PUT suivant, et la réponse contiendra le nouveau.
  • Renvoyer un PUT change l'URL. GET /webhook affiche les paramètres actuels et l'état des livraisons ; DELETE /webhook arrête les livraisons.
  • Il y a un webhook par application et par club.
  • Un nouveau webhook démarre au moment où vous le configurez. Pour tout ce qui précède, voir rattraper le retard.
  • L'URL doit être en https, ne contenir aucun identifiant et se résoudre vers une adresse publique. Tout le reste est refusé avec 422.

Ce qui arrive

Environ une fois par minute, tant que quelque chose a changé, vous recevez un POST comme celui-ci :

{
  "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 prend l'une des valeurs suivantes :
    • reservation.created
    • reservation.updated, lorsque la réservation a été déplacée, renommée ou modifiée d'une autre manière ;
    • reservation.cancelled.
  • events contient jusqu'à environ 100 modifications, de la plus ancienne à la plus récente.
  • Les réservations des membres arrivent sans nom (name vaut null) et sans les personnes qui y participent. Vous voyez que le court est pris, et quand.

Ne comptez pas sur type pour être complet. Les modifications sont regroupées entre deux livraisons, si bien qu'une réservation créée puis annulée dans la même minute n'arrive que sous la forme reservation.cancelled. Traitez reservation comme l'état actuel de la réservation et appliquez-le : si cancelled vaut true, libérez le court ; sinon, bloquez-le sur court_id de from à to.

Vous n'êtes pas informé de vos propres modifications. Une réservation que vous envoyez ou déplacez ne vous est pas renvoyée. Lorsque vous annulez la réservation d'un membre, elle revient bien sous la forme reservation.cancelled. Cet événement est sans conséquence, puisque le court est déjà libre dans votre copie.

Répondre

Répondez avec n'importe quel 2xx dans les 10 secondes. Tout le reste compte comme un échec : un autre statut, un délai dépassé ou une redirection (nous ne suivons pas les redirections). Les mêmes modifications, plus tout ce qui est plus récent, sont alors renvoyées environ une minute plus tard, et ainsi de suite jusqu'à ce que vous les acceptiez. Rien n'est perdu, et l'ordre est conservé.

Cela signifie que vous recevrez parfois une modification deux fois. Rendez le traitement idempotent : utilisez reservation.id comme clé, et ignorez une copie dont l'updated_at n'est pas plus récent que ce que vous avez déjà.

La livraison s'arrête dans chacun de ces cas :

  • le club retire son autorisation ;
  • votre application est révoquée ;
  • vous supprimez le webhook ;
  • le club arrête le webhook.

GET /webhook affiche last_error si les livraisons échouent.

Vérifier la signature

Chaque livraison comporte :

Sporty-Signature: t=1790680267,v1=6c4b1e9f…

v1 est le HMAC-SHA256 en hexadécimal de {t}.{raw body}, avec votre secret de webhook comme clé. Vérifiez-le sur le corps brut de la requête avant de l'analyser, et rejetez les horodatages anciens pour qu'une livraison interceptée ne puisse pas être rejouée.

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))
}

Prévoyez qu'une livraison arrive avec plusieurs minutes de retard : elle est renvoyée jusqu'à ce que vous l'acceptiez, mais elle est signée à nouveau à chaque fois, donc la tolérance n'a qu'à couvrir le décalage d'horloge.

Rattraper le retard

Les mêmes modifications sont disponibles sur demande. Utilisez-les après une panne de votre côté, lors de votre première connexion, ou comme vérification nocturne que les deux plannings concordent :

curl "https://api.sporty.plus/api/partner/v1/reservations?updated_since=2026-09-28T00:00:00Z" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Avec updated_since, la liste contient toutes les réservations modifiées à cet instant ou après, de la modification la plus ancienne à la plus récente, annulations comprises. Parcourez les pages avec per_page (jusqu'à 100) et page. Pour reprendre plus tard, repartez du dernier updated_at que vous avez vu ; vous reverrez peut-être cette réservation, ce qui est sans conséquence.

GET /reservations/{id} lit une seule réservation, annulée ou non.

Erreurs

StatuterrorSignification
409slot_takenLe court est réservé sur une partie de cet horaire. conflicting_reservation_id indique par quelle réservation.
422unknown_courtLe club n'a pas de court de ce type.
422unknown_categoryLe club n'a pas de catégorie de réservation de ce type.
422invalid_timesLa réservation finirait avant de commencer.
404not_foundAucune réservation de ce type dans ce club ; pour PATCH, aucune envoyée par votre application.
409not_cancelableLa réservation n'a pas pu être annulée ; error_description indique pourquoi.
409no_acting_adminAucun administrateur du club n'est enregistré comme ayant accordé l'accès à votre application. Demandez au club de l'accorder à nouveau.
409no_courtsLe club n'a aucun court à réserver.

Les erreurs générales (401, 403 insufficient_scope, 429, etc.) sont listées sur la page de l'API partenaire.

Ce que voit le club

Chaque réservation que vous bloquez, déplacez ou annulez est enregistrée au nom de l'administrateur du club qui a accordé l'accès à votre application. Si le club a enregistré l'application lui-même, elle est enregistrée au nom de l'administrateur qui l'a enregistrée. Lorsque vous annulez la réservation d'un membre, c'est le nom de cet administrateur qui figure dans la notification du membre. Prévenez le club, pour que personne ne soit surpris.

Le club voit aussi votre webhook. Sous Paramètres → Clients API, un administrateur du club voit :

  • son URL ;
  • s'il livre, s'il échoue ou s'il n'a encore rien envoyé ;
  • la date de sa dernière livraison, et la dernière erreur tant qu'il échoue.

Le secret n'y est jamais affiché. Faites en sorte que votre point de terminaison continue de répondre. Un webhook qui échoue pendant des jours, c'est ce qu'un club remarque, et ce qu'il arrête.

L'administrateur peut arrêter votre webhook sans retirer votre accès. Ensuite :

  • GET /webhook répond 404 ;
  • votre jeton fonctionne toujours ;
  • un PUT configure un nouveau webhook, avec un nouveau secret, qui est informé des modifications à partir de ce moment.

Appelez GET /reservations?updated_since=… à partir de votre dernière livraison reçue, pour récupérer ce qui s'est passé pendant que le webhook était arrêté.