La tua prenotazione
Italiano
Trova parcheggio

API operatori · v1

Carica i tuoi parcheggi. Scarica le tue prenotazioni.

Il riferimento per un operatore che collega il proprio sistema a Parkena. Tutto quello che segue descrive software già installato e che risponde — comprese le parti che dicono di no.

Leggi questo prima di pianificare un progetto attorno all’API.

Questo è un riferimento completo e accurato. Un titolare o un manager del tuo account operatore emette e revoca le chiavi nella console, in Account → API keys — il secret viene mostrato una volta sola. Quello che non è aperto è il resto: non c’è una sandbox, e colleghiamo un operatore alla volta, quindi scrivici prima di pianificare uno sviluppo. Quello che la v1 si rifiuta di fare è scritto per esteso nel §12, invece di lasciartelo scoprire alla terza settimana.

1. Che cos’è, e che cosa non è

Questo è il riferimento della v1 dell’API operatori di Parkena. È l’alternativa a gestire il tuo inventario nella console Parkena: carichi i tuoi parcheggi e i tuoi prezzi, ti riscarichi le tue prenotazioni Parkena. Le due strade scrivono le stesse tabelle attraverso la stessa sicurezza, e nessuna delle due può raggiungere i dati di un altro operatore. Puoi usarle entrambe — la console per le cose che decide una persona, l’API per la sincronizzazione notturna — e non litigheranno fra loro.

Quello che non è: un prodotto che integri senza nessuno accanto. È un titolare o un manager autenticato a generare la chiave nella console; non c’è una sandbox su cui fare pratica, e la prima integrazione si costruisce con una persona dalla nostra parte. È un’affermazione sulla fase in cui si trova Parkena, non una coda che puoi saltare.

Cinque cose che questa API non fa, dette subito

Le prenotazioni si scaricano, su un cursore, e quel cursore è la fonte di verità. Un endpoint registrato può ricevere un suggerimento firmato booking.changed che dice «scarica adesso» — il §13 — ma in un webhook non viaggiano mai dati di prenotazione, per scelta, e il §12 dice che cosa continua a non esistere.

Non esiste un calendario di disponibilità. I periodi di chiusura — il §7 — ritirano dalla vendita interi intervalli di date, ma non puoi caricare «posti liberi stanotte» come numero, e il campo che sembra fatto apposta — capacity — sono i posti TOTALI. Metterci la disponibilità descrive male il tuo parcheggio e ne invalida la revisione dell’annuncio tutte le notti.

Attraverso questa API non si muove denaro. Nessun addebito, nessun rimborso, nessun dato di versamento, nessuna cifra di commissione.

Niente qui crea, modifica, annulla, registra il check-in o rimborsa una prenotazione. Non c’è uno scope che lo faccia né un permesso dietro di esso.

L’URL di base è https://api.parkena.com/v1 — vedi il §3. Non va puntato nient’altro.

2. Due regole da azzeccare prima di scrivere una riga

Sono le due cose che anche un’integrazione fatta bene continua a sbagliare, perché in tutti e due i casi la cosa sbagliata sembra aver funzionato. È per questo che sono state portate in cima alla pagina; tutto quello che viene dopo è normale materiale di riferimento.

Regola uno: confronta prima di aggiornare

Quando un revisore Parkena approva il tuo annuncio, approva contenuti precisi. Se quei contenuti cambiano, l’approvazione non descrive più ciò che è pubblicato: viene quindi invalidata e il parcheggio torna in revisione — e smette di vendere su parkena.com finché una persona non lo approva di nuovo.

È il comportamento corretto, ma davanti a una macchina che ogni notte ricarica tutto il suo parco è anche un modo per farsi togliere dalla vetrina alle 03:00 tutte le notti, per sempre. Perciò questa API non emette un aggiornamento che non cambia nulla. Tutte e due le rotte di scrittura leggono la riga attuale, la confrontano campo per campo e, se non differisce nulla, non emettono alcuna istruzione — non un UPDATE a vuoto: nessuna istruzione. Ricevi "result": "unchanged" e "changed": [].

Non devi fare niente per ottenerlo. Non è un flag e non c’è un header da inviare. Una ripubblicazione notturna completa di dati identici è un’operazione a vuoto che costa una lettura per parcheggio.

# 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 }
Risposte abbreviate della funzione in produzione. La forma completa è nel §7.

La seconda risposta non è un avviso che puoi ignorare: quel parcheggio ha lasciato la vetrina e ci resta fuori finché non viene approvato di nuovo. Il §8 elenca ogni campo che ti costa una nuova revisione, e ogni campo che non te la costa.

Regola due: GET restituisce un campo che PUT rifiuta

GET /lots restituisce ogni parcheggio CON la sua fascia rates, perché è quella la cosa utile da leggere. PUT /lots/{external_id} NON la accetta — i prezzi si impostano su PUT /lots/{external_id}/rates. Quindi il ciclo ovvio, leggo un lot, cambio un campo e lo rimando indietro, fallisce finché non togli rates. Insieme a quello togli anche external_id: quello vive nel percorso.

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

Rimandalo indietro con rates ancora attaccato e ottieni 422 {"error":"field_belongs_to_another_route","field":"rates"}. Lo rifiutiamo invece di ignorarlo, e la differenza è tutto il punto: se mettessi un prezzo nuovo nel corpo di un lot e ti rispondessimo 200, penseresti ragionevolmente che il prezzo sia cambiato. Non lo sarebbe.

3. L’URL di base

Ogni percorso di questa pagina è relativo a https://api.parkena.com/v1. Tutto è JSON, in entrata e in uscita.

api.parkena.com è un nome host di proprietà di Parkena

Sta davanti alla funzione che risponde; i percorsi qui sotto non cambieranno se cambia quello che c’è dietro. Tieni comunque la base in configurazione e non nel sorgente.

Lo sviluppo locale contro lo stack del repository usa gli stessi percorsi sotto /functions/v1/operator-api/v1 sull’origine Supabase locale — stessi percorsi, base diversa.

Non c’è una sandbox e non c’è una base di prova. Quella qui sopra è quella di produzione, e su quale operatore agisce una richiesta si ricava dalla credenziale che presenti — mai da qualcosa che sta in un URL o in un corpo. Vedi il §4, e il §12 sulla selezione del tenant.

4. Autenticazione

Ogni richiesta porta un header:

Authorization: Bearer <key_id>.<secret>

key_id e secret sono le due metà di un’unica credenziale, unite da un punto. Il primo punto le separa; ogni altro punto appartiene al secret.

curl "$BASE/ping" \
  -H "Authorization: Bearer pk_api_3f9c….pk_sec_a71b…"
  • key_idpk_api_ seguito da 32 caratteri esadecimali. È la metà pubblica. Compare nei nostri log per scelta, e puoi tranquillamente metterlo in un file di configurazione o citarlo in una richiesta di assistenza. Conoscerlo porta qualcuno tanto lontano quanto conoscere un nome utente.
  • secretpk_sec_ seguito da 64 caratteri esadecimali, cioè 256 bit. Non lo conserviamo. Conserviamo un HMAC-SHA256 salato, in due colonne che nessun ruolo del nostro database può leggere. Non possiamo recuperartelo, mai. Se lo perdi, emetti una credenziale nuova e revoca la vecchia.

Non esiste un ripiego ?key= nella query string e non esisterà: un secret in un URL è un secret in un log di proxy, nella cronologia di un browser e in un header Referer.

Ogni fallimento di autenticazione restituisce lo stesso 401

Un key_id sconosciuto, un secret sbagliato, una credenziale revocata, una credenziale scaduta, un account operatore sospeso, una credenziale priva dello scope che una rotta richiede e un external_id che non è uno dei tuoi parcheggi sono tutti 401 {"error":"unauthorized"}. Non sono distinguibili, deliberatamente — il §9 dice che cosa ci si guadagna e che cosa ti costa.

Per verificare una credenziale usa GET /ping, invece di dedurre qualcosa da un 401.

5. Emettere, ruotare e revocare una credenziale

Le credenziali le genera un titolare o un manager autenticato sul tuo account operatore — mai questa API. Una chiave API capace di generare chiavi API è una chiave che può allargarsi i poteri da sola, quindi nessuna rotta qui ne emette una. Gli accessi staff non possono né emetterle né revocarle.

Le due chiamate dietro a tutto questo sono api_credential_issue e api_credential_revoke, fatte via PostgREST con il token di un utente autenticato e non con una credenziale API. Sono documentate qui perché un operatore con un team tecnico vorrà pilotarle direttamente; la schermata della console in Account → API keys è il modo normale di emetterne una.

Emissione

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
      }'
ParametroTipoNote
p_labelstringa, 1–80 caratteriCome chiamerai questa chiave fra sei mesi. Obbligatorio.
p_scopesarray di scopeInsieme non vuoto di scope distinti. Obbligatorio.
p_expires_in_daysintero 1–3650, oppure nullNull significa che non scade. Facoltativo.
[{
  "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 risposta — e l’unica volta in cui il secret viene restituito.

Non esiste p_expires_at e non esisterà mai. La scadenza è un numero di giorni che il server converte sul proprio orologio; questa API non accetta nessun timestamp fornito da chi chiama, da nessuna parte, su nessuna rotta.

Rotazione

Non c’è una chiamata di rotazione, perché ruotare senza un attimo di interruzione sono solo due chiamate nell’ordine giusto:

  1. Emetti una seconda credenziale con gli stessi scope.
  2. Distribuiscila nel tuo sistema e verifica che il traffico stia passando — GET /ping con la chiave nuova, poi guarda last_used_at su di essa.
  3. Revoca la vecchia.

Fra il primo e il terzo passo sono vive tutte e due le credenziali. Non c’è nessun limite che ti impedisca di tenerne due.

Revoca

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 revoca è immediata e definitiva. Una credenziale revocata non si riattiva — si sostituisce. Revocarla due volte restituisce la stessa riga con il revoked_at originale, perché una chiave non muore due volte. Un id che non è dei tuoi restituisce [], esattamente come un id che non è mai esistito.

6. Scope

Una credenziale porta con sé un insieme di scope. Ne esistono tre:

ScopeChe cosa consente
read_supplyElencare i tuoi parcheggi, le loro fasce di prezzo Parkena e i periodi di chiusura di ciascun parcheggio — compreso il conteggio aggregato overlapping_bookings di ognuno. Un conteggio, mai una prenotazione: non lo accompagnano né riferimenti, né nomi, né targhe.
write_supplyCreare e modificare i parcheggi, i loro prezzi Parkena e le loro liste di periodi di chiusura.
read_bookingsScaricare le tue prenotazioni Parkena.

Concedi il minimo che ti serve. Una sincronizzazione che carica soltanto inventario non ha bisogno di read_bookings; un processo di reportistica che si limita a scaricare prenotazioni non deve avere write_supply.

Il controllo degli scope non è un consiglio. È un argomento della stessa chiamata al database che stabilisce quali righe di quale operatore la richiesta può toccare, quindi non c’è una strada verso i tuoi dati che lo scavalchi. Una credenziale priva dello scope che una rotta richiede riceve il 401 uniforme — la stessa risposta che riceve un secret sbagliato.

Deliberatamente non esiste write_bookings. Niente in Parkena permette a una macchina di creare, modificare o annullare una prenotazione, quindi uno scope che desse un nome a quella facoltà ti sembrerebbe un confine e non sarebbe nulla del genere.

7. Rotte

MetodoPercorsoScope richiesto
GET/pingqualsiasi credenziale valida
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} è il TUO identificatore del parcheggio — comunque lo chiami il tuo sistema. Per noi è opaco: non lo interpretiamo mai, e non è obbligatorio che sia un UUID. Codificalo in percent-encoding se contiene una / o uno spazio. Gli id interni di Parkena non ti vengono mai inviati e non vengono mai accettati da te.

I parametri di query sconosciuti vengono rifiutati invece che ignorati, su ogni rotta. Un parametro scritto male e scartato in silenzio è un filtro che credi applicato e non lo è.

GET /ping

Verifica una credenziale e ti dice che cosa può fare. È la rotta da usare quando un’integrazione non funziona — ogni altro rifiuto di questa API è deliberatamente incapace di dirti quale di sei cose sia andata storta. Non stabilisce nessun contesto di operatore e non legge nessuna riga.

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 è il nostro orologio, in UTC. È informativo — a noi un orario non lo rimandi mai.

GET /lots

I tuoi parcheggi, ciascuno con la sua fascia di prezzo Parkena.

Parametro di queryTipoPredefinitoNote
afterstringaRiprende dopo questo external_id. Usa il next_after della pagina precedente.
limitintero 1–20050Chiederne di più viene rifiutato, non ridotto in silenzio.

La paginazione va sul tuo external_id, in ordine crescente — non su un offset, quindi il confine di una pagina non si sposta sotto una scrittura concorrente.

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 c’è solo quando la pagina era piena. Seguilo finché non è null e avrai visto tutto il tuo parco esattamente una volta. rates è null per un parcheggio che non ha ancora un prezzo Parkena.

PUT /lots/{external_id}

Crea o aggiorna UN parcheggio. Una richiesta, un parcheggio — non c’è un endpoint per le operazioni in blocco, ed è la forma della rotta a imporlo. Un corpo che sia un array viene rifiutato con too_many_lots.

PUT è una rappresentazione completa. Una chiave assente significa null, non «lascialo com’era». Invia ogni volta il parcheggio intero. L’alternativa rende impossibile svuotare un campo e trasforma una chiave scritta male in un’operazione a vuoto permanente e silenziosa.

CampoTipoObbligatorioNote
external_idstringaNoSe c’è deve essere uguale a quello nel percorso. Controllato, non usato.
namestringa, ≤200
timezonestringaNome IANA, ad es. Europe/Berlin. Ogni calcolo di confine fra un giorno e l’altro passa di lì.
handoverstringaself oppure attended.
online_bookablebooleanoIl tuo interruttore di vendita. È obbligatorio proprio perché assegnargli un valore di default toglierebbe dalla vendita un parcheggio attivo al primo invio parziale.
capacityintero 0–1000000NoPosti TOTALI. Non i posti liberi stanotte — vedi l’avviso qui sotto.
latitudenumero o stringaNoArrotondata a 6 cifre decimali. Va inviata insieme a longitude.
longitudenumero o stringaNoArrotondata a 6 cifre decimali. Va inviata insieme a latitude.
citystringa, ≤120No
country_codestringaNoISO 3166-1 alpha-2, ad es. DE.
featuresarray di stringheNoVedi l’elenco qui sotto. L’ordine non conta — le ordiniamo noi.
arrivaloggettoNoIndicazioni di arrivo in forma libera.
mediaoggettoNoRiferimenti a contenuti multimediali, in forma libera.
min_advance_daysintero 0–365NoVale 0 se non lo invii.
min_stay_daysintero 1–365No
max_stay_daysintero 1–365NoTutti e tre si fermano a 365, che è la distanza massima a cui Parkena quota un soggiorno. Un valore più grande verrebbe accettato e non rispettato mai.
cancellationoggetto o nullNoTutte e tre le chiavi o nessuna — vedi sotto.

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

cancellation è un solo oggetto annidato, ed è tutto o niente:

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

Inviarne una o due delle tre è 422 incomplete_cancellation_policy. Una politica con una finestra gratuita e nessuna penale dichiarata non è una politica conosciuta a metà, è una domanda sul rimborso a cui non si può rispondere. Invia null oppure ometti la chiave per dire «nessuna politica».

capacity sono i POSTI TOTALI, non la disponibilità

Se il tuo flusso notturno carica «posti liberi stanotte» dentro capacity, dirai a Parkena che il tuo parcheggio si è rimpicciolito, E invaliderai la revisione del tuo annuncio ogni singola notte, perché capacity è contenuto sottoposto a revisione.

La disponibilità in tempo reale è un meccanismo diverso e non fa parte della v1. Questa API non può accorgersi dell’errore, perché un numero è un numero.

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 invio completo.
{
  "external_id": "LOT-1",
  "result": "created",
  "changed": ["name", "timezone", "handover", "…"],
  "listing_review_superseded": false,
  "lot": { "…": "the car park as it now stands" }
}
201 quando il parcheggio è stato creato, 200 in tutti gli altri casi.
CampoSignificato
resultcreated, updated oppure unchanged.
changedI nomi dei campi che differiscono davvero. Vuoto su unchanged.
listing_review_supersededtrue se questa scrittura ha invalidato la revisione del tuo annuncio Parkena — vedi il §8.
lotIl parcheggio memorizzato, riletto.

result: "unchanged" significa che non è stata emessa nessuna istruzione — nessun blocco di riga, nessuna scrittura, nessun trigger. È l’esito normale e atteso di una ripubblicazione notturna, ed è quello che impedisce a questa API di toglierti dalla vetrina tutte le notti.

PUT /lots/{external_id}/rates

Imposta la fascia di prezzo Parkena per un parcheggio. Un piano tariffario non è un prezzo: è l’INTERVALLO dentro cui sei disposto a vendere sul canale Parkena — un minimo, un massimo e una base che sta in mezzo.

Questa rotta scrive il canale parkena e solo quello. Il tuo listino diretto è tuo e questa API non lo può toccare.

Sono accettate due forme di corpo. Un intervallo:

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

Oppure un prezzo fisso, che si espande in minimo = base = massimo:

{ "fixed_price": "12.50" }
CampoTipoObbligatorioNote
fixed_pricedecimaleuna forma o l’altraNon si può combinare con i campi dell’intervallo.
floor_pricedecimalecon l’intervalloDeve essere ≤ base_price.
base_pricedecimalecon l’intervalloDeve stare fra il minimo e il massimo.
ceiling_pricedecimalecon l’intervalloDeve essere ≥ base_price.
min_first_day_pricedecimale o nullNoDeve stare dentro la fascia.
currencystringaNoISO 4217. Se non lo invii vale la tua valuta di regolamento, e non può essere altro.
dynamicbooleanoNoVale false se non lo invii.
valid_fromYYYY-MM-DD o nullNoUna data di calendario. Un timestamp viene rifiutato, non troncato.
valid_toYYYY-MM-DD o nullNoNon deve precedere valid_from.

Il denaro è una stringa, e viene rifiutato invece che arrotondato. Invia "12.50", non 12.345. Gli importi portano due cifre decimali; una terza è 422 invalid_body, perché un prezzo che non hai digitato non è un prezzo su cui sei d’accordo. Le coordinate sono il contrario — sono una misura, quindi si arrotondano.

Inviare insieme fixed_price e un intervallo è 422. Indovinare quale dei due intendevi è il modo in cui un parcheggio finisce con un prezzo all’estremo sbagliato del proprio intervallo.

{
  "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 alla creazione, 200 in tutti gli altri casi.

envelope_widened è true quando questa scrittura ha spostato la fascia VERSO L’ESTERNO — minimo abbassato, massimo alzato, valuta cambiata, oppure dynamic ribaltato — o quando prima non c’era nessuna fascia Parkena. È l’allargamento che può costarti una revisione dell’annuncio.

È un segnale prudente e preferiamo dirlo piuttosto che farlo sembrare più di quello che è: confrontiamo con la fascia che era viva un attimo fa, non con quella che ha approvato un revisore, perché questa API deliberatamente non può leggere lo stato delle tue revisioni. Quindi può riportare true in un caso che non invalida niente. Non riporterà false quando qualcosa è stato invalidato.

GET /lots/{external_id}/blocked-periods

La lista dei periodi di chiusura del parcheggio: ogni intervallo di date in cui è ritirato dalla vendita su Parkena, chiunque lo abbia scritto — le tue sincronizzazioni e la console scrivono la stessa lista. Un periodo di chiusura ferma le NUOVE vendite Parkena per i soggiorni che lo toccano, e non fa nient’altro: non annulla niente, e non tocca le tue vendite dirette. Le due date sono comprese — ends_on è l’ultimo giorno chiuso, non il giorno dopo, e un periodo con starts_on uguale a ends_on chiude esattamente quel singolo giorno.

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 }
  ]
}
I periodi tornano con il più vecchio per primo. reason è la tua etichetta, null quando non ne è stata data una.

overlapping_bookings è un conteggio di trasparenza: quante prenotazioni di questo parcheggio — ogni canale, ogni stato tranne cancelled — hanno un soggiorno che tocca il periodo. Entrambi i confronti sono inclusivi, sui giorni di calendario del parcheggio stesso: una prenotazione che riparte la prima mattina del periodo conta ancora, come una che arriva la sua ultima sera. Nota che cosa comprende «tranne cancelled»: conta anche un soggiorno COMPLETATO. Il conteggio risponde a «che cosa è stato venduto in quelle date», storia compresa — non è il numero di auto in arrivo che una chiusura lascerebbe a piedi, quindi può risultare più alto del pannello di avviso della console, che si pone quella domanda più stretta. È un conteggio e nient’altro: nella risposta non c’è riferimento, nome, targa o data di nessuna prenotazione.

PUT /lots/{external_id}/blocked-periods

Sostituisce l’INTERA lista dei periodi di chiusura del parcheggio con quella nel corpo. Non c’è una chiamata «aggiungi un periodo» né un modo di rivolgersi a un periodo singolo — una sincronizzazione che sa solo unire è una sincronizzazione che non sa mai cancellare, e un periodo tolto nel tuo sistema resterebbe su Parkena per sempre. Al massimo 100 voci; ogni data un vero giorno di calendario fra il 2020-01-01 e il 2032-12-31; ends_on mai prima di starts_on; e due voci non possono sovrapporsi — in modo inclusivo: una coppia che condivide un solo giorno collide.

La lista che mandi è la lista che esiste

PUT è qui una rappresentazione completa, la stessa regola di PUT /lots/{external_id} — e su questa rotta la regola ha una conseguenza che merita le maiuscole: I PERIODI SCRITTI NELLA CONSOLE FANNO PARTE DELLA STESSA LISTA. Se una persona inserisce un periodo di chiusura nella console martedì e la tua sincronizzazione notturna manda solo i propri periodi martedì sera, la sincronizzazione rimuove il periodo di quella persona — in silenzio, correttamente, perché ci hai detto che la lista mandata era la lista intera.

Una macchina che possiede questa rotta possiede l’intero calendario. O rileggi questa rotta e porti nel tuo sistema i periodi scritti nella console, oppure concordi con la tua stessa squadra quale sistema possiede i periodi di chiusura. L’API non farà da arbitro.

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" }
        ]
      }'
Una sostituzione completa. Il secondo periodo non porta un reason, ed è permesso.
CampoTipoObbligatorioNote
starts_onYYYY-MM-DDIl primo giorno chiuso, compreso. Una data di calendario — un timestamp viene rifiutato, non troncato.
ends_onYYYY-MM-DDL’ULTIMO giorno chiuso, compreso — non il giorno dopo. Non deve precedere starts_on.
reasonstringa ≤200, o nullnoUn’etichetta, che la console mostra. Vuota viene rifiutata; null e assente significano entrambi «senza motivo». Conservata alla lettera, mai rifilata — uno spazio cambiato è un periodo cambiato.

Ogni rifiuto di contenuto su questa rotta porta lo stesso codice, 422 {"error":"invalid_blocked_periods"}, con field che nomina la voce colpevole nelle coordinate del tuo stesso corpo — blocked_periods[3].ends_on. I due errori di busta tengono i codici che il resto dell’API dà loro: una chiave di primo livello di troppo è unknown_field, una chiave blocked_periods assente è missing_field.

Che cosa non andava`field`
Più di 100 voci, oppure blocked_periods non è un arrayblocked_periods
Non un vero giorno di calendario (2026-02-30), un timestamp, o fuori da 2020..2032lo starts_on / ends_on della voce
ends_on prima di starts_onlo ends_on della voce
Due voci si sovrappongono (in modo inclusivo — basta condividere un giorno)lo starts_on della voce che comincia più tardi
reason vuoto, non testuale, o più lungo di 200 caratteriil reason della voce

I periodi che si sovrappongono a prenotazioni vendute NON vengono rifiutati. Una sincronizzazione automatica non deve incepparsi su un’auto venduta la settimana scorsa; le prenotazioni restano — un periodo di chiusura ferma solo le vendite NUOVE. In cambio la risposta porta il conteggio overlapping_bookings di ogni periodo (semantica sopra, soggiorni completati compresi), così che il tuo sistema, e l’umano dietro di esso, vedano esattamente quali chiusure hanno già auto vendute dentro.

{
  "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 quando il parcheggio prima non aveva nessun periodo, 200 negli altri casi.
CampoSignificato
resultcreated (prima zero periodi, adesso qualcuno), updated (tutto il resto che ha scritto — compreso un PUT di [] che ha svuotato la lista), o unchanged.
added / removedI conteggi di righe che la scrittura ha davvero mosso. Entrambi 0 su unchanged.
blocked_periodsLa lista conservata, riletta DOPO la scrittura con conteggi overlapping_bookings freschi — quello che il database contiene adesso, mai un’eco del tuo corpo.

La regola «confronta prima di aggiornare» del §2 vale qui in forma di lista: result: "unchanged" significa che la lista conservata e il tuo corpo dicevano già la stessa cosa, e che non è stata emessa nessuna istruzione. Rimandare ogni notte una lista di periodi immutata costa una lettura.

GET /bookings

Le tue prenotazioni Parkena, come CURSORE IN PULL. Quel cursore è la fonte di verità: un endpoint webhook registrato può ricevere un suggerimento firmato booking.changed che ti dice di scaricarlo prima — il §13 — ma un suggerimento non porta dati di prenotazione, e niente di ciò che costruisci su questo cursore va mai sprecato.

Parametro di queryTipoPredefinitoNote
sincestringaIl token next della tua chiamata precedente. Omettilo per dire «dall’inizio».
limitintero 1–500200Chiederne di più viene rifiutato, non ridotto in silenzio.
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
}
CampoNote
referenceIl codice di prenotazione di PARKENA. È la chiave per deduplicare.
external_referenceLa tua stringa, se ne è stata impostata una. Te la restituiamo, mai usata come chiave.
check_in_local / check_out_localL’ora dell’orologio a muro del parcheggio, SENZA offset di fuso. Leggili in timezone.
totalUna stringa decimale, non un numero in virgola mobile.
statuspending, confirmed, checked_in, completed, cancelled.
payment_statuspending, paid, partial, refunded, expired, not_required.

Come fare il polling come si deve

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)

Tre regole, e ognuna delle tre regge il peso:

  1. Deduplica su reference. La consegna è at-least-once, mai exactly-once. Vedrai la stessa prenotazione più di una volta ed è il comportamento corretto, non un guasto.
  2. Salva next DOPO aver memorizzato in modo durevole il batch, non prima. Se il tuo processo muore a metà batch, il cursore non salvato lo riconsegna — cosa che la regola 1 rende innocua.
  3. Non costruirti un cursore. È un token che abbiamo emesso noi. Non esiste ?since=<un istante che scelgo io>, e quell’assenza è deliberata: un partner che indicasse un istante nel futuro smetterebbe in silenzio di ricevere le proprie prenotazioni, e il primo sintomo sarebbe un’auto a una sbarra di cui il suo sistema non ha mai sentito parlare. Un cursore oltre la nostra soglia viene riportato indietro a essa, quindi il peggio che può fare un token manomesso è consegnare due volte le stesse righe.

Perché puoi rivedere subito una prenotazione: il cursore che ti restituiamo non punta mai oltre now() − 5 minutes. Le prenotazioni più recenti di così te le diamo lo stesso — la tua prenotazione la vuoi adesso, non fra cinque minuti — semplicemente non ci portiamo sopra il cursore. Non è prudenza per il gusto di esserlo. Una transazione di database è marcata con l’ora in cui è COMINCIATA, quindi una transazione lenta può confermare una riga marcata prima di una che ti è già stata consegnata; senza il ritardo, un cursore che saltasse dritto alla riga più recente la scavalcherebbe per sempre e in silenzio.

8. Che cosa ti costa una nuova revisione

Il §2 ha la regola; questo è l’elenco dei campi che ci sta dietro. Cambiare uno qualsiasi dei seguenti su PUT /lots/{external_id} invalida un annuncio in attesa o approvato, e la risposta lo dice con "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

Che cosa invece non te ne costa una

  • online_bookable. È il tuo interruttore di vendita, non contenuto sottoposto a revisione. Spegnerlo toglie subito il parcheggio dalla vendita senza invalidare niente — che è esattamente il motivo per cui è un campo obbligatorio su ogni PUT.
  • Tutto quello che sulla rotta delle tariffe non allarga la fascia. Alzare il tuo minimo o abbassare il tuo massimo è restare dentro una promessa che hai già fatto. Abbassare il minimo, alzare il massimo, cambiare valuta o ribaltare dynamic è farne una nuova, e può invalidare la revisione — envelope_widened ti dice quando.
  • valid_from / valid_to. Scadenza e rinnovo si leggono dal vivo, quindi un cambio di finestra toglie il parcheggio dalla vetrina e ce lo rimette senza bisogno di una persona.

Due trappole che vale la pena conoscere

  • L’ordine delle dotazioni non conta per te, ma una volta contava per noi. Ordiniamo features prima di memorizzarle, quindi ["valet","indoor"] e ["indoor","valet"] sono lo stesso invio. Non devi ordinarle tu.
  • La precisione delle coordinate non conta. Arrotondiamo a sei cifre decimali prima di confrontare, quindi inviare 52.5200001 tutte le notti non segnala latitude come cambiata per sempre.

Non c’è nessuna cancellazione

Questa API non può cancellare un parcheggio o un prezzo, e non c’è nessun permesso che glielo lascerebbe fare. delete seguito da insert è il modo di fare upsert più diffuso nel codice di importazione, e su questi dati è catastrofico: il parcheggio prenderebbe un’identità nuova e si porterebbe dietro la storia del suo annuncio, i suoi prezzi e la sua disponibilità — da un processo che nessuno stava guardando. Togliere un parcheggio è una cosa che fa una persona nella console, deliberatamente, dove la conferma elenca tutto quello che se ne va insieme. Un parcheggio che abbia mai ricevuto una prenotazione non si può togliere affatto, da nessuno: una prenotazione è un documento contabile, si conserva, e con essa si conserva il parcheggio per cui è stata fatta. Per smettere di venderne uno, imposta "online_bookable": false.

9. Errori

Ogni errore è JSON con un codice error stabile e leggibile da una macchina:

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

field, quando c’è, è il TUO nome di campo, restituito tale e quale. Non restituiamo mai un messaggio del database, il nome di un vincolo o il nome di una tabella — quelli sono nostri e possiamo rinominarli, e la tua integrazione non deve rompersi quando succede. Ramifica su error.

Credenziale

StatoCodiceSignificato
401unauthorizedVedi sotto — copre sei situazioni diverse e si rifiuta di dire quale.

Forma della richiesta

StatoCodiceSignificato
404not_foundQuella ROTTA non esiste. Non lo usiamo mai per una risorsa.
405method_not_allowedPercorso giusto, verbo sbagliato. L’header Allow nomina il verbo che volevamo.
413payload_too_largeCorpo oltre i 64 KiB.
415unsupported_media_typeContent-Type non era application/json.
422invalid_jsonIl corpo non era JSON interpretabile.
422invalid_bodyJSON ben formato, forma sbagliata o valore inutilizzabile.
422missing_fieldMancava un campo obbligatorio.
422unknown_fieldUn campo che non riconosciamo. Rifiutato, non ignorato.
422field_belongs_to_another_routeUn campo che esiste davvero, ma che si imposta da un’altra parte. Oggi l’unico è rates — vedi il §2.
422invalid_queryUn parametro di query sbagliato o sconosciuto.
422invalid_cursorIl token since non era uno dei nostri.
422too_many_lotsUn corpo che è un array. Una richiesta, un parcheggio.

Contenuto

StatoCodiceSignificato
422invalid_timezoneNon è un nome di fuso orario IANA.
422invalid_coordinatesFuori intervallo, oppure una latitudine senza longitudine.
422invalid_country_codeNon è ISO 3166-1 alpha-2.
422invalid_stay_boundsmin_stay_days / max_stay_days non vanno d’accordo.
422incomplete_cancellation_policyUna o due delle tre chiavi di cancellazione.
422invalid_price_envelopeMinimo, base e massimo non sono in ordine.
422invalid_first_day_pricemin_first_day_price fuori dalla fascia.
422invalid_validity_windowvalid_to precede valid_from.
422currency_is_not_settlement_currencyNon è la tua valuta di regolamento.
422invalid_blocked_periodsUna voce dei periodi di chiusura è inutilizzabile; field la nomina nelle coordinate del tuo stesso corpo (blocked_periods[3].ends_on). La tabella dei rifiuti è nel §7.
409conflictUna vera race condition: due invii hanno creato lo stesso external_id nello stesso momento. Riprova.

Nostri

StatoCodiceSignificato
429rate_limitedOltre un limite. Rispetta Retry-After.
503try_againUn conflitto transitorio del database. Riprova; Retry-After è impostato.
500internal_errorColpa nostra. È nei nostri log per intero. Riprovare è ragionevole.

Perché il 401 non ti dirà di più

unauthorized copre tutti questi casi e si rifiuta di distinguerli:

  1. Nessun header Authorization, o uno che non riusciamo a interpretare.
  2. Un key_id che non nomina nessuna credenziale.
  3. Un key_id che esiste, con il secret sbagliato.
  4. Una credenziale revocata, scaduta, o il cui account operatore è sospeso.
  5. Una credenziale valida priva dello scope che questa rotta richiede.
  6. Una credenziale valida che nomina un external_id che non è uno dei suoi parcheggi.

I casi dal 2 al 5 non sono distinguibili nemmeno dentro il nostro processo — il database risponde a tutti in modo identico, con lo stesso lavoro svolto, così che nessuno possa enumerare l’elenco degli operatori né mappare quali facoltà di una chiave rubata funzionino ancora.

Il caso 6 ti costa un po’ di comodità e ti diciamo perché, invece di limitarci ad affermarlo: una credenziale che ha solo write_supply non può elencare i parcheggi. Se un external_id sconosciuto rispondesse lot_not_found, quella credenziale avrebbe un oracolo di enumerazione funzionante esattamente sul parco che i suoi scope le negano — costruito con un rifiuto. Quindi riceve lo stesso 401.

GET /ping è la risposta a tutto questo. Ti dice che la tua chiave è viva e che cosa può fare, senza che tu debba indovinare da un 401. Se il ping riesce e una rotta risponde 401, hai davanti uno scope che non possiedi o un external_id che non è tuo — controllali entrambi con GET /lots.

10. Limiti di frequenza, limiti di dimensione e che cosa registriamo

LimiteValore
Corpo della richiesta64 KiB
Parcheggi per richiesta1
Pagina di GET /lotsda 1 a 200, di default 50
Pagina di GET /bookingsda 1 a 500, di default 200
Periodi di chiusura per PUT …/blocked-periods100 — una lista, un parcheggio

I limiti di frequenza sono token bucket: una capacità di burst che si ricarica di continuo.

BucketConteggiato suBurstRicarica
Tutte le richiesteindirizzo di provenienza2404 / secondo
Autenticazioni falliteindirizzo di provenienza201 ogni 3 secondi
Letturecredenziale1202 / secondo
Scritture (burst)credenziale601 / secondo
Scritture (a ore)credenziale10001000 / ora

Un rifiuto è un 429 con un header Retry-After in secondi interi. Essere rifiutati non consuma un token, quindi riprovare non allontana ulteriormente il momento in cui torni a passare.

Le scritture sono limitate due volte, e la finestra oraria è quella che conta. Una chiave di scrittura trapelata che azzera un intero parco somiglia in tutto e per tutto a una legittima sincronizzazione notturna — stessa credenziale, stessa rotta, stessa forma, stessa ora della notte — quindi un limite di burst da solo non può distinguerle, perché anche una sincronizzazione vera è un burst. Il tetto orario limita quanta parte di un parco può riscrivere una sola chiave rubata prima che una persona possa plausibilmente accorgersene. Un operatore con 1000 parcheggi che ne carica ciascuno una volta a notte ci sta comodo; se il tuo parco è più grande, chiedicelo e lo alzeremo, invece di lasciarti aggirare il problema.

Le rotte dei periodi di chiusura spendono gli stessi secchielli di tutto il resto: il GET costa un gettone di lettura, e il PUT è una scrittura, conteggiata su entrambi i secchielli di scrittura — una sincronizzazione notturna dei periodi conta sullo stesso tetto di 1000 all’ora dei tuoi invii di parcheggi, per scelta, perché una chiave trafugata che ritira un parco dalla vendita è esattamente la forma che quel tetto esiste per delimitare.

Le autenticazioni fallite sono conteggiate sull’INDIRIZZO DI PROVENIENZA, mai sul key_id presentato. Conteggiarle sulla chiave sarebbe un denial of service puntato contro di te: key_id è la metà non segreta e compare per scelta nei log e nei file di configurazione, quindi chiunque ne leggesse uno potrebbe chiuderti fuori dalla tua stessa integrazione con qualche decina di secret sbagliati.

Nota che anche un external_id sconosciuto consuma il budget dei fallimenti, perché restituisce lo stesso 401 di tutto il resto. Un limitatore che trattasse i due casi in modo diverso sarebbe un oracolo di esistenza costruito con un 429. Se ti porti dietro una mappatura obsoleta, riconciliala con GET /lots invece di andare a tentoni.

Che cosa registriamo

Ogni richiesta produce una riga di log dalla nostra parte; ogni scrittura che ha cambiato qualcosa ne produce una seconda, che porta i NOMI DEI CAMPI cambiati e se il cambiamento ti è costato una revisione. Nomi di campo, mai valori — il log risponde alla domanda «che cosa è successo a questo parco stanotte», e i tuoi prezzi lì dentro non ci sono. Il tuo key_id sì, ed è così che puoi chiederci quale delle tue integrazioni ha fatto qualcosa. Il tuo secret non c’è mai, in nessuna forma.

11. Prima che il tuo parcheggio possa vendere

Caricare un parcheggio con questa API lo crea, ma un parcheggio nuovo non comincia a vendere su parkena.com nel momento in cui l’API restituisce 201. Una parte di quello che serve è contenuto che questa API può fornire, e un’altra parte è una decisione che una persona deve confermare nella console.

Un PUT /lots/{external_id} completo più un PUT …/rates soddisfa i requisiti di capienza, prezzo, geografia, città/paese e politica di cancellazione. Restano in sospeso questi punti, ed è possibile chiuderli solo nella console:

  • Confermare il fuso orario e le regole di prenotazione — sono ricavati e impostati per default, e una risposta sbagliata ma plausibile sbaglia i prezzi delle prenotazioni in silenzio, quindi una persona li conferma una volta.
  • Le indicazioni di arrivo e un’immagine di copertina, per l’annuncio pubblico.
  • Accettare il contratto di pubblicazione Parkena, una volta per tutto l’account.
  • Presentare il parcheggio per la revisione.

Quest’ultimo punto è voluto: presentare un parcheggio a un revisore umano è una dichiarazione che fai sulla tua attività, e una macchina che ha in mano una chiave non deve poterla fare al posto tuo.

La console mostra ogni requisito, se è soddisfatto e che cosa protegge. Non è una limitazione che intendiamo togliere nella v1.

12. Che cosa la v1 non fa

Detto chiaramente, perché un’integrazione costruita su un presupposto che non abbiamo mai dato è peggio di una costruita su una lacuna documentata.

  • Nessun numero di disponibilità in tempo reale. Adesso puoi ritirare dalla vendita interi intervalli di date con PUT /lots/{external_id}/blocked-periods — il §7 — ma continua a non esserci modo di caricare «posti liberi stanotte» come numero, e capacity sono i posti totali — metterci la disponibilità in tempo reale descriverà male il tuo parcheggio e lo rimanderà in revisione tutte le notti. Un calendario di disponibilità a conteggi non è nella v1.
  • Nessun contenuto nei webhook. Un endpoint registrato riceve il SUGGERIMENTO firmato booking.changed del §13 — un codice di prenotazione e nient’altro — e il cursore in pull resta la fonte di verità, esattamente come questa lista prometteva prima che il suggerimento esistesse. Che cosa continua a non esserci: contenuti per evento (in un webhook non viaggiano mai dati di prenotazione), garanzie di ordine (i suggerimenti si fondono e si ritentano; la sequenza è affare del cursore), o un’API di ritrasmissione (niente da ritrasmettere — riscarica il cursore). Un’integrazione deve funzionare a suggerimenti spenti, perché un suggerimento che fallisce cinque consegne muore in silenzio, di proposito.
  • Nessuna scrittura di prenotazioni. Attraverso questa API non puoi creare, modificare, annullare, registrare il check-in o rimborsare una prenotazione. Non c’è uno scope che lo faccia né un permesso dietro di esso.
  • Nessun pagamento. Nessun addebito, nessun rimborso, nessun dato di versamento, nessuna cifra di commissione. Il flusso delle prenotazioni porta il totale della vendita e la valuta, e niente su come si è mosso il denaro.
  • Nessuna distribuzione verso OTA o channel manager. Questa API scrive solo il canale parkena. Non è un channel manager e non invia niente a nessun altro.
  • Nessuna cancellazione. Vedi il §8.
  • Nessun endpoint per le operazioni in blocco. Una richiesta, un parcheggio.
  • Nessuna presentazione dell’annuncio né stato della revisione. Dall’API non puoi presentare un annuncio per la revisione né leggere lo stato della tua revisione. listing_review_superseded ed envelope_widened sono gli unici segnali vicini alla revisione, e il secondo è deliberatamente prudente.
  • Nessuna selezione del tenant. Non c’è nessun tenant_id in nessun corpo né in nessuna query string che questa API interpreti. Su quale operatore agisce una richiesta si ricava dalla credenziale e da nient’altro — un id di operatore fornito nella richiesta sarebbe una chiave di scrittura fra un tenant e l’altro.
  • Nessun timestamp fornito da chi chiama, da nessuna parte. Nessun as_of, nessun watermark, nessun updated_since. Le uniche date che puoi inviare sono valid_from e valid_to, che sono giorni di calendario che dichiari a proposito del tuo prezzo. Tutto il resto è il nostro orologio.

13. Webhook: il suggerimento `booking.changed`

Puoi registrare un endpoint HTTPS per ogni account operatore, e gli invieremo in POST un suggerimento firmato ogni volta che una tua prenotazione viene creata o cambia — ogni canale, ogni campo. Leggi l’avvertimento qui sotto prima di progettare qualsiasi cosa attorno.

Un suggerimento non è dati. Il cursore è i dati.

L’intero corpo di un suggerimento è un nome di evento, un codice di prenotazione e un timestamp. Niente stato, niente date, niente viaggiatore, niente importi — nulla su cui il tuo sistema possa agire direttamente, e nulla che invecchi per strada. L’unica risposta corretta a un suggerimento è la cosa che la tua integrazione fa già: scaricare GET /bookings con il cursore salvato.

Un partner il cui endpoint resta giù per un giorno perde latenza, mai dati — il cursore riconsegna tutto alla prossima interrogazione. Se la tua integrazione non può sopravvivere a suggerimenti spenti, è costruita male.

Questa divisione dei compiti è la ragione per cui il §12 non dice più «nessun webhook»: quello che ci rifiutavamo di rilasciare era un webhook che PORTASSE la prenotazione, perché un webhook che fallisce in silenzio è una prenotazione di cui non vieni mai a sapere mentre l’auto arriva lo stesso alla tua sbarra. Un suggerimento può fallire in silenzio e non costarti niente.

La consegna

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 solo evento nella v1 — booking.changed, un solo nome per creazione, modifica e annullamento insieme, perché il suggerimento non dice che cosa è successo; lo dice il cursore.
  • booking_reference è la stessa reference che porta il flusso delle prenotazioni — dalla in pasto alla tua deduplicazione, esattamente come deduplichi le righe del cursore.
  • occurred_at è il momento in cui il cambiamento è stato registrato da noi, non quello in cui questo tentativo è stato inviato. I ritentativi lo rimandano alla lettera.

I cambiamenti successivi della stessa prenotazione SI FONDONO finché un suggerimento che la riguarda è ancora da consegnare: cinque modifiche in un minuto producono un solo avviso, ed è corretto proprio perché il suggerimento non porta stato — per quante scritture rappresenti, la tua prossima lettura del cursore vede la riga finale. La consegna è almeno una volta, come tutto il resto su questa API: puoi ricevere due suggerimenti per uno stesso cambiamento, e deduplicare su booking_reference lo rende gratuito.

Registrare un endpoint, e le regole che deve rispettare

Gli endpoint si registrano nella console, sulla stessa pagina Account → API keys dove si emettono le chiavi, da un proprietario o un manager — non attraverso questa API, per la stessa ragione delle chiavi. Il secret di firma (whsec_ seguito da 64 caratteri esadecimali) viene generato nel tuo browser e mostrato UNA VOLTA: lo conserviamo per firmare, ma nessuna schermata della console e nessuna query può mai rileggerlo. Nella v1 può essere attivo un endpoint per account. Un endpoint non si modifica mai — un URL nuovo o un secret ruotato è un endpoint nuovo (disattiva prima il vecchio); la disattivazione è l’unico interruttore che la console offre dopo la nascita.

  • Solo HTTPS, solo porta 443. http://, e qualsiasi porta esplicita diversa da 443, non viene mai tentato.
  • Un nome host, non un indirizzo. I letterali IP (v4 o v6), localhost e tutto ciò che vive sui domini della nostra stessa piattaforma vengono rifiutati.
  • I redirect non vengono mai seguiti. Un redirect è un secondo URL che nessuno ha esaminato; il tentativo fallisce invece.
  • Cinque secondi di limite, e oltre il codice di stato la tua risposta non viene mai letta. Rispondi in fretta e lavora dopo — la forma giusta è «accoda e restituisci 204».

Il contratto del 2xx, i ritentativi e la morte

Qualsiasi 2xx entro il limite di tempo significa consegnato. Tutto il resto — un 4xx, un 5xx, un tempo scaduto, una connessione rifiutata — viene ritentato con un backoff fisso, e dopo il quinto tentativo fallito il suggerimento è morto: l’ultimo stato HTTP e la ragione del fallimento vengono registrati e sono visibili nella console, e nessun altro tentativo ha luogo. La prenotazione, come sempre, aspetta sul cursore. Disattivare un endpoint uccide i suoi suggerimenti in sospeso alla passata successiva, invece di consegnarli più tardi a un endpoint che hai spento.

Tentativi falliti finoraProssimo tentativo
1dopo 1 minuto
2dopo 5 minuti
3dopo 30 minuti
4dopo 2 ore
5nessuno — il suggerimento è morto, con ultimo stato e ragione registrati

Verificare la firma

Ogni consegna porta un header Parkena-Signature: t=<unix-seconds>,v1=<hex HMAC-SHA-256>. Il payload firmato è la stringa letterale t + . + il corpo GREZZO della richiesta — firma i byte che hai ricevuto, mai una ri-serializzazione del JSON interpretato. È di proposito lo schema esatto che Stripe usa per i suoi webhook, prefisso del secret whsec_ compreso: qualsiasi verificatore di webhook Stripe che già mandi in produzione — o quello pubblicato nel repository stesso di Parkena — verifica queste consegne senza modifiche.

# 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);
Il tutto, in uno schizzo.

Tre dettagli che un’implementazione frettolosa sbaglia: confronta con un’uguaglianza a TEMPO COSTANTE, non ===, così il tempo di risposta non tradisce quanta parte di una firma contraffatta era giusta; rifiuta un t più vecchio di qualche minuto — consigliamo 300 secondi — che è ciò che rende inutile riprodurre più tardi una consegna catturata; e se interpreti l’header come si deve invece di spezzarlo ingenuamente, verifica OGNI coppia v1= presente e accetta se una combacia — è ciò che impedisce a una futura rotazione del secret di firma di romperti a metà finestra.

Un suggerimento che non supera la tua verifica non è una consegna Parkena. Rispondigli 401 e non fare nient’altro — in particolare, non scaricare il cursore al ritmo che ti detta. Scaricare il cursore è sempre SICURO; rifiutare serve a non lasciare che un chiamante non autenticato governi la cadenza del tuo sistema.

14. Ottenere l’accesso, e ottenere aiuto

Le chiavi si emettono nella console, da un titolare o da un manager, in Account → API keys. Non c’è una sandbox, e l’accesso al progetto pilota si concorda con noi un operatore alla volta — se quello che hai letto qui si adatta al sistema che già fai girare, è questa la cosa da dirci quando ci scrivi.

Scrivi a [email protected]. Per assistenza su un’integrazione già in funzione: cita il tuo key_id — mai il tuo secret — e l’external_id e l’orario della richiesta di cui ci stai chiedendo. Compaiono tutti e due nei nostri log, e insieme identificano una singola richiesta.

Chiedici del progetto pilota.

Raccontaci qual è il tuo sistema e che cosa vuoi che carichi. Ti diremo onestamente se la v1 lo copre — il §12 è l’elenco completo di quello che non sa fare, scritto per esteso proprio perché tu possa decidere di lasciar perdere prima di costruire qualsiasi cosa.