Votre réservation
Français
Trouver un parking

API exploitant · v1

Envoyez vos parkings. Récupérez vos réservations.

La référence destinée à l’exploitant qui connecte son propre système à Parkena. Tout ce qui suit décrit un logiciel déployé et qui répond — y compris les parties qui disent non.

À lire avant de bâtir un projet autour de cette API.

Cette référence est complète et exacte. Un propriétaire ou un gestionnaire de votre compte exploitant émet et révoque les clés dans la console, sous Account → API keys — le secret n’est affiché qu’une fois. Ce qui n’est pas ouvert, c’est le reste : il n’y a pas de bac à sable, et nous connectons un exploitant à la fois, alors écrivez-nous avant de prévoir un développement autour de cette API. Ce que la v1 refuse de faire est écrit noir sur blanc au §12, plutôt que laissé à votre découverte en troisième semaine.

1. Ce que c’est, et ce que ce n’est pas

Ceci est la référence de la v1 de l’API exploitant de Parkena. C’est l’alternative à la gestion de votre offre dans la console Parkena : envoyez-y vos parkings et vos prix, récupérez-en vos réservations Parkena. Les deux voies écrivent les mêmes tables à travers la même sécurité, et aucune ne peut atteindre les données d’un autre exploitant. Vous pouvez utiliser les deux — la console pour ce qu’une personne décide, l’API pour la synchronisation nocturne — et elles ne se gêneront pas l’une l’autre.

Ce que ce n’est pas, c’est un produit avec lequel on s’intègre sans nous parler. Un propriétaire ou un gestionnaire connecté crée la clé dans la console ; il n’y a pas de bac à sable pour s’entraîner, et la première intégration se construit avec quelqu’un de notre côté. C’est un constat sur l’étape où en est Parkena, et non une file d’attente que vous pourriez sauter.

Cinq choses que cette API ne fait pas, annoncées d’emblée

Les réservations se récupèrent, au curseur, et ce curseur est la source de vérité. Un point de terminaison enregistré peut recevoir un indice signé booking.changed qui dit « récupérez maintenant » — le §13 — mais aucune donnée de réservation ne voyage jamais dans un webhook, délibérément, et le §12 dit ce qui n’existe toujours pas.

Il n’y a pas de calendrier de disponibilité. Vous ne pouvez pas envoyer « places libres ce soir », et le champ qui y ressemble — capacity — est le nombre TOTAL de places. Y mettre la disponibilité décrit faussement votre parking et annule son examen d’annonce chaque nuit.

Aucun argent ne transite par cette API. Ni débits, ni remboursements, ni données de versement, ni chiffres de commission.

Rien ici ne crée, ne modifie, n’annule, n’enregistre l’arrivée ni ne rembourse une réservation. Il n’existe ni scope pour cela, ni droit derrière.

L’URL de base est https://api.parkena.com/v1 — voir le §3. Rien d’autre ne doit être visé.

2. Deux règles à respecter avant d’écrire une ligne

Voici les deux choses qu’une intégration compétente rate encore, parce que dans les deux cas la mauvaise issue a l’air d’avoir marché. C’est pour cela qu’elles sont remontées en haut de cette page ; tout ce qui suit est de la documentation de référence ordinaire.

Règle un : comparez avant de mettre à jour

Quand un relecteur Parkena approuve votre annonce, il approuve un contenu précis. Si ce contenu change, l’approbation ne décrit plus ce qui est publié : elle est donc annulée et le parking repart en examen — et il cesse de se vendre sur parkena.com jusqu’à ce qu’une personne l’approuve de nouveau.

C’est le comportement correct, et devant une machine qui renvoie tout son parc chaque nuit, c’est aussi une façon d’être déréférencé à 3 h du matin, toutes les nuits, pour toujours. Cette API n’émet donc pas de mise à jour qui ne change rien. Les deux routes d’écriture lisent la ligne actuelle, la comparent champ par champ, et si rien ne diffère elles n’émettent aucune instruction du tout — pas un UPDATE sans effet ; aucune instruction. Vous obtenez "result": "unchanged" et "changed": [].

Vous n’avez rien à faire pour en bénéficier. Ce n’est pas un drapeau et il n’y a pas d’en-tête à envoyer. Un renvoi nocturne complet de données identiques est une opération sans effet, qui coûte une lecture par parking.

# an identical re-push of an approved car park
{ "result": "unchanged", "changed": [], "listing_review_superseded": false }
#
# the same car park, with one word of the name changed
{ "result": "updated",   "changed": ["name"], "listing_review_superseded": true }
Réponses abrégées de la fonction déployée. La forme complète est au §7.

La seconde réponse n’est pas un avertissement que vous pouvez ignorer : ce parking a quitté la vitrine et en reste absent jusqu’à une nouvelle approbation. Le §8 énumère chaque champ qui vous coûte un nouvel examen, et chaque champ qui n’en coûte pas.

Règle deux : GET renvoie un champ que PUT refuse

GET /lots renvoie chaque parking AVEC son enveloppe rates, parce que c’est la chose utile à lire. PUT /lots/{external_id} n’en accepte PAS : les prix se fixent sur PUT /lots/{external_id}/rates. La boucle évidente — lire un parking, changer un champ, le renvoyer — échoue donc tant que vous ne retirez pas rates. Retirez external_id avec lui : celui-ci vit dans le chemin.

curl "$BASE/lots" -H "Authorization: Bearer $KEY.$SECRET" \
  | jq '.lots[0] | del(.rates, .external_id)' > lot.json
# edit lot.json
curl -X PUT "$BASE/lots/edge-main" -H "Authorization: Bearer $KEY.$SECRET" \
  -H 'Content-Type: application/json' --data @lot.json

Renvoyez-le avec rates encore attaché et vous obtenez 422 {"error":"field_belongs_to_another_route","field":"rates"}. Nous le refusons au lieu de l’ignorer, et la distinction est tout l’enjeu : si vous mettiez un nouveau prix dans le corps d’un parking et que nous répondions 200, vous croiriez raisonnablement que le prix a changé. Il n’en serait rien.

3. L’URL de base

Chaque chemin de cette page est relatif à https://api.parkena.com/v1. Tout est en JSON, à l’aller comme au retour.

api.parkena.com est un nom d’hôte qui appartient à Parkena

Il se place devant la fonction qui répond ; les chemins ci-dessous ne changeront pas si ce qui est derrière change. Gardez tout de même la base en configuration plutôt que dans le code source.

Le développement local contre la pile du dépôt utilise les mêmes chemins sous /functions/v1/operator-api/v1 sur l’origine Supabase locale — les mêmes chemins, une autre base.

Il n’y a ni bac à sable ni base de test. La base ci-dessus est celle de production, et l’exploitant sur lequel agit une requête se déduit de la clé que vous présentez — jamais de quoi que ce soit dans une URL ou dans un corps. Voir le §4, et le §12 sur la sélection de locataire.

4. Authentification

Chaque requête porte un en-tête :

Authorization: Bearer <key_id>.<secret>

key_id et secret sont les deux moitiés d’une même clé, jointes par un point. Le premier point les sépare ; tous les points suivants appartiennent au secret.

curl "$BASE/ping" \
  -H "Authorization: Bearer pk_api_3f9c….pk_sec_a71b…"
  • key_idpk_api_ suivi de 32 caractères hexadécimaux. C’est la moitié publique. Elle apparaît dans nos journaux par conception, et vous pouvez sans risque la mettre dans un fichier de configuration ou la citer dans une demande d’assistance. La connaître mène aussi loin que connaître un nom d’utilisateur.
  • secretpk_sec_ suivi de 64 caractères hexadécimaux, soit 256 bits. Nous ne le stockons pas. Nous stockons un HMAC-SHA256 salé de ce secret, dans deux colonnes qu’aucun rôle de notre base de données ne peut lire. Nous ne pouvons jamais vous le restituer. Si vous le perdez, émettez une nouvelle clé et révoquez l’ancienne.

Il n’existe pas de repli par chaîne de requête ?key= et il n’y en aura pas : un secret dans une URL est un secret dans un journal de proxy, dans un historique de navigateur et dans un en-tête Referer.

Tout échec d’authentification renvoie le même 401

Un key_id inconnu, un mauvais secret, une clé révoquée, une clé expirée, un compte exploitant suspendu, une clé dépourvue du scope qu’exige une route, et un external_id qui n’est pas l’un de vos parkings donnent tous 401 {"error":"unauthorized"}. Ils ne sont pas distinguables, délibérément — le §9 dit ce que cela apporte et ce que cela vous coûte.

Utilisez GET /ping pour vérifier une clé, plutôt que de déduire quoi que ce soit d’un 401.

5. Émettre, renouveler et révoquer une clé

Les clés sont créées par un propriétaire ou un gestionnaire connecté à votre compte exploitant — jamais par cette API. Une clé d’API capable de créer des clés d’API est une clé qui peut élargir ses propres droits : aucune route ici n’en émet. Les comptes du personnel ne peuvent ni en émettre ni en révoquer.

Les deux appels sous-jacents sont api_credential_issue et api_credential_revoke, effectués via PostgREST avec le jeton d’un utilisateur connecté plutôt qu’avec une clé d’API. Ils sont documentés ici parce qu’un exploitant doté d’une équipe technique voudra les piloter directement ; l’écran de la console sous Account → API keys est la façon ordinaire d’en émettre une.

Émettre

curl -X POST '<project rest url>/rpc/api_credential_issue' \
  -H "apikey: <anon key>" \
  -H "Authorization: Bearer <a signed-in owner or manager’s JWT>" \
  -H 'Content-Type: application/json' \
  -d '{
        "p_label": "nightly sync",
        "p_scopes": ["read_supply", "write_supply", "read_bookings"],
        "p_expires_in_days": 365
      }'
ParamètreTypeRemarques
p_labelchaîne, 1–80 caractèresLe nom que vous donnerez à cette clé dans six mois. Obligatoire.
p_scopestableau de scopesEnsemble non vide de scopes distincts. Obligatoire.
p_expires_in_daysentier 1–3650, ou nullNull signifie qu’elle n’expire pas. Facultatif.
[{
  "credential_id": "01a01a73-a650-710e-9be1-08ffc77b4696",
  "key_id":        "pk_api_3f9c…",
  "secret":        "pk_sec_a71b…",
  "label":         "nightly sync",
  "scopes":        ["read_supply", "write_supply", "read_bookings"],
  "created_at":    "2026-08-19T14:22:07.113904Z",
  "expires_at":    "2027-08-19T14:22:07.113904Z"
}]
La réponse — et le seul moment où le secret est renvoyé.

Il n’y a pas de p_expires_at et il n’y en aura jamais. L’expiration est un nombre de jours que le serveur convertit contre sa propre horloge ; cette API n’accepte aucun horodatage fourni par l’appelant, nulle part, sur aucune route.

Renouveler

Il n’y a pas d’appel de renouvellement, parce qu’un renouvellement sans interruption de service n’est que deux appels dans le bon ordre :

  1. Émettez une deuxième clé avec les mêmes scopes.
  2. Déployez-la dans votre système et vérifiez que le trafic passe — GET /ping avec la nouvelle clé, puis surveillez son last_used_at.
  3. Révoquez l’ancienne.

Les deux clés sont actives entre la première étape et la troisième. Aucune limite ne vous empêche d’en détenir deux.

Révoquer

curl -X POST '<project rest url>/rpc/api_credential_revoke' \
  -H "apikey: <anon key>" \
  -H "Authorization: Bearer <a signed-in owner or manager’s JWT>" \
  -H 'Content-Type: application/json' \
  -d '{"p_credential_id": "01a01a73-a650-710e-9be1-08ffc77b4696"}'

La révocation est immédiate et définitive. Une clé révoquée n’est pas rétablie — elle est remplacée. Révoquer deux fois renvoie la même ligne avec le revoked_at d’origine, parce qu’une clé ne meurt pas deux fois. Un identifiant qui n’est pas l’un des vôtres renvoie [], exactement comme un identifiant qui n’a jamais existé.

6. Les scopes

Une clé porte un ensemble de scopes. Il en existe trois :

ScopeCe qu’il permet
read_supplyLister vos parkings, leurs enveloppes de prix Parkena, et les périodes bloquées de chaque parking — y compris le compte agrégé overlapping_bookings porté par chacune. Un compte, jamais une réservation : ni référence, ni nom, ni plaque ne l’accompagne.
write_supplyCréer et modifier des parkings, leurs prix Parkena, et leurs listes de périodes bloquées.
read_bookingsRécupérer vos réservations Parkena.

Accordez le minimum nécessaire. Une synchronisation qui ne fait qu’envoyer l’offre n’a pas besoin de read_bookings ; un traitement de reporting qui ne fait que récupérer des réservations ne doit pas détenir write_supply.

La vérification du scope n’est pas indicative. C’est un argument passé à l’appel de base de données qui établit lui-même quelles lignes d’exploitant la requête a le droit de toucher : il n’existe donc aucun chemin vers vos données qui la contourne. Une clé dépourvue du scope qu’exige une route reçoit le 401 uniforme — la même réponse qu’un mauvais secret.

Il n’y a délibérément pas de write_bookings. Rien dans Parkena ne permet à une machine de créer, modifier ou annuler une réservation ; un scope nommant cette capacité se lirait donc chez vous comme une frontière alors qu’il n’en serait pas une.

7. Les routes

MéthodeCheminScope exigé
GET/pingtoute clé valide
GET/lotsread_supply
PUT/lots/{external_id}write_supply
PUT/lots/{external_id}/rateswrite_supply
GET/lots/{external_id}/blocked-periodsread_supply
PUT/lots/{external_id}/blocked-periodswrite_supply
GET/bookingsread_bookings

{external_id} est VOTRE PROPRE identifiant de parking — celui que votre système lui donne. Il est opaque pour nous : nous ne l’analysons jamais, et il n’a pas besoin d’être un UUID. Encodez-le en pourcentage s’il contient un / ou un espace. Les identifiants internes de Parkena ne vous sont jamais transmis et ne sont jamais acceptés de votre part.

Les paramètres de requête inconnus sont refusés plutôt qu’ignorés, sur chaque route. Un paramètre mal orthographié qui disparaît en silence est un filtre que vous croyez appliqué et qui ne l’est pas.

GET /ping

Vérifie une clé et vous dit ce qu’elle a le droit de faire. C’est la route à utiliser quand une intégration ne fonctionne pas — tout autre refus sur cette API est délibérément incapable de vous dire laquelle de six choses a échoué. Elle n’établit aucun contexte d’exploitant et ne lit aucune ligne.

curl "$BASE/ping" -H "Authorization: Bearer $KEY.$SECRET"
#
{
  "ok": true,
  "key_id": "pk_api_3f9c…",
  "scopes": ["read_supply", "write_supply", "read_bookings"],
  "server_time": "2026-08-19T15:12:09.870151Z"
}

server_time est notre horloge, en UTC. Elle est informative — vous ne nous renvoyez jamais d’heure.

GET /lots

Vos parkings, avec l’enveloppe de prix Parkena de chacun.

Paramètre de requêteTypePar défautRemarques
afterchaîneReprendre après cet external_id. Utilisez le next_after de la page précédente.
limitentier 1–20050En demander plus est refusé, et non réduit en silence.

La pagination se fait sur votre propre external_id, en ordre croissant — ce n’est pas un décalage, donc une limite de page ne se déplace pas sous l’effet d’une écriture concurrente.

curl "$BASE/lots?limit=50" -H "Authorization: Bearer $KEY.$SECRET"
#
{
  "lots": [
    {
      "external_id": "LOT-1",
      "name": "Terminal Park",
      "timezone": "Europe/Berlin",
      "handover": "self",
      "online_bookable": true,
      "capacity": 250,
      "latitude": "52.520000",
      "longitude": "13.405000",
      "city": "Berlin",
      "country_code": "DE",
      "features": ["indoor", "valet"],
      "arrival": null,
      "media": null,
      "min_advance_days": 1,
      "min_stay_days": 1,
      "max_stay_days": 60,
      "cancellation": {
        "free_until_hours_before_check_in": 48,
        "penalty_percent_after": "50.00",
        "no_show_forfeits_full": true
      },
      "rates": {
        "channel": "parkena",
        "currency": "EUR",
        "base_price": "12.50",
        "floor_price": "9.00",
        "ceiling_price": "18.00",
        "min_first_day_price": "11.00",
        "dynamic": false,
        "valid_from": null,
        "valid_to": null
      }
    }
  ],
  "next_after": null
}

next_after n’est présent que si la page était pleine. Suivez-le jusqu’à ce qu’il vaille null et vous aurez vu tout votre parc exactement une fois. rates vaut null pour un parking qui n’a pas encore de prix Parkena.

PUT /lots/{external_id}

Crée ou met à jour UN parking. Une requête, un parking — il n’y a pas de point d’entrée groupé, et c’est la forme de la route qui l’impose. Un corps de type tableau est refusé avec too_many_lots.

PUT est une représentation complète. Une clé absente du corps signifie null, et non « laisse-la telle quelle ». Envoyez le parking entier à chaque fois. L’alternative rend impossible l’effacement d’un champ et transforme une clé mal orthographiée en une opération sans effet, permanente et silencieuse.

ChampTypeObligatoireRemarques
external_idchaînenonS’il est présent, il doit être égal à celui du chemin. Vérifié, non utilisé.
namechaîne, ≤200oui
timezonechaîneouiNom IANA, par exemple Europe/Berlin. Tout calcul de limite de journée passe par lui.
handoverchaîneouiself ou attended.
online_bookablebooléenouiVotre interrupteur de vente. Obligatoire précisément parce qu’une valeur par défaut retirerait de la vente un parking actif dès le premier envoi partiel.
capacityentier 0–1000000nonNombre TOTAL de places. Pas les places libres ce soir — voir l’avertissement ci-dessous.
latitudenombre ou chaînenonArrondie à 6 décimales. Doit être envoyée avec longitude.
longitudenombre ou chaînenonArrondie à 6 décimales. Doit être envoyée avec latitude.
citychaîne, ≤120non
country_codechaînenonISO 3166-1 alpha-2, par exemple DE.
featurestableau de chaînesnonVoir la liste ci-dessous. L’ordre est sans importance — nous les trions.
arrivalobjetnonConsignes d’arrivée en forme libre.
mediaobjetnonRéférences média en forme libre.
min_advance_daysentier 0–365nonVaut 0 par défaut.
min_stay_daysentier 1–365non
max_stay_daysentier 1–365nonLes trois s’arrêtent à 365, qui est l’horizon au-delà duquel Parkena ne chiffre plus un séjour du tout. Une valeur supérieure serait acceptée et jamais honorée.
cancellationobjet ou nullnonLes trois clés ou aucune — voir ci-dessous.

features accepte : indoor, security, cameras, gate_automation, plate_recognition, ev_charging, disabled_access, oversize_vehicle, valet.

cancellation est un seul objet imbriqué, et c’est tout ou rien :

"cancellation": {
  "free_until_hours_before_check_in": 48,
  "penalty_percent_after": "50.00",
  "no_show_forfeits_full": true
}

En envoyer une ou deux des trois donne 422 incomplete_cancellation_policy. Une politique avec une fenêtre gratuite et sans pénalité annoncée n’est pas une politique partiellement connue, c’est une question de remboursement sans réponse possible. Envoyez null ou omettez la clé pour « aucune politique ».

capacity est le NOMBRE TOTAL DE PLACES, pas la disponibilité

Si votre flux nocturne envoie « places libres ce soir » dans capacity, vous direz à Parkena que votre parking a rétréci, ET vous annulerez l’examen de votre annonce chaque nuit, parce que capacity fait partie du contenu examiné.

La disponibilité en temps réel relève d’un autre mécanisme et ne fait pas partie de la v1. Cette API ne peut pas détecter l’erreur, parce qu’un nombre est un nombre.

curl -X PUT "$BASE/lots/LOT-1" \
  -H "Authorization: Bearer $KEY.$SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
        "name": "Terminal Park",
        "timezone": "Europe/Berlin",
        "handover": "self",
        "online_bookable": true,
        "capacity": 250,
        "latitude": 52.5200,
        "longitude": 13.4050,
        "city": "Berlin",
        "country_code": "DE",
        "features": ["valet", "indoor"],
        "min_advance_days": 1,
        "min_stay_days": 1,
        "max_stay_days": 60,
        "cancellation": {
          "free_until_hours_before_check_in": 48,
          "penalty_percent_after": "50.00",
          "no_show_forfeits_full": true
        }
      }'
Un envoi complet.
{
  "external_id": "LOT-1",
  "result": "created",
  "changed": ["name", "timezone", "handover", "…"],
  "listing_review_superseded": false,
  "lot": { "…": "the car park as it now stands" }
}
201 quand le parking a été créé, 200 sinon.
ChampSignification
resultcreated, updated ou unchanged.
changedLes noms des champs qui diffèrent réellement. Vide quand unchanged.
listing_review_supersededtrue si cette écriture a annulé l’examen de votre annonce Parkena — voir le §8.
lotLe parking stocké, relu.

result: "unchanged" signifie qu’aucune instruction n’a été émise du tout — pas de verrou de ligne, pas d’écriture, pas de déclencheur. C’est l’issue normale et attendue d’un renvoi nocturne, et c’est ce qui empêche cette API de vous déréférencer toutes les nuits.

PUT /lots/{external_id}/rates

Fixe l’enveloppe de prix Parkena d’un parking. Un barème n’est pas un prix : c’est la FOURCHETTE dans laquelle vous acceptez de vendre sur le canal Parkena — un plancher, un plafond, et une base entre les deux.

Cette route écrit le canal parkena et lui seul. Votre tarification directe vous appartient et cette API ne peut pas y toucher.

Deux formes de corps sont acceptées. Une fourchette :

{
  "floor_price": "9.00",
  "base_price": "12.50",
  "ceiling_price": "18.00",
  "min_first_day_price": "11.00",
  "dynamic": false
}

Ou un prix fixe, qui se déploie en plancher = base = plafond :

{ "fixed_price": "12.50" }
ChampTypeObligatoireRemarques
fixed_pricedécimall’une ou l’autre formeNe peut pas être combiné avec les champs de la fourchette.
floor_pricedécimalavec la fourchetteDoit être ≤ base_price.
base_pricedécimalavec la fourchetteDoit se situer entre le plancher et le plafond.
ceiling_pricedécimalavec la fourchetteDoit être ≥ base_price.
min_first_day_pricedécimal ou nullnonDoit se situer à l’intérieur de l’enveloppe.
currencychaînenonISO 4217. Vaut par défaut votre devise de règlement, et ne peut être rien d’autre.
dynamicbooléennonVaut false par défaut.
valid_fromYYYY-MM-DD ou nullnonUne date de calendrier. Un horodatage est refusé, et non tronqué.
valid_toYYYY-MM-DD ou nullnonNe doit pas précéder valid_from.

L’argent est une chaîne, et il est refusé plutôt qu’arrondi. Envoyez "12.50", pas 12.345. Les montants portent deux décimales ; une troisième donne 422 invalid_body, parce qu’un prix que vous n’avez pas tapé n’est pas un prix que vous avez accepté. Les coordonnées, c’est l’inverse — ce sont une mesure, elles s’arrondissent donc.

Envoyer à la fois fixed_price et une fourchette donne 422. Deviner ce que vous vouliez dire, c’est ainsi qu’un parking finit tarifé au mauvais bout de sa propre fourchette.

{
  "external_id": "LOT-1",
  "result": "updated",
  "changed": ["floor_price"],
  "envelope_widened": true,
  "rates": {
    "channel": "parkena",
    "currency": "EUR",
    "base_price": "12.50",
    "floor_price": "7.00",
    "ceiling_price": "18.00",
    "min_first_day_price": "11.00",
    "dynamic": false,
    "valid_from": null,
    "valid_to": null
  }
}
201 à la création, 200 sinon.

envelope_widened vaut true quand cette écriture a déplacé l’enveloppe VERS L’EXTÉRIEUR — plancher abaissé, plafond relevé, devise changée, ou dynamic basculé — ou quand il n’y avait pas encore d’enveloppe Parkena. C’est l’élargissement qui peut vous coûter un examen d’annonce.

C’est un signal conservateur et nous préférons le dire plutôt que de le surestimer : nous comparons avec l’enveloppe qui était active il y a un instant, et non avec celle qu’un relecteur a approuvée, parce que cette API est délibérément incapable de lire l’état de votre examen. Elle peut donc signaler true dans un cas qui n’annule rien. Elle ne signalera pas false quand quelque chose a été annulé.

GET /lots/{external_id}/blocked-periods

La liste des périodes bloquées du parking : chaque plage de dates pendant laquelle il est retiré de la vente sur Parkena, quel qu’en soit l’auteur — vos synchronisations et la console écrivent la même liste. Une période bloquée arrête les NOUVELLES ventes Parkena pour les séjours qui la touchent, et ne fait rien d’autre : elle n’annule rien, et elle ne touche pas vos propres ventes directes. Les deux dates sont incluses — ends_on est le dernier jour bloqué, pas le lendemain, et une période dont starts_on égale ends_on bloque exactement ce jour-là.

curl "$BASE/lots/LOT-1/blocked-periods" -H "Authorization: Bearer $KEY.$SECRET"
#
{
  "external_id": "LOT-1",
  "blocked_periods": [
    { "starts_on": "2026-11-02",
      "ends_on":   "2026-11-08",
      "reason":    "resurfacing",
      "overlapping_bookings": 2 },
    { "starts_on": "2026-12-24",
      "ends_on":   "2026-12-26",
      "reason":    null,
      "overlapping_bookings": 0 }
  ]
}
Les périodes reviennent la plus ancienne d’abord. reason est votre propre libellé, null quand aucun n’a été donné.

overlapping_bookings est un compte de transparence : combien de réservations de ce parking — tous canaux, tous statuts sauf cancelled — ont un séjour qui touche la période. Les deux comparaisons sont inclusives, sur les dates de calendrier du parking lui-même : une réservation qui repart le premier matin de la période compte encore, comme celle qui arrive son dernier soir. Notez ce que « sauf cancelled » inclut : un séjour TERMINÉ compte aussi. Le compte répond à « qu’a-t-on vendu sur ces dates », historique compris — ce n’est pas le nombre de voitures à venir qu’une période bloquée laisserait en rade, il peut donc être plus élevé que le panneau d’avertissement de la console, qui pose cette question plus étroite. C’est un compte et rien de plus : aucune référence, aucun nom, aucune plaque, aucune date d’aucune réservation ne figure dans la réponse.

PUT /lots/{external_id}/blocked-periods

Remplace la liste ENTIÈRE des périodes bloquées du parking par celle du corps. Il n’existe pas d’appel « ajouter une période » ni de moyen d’adresser une période isolée — une synchronisation qui ne sait que fusionner est une synchronisation qui ne sait jamais supprimer, et une période levée dans votre système resterait sur Parkena pour toujours. Au plus 100 entrées ; chaque date est un vrai jour de calendrier entre 2020-01-01 et 2032-12-31 ; ends_on jamais avant starts_on ; et deux entrées ne peuvent pas se chevaucher — inclusivement : une paire qui partage un seul jour entre en collision.

La liste que vous envoyez est la liste qui existe

PUT est ici une représentation complète, la même règle que PUT /lots/{external_id} — et sur cette route, la règle a une conséquence qui mérite des majuscules : LES PÉRIODES SAISIES DANS LA CONSOLE FONT PARTIE DE LA MÊME LISTE. Si une personne saisit une période bloquée dans la console mardi et que votre synchronisation nocturne n’envoie que ses propres périodes mardi soir, la synchronisation supprime la période de cette personne — silencieusement, correctement, parce que vous nous avez dit que la liste envoyée était la liste entière.

Une machine qui possède cette route possède tout le calendrier. Soit vous relisez cette route et portez dans votre propre système les périodes saisies dans la console, soit vous convenez avec vos propres équipes du système qui possède les périodes bloquées. L’API n’arbitrera pas.

curl -X PUT "$BASE/lots/LOT-1/blocked-periods" \
  -H "Authorization: Bearer $KEY.$SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
        "blocked_periods": [
          { "starts_on": "2026-11-02", "ends_on": "2026-11-08", "reason": "resurfacing" },
          { "starts_on": "2026-12-24", "ends_on": "2026-12-26" }
        ]
      }'
Un remplacement complet. La seconde période ne porte pas de reason, ce qui est permis.
ChampTypeObligatoireRemarques
starts_onYYYY-MM-DDouiLe premier jour bloqué, inclus. Une date de calendrier — un horodatage est refusé, et non tronqué.
ends_onYYYY-MM-DDouiLe DERNIER jour bloqué, inclus — pas le lendemain. Ne doit pas précéder starts_on.
reasonchaîne ≤200, ou nullnonUn libellé, affiché dans la console. Vide est refusé ; null et absent signifient tous deux « sans raison ». Stocké tel quel, jamais élagué — une espace changée est une période changée.

Chaque refus de contenu sur cette route porte le même code, 422 {"error":"invalid_blocked_periods"}, avec field nommant l’entrée fautive dans les coordonnées de votre propre corps — blocked_periods[3].ends_on. Les deux erreurs d’enveloppe gardent les codes que le reste de l’API leur donne : une clé de premier niveau parasite est unknown_field, une clé blocked_periods absente est missing_field.

Ce qui n’allait pas`field`
Plus de 100 entrées, ou blocked_periods n’est pas un tableaublocked_periods
Pas un vrai jour de calendrier (2026-02-30), un horodatage, ou hors de 2020..2032le starts_on / ends_on de l’entrée
ends_on avant starts_onle ends_on de l’entrée
Deux entrées se chevauchent (inclusivement — partager un seul jour suffit)le starts_on de l’entrée qui commence le plus tard
reason vide, non textuel, ou de plus de 200 caractèresle reason de l’entrée

Les périodes qui chevauchent des réservations vendues ne sont PAS refusées. Une synchronisation machine ne doit pas se coincer sur une voiture vendue la semaine dernière ; les réservations tiennent — une période bloquée n’arrête que les NOUVELLES ventes. À la place, la réponse porte le compte overlapping_bookings de chaque période (sémantique ci-dessus, séjours terminés compris), pour que votre système, et l’humain derrière lui, voient exactement quelles périodes ont déjà des voitures vendues dedans.

{
  "external_id": "LOT-1",
  "result": "updated",
  "added": 1,
  "removed": 1,
  "blocked_periods": [
    { "starts_on": "2026-11-02",
      "ends_on":   "2026-11-08",
      "reason":    "resurfacing",
      "overlapping_bookings": 2 },
    { "starts_on": "2026-12-24",
      "ends_on":   "2026-12-26",
      "reason":    null,
      "overlapping_bookings": 0 }
  ]
}
201 quand le parking n’avait auparavant aucune période, 200 sinon.
ChampSignification
resultcreated (aucune période avant, au moins une maintenant), updated (tout le reste qui a écrit — y compris un PUT de [] qui a vidé la liste), ou unchanged.
added / removedLes comptes de lignes que l’écriture a réellement déplacées. Tous deux à 0 sur unchanged.
blocked_periodsLa liste stockée, relue APRÈS l’écriture avec des comptes overlapping_bookings frais — ce que la base de données contient désormais, jamais un écho de votre corps.

La règle « comparer avant de mettre à jour » du §2 vaut ici sous forme de liste : result: "unchanged" signifie que la liste stockée et votre corps disaient déjà la même chose, et qu’aucune instruction n’a été émise du tout. Renvoyer chaque nuit une liste de périodes inchangée coûte une lecture.

GET /bookings

Vos réservations Parkena, sous forme de CURSEUR DE RÉCUPÉRATION. Ce curseur est la source de vérité : un point de terminaison webhook enregistré peut recevoir un indice signé booking.changed qui vous dit de le récupérer plus tôt — le §13 — mais un indice ne porte aucune donnée de réservation, et rien de ce que vous construisez sur ce curseur n’est jamais perdu.

Paramètre de requêteTypePar défautRemarques
sincechaîneLe jeton next de votre appel précédent. À omettre pour « depuis le début ».
limitentier 1–500200En demander plus est refusé, et non réduit en silence.
curl "$BASE/bookings?since=$CURSOR" -H "Authorization: Bearer $KEY.$SECRET"
#
{
  "bookings": [
    {
      "reference": "PK0000000040",
      "external_reference": "OPS-9911",
      "lot_external_id": "LOT-1",
      "lot_name": "Terminal Park",
      "timezone": "Europe/Berlin",
      "check_in_local": "2026-09-01T08:00:00",
      "check_out_local": "2026-09-05T19:30:00",
      "parking_days": 5,
      "status": "confirmed",
      "payment_status": "paid",
      "currency": "EUR",
      "total": "56.00",
      "channel": "direct",
      "flight_number": "LH401",
      "customer": {
        "first_name": "Ada",
        "last_name": "Lovelace",
        "email": "[email protected]",
        "phone": "+49301234567"
      },
      "vehicle_plates": ["BXY4242"],
      "created_at": "2026-08-19T15:18:01.099213Z",
      "cancelled_at": null
    }
  ],
  "next": "eyJ2IjoxLCJ0IjoiMjAyNi0wOC0xOVQxNToxMzowOC44MjQxMDhaIiwi…",
  "has_more": false
}
ChampRemarques
referenceLa référence de réservation de PARKENA. C’est la clé de dédoublonnage.
external_referenceVotre propre chaîne, si elle a été renseignée. Renvoyée telle quelle, jamais utilisée comme clé.
check_in_local / check_out_localL’heure murale du parking lui-même, SANS décalage de fuseau. Lisez-les dans timezone.
totalUne chaîne décimale, pas un flottant.
statuspending, confirmed, checked_in, completed, cancelled.
payment_statuspending, paid, partial, refunded, expired, not_required.

Comment interroger correctement

cursor = load_saved_cursor()          # null on the first run
loop:
    r = GET /bookings?since=<cursor>&limit=200
    for b in r.bookings:
        upsert_into_your_system(b, key = b.reference)   # NOT external_reference
    cursor = r.next
    save_cursor(cursor)
    if not r.has_more: sleep(60)

Trois règles, et chacune porte le poids :

  1. Dédoublonnez sur reference. La livraison est au moins une fois, jamais exactement une fois. Vous verrez la même réservation plus d’une fois, et c’est le comportement correct, pas une panne.
  2. Enregistrez next APRÈS avoir stocké durablement le lot, et non avant. Si votre processus meurt en plein lot, le curseur non enregistré fait relivrer — ce que la règle 1 rend inoffensif.
  3. Ne fabriquez pas de curseur. C’est un jeton que nous avons émis. Il n’existe pas de ?since=<an instant I chose>, et cette absence est délibérée : un partenaire qui nommerait un instant futur cesserait silencieusement de recevoir ses propres réservations, et le premier symptôme serait une voiture à une barrière dont son système n’a jamais entendu parler. Un curseur au-delà de notre borne est ramené à celle-ci, si bien que le pire qu’un jeton trafiqué puisse faire est de livrer des lignes deux fois.

Pourquoi vous pouvez revoir une réservation immédiatement : le curseur que nous vous rendons ne pointe jamais au-delà de now() − 5 minutes. Les réservations plus récentes que cela sont tout de même renvoyées — vous voulez votre réservation maintenant, pas dans cinq minutes — nous ne validons simplement pas le curseur jusqu’à elles. Ce n’est pas de la prudence pour la prudence. Une transaction de base de données est estampillée à son heure de DÉBUT : une transaction lente peut donc valider une ligne estampillée plus tôt qu’une ligne qui vous a déjà été livrée ; sans ce retard, un curseur qui sauterait droit à la ligne la plus récente l’enjamberait définitivement et en silence.

8. Ce qui vous coûte un nouvel examen

Le §2 donne la règle ; voici la liste des champs qui la sous-tend. Modifier l’un des champs suivants sur PUT /lots/{external_id} annule une annonce en attente ou approuvée, et la réponse le dit avec "listing_review_superseded": true :

name · timezone · handover · capacity · latitude · longitude · city · country_code · features · arrival · media · min_advance_days · min_stay_days · max_stay_days · cancellation_free_until_hours_before_check_in · cancellation_penalty_percent_after · cancellation_no_show_forfeits_full

Ce qui ne vous en coûte pas

  • online_bookable. C’est votre interrupteur de vente, pas du contenu examiné. Le désactiver retire immédiatement le parking de la vente sans rien annuler — c’est exactement pour cela qu’il est obligatoire sur chaque PUT.
  • Tout ce qui, sur la route des tarifs, n’élargit pas l’enveloppe. Relever votre plancher ou abaisser votre plafond, c’est rester à l’intérieur d’une promesse déjà faite. Abaisser le plancher, relever le plafond, changer de devise ou basculer dynamic, c’est en faire une nouvelle, et cela peut annuler l’examen — envelope_widened vous dit quand.
  • valid_from / valid_to. L’expiration et le renouvellement sont lus en direct : un changement de fenêtre retire donc le parking de la vitrine et l’y remet sans intervention humaine.

Deux pièges à connaître

  • L’ordre des équipements ne compte pas pour vous, mais il a compté pour nous autrefois. Nous trions features avant de les stocker : ["valet","indoor"] et ["indoor","valet"] sont donc le même envoi. Vous n’avez pas à trier.
  • La précision des coordonnées n’a pas d’importance. Nous arrondissons à six décimales avant de comparer : envoyer 52.5200001 chaque nuit ne signale donc pas latitude comme modifiée pour l’éternité.

Il n’y a pas de suppression

Cette API ne peut supprimer ni un parking ni un prix, et aucun droit ne le lui permettrait. Le delete suivi d’un insert est l’idiome d’upsert le plus répandu dans le code d’ingestion, et sur ces données il est catastrophique : le parking recevrait une nouvelle identité et emporterait avec lui l’historique de son annonce, ses prix et sa disponibilité — depuis un traitement que personne ne regardait. Retirer un parking est une chose qu’une personne fait dans la console, délibérément, là où la confirmation nomme tout ce qui part avec lui. Un parking qui a déjà pris une réservation ne peut plus être retiré du tout, par qui que ce soit : une réservation est une pièce comptable, elle est conservée, et le parking pour lequel elle a été prise l’est aussi. Pour cesser d’en vendre un, mettez "online_bookable": false.

9. Les erreurs

Chaque erreur est un JSON portant un code error stable et lisible par une machine :

{ "error": "invalid_price_envelope", "field": "floor_price" }

field, quand il est présent, est VOTRE PROPRE nom de champ, renvoyé tel quel. Nous ne renvoyons jamais de message de base de données, de nom de contrainte ni de nom de table — ceux-là sont à nous et nous les renommons quand nous voulons, et votre intégration ne doit pas casser à ce moment-là. Aiguillez sur error.

La clé

StatutCodeSignification
401unauthorizedVoir ci-dessous — il couvre six situations différentes et refuse de dire laquelle.

La forme de la requête

StatutCodeSignification
404not_foundCette ROUTE n’existe pas. Jamais utilisé pour une ressource.
405method_not_allowedBon chemin, mauvais verbe. L’en-tête Allow nomme le verbe attendu.
413payload_too_largeCorps de plus de 64 KiB.
415unsupported_media_typeLe Content-Type n’était pas application/json.
422invalid_jsonLe corps n’était pas un JSON analysable.
422invalid_bodyJSON bien formé, mauvaise forme ou valeur inutilisable.
422missing_fieldUn champ obligatoire était absent.
422unknown_fieldUn champ que nous ne reconnaissons pas. Refusé, pas ignoré.
422field_belongs_to_another_routeUn champ qui existe bien, mais qui se règle ailleurs. Aujourd’hui le seul est rates — voir le §2.
422invalid_queryUn paramètre de requête erroné ou inconnu.
422invalid_cursorLe jeton since n’était pas l’un des nôtres.
422too_many_lotsUn corps de type tableau. Une requête, un parking.

Le contenu

StatutCodeSignification
422invalid_timezonePas un nom de fuseau horaire IANA.
422invalid_coordinatesHors plage, ou une latitude sans longitude.
422invalid_country_codePas un code ISO 3166-1 alpha-2.
422invalid_stay_boundsmin_stay_days et max_stay_days se contredisent.
422incomplete_cancellation_policyUne ou deux des trois clés d’annulation.
422invalid_price_envelopeLe plancher, la base et le plafond ne sont pas dans l’ordre.
422invalid_first_day_pricemin_first_day_price hors de l’enveloppe.
422invalid_validity_windowvalid_to précède valid_from.
422currency_is_not_settlement_currencyCe n’est pas votre devise de règlement.
422invalid_blocked_periodsUne entrée de périodes bloquées est inutilisable ; field la nomme dans les coordonnées de votre propre corps (blocked_periods[3].ends_on). Le tableau des refus est au §7.
409conflictUne vraie course : deux envois ont créé le même external_id en même temps. Réessayez.

Les nôtres

StatutCodeSignification
429rate_limitedAu-delà d’une limite. Respectez Retry-After.
503try_againUn conflit passager de base de données. Réessayez ; Retry-After est renseigné.
500internal_errorNotre faute. C’est dans nos journaux en entier. Réessayer est raisonnable.

Pourquoi le 401 ne vous en dira pas plus

unauthorized couvre tous les cas suivants et refuse de les distinguer :

  1. Aucun en-tête Authorization, ou un en-tête que nous ne savons pas analyser.
  2. Un key_id qui ne nomme aucune clé.
  3. Un key_id qui existe, avec le mauvais secret.
  4. Une clé révoquée, expirée, ou dont le compte exploitant est suspendu.
  5. Une clé valide à qui manque le scope qu’exige cette route.
  6. Une clé valide qui nomme un external_id qui n’est pas l’un de ses parkings.

Les cas 2 à 5 ne sont pas distinguables, même à l’intérieur de notre propre processus — la base de données répond identiquement à tous, avec le même travail effectué, pour que personne ne puisse énumérer la liste des exploitants ni cartographier celles des capacités d’une clé volée qui fonctionnent encore.

Le cas 6 vous coûte un peu de confort, et nous dirons pourquoi plutôt que de nous contenter de l’affirmer : une clé ne portant que write_supply ne peut pas lister les parkings. Si un external_id inconnu répondait lot_not_found, cette clé disposerait d’un oracle d’énumération fonctionnel portant exactement sur le parc que ses scopes lui refusent — bâti à partir d’un refus. Elle reçoit donc le même 401.

GET /ping est la réponse à cela. Il vous dit que votre clé est active et ce qu’elle a le droit de faire, sans que vous ayez à deviner à partir d’un 401. Si le ping réussit et qu’une route renvoie 401, vous avez affaire à un scope que vous ne détenez pas ou à un external_id qui ne vous appartient pas — vérifiez les deux avec GET /lots.

10. Limites de débit, limites de taille, et ce que nous journalisons

LimiteValeur
Corps de requête64 KiB
Parkings par requête1
Page de GET /lots1–200, 50 par défaut
Page de GET /bookings1–500, 200 par défaut
Périodes bloquées par PUT …/blocked-periods100 — une liste, un parking

Les limites de débit sont des seaux à jetons — une capacité de rafale qui se recharge en continu.

SeauCompté surRafaleRecharge
Toutes les requêtesadresse source2404 / seconde
Authentifications échouéesadresse source201 toutes les 3 secondes
Lecturesclé1202 / seconde
Écritures (rafale)clé601 / seconde
Écritures (horaire)clé10001000 / heure

Un refus est un 429 assorti d’un en-tête Retry-After en secondes entières. Être refusé ne dépense pas de jeton : réessayer n’éloigne donc pas votre propre rétablissement.

Les écritures sont bornées deux fois, et c’est la fenêtre horaire qui compte. Une clé d’écriture fuitée qui remet un parc entier à zéro ressemble exactement à une synchronisation nocturne légitime — même clé, même route, même forme, même heure de la nuit — une limite de rafale seule ne peut donc pas les distinguer, parce qu’une vraie synchronisation est elle aussi une rafale. Le plafond horaire borne la part d’un parc qu’une seule clé volée peut réécrire avant qu’une personne puisse plausiblement être en train de regarder. Un exploitant de 1000 parkings qui envoie chacun une fois par nuit tient dedans ; si votre parc est plus grand, demandez-nous et nous relèverons la limite plutôt que de vous laisser la contourner.

Les routes de périodes bloquées dépensent les mêmes seaux que tout le reste : le GET coûte un jeton de lecture, et le PUT est une écriture, comptée sur les deux seaux d’écriture — une synchronisation nocturne de périodes compte sur le même plafond de 1000 par heure que vos envois de parkings, délibérément, parce qu’une clé fuitée qui retire un parc de la vente est exactement la forme que ce plafond existe pour borner.

Les authentifications échouées sont comptées sur L’ADRESSE SOURCE, jamais sur le key_id présenté. Les compter sur la clé serait un déni de service dirigé contre vous : key_id est la moitié non secrète et apparaît par conception dans les journaux et les fichiers de configuration, si bien que quiconque en lirait un pourrait vous verrouiller hors de votre propre intégration avec quelques dizaines de mauvais secrets.

Notez qu’un external_id inconnu dépense lui aussi du budget d’échec, parce qu’il renvoie le même 401 que tout le reste. Un limiteur qui traiterait les deux différemment serait un oracle d’existence bâti à partir d’un 429. Si vous portez une correspondance périmée, rapprochez-la de GET /lots plutôt que de sonder.

Ce que nous journalisons

Chaque requête produit une ligne de journal chez nous ; chaque écriture qui a changé quelque chose en produit une deuxième, portant les NOMS DES CHAMPS qui ont changé et indiquant si le changement vous a coûté un examen. Des noms de champs, jamais des valeurs — le journal répond à « qu’est-il arrivé à ce parc la nuit dernière », et vos prix n’y sont pas. Votre key_id, si, et c’est ainsi que vous pouvez nous demander laquelle de vos intégrations a fait quelque chose. Votre secret n’y est jamais, sous aucune forme.

11. Avant que votre parking puisse se vendre

Envoyer un parking par cette API le crée, mais un nouveau parking ne se met pas à vendre sur parkena.com à l’instant où l’API renvoie 201. Une partie de ce qui est exigé est du contenu que cette API peut fournir ; le reste est une décision qu’une personne doit confirmer dans la console.

Un PUT /lots/{external_id} complet, plus un PUT …/rates, satisfait les exigences de capacité, de prix, de géographie, de ville et pays, et de politique d’annulation. Restent en suspens, et faisables uniquement dans la console :

  • Confirmer le fuseau horaire et les règles de réservation — ils sont déduits et pré-remplis, et une réponse fausse mais plausible tarifie mal des réservations en silence : une personne les confirme donc une fois.
  • Les consignes d’arrivée et une image principale, pour l’annonce publique.
  • L’acceptation du contrat d’annonce Parkena, une fois pour tout le compte.
  • La soumission du parking à l’examen.

Ce dernier point est délibéré : soumettre un parking à un relecteur humain est une déclaration que vous faites sur votre entreprise, et une machine qui détient une clé ne devrait pas pouvoir la faire en votre nom.

La console montre chaque exigence, si elle est satisfaite, et ce qu’elle protège. Ce n’est pas une limitation que nous comptons lever en v1.

12. Ce que la v1 ne fait pas

Dit sans détour, parce qu’une intégration bâtie sur une hypothèse que nous n’avons jamais formulée est pire qu’une intégration bâtie sur une lacune documentée.

  • Pas de chiffres de disponibilité en direct. Vous pouvez désormais retirer de la vente des plages de dates entières avec PUT /lots/{external_id}/blocked-periods — le §7 — mais il n’existe toujours aucun moyen d’envoyer « places libres ce soir » en nombre, et capacity est le nombre total de places — y mettre la disponibilité en temps réel décrira faussement votre parking et le renverra en examen chaque nuit. Un calendrier de disponibilité en nombres n’est pas dans la v1.
  • Pas de contenu dans les webhooks. Un point de terminaison enregistré reçoit l’INDICE signé booking.changed du §13 — une référence de réservation et rien d’autre — et le curseur de récupération reste la source de vérité, exactement comme cette liste le promettait avant que l’indice existe. Ce qui n’existe toujours pas : des contenus par événement (aucune donnée de réservation ne voyage jamais dans un webhook), des garanties d’ordre (les indices se regroupent et se retentent ; l’ordre est l’affaire du curseur), ou une API de relivraison (rien à relivrer — récupérez le curseur de nouveau). Une intégration doit fonctionner indices éteints, parce qu’un indice qui échoue cinq livraisons meurt sans bruit, à dessein.
  • Pas d’écriture de réservations. Vous ne pouvez pas créer, modifier, annuler, enregistrer l’arrivée ni rembourser une réservation par cette API. Il n’existe ni scope pour cela, ni droit derrière.
  • Pas de paiements. Ni débits, ni remboursements, ni données de versement, ni chiffres de commission. Le flux de réservations porte le total de la vente et la devise, et rien sur la façon dont l’argent a circulé.
  • Pas de distribution vers une OTA ni vers un channel manager. Cette API écrit le canal parkena, et lui seul. Ce n’est pas un channel manager et elle n’envoie rien à personne d’autre.
  • Pas de suppressions. Voir le §8.
  • Pas de point d’entrée groupé. Une requête, un parking.
  • Pas de soumission d’annonce ni d’état d’examen. Vous ne pouvez ni soumettre à l’examen ni lire votre statut d’examen par l’API. listing_review_superseded et envelope_widened sont les seuls signaux proches de l’examen, et le second est délibérément conservateur.
  • Pas de sélection de locataire. Il n’y a de tenant_id dans aucun corps ni aucune chaîne de requête que cette API analyse. L’exploitant sur lequel agit une requête se déduit de la clé et de rien d’autre — un identifiant d’exploitant fourni dans la requête serait une clé d’écriture inter-locataires.
  • Pas d’horodatage fourni par l’appelant, nulle part. Pas d’as_of, pas de watermark, pas d’updated_since. Les seules dates que vous pouvez envoyer sont valid_from et valid_to, qui sont des jours de calendrier que vous déclarez à propos de votre propre prix. Tout le reste, c’est notre horloge.

13. Webhooks : l’indice `booking.changed`

Vous pouvez enregistrer un point de terminaison HTTPS par compte exploitant, et nous lui enverrons en POST un indice signé chaque fois qu’une de vos réservations est créée ou change — tout canal, tout champ. Lisez l’avertissement ci-dessous avant de rien concevoir autour.

Un indice n’est pas une donnée. Le curseur est la donnée.

Le corps entier d’un indice, c’est un nom d’événement, une référence de réservation et un horodatage. Ni statut, ni dates, ni voyageur, ni montants — rien sur quoi votre système pourrait agir directement, et rien qui périme en transit. La seule réponse correcte à un indice est la chose que votre intégration fait déjà : récupérer GET /bookings avec votre curseur enregistré.

Un partenaire dont le point de terminaison est en panne une journée perd de la latence, jamais des données — le curseur relivre tout à l’interrogation suivante. Si votre intégration ne peut pas survivre indices éteints, elle est mal construite.

Cette répartition des rôles est la raison pour laquelle le §12 ne dit plus « pas de webhooks » : ce que nous refusions de livrer, c’était un webhook qui PORTAIT la réservation, parce qu’un webhook qui échoue en silence est une réservation dont vous n’entendez jamais parler pendant que la voiture arrive quand même à votre barrière. Un indice peut échouer en silence et ne rien vous coûter.

La livraison

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Parkena-Webhooks/1
Parkena-Signature: t=1767139200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
#
{"event":"booking.changed","booking_reference":"PK0000000040","occurred_at":"2026-08-27T09:15:12.114532Z"}
Un seul événement en v1 — booking.changed, un seul nom pour la création, la modification et l’annulation, parce que l’indice ne dit pas ce qui s’est passé ; le curseur le dit.
  • booking_reference est la même reference que porte le flux de réservations — donnez-la à votre dédoublonnage, exactement comme vous dédoublonnez les lignes du curseur.
  • occurred_at est le moment où le changement a été enregistré chez nous, pas celui où cette tentative a été envoyée. Les reprises le renvoient tel quel.

Les changements successifs d’une même réservation SE REGROUPENT tant qu’un indice la concernant n’est pas encore livré : cinq modifications en une minute produisent un seul signal, et c’est correct précisément parce que l’indice ne porte aucun état — quel que soit le nombre d’écritures qu’il représente, votre prochaine lecture du curseur voit la ligne finale. La livraison est au moins une fois, comme tout le reste sur cette API : vous pouvez recevoir deux indices pour un même changement, et dédoublonner sur booking_reference rend cela gratuit.

Enregistrer un point de terminaison, et les règles qu’il doit respecter

Les points de terminaison s’enregistrent dans la console, sur la même page Account → API keys où s’émettent les clés, par un propriétaire ou un gestionnaire — pas par cette API, pour la même raison que les clés. Le secret de signature (whsec_ suivi de 64 caractères hexadécimaux) est généré dans votre navigateur et affiché UNE FOIS : nous le stockons pour signer, mais aucun écran de console et aucune requête ne peut jamais le relire. Un seul point de terminaison peut être actif par compte en v1. Un point de terminaison ne se modifie jamais — une nouvelle URL ou un secret tourné est un nouveau point de terminaison (désarmez d’abord l’ancien) ; le désarmement est le seul interrupteur que la console offre après la naissance.

  • HTTPS seulement, port 443 seulement. http://, et tout port explicite autre que 443, n’est jamais tenté.
  • Un nom d’hôte, pas une adresse. Les adresses IP littérales (v4 ou v6), localhost et tout ce qui vit sur les domaines de notre propre plateforme sont refusés.
  • Les redirections ne sont jamais suivies. Une redirection est une seconde URL que personne n’a examinée ; la tentative échoue à la place.
  • Délai de cinq secondes, et au-delà du code de statut votre réponse n’est jamais lue. Répondez vite et travaillez ensuite — la bonne forme est « mettre en file et renvoyer 204 ».

Le contrat du 2xx, les reprises, et la mort

Tout 2xx dans le délai vaut livraison. Tout le reste — un 4xx, un 5xx, un délai dépassé, une connexion refusée — est retenté selon un délai croissant fixe, et après la cinquième tentative échouée l’indice est mort : le dernier statut HTTP et la raison de l’échec sont enregistrés et visibles dans la console, et aucune autre tentative n’a lieu. La réservation, elle, attend comme toujours sur le curseur. Désarmer un point de terminaison tue ses indices en attente au balayage suivant plutôt que de les livrer plus tard à un point de terminaison que vous avez éteint.

Tentatives échouéesTentative suivante
1après 1 minute
2après 5 minutes
3après 30 minutes
4après 2 heures
5aucune — l’indice est mort, son dernier statut et sa raison enregistrés

Vérifier la signature

Chaque livraison porte un en-tête Parkena-Signature : t=<unix-seconds>,v1=<hex HMAC-SHA-256>. La charge signée est la chaîne littérale t + . + le corps BRUT de la requête — signez les octets reçus, jamais une re-sérialisation du JSON analysé. C’est délibérément le schéma exact que Stripe emploie pour ses webhooks, préfixe de secret whsec_ compris : tout vérificateur de webhooks Stripe que vous exploitez déjà — ou celui publié dans le propre dépôt de Parkena — vérifie ces livraisons sans modification.

# header: "t=1767139200,v1=5257a869…"   secret: "whsec_…" exactly as shown once
const pairs = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = hexHmacSha256(secret, `${pairs.t}.${rawBody}`);
const fresh = Math.abs(nowSeconds() - Number(pairs.t)) <= 300;
const ok = fresh && timingSafeEqual(pairs.v1, expected);
Le tout, en esquisse.

Trois détails qu’une implémentation rapide rate : comparez avec une égalité à TEMPS CONSTANT, pas ===, pour que le temps de réponse ne trahisse pas la part juste d’une signature forgée ; rejetez un t plus vieux que quelques minutes — nous recommandons 300 secondes — c’est ce qui rend une livraison capturée sans valeur à rejouer plus tard ; et si vous analysez l’en-tête proprement plutôt que de le découper naïvement, vérifiez CHAQUE paire v1= présente et acceptez si l’une correspond — c’est ce qui empêche une future rotation du secret de signature de vous casser en pleine fenêtre.

Un indice qui échoue à votre vérification n’est pas une livraison Parkena. Répondez-lui 401 et ne faites rien d’autre — en particulier, ne lisez pas le curseur au rythme qu’il vous dicte. Lire le curseur est toujours SÛR ; refuser, c’est ne pas laisser un appelant non authentifié piloter la cadence de votre système.

14. Obtenir un accès, et obtenir de l’aide

Les clés sont émises dans la console par un propriétaire ou un gestionnaire, sous Account → API keys. Il n’y a pas de bac à sable, et l’accès au pilote s’organise avec nous un exploitant à la fois — si ce que vous venez de lire correspond au système que vous exploitez déjà, c’est cela qu’il faut nous dire en nous écrivant.

Écrivez à [email protected]. Pour une assistance sur une intégration déjà en service : citez votre key_id — jamais votre secret — ainsi que l’external_id et l’horodatage de la requête dont vous parlez. Les deux figurent dans nos journaux, et ensemble ils identifient une requête unique.

Posez vos questions sur le pilote.

Dites-nous ce qu’est votre système et ce que vous voulez lui faire envoyer. Nous vous dirons honnêtement si la v1 le couvre — le §12 est la liste complète de ce qu’elle ne peut pas faire, écrite en entier pour que vous puissiez décider contre elle avant de construire quoi que ce soit.