A sua reserva
Português
Encontrar estacionamento

API para operadores · v1

Envie os seus estacionamentos. Traga as suas reservas.

A referência para um operador que liga o seu próprio sistema à Parkena. Tudo o que se segue descreve software que está em produção e a responder — incluindo as partes que dizem que não.

Leia isto antes de planear um projeto à volta dela.

Esta é uma referência completa e exata. Um proprietário ou um gestor da sua conta de operador emite e revoga chaves na consola, em Account → API keys — o segredo é mostrado uma única vez. O que não está aberto é o resto: não há sandbox, e ligamos um operador de cada vez, por isso escreva-nos antes de planear uma construção à volta disto. O que a v1 se recusa a fazer está escrito por extenso no §12, em vez de ficar para descobrir na terceira semana.

1. O que isto é, e o que não é

Esta é a referência da v1 da API para operadores da Parkena. É a alternativa a gerir o seu inventário na consola da Parkena: envie para cá os seus estacionamentos e os seus preços, e traga de volta as suas reservas da Parkena. As duas vias escrevem nas mesmas tabelas através da mesma segurança, e nenhuma delas consegue chegar aos dados de outro operador. Pode usar as duas — a consola para aquilo que uma pessoa decide, a API para a sincronização noturna — e não vão entrar em conflito uma com a outra.

O que ela não é é um produto com que se integre sem acompanhamento. É um proprietário ou um gestor com sessão iniciada que emite a chave na consola; não há sandbox onde praticar, e a primeira integração é construída com uma pessoa do nosso lado. Isso diz respeito à fase em que a Parkena está, e não a uma fila que possa saltar.

Cinco coisas que esta API não faz, ditas já

As reservas são trazidas por si, através de um cursor, e esse cursor é a fonte de verdade. Um endpoint registado pode receber uma pista assinada booking.changed que diz «traga agora» — o §13 — mas num webhook nunca viajam dados de reserva, de propósito, e o §12 diz o que continua a não existir.

Não há calendário de disponibilidade. Os períodos bloqueados — o §7 — retiram de venda intervalos de datas inteiros, mas não pode enviar “lugares livres esta noite” como número, e o campo que se parece com isso — capacity — são os lugares TOTAIS. Pôr lá a disponibilidade dá uma imagem errada do seu estacionamento e anula a análise do anúncio dele todas as noites.

Não passa dinheiro nenhum por esta API. Sem cobranças, sem reembolsos, sem dados de pagamentos, sem valores de comissão.

Nada aqui cria, altera, cancela, dá entrada nem reembolsa uma reserva. Não há âmbito para isso nem permissão por trás disso.

O URL base é https://api.parkena.com/v1 — ver o §3. Não deve apontar para mais nada.

2. Duas regras para acertar antes de escrever uma linha

São estas as duas coisas em que uma integração competente ainda se engana, porque nos dois casos a coisa errada parece ter funcionado. É por essa razão que estão no topo desta página; tudo o que vem depois delas é material de referência normal.

Regra um: compare antes de atualizar

Quando um analista da Parkena aprova o seu anúncio, aprova conteúdo específico. Se esse conteúdo mudar, a aprovação deixa de descrever o que está publicado, por isso é anulada e o estacionamento volta para análise — e deixa de vender no parkena.com até que uma pessoa o aprove outra vez.

Esse comportamento é o correto e, à frente de uma máquina que reenvia todo o seu portefólio todas as noites, é também uma forma de ser retirado do site às 03:00 todas as noites para sempre. Por isso esta API não emite uma atualização que não muda nada. As duas rotas de escrita leem a linha atual, comparam-na campo a campo e, se nada diferir, não emitem instrução nenhuma — não é um UPDATE sem efeito; é instrução nenhuma. Recebe "result": "unchanged" e "changed": [].

Não tem de fazer nada para ter isto. Não é uma flag e não há cabeçalho para enviar. Um reenvio noturno completo de dados idênticos é uma operação sem efeito que lhe custa uma leitura por estacionamento.

# 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 }
Respostas abreviadas da função que está em produção. A forma completa está no §7.

A segunda resposta não é um aviso que possa ignorar: aquele estacionamento saiu do parkena.com e fica de fora até ser aprovado outra vez. O §8 enumera todos os campos que lhe custam uma nova análise, e todos os que não custam.

Regra dois: o GET devolve um campo que o PUT recusa

O GET /lots devolve cada estacionamento COM o seu envelope rates, porque é isso que é útil ler. O PUT /lots/{external_id} NÃO aceita nenhum — os preços definem-se em PUT /lots/{external_id}/rates. Por isso o ciclo óbvio, ler um lot, mudar um campo e voltar a enviá-lo, falha até tirar rates. Tire external_id com ele: esse vive no caminho.

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

Envie-o de volta com rates ainda agarrado e recebe 422 {"error":"field_belongs_to_another_route","field":"rates"}. Recusamos em vez de ignorar, e a distinção é o ponto todo: se pusesse um preço novo no corpo de um lot e nós respondêssemos 200, você acreditaria, e com razão, que o preço tinha mudado. Não teria.

3. O URL base

Todos os caminhos desta página são relativos a https://api.parkena.com/v1. Tudo é JSON, à entrada e à saída.

api.parkena.com é um nome de anfitrião que pertence à Parkena

Está à frente da função que responde; os caminhos abaixo não mudam se o que está por trás dele mudar. Ainda assim, mantenha a base na configuração e não no código.

O desenvolvimento local contra a stack do próprio repositório usa os mesmos caminhos sob /functions/v1/operator-api/v1 na origem local do Supabase — os mesmos caminhos, uma base diferente.

Não há sandbox nem base de testes. A base acima é a de produção, e o operador sobre o qual um pedido atua é resolvido a partir da credencial que apresenta — nunca a partir de seja o que for num URL ou num corpo. Ver o §4, e o §12 sobre a seleção de tenant.

4. Autenticação

Todos os pedidos levam um cabeçalho:

Authorization: Bearer <key_id>.<secret>

key_id e secret são as duas metades de uma só credencial, unidas por um ponto. O primeiro ponto separa-as; quaisquer outros pontos pertencem ao segredo.

curl "$BASE/ping" \
  -H "Authorization: Bearer pk_api_3f9c….pk_sec_a71b…"
  • key_idpk_api_ seguido de 32 caracteres hexadecimais. É a metade pública. Aparece nos nossos registos por desenho, e pode pô-la num ficheiro de configuração ou citá-la num pedido de apoio sem risco nenhum. Sabê-la leva alguém tão longe quanto saber um nome de utilizador.
  • secretpk_sec_ seguido de 64 caracteres hexadecimais, o que são 256 bits. Não o guardamos. Guardamos um HMAC-SHA256 dele com sal, em duas colunas que nenhum papel da nossa base de dados consegue ler. Não o conseguimos recuperar para si, nunca. Se o perder, emita uma credencial nova e revogue a antiga.

Não há alternativa por query string ?key= e não vai passar a haver: um segredo dentro de um URL é um segredo no registo de um proxy, no histórico de um browser e num cabeçalho Referer.

Todas as falhas de autenticação devolvem o mesmo 401

Um key_id desconhecido, um segredo errado, uma credencial revogada, uma credencial expirada, uma conta de operador suspensa, uma credencial sem o âmbito que a rota exige e um external_id que não pertence aos seus estacionamentos são todos 401 {"error":"unauthorized"}. Não são distinguíveis, de propósito — o §9 diz o que isso compra e o que lhe custa.

Use o GET /ping para verificar uma credencial, em vez de inferir seja o que for a partir de um 401.

5. Emitir, rodar e revogar uma credencial

As credenciais são emitidas por um proprietário ou um gestor com sessão iniciada na sua conta de operador — nunca através desta API. Uma chave de API capaz de criar chaves de API é uma chave que se promove a si própria, por isso nenhuma rota daqui emite uma. As contas de pessoal não podem emitir nem revogar.

As duas chamadas por trás disto são api_credential_issue e api_credential_revoke, feitas sobre PostgREST com o token de um utilizador com sessão iniciada e não com uma credencial de API. Estão documentadas aqui porque um operador com equipa de engenharia vai querer usá-las diretamente; o ecrã da consola em Account → API keys é a forma normal de emitir uma.

Emitir

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
      }'
ParâmetroTipoNotas
p_labelstring, 1–80 caracteresComo vai chamar a esta chave daqui a seis meses. Obrigatório.
p_scopesarray de scopeConjunto não vazio de âmbitos distintos. Obrigatório.
p_expires_in_daysinteger de 1 a 3650, ou nullNull significa que não expira. Opcional.
[{
  "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"
}]
A resposta — e a única vez em que o segredo alguma vez é devolvido.

Não existe p_expires_at e nunca vai existir. A expiração é um número de dias que o servidor converte contra o seu próprio relógio; esta API não aceita nenhum timestamp fornecido por quem chama, em lado nenhum, em nenhuma rota.

Rodar

Não há chamada de rotação, porque rodar sem tempo de indisponibilidade são apenas duas chamadas pela ordem certa:

  1. Emita uma segunda credencial com os mesmos âmbitos.
  2. Coloque-a no seu sistema e confirme que há tráfego a passar — GET /ping com a chave nova, e depois observe o last_used_at dela.
  3. Revogue a antiga.

As duas credenciais estão ativas entre o primeiro passo e o terceiro. Não há limite nenhum que o impeça de ter duas.

Revogar

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

A revogação é imediata e permanente. Uma credencial revogada não é reposta — é substituída. Revogar duas vezes devolve a mesma linha com o revoked_at original, porque uma chave não morre duas vezes. Um id que não seja seu devolve [], exatamente como devolve um id que nunca existiu.

6. Âmbitos

Uma credencial leva consigo um conjunto de âmbitos (scopes). Existem três:

ÂmbitoO que permite
read_supplyListar os seus estacionamentos, os envelopes de preços da Parkena de cada um, e os períodos bloqueados de cada estacionamento — incluindo a contagem agregada overlapping_bookings de cada um. Uma contagem, nunca uma reserva: não a acompanha referência, nome nem matrícula.
write_supplyCriar e alterar estacionamentos, os preços da Parkena deles, e as listas de períodos bloqueados deles.
read_bookingsTrazer as suas reservas da Parkena.

Conceda o mínimo de que precisa. Uma sincronização que só envia inventário não precisa de read_bookings; um processo de relatório que só traz reservas não pode ter write_supply.

A verificação do âmbito não é indicativa. É um argumento da mesma chamada à base de dados que estabelece as linhas de que operador o pedido pode tocar, por isso não existe caminho até aos seus dados que a salte. Uma credencial sem o âmbito que a rota exige recebe o 401 uniforme — a mesma resposta que recebe um segredo errado.

Não existe write_bookings, e a ausência é deliberada. Nada na Parkena deixa uma máquina criar, alterar ou cancelar uma reserva, por isso um âmbito com esse nome parecer-lhe-ia uma fronteira e não seria nada disso.

7. Rotas

MétodoCaminhoÂmbito exigido
GET/pingqualquer credencial válida
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

O {external_id} é o SEU PRÓPRIO identificador do estacionamento — aquilo que o seu sistema lhe chama. Para nós é opaco: nunca o interpretamos, e não tem de ser um UUID. Aplique percent-encoding se ele contiver uma / ou um espaço. Os ids internos da Parkena nunca lhe são enviados e nunca são aceites de si.

Os parâmetros de query desconhecidos são recusados em vez de ignorados, em todas as rotas. Um parâmetro mal escrito que é descartado em silêncio é um filtro que julga estar aplicado e não está.

GET /ping

Verifica uma credencial e diz-lhe o que ela pode fazer. É esta a rota a usar quando uma integração não está a funcionar — todas as outras recusas desta API são deliberadamente incapazes de lhe dizer qual das seis coisas correu mal. Não estabelece contexto de operador e não lê linha nenhuma.

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

O server_time é o nosso relógio, em UTC. É informativo — você nunca nos envia uma hora de volta.

GET /lots

Os seus estacionamentos, cada um com o seu envelope de preços da Parkena.

Parâmetro de queryTipoPor omissãoNotas
afterstringRetomar a seguir a este external_id. Use o next_after da página anterior.
limitinteger de 1 a 20050Pedir mais é recusado, e não reduzido em silêncio.

A paginação é feita sobre o seu próprio external_id, por ordem crescente — e não sobre um offset, por isso a fronteira de uma página não se desloca por baixo de uma escrita 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
}

O next_after só está presente quando a página vinha cheia. Siga-o até ser null e terá visto todo o seu portefólio exatamente uma vez. O rates é null para um estacionamento que ainda não tem preço na Parkena.

PUT /lots/{external_id}

Cria ou atualiza UM estacionamento. Um pedido, um estacionamento — não há endpoint em lote, e é a forma da rota que o impõe. Um corpo em array é recusado com too_many_lots.

O PUT é uma representação completa. Uma chave ausente significa null, e não “deixa como estava”. Envie o estacionamento inteiro de cada vez. A alternativa torna impossível limpar um campo e transforma uma chave mal escrita numa operação sem efeito, permanente e silenciosa.

CampoTipoObrigatórioNotas
external_idstringnãoSe estiver presente, tem de ser igual ao do caminho. É verificado, não é usado.
namestring, ≤200 caracteressim
timezonestringsimNome IANA, p. ex. Europe/Berlin. Todos os cálculos de fronteira de dia passam por ele.
handoverstringsimself ou attended.
online_bookablebooleansimO seu interruptor de venda. É obrigatório precisamente porque assumi-lo por omissão tiraria de venda um estacionamento ativo no primeiro envio incompleto.
capacityinteger de 0 a 1000000nãoLugares TOTAIS. Não os lugares livres esta noite — ver o aviso abaixo.
latitudenumber ou stringnãoArredondada a 6 casas decimais. Tem de ser enviada com longitude.
longitudenumber ou stringnãoArredondada a 6 casas decimais. Tem de ser enviada com latitude.
citystring, ≤120 caracteresnão
country_codestringnãoISO 3166-1 alfa-2, p. ex. DE.
featuresarray de stringnãoVer a lista abaixo. A ordem não importa — nós ordenamos.
arrivalobjectnãoIndicações de chegada em formato livre.
mediaobjectnãoReferências a media em formato livre.
min_advance_daysinteger de 0 a 365nãoPor omissão, 0.
min_stay_daysinteger de 1 a 365não
max_stay_daysinteger de 1 a 365nãoOs três param nos 365, que é a distância a que a Parkena chega sequer a orçamentar uma estadia. Um valor maior seria aceite e nunca honrado.
cancellationobject ou nullnãoAs três chaves ou nenhuma — ver abaixo.

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

O cancellation é um objeto aninhado, e é tudo ou nada:

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

Enviar uma ou duas das três é 422 incomplete_cancellation_policy. Uma política com uma janela gratuita e sem penalização declarada não é uma política parcialmente conhecida, é uma pergunta sobre reembolsos sem resposta possível. Envie null ou omita a chave para dizer “sem política”.

O capacity são os LUGARES TOTAIS, não a disponibilidade

Se o seu feed noturno enviar “lugares livres esta noite” no capacity, vai dizer à Parkena que o seu estacionamento encolheu E vai anular a análise do seu anúncio todas as noites, porque o capacity é conteúdo sujeito a análise.

A disponibilidade em tempo real é um mecanismo diferente e não faz parte da v1. Esta API não consegue detetar o erro, porque um número é um número.

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
        }
      }'
Um envio 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 o estacionamento foi criado, 200 nos restantes casos.
CampoSignificado
resultcreated, updated ou unchanged.
changedOs nomes dos campos que diferem mesmo. Vazio quando é unchanged.
listing_review_supersededtrue se esta escrita anulou a análise do seu anúncio na Parkena — ver o §8.
lotO estacionamento guardado, relido.

Um result: "unchanged" significa que não foi emitida instrução nenhuma — sem bloqueio de linha, sem escrita, sem trigger. É esse o resultado normal e esperado de um reenvio noturno, e é ele que impede esta API de o retirar do site todas as noites.

PUT /lots/{external_id}/rates

Define o envelope de preços da Parkena para um estacionamento. Um tarifário não é um preço — é o INTERVALO dentro do qual está disposto a vender no canal Parkena: um mínimo, um máximo e uma base entre os dois.

Esta rota escreve no canal parkena e só nesse canal. Os preços da sua própria venda direta são seus e esta API não lhes toca.

São aceites duas formas de corpo. Um intervalo:

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

Ou um preço fixo, que se expande para mínimo = base = máximo:

{ "fixed_price": "12.50" }
CampoTipoObrigatórioNotas
fixed_pricedecimaluma forma ou a outraNão pode ser combinado com os campos do intervalo.
floor_pricedecimalcom o intervaloTem de ser ≤ base_price.
base_pricedecimalcom o intervaloTem de estar entre o mínimo e o máximo.
ceiling_pricedecimalcom o intervaloTem de ser ≥ base_price.
min_first_day_pricedecimal ou nullnãoTem de ficar dentro do envelope.
currencystringnãoISO 4217. Por omissão, a sua moeda de liquidação, e não pode ser outra coisa.
dynamicbooleannãoPor omissão, false.
valid_fromYYYY-MM-DD ou nullnãoUma data de calendário. Um timestamp é recusado, não truncado.
valid_toYYYY-MM-DD ou nullnãoNão pode ser anterior a valid_from.

O dinheiro é uma string, e é recusado em vez de arredondado. Envie "12.50", não 12.345. Os valores levam duas casas decimais; uma terceira é 422 invalid_body, porque um preço que você não escreveu não é um preço com que concordou. As coordenadas são o contrário — são uma medição, por isso arredondam.

Enviar fixed_price e um intervalo ao mesmo tempo é 422. Adivinhar qual dos dois queria dizer é a maneira de um estacionamento acabar com o preço no extremo errado do seu próprio intervalo.

{
  "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 na criação, 200 nos restantes casos.

O envelope_widened é true quando esta escrita moveu o envelope PARA FORA — mínimo para baixo, máximo para cima, moeda alterada, ou o dynamic invertido — ou quando não havia envelope da Parkena nenhum antes. É o alargamento que lhe pode custar uma análise do anúncio.

É um sinal conservador, e preferimos dizê-lo a exagerá-lo: comparamos com o envelope que estava ativo há um instante, e não com aquele que um analista aprovou, porque esta API é deliberadamente incapaz de ler o estado da sua análise. Por isso pode indicar true num caso que não anula nada. O que não faz é indicar false quando alguma coisa foi anulada.

GET /lots/{external_id}/blocked-periods

A lista de períodos bloqueados do estacionamento: cada intervalo de datas em que está retirado de venda na Parkena, tenha-o escrito quem o tiver escrito — as suas sincronizações e a consola escrevem a mesma lista. Um período bloqueado trava as vendas NOVAS da Parkena para as estadias que lhe tocam, e não faz mais nada: não cancela nada, e não toca nas suas próprias vendas diretas. As duas datas são inclusivas — o ends_on é o último dia bloqueado, não o dia seguinte, e um período com starts_on igual a ends_on bloqueia exatamente esse único dia.

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 }
  ]
}
Os períodos voltam com o mais antigo primeiro. O reason é a sua própria etiqueta, null quando nenhuma foi dada.

O overlapping_bookings é uma contagem de transparência: quantas reservas deste estacionamento — qualquer canal, qualquer estado exceto cancelled — têm uma estadia que toca no período. As duas comparações são inclusivas, sobre os dias de calendário do próprio estacionamento: uma reserva que sai na primeira manhã do período ainda conta, tal como uma que chega na última tarde dele. Repare no que o «exceto cancelled» inclui: uma estadia CONCLUÍDA também conta. A contagem responde a «o que foi vendido para essas datas», história incluída — não é o número de carros por vir que um período bloqueado deixaria pendurados, por isso pode sair mais alta do que o painel de aviso da consola, que faz essa pergunta mais estreita. É uma contagem e mais nada: na resposta não há referência, nome, matrícula nem data de reserva nenhuma.

PUT /lots/{external_id}/blocked-periods

Substitui a lista INTEIRA de períodos bloqueados do estacionamento pela que vem no corpo. Não há uma chamada «acrescentar um período» nem forma de endereçar um período isolado — uma sincronização que só sabe fundir é uma sincronização que nunca sabe apagar, e um período levantado no seu sistema ficaria na Parkena para sempre. No máximo 100 entradas; cada data um dia de calendário real entre 2020-01-01 e 2032-12-31; o ends_on nunca antes do starts_on; e duas entradas não podem sobrepor-se — inclusivamente: um par que partilha um único dia colide.

A lista que envia é a lista que existe

O PUT é aqui uma representação completa, a mesma regra do PUT /lots/{external_id} — e nesta rota a regra tem uma consequência que merece maiúsculas: OS PERÍODOS ESCRITOS NA CONSOLA FAZEM PARTE DA MESMA LISTA. Se uma pessoa apontar um período bloqueado na consola na terça-feira e a sua sincronização noturna enviar só os próprios períodos na terça à noite, a sincronização remove o período dessa pessoa — em silêncio, corretamente, porque nos disse que a lista enviada era a lista inteira.

Uma máquina que é dona desta rota é dona do calendário inteiro. Ou relê esta rota e transporta no seu próprio sistema os períodos escritos na consola, ou combina com a sua própria equipa qual dos sistemas é dono dos períodos bloqueados. A API não vai arbitrar.

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" }
        ]
      }'
Uma substituição completa. O segundo período não leva reason, o que é permitido.
CampoTipoObrigatórioNotas
starts_onYYYY-MM-DDsimO primeiro dia bloqueado, inclusive. Uma data de calendário — um timestamp é recusado, não truncado.
ends_onYYYY-MM-DDsimO ÚLTIMO dia bloqueado, inclusive — não o dia seguinte. Não pode preceder o starts_on.
reasonstring ≤200, ou nullnãoUma etiqueta, que a consola mostra. Vazia é recusada; null e ausente significam ambos «sem motivo». Guardada tal e qual, nunca aparada — um espaço mudado é um período mudado.

Cada recusa de conteúdo nesta rota leva o mesmo código, 422 {"error":"invalid_blocked_periods"}, com o field a nomear a entrada culpada nas coordenadas do seu próprio corpo — blocked_periods[3].ends_on. Os dois erros de envelope mantêm os códigos que o resto da API lhes dá: uma chave a mais no nível de topo é unknown_field, uma chave blocked_periods ausente é missing_field.

O que estava errado`field`
Mais de 100 entradas, ou o blocked_periods não é um arrayblocked_periods
Não é um dia de calendário real (2026-02-30), é um timestamp, ou está fora de 2020..2032o starts_on / ends_on da entrada
ends_on antes do starts_ono ends_on da entrada
Duas entradas sobrepõem-se (inclusive — basta partilharem um dia)o starts_on da entrada que começa mais tarde
reason vazio, não textual, ou com mais de 200 caractereso reason da entrada

Os períodos que se sobrepõem a reservas vendidas NÃO são recusados. Uma sincronização de máquina não pode encravar por causa de um carro vendido na semana passada; as reservas ficam de pé — um período bloqueado só trava as vendas NOVAS. Em vez disso, a resposta leva a contagem overlapping_bookings de cada período (semântica acima, estadias concluídas incluídas), para que o seu sistema, e o humano por trás dele, vejam exatamente que períodos já têm carros vendidos lá 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 o estacionamento não tinha antes período nenhum, 200 nos restantes casos.
CampoSignificado
resultcreated (antes zero períodos, agora alguns), updated (todo o resto que escreveu — incluindo um PUT de [] que esvaziou a lista), ou unchanged.
added / removedAs contagens de linhas que a escrita moveu de facto. Ambas a 0 no unchanged.
blocked_periodsA lista guardada, relida DEPOIS da escrita com contagens overlapping_bookings frescas — o que a base de dados contém agora, nunca um eco do seu corpo.

A regra «compare antes de atualizar» do §2 vale aqui em forma de lista: result: "unchanged" significa que a lista guardada e o seu corpo já diziam o mesmo, e que não foi emitida instrução nenhuma. Reenviar todas as noites uma lista de períodos sem alterações custa uma leitura.

GET /bookings

As suas reservas da Parkena, como um CURSOR QUE VOCÊ CONSULTA. Esse cursor é a fonte de verdade: um endpoint de webhook registado pode receber uma pista assinada booking.changed que lhe diz para o consultar mais cedo — o §13 — mas uma pista não leva dados de reserva, e nada do que construir sobre este cursor é alguma vez desperdiçado.

Parâmetro de queryTipoPor omissãoNotas
sincestringO token next da sua chamada anterior. Omita-o para dizer “desde o início”.
limitinteger de 1 a 500200Pedir mais é recusado, e não reduzido em silêncio.
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
}
CampoNotas
referenceA referência da reserva da PARKENA. É esta a chave de deduplicação.
external_referenceA sua própria string, se tiver sido definida. É devolvida tal e qual, nunca serve de chave.
check_in_local / check_out_localA hora local do próprio estacionamento, SEM deslocamento de fuso. Leia-as em timezone.
totalUma string decimal, não um float.
statuspending, confirmed, checked_in, completed, cancelled.
payment_statuspending, paid, partial, refunded, expired, not_required.

Como consultar o cursor corretamente

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)

Três regras, e nenhuma delas é decorativa:

  1. Faça a deduplicação por reference. A entrega é pelo menos uma vez, nunca exatamente uma vez. Vai ver a mesma reserva mais do que uma vez, e isso é o comportamento correto e não uma avaria.
  2. Guarde o next DEPOIS de ter guardado o lote de forma duradoura, e não antes. Se o seu processo morrer a meio de um lote, o cursor que não foi guardado volta a entregar — o que a regra 1 torna inofensivo.
  3. Não construa um cursor. É um token que nós emitimos. Não existe ?since=<um instante à minha escolha>, e essa ausência é deliberada: um parceiro que indicasse um instante futuro deixaria de receber as suas próprias reservas em silêncio, e o primeiro sintoma seria um carro a uma barreira de que o sistema dele nunca ouviu falar. Um cursor para além da nossa marca temporal é reposto nela, por isso o pior que um token adulterado consegue fazer é entregar linhas duas vezes.

Porque é que pode voltar a ver uma reserva logo a seguir: o cursor que lhe devolvemos nunca aponta para lá de now() − 5 minutos. As reservas mais recentes do que isso continuam a ser devolvidas — você quer a sua reserva agora, não daqui a cinco minutos —, apenas não comprometemos o cursor com elas. Isto não é cautela por cautela. Uma transação de base de dados é carimbada com a hora a que COMEÇOU, por isso uma transação lenta pode confirmar uma linha carimbada mais cedo do que outra que já lhe foi entregue; sem o atraso, um cursor que saltasse logo para a linha mais recente passaria por cima dela para sempre e em silêncio.

8. O que lhe custa uma nova análise

O §2 tem a regra; esta é a lista de campos por trás dela. Alterar qualquer um dos seguintes num PUT /lots/{external_id} anula um anúncio pendente ou aprovado, e a resposta di-lo com "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

O que não lhe custa nenhuma

  • O online_bookable. É o seu interruptor de venda, não é conteúdo sujeito a análise. Desligá-lo tira o estacionamento de venda de imediato sem anular nada — que é exatamente por isso que é um campo obrigatório em todos os PUT.
  • Tudo o que na rota de preços não alarga o envelope. Subir o seu mínimo ou baixar o seu máximo é ficar dentro de uma promessa que já fez. Baixar o mínimo, subir o máximo, mudar de moeda ou inverter o dynamic é fazer uma promessa nova, e pode anular a análise — o envelope_widened diz-lhe quando.
  • O valid_from / valid_to. A expiração e a renovação são lidas em tempo real, por isso uma alteração da janela tira o estacionamento do parkena.com e volta a pô-lo lá sem passar por uma pessoa.

Duas armadilhas que vale a pena conhecer

  • A ordem das features não lhe importa a si, mas já nos importou a nós. Ordenamos o features antes de o guardar, por isso ["valet","indoor"] e ["indoor","valet"] são o mesmo envio. Não precisa de ordenar.
  • A precisão das coordenadas não importa. Arredondamos a seis casas decimais antes de comparar, por isso enviar 52.5200001 todas as noites não faz com que a latitude apareça como alterada para sempre.

Não existe delete

Esta API não consegue apagar um estacionamento nem um preço, e não há permissão nenhuma que lho permitisse. Fazer delete e depois insert é o idioma de upsert mais comum em código de ingestão e, sobre estes dados, é catastrófico: o estacionamento ganharia uma identidade nova e levaria com ele o histórico do anúncio, os preços e a disponibilidade — a partir de um processo que ninguém estava a ver. Remover um estacionamento é uma coisa que uma pessoa faz na consola, deliberadamente, onde a confirmação enumera tudo o que vai atrás dele. Um estacionamento que alguma vez recebeu uma reserva não pode ser removido de todo, por ninguém: uma reserva é um registo financeiro, é guardada, e o estacionamento para o qual foi feita também. Para deixar de vender um, defina "online_bookable": false.

9. Erros

Todos os erros são JSON, com um código error estável e legível por uma máquina:

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

O field, quando está presente, é o NOME DO SEU PRÓPRIO CAMPO, devolvido tal e qual. Nunca devolvemos uma mensagem da base de dados, o nome de uma restrição nem o nome de uma tabela — esses são nossos e podemos mudar-lhes o nome, e a sua integração não deve partir quando o fizermos. Ramifique com base no error.

Credencial

EstadoCódigoSignificado
401unauthorizedVer abaixo — cobre seis situações diferentes e recusa-se a dizer qual delas é.

Forma do pedido

EstadoCódigoSignificado
404not_foundEssa ROTA não existe. Nunca é usado para um recurso.
405method_not_allowedCaminho certo, verbo errado. O cabeçalho Allow nomeia o verbo que queríamos.
413payload_too_largeCorpo acima de 64 KiB.
415unsupported_media_typeO Content-Type não era application/json.
422invalid_jsonO corpo não era JSON interpretável.
422invalid_bodyJSON bem formado, forma errada ou um valor inutilizável.
422missing_fieldFaltava um campo obrigatório.
422unknown_fieldUm campo que não reconhecemos. Recusado, não ignorado.
422field_belongs_to_another_routeUm campo que existe mesmo, mas que se define noutro sítio. Hoje o único é o rates — ver o §2.
422invalid_queryUm parâmetro de query mau ou desconhecido.
422invalid_cursorO token since não era nosso.
422too_many_lotsUm corpo em array. Um pedido, um estacionamento.

Conteúdo

EstadoCódigoSignificado
422invalid_timezoneNão é um nome de fuso horário IANA.
422invalid_coordinatesFora do intervalo, ou uma latitude sem uma longitude.
422invalid_country_codeNão é ISO 3166-1 alfa-2.
422invalid_stay_boundsO min_stay_days / max_stay_days não batem certo.
422incomplete_cancellation_policyUma ou duas das três chaves de cancelamento.
422invalid_price_envelopeO mínimo, a base e o máximo não estão por ordem.
422invalid_first_day_priceO min_first_day_price está fora do envelope.
422invalid_validity_windowO valid_to é anterior ao valid_from.
422currency_is_not_settlement_currencyNão é a sua moeda de liquidação.
422invalid_blocked_periodsUma entrada de períodos bloqueados é inutilizável; o field nomeia-a nas coordenadas do seu próprio corpo (blocked_periods[3].ends_on). A tabela das recusas está no §7.
409conflictUma corrida a sério: dois envios criaram o mesmo external_id ao mesmo tempo. Repita.

Nossos

EstadoCódigoSignificado
429rate_limitedAcima de um limite. Respeite o Retry-After.
503try_againUm conflito transitório na base de dados. Repita; o Retry-After vem preenchido.
500internal_errorCulpa nossa. Está por inteiro nos nossos registos. Voltar a tentar é razoável.

Porque é que o 401 não lhe diz mais

O unauthorized cobre todos estes casos e recusa-se a distingui-los:

  1. Nenhum cabeçalho Authorization, ou um que não conseguimos interpretar.
  2. Um key_id que não corresponde a credencial nenhuma.
  3. Um key_id que existe, com o segredo errado.
  4. Uma credencial revogada, expirada, ou cuja conta de operador está suspensa.
  5. Uma credencial válida que não tem o âmbito que esta rota exige.
  6. Uma credencial válida que nomeia um external_id que não é de um dos estacionamentos dela.

Os casos 2 a 5 não são distinguíveis nem dentro do nosso próprio processo — a base de dados responde a todos de forma idêntica, com o mesmo trabalho feito, para que ninguém consiga enumerar a lista de operadores nem mapear que capacidades de uma chave roubada ainda funcionam.

O caso 6 custa-lhe algum conforto, e vamos dizer porquê em vez de nos limitarmos a afirmá-lo: uma credencial que só tem write_supply não consegue listar estacionamentos. Se um external_id desconhecido respondesse lot_not_found, essa credencial passaria a ter um oráculo de enumeração a funcionar exatamente sobre o portefólio que os âmbitos dela lhe negam — construído a partir de uma recusa. Por isso recebe o mesmo 401.

O GET /ping é a resposta a isto. Diz-lhe que a sua chave está ativa e o que ela pode fazer, sem ter de adivinhar a partir de um 401. Se o ping resultar e uma rota devolver 401, está perante um âmbito que não tem ou um external_id que não é seu — verifique os dois contra o GET /lots.

10. Limites de pedidos, limites de tamanho e o que registamos

LimiteValor
Corpo do pedido64 KiB
Estacionamentos por pedido1
Página do GET /lots1–200, por omissão 50
Página do GET /bookings1–500, por omissão 200
Períodos bloqueados por PUT …/blocked-periods100 — uma lista, um estacionamento

Os limites de pedidos são token buckets — uma capacidade de rajada que se repõe continuamente.

BaldeContado contraRajadaReposição
Todos os pedidosendereço de origem2404 / segundo
Autenticações falhadasendereço de origem201 por cada 3 segundos
Leiturascredencial1202 / segundo
Escritas (rajada)credencial601 / segundo
Escritas (por hora)credencial10001000 / hora

Uma recusa é um 429 com um cabeçalho Retry-After em segundos inteiros. Ser recusado não gasta um token, por isso voltar a tentar não afasta ainda mais a sua própria recuperação.

As escritas são limitadas duas vezes, e a janela horária é a que importa. Uma chave de escrita roubada a zerar um portefólio é exatamente igual a uma sincronização noturna legítima — a mesma credencial, a mesma rota, a mesma forma, a mesma hora da noite — por isso um limite de rajada sozinho não consegue distingui-las, porque uma sincronização a sério também é uma rajada. O teto horário limita quanto de um portefólio uma chave roubada consegue reescrever antes de ser plausível que alguém esteja a olhar. Um operador com 1000 estacionamentos a enviar cada um uma vez por noite cabe lá dentro; se o seu portefólio for maior, peça-nos e nós subimos o limite, em vez de o obrigarmos a contorná-lo.

As rotas de períodos bloqueados gastam os mesmos baldes que tudo o resto: o GET custa um token de leitura, e o PUT é uma escrita, contada contra os dois baldes de escrita — uma sincronização noturna de períodos conta contra o mesmo teto de 1000 por hora dos seus envios de estacionamentos, de propósito, porque uma chave fugida que retira um parque de venda é exatamente a forma que esse teto existe para delimitar.

As autenticações falhadas são contadas contra O ENDEREÇO DE ORIGEM, nunca contra o key_id apresentado. Contá-las contra a chave seria uma negação de serviço apontada a si: o key_id é a metade não secreta e aparece em registos e em ficheiros de configuração por desenho, por isso qualquer pessoa que lesse um poderia trancá-lo fora da sua própria integração com umas dezenas de segredos errados.

Note que um external_id desconhecido também gasta orçamento de falhas, porque devolve o mesmo 401 que tudo o resto. Um limitador que tratasse os dois de forma diferente seria um oráculo de existência construído a partir de um 429. Se estiver a arrastar um mapeamento desatualizado, reconcilie-o contra o GET /lots em vez de andar a sondar.

O que registamos

Cada pedido produz uma linha de registo do nosso lado; cada escrita que mudou alguma coisa produz uma segunda, a levar os NOMES DOS CAMPOS que mudaram e se a mudança lhe custou uma análise. Nomes de campos, nunca valores — o registo responde a “o que aconteceu a este portefólio a noite passada”, e os seus preços não estão lá. O seu key_id está, e é assim que nos pode perguntar qual das suas integrações fez alguma coisa. O seu segredo nunca está, sob forma nenhuma.

11. Antes de o seu estacionamento poder vender

Enviar um estacionamento através desta API cria-o, mas um estacionamento novo não começa a vender no parkena.com no momento em que a API devolve 201. Parte do que é preciso é conteúdo que esta API consegue fornecer, e parte é uma decisão que uma pessoa tem de confirmar na consola.

Um PUT /lots/{external_id} completo mais um PUT …/rates satisfazem os requisitos de capacidade, preço, geografia, cidade/país e política de cancelamento. Continuam por resolver, e só são possíveis na consola:

  • Confirmar o fuso horário e as regras de reserva — são derivados e assumidos por omissão, e uma resposta errada mas plausível erra o preço das reservas em silêncio, por isso uma pessoa confirma-os uma vez.
  • As indicações de chegada e uma imagem principal, para o anúncio público.
  • Aceitar o contrato de anúncio da Parkena, uma vez para toda a conta.
  • Submeter o estacionamento para análise.

Este último ponto é deliberado: submeter um estacionamento a um analista humano é uma declaração que você faz sobre o seu negócio, e uma máquina que tem uma chave não devia poder fazê-la em seu nome.

A consola mostra todos os requisitos, se cada um está ou não satisfeito, e o que protege. Não é uma limitação que tencionemos remover na v1.

12. O que a v1 não faz

Dito com clareza, porque uma integração construída sobre um pressuposto que nunca assumimos é pior do que uma construída sobre uma lacuna documentada.

  • Sem números de disponibilidade em tempo real. Agora pode retirar de venda intervalos de datas inteiros com o PUT /lots/{external_id}/blocked-periods — o §7 — mas continua a não haver forma de enviar “lugares livres esta noite” como número, e o capacity são os lugares totais — pôr lá a disponibilidade em tempo real dá uma imagem errada do seu estacionamento e manda-o para nova análise todas as noites. Um calendário de disponibilidade por contagens não faz parte da v1.
  • Sem cargas nos webhooks. Um endpoint registado recebe a PISTA assinada booking.changed do §13 — uma referência da reserva e mais nada — e o cursor que você consulta continua a ser a fonte de verdade, exatamente como esta lista prometia antes de a pista existir. O que continua a não existir: cargas por evento (num webhook nunca viajam dados de reserva), garantias de ordem (as pistas fundem-se e repetem-se; a sequência é assunto do cursor), ou uma API de reenvio (nada para reenviar — volte a consultar o cursor). Uma integração tem de funcionar com as pistas desligadas, porque uma pista que falha cinco entregas morre em silêncio, de propósito.
  • Sem escrita de reservas. Não pode criar, alterar, cancelar, dar entrada nem reembolsar uma reserva através desta API. Não há âmbito para isso nem permissão por trás disso.
  • Sem pagamentos. Sem cobranças, sem reembolsos, sem dados de pagamentos, sem valores de comissão. O feed de reservas leva o total da venda e a moeda e nada sobre como o dinheiro se moveu.
  • Sem distribuição para OTA nem para um gestor de canais. Esta API escreve apenas no canal parkena. Não é um gestor de canais e não envia nada para mais ninguém.
  • Sem deletes. Ver o §8.
  • Sem endpoint em lote. Um pedido, um estacionamento.
  • Sem submissão de anúncios nem estado de análise. Não pode submeter para análise nem ler o estado da sua análise através da API. O listing_review_superseded e o envelope_widened são os únicos sinais próximos da análise, e o segundo é deliberadamente conservador.
  • Sem seleção de tenant. Não existe tenant_id em nenhum corpo nem em nenhuma query string que esta API interprete. O operador sobre o qual um pedido atua é resolvido a partir da credencial e de mais nada — um id de operador vindo no pedido seria uma chave de escrita entre tenants.
  • Sem timestamps fornecidos por quem chama, em lado nenhum. Sem as_of, sem watermark, sem updated_since. As únicas datas que pode enviar são valid_from e valid_to, que são dias de calendário que você declara sobre o seu próprio preço. Todo o resto é o nosso relógio.

13. Webhooks: a pista `booking.changed`

Pode registar um endpoint HTTPS por conta de operador, e enviamos-lhe por POST uma pista assinada sempre que uma reserva sua é criada ou muda — qualquer canal, qualquer campo. Leia o aviso abaixo antes de desenhar seja o que for à volta disto.

Uma pista não é dados. O cursor é os dados.

O corpo inteiro de uma pista é um nome de evento, uma referência da reserva e um timestamp. Sem estado, sem datas, sem viajante, sem valores — nada sobre o qual o seu sistema possa agir diretamente, e nada que envelheça pelo caminho. A única resposta correta a uma pista é aquilo que a sua integração já faz: consultar o GET /bookings com o cursor guardado.

Um parceiro cujo endpoint está em baixo um dia inteiro perde latência, nunca dados — o cursor volta a entregar tudo na consulta seguinte. Se a sua integração não consegue sobreviver com as pistas desligadas, está mal construída.

Essa divisão de trabalho é a razão por que o §12 já não diz «sem webhooks»: o que nos recusávamos a lançar era um webhook que LEVASSE a reserva, porque um webhook que falha em silêncio é uma reserva de que nunca soube enquanto o carro chega à sua barreira na mesma. Uma pista pode falhar em silêncio e não lhe custar nada.

A entrega

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"}
Um único evento na v1 — booking.changed, um só nome para criar, alterar e cancelar, porque a pista não diz o que aconteceu; isso diz o cursor.
  • O booking_reference é a mesma reference que o feed de reservas leva — entregue-a à sua deduplicação, exatamente como deduplica as linhas do cursor.
  • O occurred_at é quando a alteração foi registada do nosso lado, não quando esta tentativa foi enviada. As repetições reenviam-no tal e qual.

As alterações sucessivas da mesma reserva FUNDEM-SE enquanto uma pista dela continuar por entregar: cinco alterações num minuto produzem um único toque, e isso é correto precisamente porque a pista não leva estado — por muitas escritas que represente, a sua próxima consulta do cursor vê a linha final. A entrega é pelo menos uma vez, como tudo o resto nesta API: pode receber duas pistas para a mesma alteração, e deduplicar pelo booking_reference torna isso gratuito.

Registar um endpoint, e as regras que ele tem de cumprir

Os endpoints registam-se na consola, na mesma página Account → API keys onde se emitem as chaves, por um proprietário ou um gestor — não através desta API, pela mesma razão das chaves. O segredo de assinatura (whsec_ seguido de 64 caracteres hexadecimais) é gerado no seu navegador e mostrado UMA VEZ: guardamo-lo para assinar, mas nenhum ecrã da consola e nenhuma consulta consegue voltar a lê-lo. Na v1 pode estar ativo um endpoint por conta. Um endpoint nunca é editado — um URL novo ou um segredo rodado é um endpoint novo (desarme primeiro o antigo); desarmar é o único interruptor que a consola oferece depois do nascimento.

  • Só HTTPS, só a porta 443. http://, e qualquer porta explícita diferente de 443, nunca é tentado.
  • Um nome de host, não um endereço. Literais de IP (v4 ou v6), localhost e tudo o que viva nos domínios da nossa própria plataforma são recusados.
  • Os redirecionamentos nunca são seguidos. Um redirecionamento é um segundo URL que ninguém examinou; a tentativa falha em vez disso.
  • Cinco segundos de limite, e para lá do código de estado a sua resposta nunca é lida. Responda depressa e trabalhe depois — a forma certa é «pôr em fila e devolver 204».

O contrato do 2xx, as repetições e a morte

Qualquer 2xx dentro do limite de tempo significa entregue. Tudo o resto — um 4xx, um 5xx, um tempo esgotado, uma ligação recusada — é repetido com um backoff fixo, e depois da quinta tentativa falhada a pista está morta: o último estado HTTP e a razão da falha ficam registados e visíveis na consola, e não há mais nenhuma tentativa. A reserva, como sempre, está à espera no cursor. Desarmar um endpoint mata as pistas pendentes dele na passagem seguinte, em vez de as entregar mais tarde a um endpoint que você desligou.

Tentativas falhadas até agoraPróxima tentativa
1passado 1 minuto
2passados 5 minutos
3passados 30 minutos
4passadas 2 horas
5nenhuma — a pista está morta, com o último estado e a razão registados

Verificar a assinatura

Cada entrega leva um cabeçalho Parkena-Signature: t=<unix-seconds>,v1=<hex HMAC-SHA-256>. A carga assinada é a cadeia literal t + . + o corpo EM BRUTO do pedido — assine os bytes que recebeu, nunca uma re-serialização do JSON interpretado. É de propósito o esquema exato que a Stripe usa nos webhooks dela, prefixo whsec_ do segredo incluído: qualquer verificador de webhooks da Stripe que já tenha a correr — ou o publicado no próprio repositório da Parkena — verifica estas entregas sem alterações.

# 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);
O conjunto, em esboço.

Três pormenores que uma implementação apressada erra: compare com uma igualdade de TEMPO CONSTANTE, não ===, para que o tempo de resposta não denuncie quanto de uma assinatura forjada estava certo; recuse um t mais velho do que uns minutos — recomendamos 300 segundos — que é o que torna inútil reproduzir mais tarde uma entrega capturada; e se interpretar o cabeçalho a sério em vez de o partir ingenuamente, verifique CADA par v1= presente e aceite se algum coincidir — é isso que impede uma futura rotação do segredo de assinatura de o partir a meio da janela.

Uma pista que falha a sua verificação não é uma entrega da Parkena. Responda-lhe 401 e não faça mais nada — em particular, não consulte o cursor ao ritmo que ela lhe ditar. Consultar o cursor é sempre SEGURO; recusar serve para não deixar um chamador não autenticado comandar a cadência do seu sistema.

14. Obter acesso, e obter ajuda

As chaves são emitidas na consola por um proprietário ou um gestor, em Account → API keys. Não há sandbox, e o acesso ao piloto é combinado connosco um operador de cada vez — se o que leu aqui encaixa no sistema que já tem, é isso que deve dizer quando nos escrever.

Escreva para [email protected]. Para apoio numa integração que já está a correr: indique o seu key_id — nunca o seu segredo — e o external_id e o timestamp do pedido sobre o qual está a perguntar. Ambos aparecem nos nossos registos e, juntos, identificam um único pedido.

Pergunte-nos pelo piloto.

Diga-nos qual é o seu sistema e o que quer que ele envie. Dizemos-lhe honestamente se a v1 cobre isso — o §12 é a lista completa do que ela não consegue fazer, escrita por extenso para que possa decidir contra ela antes de construir seja o que for.