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 }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.jsonRenvoyez-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_id—pk_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.secret—pk_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ètre | Type | Remarques |
|---|---|---|
p_label | chaîne, 1–80 caractères | Le nom que vous donnerez à cette clé dans six mois. Obligatoire. |
p_scopes | tableau de scopes | Ensemble non vide de scopes distincts. Obligatoire. |
p_expires_in_days | entier 1–3650, ou null | Null 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"
}]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 :
- Émettez une deuxième clé avec les mêmes scopes.
- Déployez-la dans votre système et vérifiez que le trafic passe —
GET /pingavec la nouvelle clé, puis surveillez sonlast_used_at. - 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 :
| Scope | Ce qu’il permet |
|---|---|
read_supply | Lister 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_supply | Créer et modifier des parkings, leurs prix Parkena, et leurs listes de périodes bloquées. |
read_bookings | Ré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éthode | Chemin | Scope exigé |
|---|---|---|
GET | /ping | toute clé valide |
GET | /lots | read_supply |
PUT | /lots/{external_id} | write_supply |
PUT | /lots/{external_id}/rates | write_supply |
GET | /lots/{external_id}/blocked-periods | read_supply |
PUT | /lots/{external_id}/blocked-periods | write_supply |
GET | /bookings | read_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ête | Type | Par défaut | Remarques |
|---|---|---|---|
after | chaîne | — | Reprendre après cet external_id. Utilisez le next_after de la page précédente. |
limit | entier 1–200 | 50 | En 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.
| Champ | Type | Obligatoire | Remarques |
|---|---|---|---|
external_id | chaîne | non | S’il est présent, il doit être égal à celui du chemin. Vérifié, non utilisé. |
name | chaîne, ≤200 | oui | — |
timezone | chaîne | oui | Nom IANA, par exemple Europe/Berlin. Tout calcul de limite de journée passe par lui. |
handover | chaîne | oui | self ou attended. |
online_bookable | booléen | oui | Votre 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. |
capacity | entier 0–1000000 | non | Nombre TOTAL de places. Pas les places libres ce soir — voir l’avertissement ci-dessous. |
latitude | nombre ou chaîne | non | Arrondie à 6 décimales. Doit être envoyée avec longitude. |
longitude | nombre ou chaîne | non | Arrondie à 6 décimales. Doit être envoyée avec latitude. |
city | chaîne, ≤120 | non | — |
country_code | chaîne | non | ISO 3166-1 alpha-2, par exemple DE. |
features | tableau de chaînes | non | Voir la liste ci-dessous. L’ordre est sans importance — nous les trions. |
arrival | objet | non | Consignes d’arrivée en forme libre. |
media | objet | non | Références média en forme libre. |
min_advance_days | entier 0–365 | non | Vaut 0 par défaut. |
min_stay_days | entier 1–365 | non | — |
max_stay_days | entier 1–365 | non | Les 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. |
cancellation | objet ou null | non | Les 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
}
}'{
"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.| Champ | Signification |
|---|---|
result | created, updated ou unchanged. |
changed | Les noms des champs qui diffèrent réellement. Vide quand unchanged. |
listing_review_superseded | true si cette écriture a annulé l’examen de votre annonce Parkena — voir le §8. |
lot | Le 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" }| Champ | Type | Obligatoire | Remarques |
|---|---|---|---|
fixed_price | décimal | l’une ou l’autre forme | Ne peut pas être combiné avec les champs de la fourchette. |
floor_price | décimal | avec la fourchette | Doit être ≤ base_price. |
base_price | décimal | avec la fourchette | Doit se situer entre le plancher et le plafond. |
ceiling_price | décimal | avec la fourchette | Doit être ≥ base_price. |
min_first_day_price | décimal ou null | non | Doit se situer à l’intérieur de l’enveloppe. |
currency | chaîne | non | ISO 4217. Vaut par défaut votre devise de règlement, et ne peut être rien d’autre. |
dynamic | booléen | non | Vaut false par défaut. |
valid_from | YYYY-MM-DD ou null | non | Une date de calendrier. Un horodatage est refusé, et non tronqué. |
valid_to | YYYY-MM-DD ou null | non | Ne 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 }
]
}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" }
]
}'reason, ce qui est permis.| Champ | Type | Obligatoire | Remarques |
|---|---|---|---|
starts_on | YYYY-MM-DD | oui | Le premier jour bloqué, inclus. Une date de calendrier — un horodatage est refusé, et non tronqué. |
ends_on | YYYY-MM-DD | oui | Le DERNIER jour bloqué, inclus — pas le lendemain. Ne doit pas précéder starts_on. |
reason | chaîne ≤200, ou null | non | Un 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 tableau | blocked_periods |
Pas un vrai jour de calendrier (2026-02-30), un horodatage, ou hors de 2020..2032 | le starts_on / ends_on de l’entrée |
ends_on avant starts_on | le 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ères | le 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.| Champ | Signification |
|---|---|
result | created (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 / removed | Les comptes de lignes que l’écriture a réellement déplacées. Tous deux à 0 sur unchanged. |
blocked_periods | La 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ête | Type | Par défaut | Remarques |
|---|---|---|---|
since | chaîne | — | Le jeton next de votre appel précédent. À omettre pour « depuis le début ». |
limit | entier 1–500 | 200 | En 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
}| Champ | Remarques |
|---|---|
reference | La référence de réservation de PARKENA. C’est la clé de dédoublonnage. |
external_reference | Votre propre chaîne, si elle a été renseignée. Renvoyée telle quelle, jamais utilisée comme clé. |
check_in_local / check_out_local | L’heure murale du parking lui-même, SANS décalage de fuseau. Lisez-les dans timezone. |
total | Une chaîne décimale, pas un flottant. |
status | pending, confirmed, checked_in, completed, cancelled. |
payment_status | pending, 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 :
- 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. - Enregistrez
nextAPRÈ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. - 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 chaquePUT.- 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_widenedvous 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
featuresavant 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.5200001chaque nuit ne signale donc paslatitudecomme 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é
| Statut | Code | Signification |
|---|---|---|
401 | unauthorized | Voir ci-dessous — il couvre six situations différentes et refuse de dire laquelle. |
La forme de la requête
| Statut | Code | Signification |
|---|---|---|
404 | not_found | Cette ROUTE n’existe pas. Jamais utilisé pour une ressource. |
405 | method_not_allowed | Bon chemin, mauvais verbe. L’en-tête Allow nomme le verbe attendu. |
413 | payload_too_large | Corps de plus de 64 KiB. |
415 | unsupported_media_type | Le Content-Type n’était pas application/json. |
422 | invalid_json | Le corps n’était pas un JSON analysable. |
422 | invalid_body | JSON bien formé, mauvaise forme ou valeur inutilisable. |
422 | missing_field | Un champ obligatoire était absent. |
422 | unknown_field | Un champ que nous ne reconnaissons pas. Refusé, pas ignoré. |
422 | field_belongs_to_another_route | Un champ qui existe bien, mais qui se règle ailleurs. Aujourd’hui le seul est rates — voir le §2. |
422 | invalid_query | Un paramètre de requête erroné ou inconnu. |
422 | invalid_cursor | Le jeton since n’était pas l’un des nôtres. |
422 | too_many_lots | Un corps de type tableau. Une requête, un parking. |
Le contenu
| Statut | Code | Signification |
|---|---|---|
422 | invalid_timezone | Pas un nom de fuseau horaire IANA. |
422 | invalid_coordinates | Hors plage, ou une latitude sans longitude. |
422 | invalid_country_code | Pas un code ISO 3166-1 alpha-2. |
422 | invalid_stay_bounds | min_stay_days et max_stay_days se contredisent. |
422 | incomplete_cancellation_policy | Une ou deux des trois clés d’annulation. |
422 | invalid_price_envelope | Le plancher, la base et le plafond ne sont pas dans l’ordre. |
422 | invalid_first_day_price | min_first_day_price hors de l’enveloppe. |
422 | invalid_validity_window | valid_to précède valid_from. |
422 | currency_is_not_settlement_currency | Ce n’est pas votre devise de règlement. |
422 | invalid_blocked_periods | Une 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. |
409 | conflict | Une vraie course : deux envois ont créé le même external_id en même temps. Réessayez. |
Les nôtres
| Statut | Code | Signification |
|---|---|---|
429 | rate_limited | Au-delà d’une limite. Respectez Retry-After. |
503 | try_again | Un conflit passager de base de données. Réessayez ; Retry-After est renseigné. |
500 | internal_error | Notre 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 :
- Aucun en-tête
Authorization, ou un en-tête que nous ne savons pas analyser. - Un
key_idqui ne nomme aucune clé. - Un
key_idqui existe, avec le mauvais secret. - Une clé révoquée, expirée, ou dont le compte exploitant est suspendu.
- Une clé valide à qui manque le scope qu’exige cette route.
- Une clé valide qui nomme un
external_idqui 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
| Limite | Valeur |
|---|---|
| Corps de requête | 64 KiB |
| Parkings par requête | 1 |
Page de GET /lots | 1–200, 50 par défaut |
Page de GET /bookings | 1–500, 200 par défaut |
Périodes bloquées par PUT …/blocked-periods | 100 — une liste, un parking |
Les limites de débit sont des seaux à jetons — une capacité de rafale qui se recharge en continu.
| Seau | Compté sur | Rafale | Recharge |
|---|---|---|---|
| Toutes les requêtes | adresse source | 240 | 4 / seconde |
| Authentifications échouées | adresse source | 20 | 1 toutes les 3 secondes |
| Lectures | clé | 120 | 2 / seconde |
| Écritures (rafale) | clé | 60 | 1 / seconde |
| Écritures (horaire) | clé | 1000 | 1000 / 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, etcapacityest 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.changeddu §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_supersededetenvelope_widenedsont 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_iddans 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 dewatermark, pas d’updated_since. Les seules dates que vous pouvez envoyer sontvalid_frometvalid_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"}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_referenceest la mêmereferenceque porte le flux de réservations — donnez-la à votre dédoublonnage, exactement comme vous dédoublonnez les lignes du curseur.occurred_atest 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),
localhostet 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ées | Tentative suivante |
|---|---|
| 1 | après 1 minute |
| 2 | après 5 minutes |
| 3 | après 30 minutes |
| 4 | après 2 heures |
| 5 | aucune — 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);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.
