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 }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.jsonEnvie-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_id—pk_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.secret—pk_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âmetro | Tipo | Notas |
|---|---|---|
p_label | string, 1–80 caracteres | Como vai chamar a esta chave daqui a seis meses. Obrigatório. |
p_scopes | array de scope | Conjunto não vazio de âmbitos distintos. Obrigatório. |
p_expires_in_days | integer de 1 a 3650, ou null | Null 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"
}]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:
- Emita uma segunda credencial com os mesmos âmbitos.
- Coloque-a no seu sistema e confirme que há tráfego a passar —
GET /pingcom a chave nova, e depois observe olast_used_atdela. - 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:
| Âmbito | O que permite |
|---|---|
read_supply | Listar 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_supply | Criar e alterar estacionamentos, os preços da Parkena deles, e as listas de períodos bloqueados deles. |
read_bookings | Trazer 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étodo | Caminho | Âmbito exigido |
|---|---|---|
GET | /ping | qualquer credencial válida |
GET | /lots | read_supply |
PUT | /lots/{external_id} | write_supply |
PUT | /lots/{external_id}/rates | write_supply |
GET | /lots/{external_id}/blocked-periods | read_supply |
PUT | /lots/{external_id}/blocked-periods | write_supply |
GET | /bookings | read_bookings |
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 query | Tipo | Por omissão | Notas |
|---|---|---|---|
after | string | — | Retomar a seguir a este external_id. Use o next_after da página anterior. |
limit | integer de 1 a 200 | 50 | Pedir 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.
| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
external_id | string | não | Se estiver presente, tem de ser igual ao do caminho. É verificado, não é usado. |
name | string, ≤200 caracteres | sim | — |
timezone | string | sim | Nome IANA, p. ex. Europe/Berlin. Todos os cálculos de fronteira de dia passam por ele. |
handover | string | sim | self ou attended. |
online_bookable | boolean | sim | O seu interruptor de venda. É obrigatório precisamente porque assumi-lo por omissão tiraria de venda um estacionamento ativo no primeiro envio incompleto. |
capacity | integer de 0 a 1000000 | não | Lugares TOTAIS. Não os lugares livres esta noite — ver o aviso abaixo. |
latitude | number ou string | não | Arredondada a 6 casas decimais. Tem de ser enviada com longitude. |
longitude | number ou string | não | Arredondada a 6 casas decimais. Tem de ser enviada com latitude. |
city | string, ≤120 caracteres | não | — |
country_code | string | não | ISO 3166-1 alfa-2, p. ex. DE. |
features | array de string | não | Ver a lista abaixo. A ordem não importa — nós ordenamos. |
arrival | object | não | Indicações de chegada em formato livre. |
media | object | não | Referências a media em formato livre. |
min_advance_days | integer de 0 a 365 | não | Por omissão, 0. |
min_stay_days | integer de 1 a 365 | não | — |
max_stay_days | integer de 1 a 365 | não | Os 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. |
cancellation | object ou null | não | As 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
}
}'{
"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.| Campo | Significado |
|---|---|
result | created, updated ou unchanged. |
changed | Os nomes dos campos que diferem mesmo. Vazio quando é unchanged. |
listing_review_superseded | true se esta escrita anulou a análise do seu anúncio na Parkena — ver o §8. |
lot | O 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" }| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
fixed_price | decimal | uma forma ou a outra | Não pode ser combinado com os campos do intervalo. |
floor_price | decimal | com o intervalo | Tem de ser ≤ base_price. |
base_price | decimal | com o intervalo | Tem de estar entre o mínimo e o máximo. |
ceiling_price | decimal | com o intervalo | Tem de ser ≥ base_price. |
min_first_day_price | decimal ou null | não | Tem de ficar dentro do envelope. |
currency | string | não | ISO 4217. Por omissão, a sua moeda de liquidação, e não pode ser outra coisa. |
dynamic | boolean | não | Por omissão, false. |
valid_from | YYYY-MM-DD ou null | não | Uma data de calendário. Um timestamp é recusado, não truncado. |
valid_to | YYYY-MM-DD ou null | não | Nã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 }
]
}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" }
]
}'reason, o que é permitido.| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
starts_on | YYYY-MM-DD | sim | O primeiro dia bloqueado, inclusive. Uma data de calendário — um timestamp é recusado, não truncado. |
ends_on | YYYY-MM-DD | sim | O ÚLTIMO dia bloqueado, inclusive — não o dia seguinte. Não pode preceder o starts_on. |
reason | string ≤200, ou null | não | Uma 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 array | blocked_periods |
Não é um dia de calendário real (2026-02-30), é um timestamp, ou está fora de 2020..2032 | o starts_on / ends_on da entrada |
ends_on antes do starts_on | o 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 caracteres | o 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.| Campo | Significado |
|---|---|
result | created (antes zero períodos, agora alguns), updated (todo o resto que escreveu — incluindo um PUT de [] que esvaziou a lista), ou unchanged. |
added / removed | As contagens de linhas que a escrita moveu de facto. Ambas a 0 no unchanged. |
blocked_periods | A 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 query | Tipo | Por omissão | Notas |
|---|---|---|---|
since | string | — | O token next da sua chamada anterior. Omita-o para dizer “desde o início”. |
limit | integer de 1 a 500 | 200 | Pedir 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
}| Campo | Notas |
|---|---|
reference | A referência da reserva da PARKENA. É esta a chave de deduplicação. |
external_reference | A sua própria string, se tiver sido definida. É devolvida tal e qual, nunca serve de chave. |
check_in_local / check_out_local | A hora local do próprio estacionamento, SEM deslocamento de fuso. Leia-as em timezone. |
total | Uma string decimal, não um float. |
status | pending, confirmed, checked_in, completed, cancelled. |
payment_status | pending, 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:
- 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. - Guarde o
nextDEPOIS 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. - 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 osPUT. - 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 — oenvelope_wideneddiz-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
featuresantes 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.5200001todas as noites não faz com que alatitudeapareç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
| Estado | Código | Significado |
|---|---|---|
401 | unauthorized | Ver abaixo — cobre seis situações diferentes e recusa-se a dizer qual delas é. |
Forma do pedido
| Estado | Código | Significado |
|---|---|---|
404 | not_found | Essa ROTA não existe. Nunca é usado para um recurso. |
405 | method_not_allowed | Caminho certo, verbo errado. O cabeçalho Allow nomeia o verbo que queríamos. |
413 | payload_too_large | Corpo acima de 64 KiB. |
415 | unsupported_media_type | O Content-Type não era application/json. |
422 | invalid_json | O corpo não era JSON interpretável. |
422 | invalid_body | JSON bem formado, forma errada ou um valor inutilizável. |
422 | missing_field | Faltava um campo obrigatório. |
422 | unknown_field | Um campo que não reconhecemos. Recusado, não ignorado. |
422 | field_belongs_to_another_route | Um campo que existe mesmo, mas que se define noutro sítio. Hoje o único é o rates — ver o §2. |
422 | invalid_query | Um parâmetro de query mau ou desconhecido. |
422 | invalid_cursor | O token since não era nosso. |
422 | too_many_lots | Um corpo em array. Um pedido, um estacionamento. |
Conteúdo
| Estado | Código | Significado |
|---|---|---|
422 | invalid_timezone | Não é um nome de fuso horário IANA. |
422 | invalid_coordinates | Fora do intervalo, ou uma latitude sem uma longitude. |
422 | invalid_country_code | Não é ISO 3166-1 alfa-2. |
422 | invalid_stay_bounds | O min_stay_days / max_stay_days não batem certo. |
422 | incomplete_cancellation_policy | Uma ou duas das três chaves de cancelamento. |
422 | invalid_price_envelope | O mínimo, a base e o máximo não estão por ordem. |
422 | invalid_first_day_price | O min_first_day_price está fora do envelope. |
422 | invalid_validity_window | O valid_to é anterior ao valid_from. |
422 | currency_is_not_settlement_currency | Não é a sua moeda de liquidação. |
422 | invalid_blocked_periods | Uma 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. |
409 | conflict | Uma corrida a sério: dois envios criaram o mesmo external_id ao mesmo tempo. Repita. |
Nossos
| Estado | Código | Significado |
|---|---|---|
429 | rate_limited | Acima de um limite. Respeite o Retry-After. |
503 | try_again | Um conflito transitório na base de dados. Repita; o Retry-After vem preenchido. |
500 | internal_error | Culpa 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:
- Nenhum cabeçalho
Authorization, ou um que não conseguimos interpretar. - Um
key_idque não corresponde a credencial nenhuma. - Um
key_idque existe, com o segredo errado. - Uma credencial revogada, expirada, ou cuja conta de operador está suspensa.
- Uma credencial válida que não tem o âmbito que esta rota exige.
- Uma credencial válida que nomeia um
external_idque 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
| Limite | Valor |
|---|---|
| Corpo do pedido | 64 KiB |
| Estacionamentos por pedido | 1 |
Página do GET /lots | 1–200, por omissão 50 |
Página do GET /bookings | 1–500, por omissão 200 |
Períodos bloqueados por PUT …/blocked-periods | 100 — uma lista, um estacionamento |
Os limites de pedidos são token buckets — uma capacidade de rajada que se repõe continuamente.
| Balde | Contado contra | Rajada | Reposição |
|---|---|---|---|
| Todos os pedidos | endereço de origem | 240 | 4 / segundo |
| Autenticações falhadas | endereço de origem | 20 | 1 por cada 3 segundos |
| Leituras | credencial | 120 | 2 / segundo |
| Escritas (rajada) | credencial | 60 | 1 / segundo |
| Escritas (por hora) | credencial | 1000 | 1000 / 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 ocapacitysã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.changeddo §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_supersedede oenvelope_widenedsão os únicos sinais próximos da análise, e o segundo é deliberadamente conservador. - Sem seleção de tenant. Não existe
tenant_idem 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, semwatermark, semupdated_since. As únicas datas que pode enviar sãovalid_fromevalid_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"}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 mesmareferenceque 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),
localhoste 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é agora | Próxima tentativa |
|---|---|
| 1 | passado 1 minuto |
| 2 | passados 5 minutos |
| 3 | passados 30 minutos |
| 4 | passadas 2 horas |
| 5 | nenhuma — 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);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.
