Tu reserva
Español
Buscar aparcamiento

API para operadores · v1

Envía tus aparcamientos. Recoge tus reservas.

La referencia para el operador que conecta su propio sistema con Parkena. Todo lo que hay debajo describe software que está desplegado y respondiendo, incluidas las partes que dicen que no.

Lee esto antes de planificar un proyecto en torno a esta API.

Esta es una referencia completa y exacta. Un propietario o un gestor de tu cuenta de operador emite y revoca claves en la consola, en Account → API keys; el secreto se muestra una sola vez. Lo que no está abierto es el resto: no hay entorno de pruebas y conectamos a un operador cada vez, así que escríbenos antes de planificar un desarrollo en torno a esto. Lo que la v1 se niega a hacer está escrito por extenso en el §12, en lugar de dejar que lo descubras en la tercera semana.

1. Qué es esto, y qué no es

Esta es la referencia de la v1 de la API para operadores de Parkena. Es la alternativa a llevar tu inventario en la consola de Parkena: envía tus aparcamientos y tus precios, y recoge de vuelta tus reservas de Parkena. Las dos vías escriben en las mismas tablas a través de la misma seguridad, y ninguna de las dos puede alcanzar los datos de otro operador. Puedes usar las dos —la consola para lo que decide una persona, la API para la sincronización nocturna— y no se pelearán entre ellas.

Lo que no es: un producto que integres sin que nadie te atienda. Un propietario o un gestor con la sesión iniciada acuña la clave en la consola; no hay entorno de pruebas con el que practicar, y la primera integración se construye con una persona de nuestro lado. Eso dice en qué etapa está Parkena, no que haya una cola que puedas saltarte.

Cinco cosas que esta API no hace, dichas de entrada

Las reservas se recogen, con un cursor, y ese cursor es la fuente de la verdad. Un endpoint registrado puede recibir una pista firmada booking.changed que dice «recoge ahora» —el §13—, pero en un webhook nunca viajan datos de reserva, a propósito, y el §12 dice qué sigue sin existir.

No hay calendario de disponibilidad. Los periodos bloqueados —el §7— retiran de la venta rangos de fechas enteros, pero no puedes enviar «plazas libres esta noche» como número, y el campo que lo parece —capacity— son las plazas TOTALES. Poner ahí la disponibilidad informa mal de tu aparcamiento y anula la revisión de su ficha cada noche.

Por esta API no se mueve dinero. Ni cobros, ni reembolsos, ni datos de liquidación, ni cifras de comisión.

Nada de lo que hay aquí crea, modifica, cancela, hace el check-in ni reembolsa una reserva. No existe un ámbito para ello ni un permiso concedido detrás.

La URL base es https://api.parkena.com/v1 — mira el §3. No hay que apuntar a ninguna otra.

2. Dos reglas que acertar antes de escribir una línea

Estas son las dos cosas que una integración competente sigue haciendo mal, porque en los dos casos lo incorrecto parece haber funcionado. Están subidas al principio de esta página justo por eso; todo lo que viene después es material de referencia normal.

Regla uno: compara antes de actualizar

Cuando un revisor de Parkena aprueba tu ficha, aprueba un contenido concreto. Si ese contenido cambia, la aprobación ya no describe lo que está publicado, así que se anula y el aparcamiento vuelve a revisión — y deja de venderse en parkena.com hasta que una persona lo apruebe otra vez.

Ese comportamiento es correcto, y delante de una máquina que reenvía todo su parque cada noche también es una forma de quedarse fuera del escaparate a las 03:00 todas las noches para siempre. Por eso esta API no emite una actualización que no cambia nada. Las dos rutas de escritura leen la fila actual, la comparan campo a campo y, si no difiere nada, no emiten ninguna sentencia: ni un UPDATE en vacío; ninguna sentencia. Recibes "result": "unchanged" y "changed": [].

No tienes que hacer nada para conseguirlo. No es un flag y no hay ninguna cabecera que enviar. Un reenvío nocturno completo de datos idénticos es una operación en vacío que cuesta una lectura por aparcamiento.

# 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 }
Respuestas abreviadas de la función desplegada. La forma completa está en el §7.

La segunda respuesta no es un aviso que puedas ignorar: ese aparcamiento se ha ido del escaparate y sigue fuera hasta que vuelva a aprobarse. El §8 enumera todos los campos que te cuestan una nueva revisión, y todos los que no.

Regla dos: GET devuelve un campo que PUT rechaza

GET /lots devuelve cada aparcamiento CON su envoltorio rates, porque es lo útil de leer. PUT /lots/{external_id} NO lo acepta: los precios se fijan en PUT /lots/{external_id}/rates. Así que el bucle obvio —lees un aparcamiento, cambias un campo y lo devuelves— falla hasta que quitas rates. Quita external_id con él: ese vive en la ruta.

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

Si lo devuelves con rates todavía dentro, recibes 422 {"error":"field_belongs_to_another_route","field":"rates"}. Lo rechazamos en lugar de ignorarlo, y la distinción es justo lo importante: si pusieras un precio nuevo en el cuerpo de un aparcamiento y te respondiéramos 200, creerías con razón que el precio ha cambiado. No habría cambiado.

3. La URL base

Todas las rutas de esta página son relativas a https://api.parkena.com/v1. Todo es JSON, a la entrada y a la salida.

api.parkena.com es un nombre de host que pertenece a Parkena

Está delante de la función que responde; las rutas de abajo no cambiarán aunque cambie lo que hay detrás. Aun así, guarda la base en la configuración y no en el código fuente.

El desarrollo local contra el propio stack del repositorio usa esas mismas rutas bajo /functions/v1/operator-api/v1 en el origen local de Supabase: las mismas rutas, otra base.

No hay entorno de pruebas ni base de pruebas. La base de arriba es la de producción, y sobre qué operador actúa una petición se resuelve a partir de la credencial que presentas, nunca a partir de nada que venga en una URL o en un cuerpo. Mira el §4, y el §12 sobre la selección de tenant.

4. Autenticación

Cada petición lleva una cabecera:

Authorization: Bearer <key_id>.<secret>

key_id y secret son las dos mitades de una misma credencial, unidas por un punto. El primer punto las separa; cualquier punto posterior pertenece al secreto.

curl "$BASE/ping" \
  -H "Authorization: Bearer pk_api_3f9c….pk_sec_a71b…"
  • key_idpk_api_ seguido de 32 caracteres hexadecimales. Es la mitad pública. Aparece en nuestros registros a propósito, y puedes ponerlo tranquilamente en un fichero de configuración o citarlo en una petición de soporte. Conocerlo llega tan lejos como conocer un nombre de usuario.
  • secretpk_sec_ seguido de 64 caracteres hexadecimales, que son 256 bits. No lo guardamos. Guardamos un HMAC-SHA256 con sal, en dos columnas que ningún rol de nuestra base de datos puede leer. No podemos recuperarlo por ti, nunca. Si lo pierdes, emite una credencial nueva y revoca la anterior.

No existe una alternativa ?key= en la cadena de consulta, y no la habrá: un secreto en una URL es un secreto en el registro de un proxy, en el historial de un navegador y en una cabecera Referer.

Todos los fallos de autenticación devuelven el mismo 401

Un key_id desconocido, un secreto equivocado, una credencial revocada, una credencial caducada, una cuenta de operador suspendida, una credencial a la que le falta el ámbito que exige una ruta y un external_id que no es de ninguno de tus aparcamientos son todos 401 {"error":"unauthorized"}. No se distinguen, y es deliberado: el §9 dice qué se compra con eso y qué te cuesta.

Usa GET /ping para comprobar una credencial en lugar de deducir nada de un 401.

5. Emitir, rotar y revocar una credencial

Las credenciales las acuña un propietario o un gestor con la sesión iniciada en tu cuenta de operador, nunca a través de esta API. Una clave de API capaz de acuñar claves de API es una clave que puede escalar sus propios permisos, así que aquí no hay ninguna ruta que emita una. Las cuentas de personal no pueden ni emitir ni revocar.

Las dos llamadas que hay detrás son api_credential_issue y api_credential_revoke, hechas sobre PostgREST con el token de un usuario con la sesión iniciada y no con una credencial de API. Están documentadas aquí porque un operador con equipo de ingeniería querrá invocarlas directamente; la pantalla de la consola en Account → API keys es la forma normal de emitir una.

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, de 1 a 80 caracteresCómo llamarás a esta clave dentro de seis meses. Obligatorio.
p_scopesarray de ámbitosConjunto no vacío de ámbitos distintos. Obligatorio.
p_expires_in_daysinteger de 1 a 3650, o nullNull significa que no caduca. 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"
}]
La respuesta — y la única vez que se devuelve el secreto.

No existe p_expires_at y nunca existirá. La caducidad es un número de días que el servidor convierte contra su propio reloj; esta API no acepta ninguna marca de tiempo puesta por quien llama, en ninguna ruta.

Rotar

No hay una llamada de rotación, porque rotar sin ningún corte de servicio son solo dos llamadas en el orden correcto:

  1. Emite una segunda credencial con los mismos ámbitos.
  2. Despliégala en tu sistema y confirma que el tráfico circula: GET /ping con la clave nueva y, después, vigila su last_used_at.
  3. Revoca la antigua.

Las dos credenciales están vivas entre el primer paso y el tercero. No hay ningún límite que te impida tener dos.

Revocar

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

La revocación es inmediata y permanente. Una credencial revocada no se restablece: se sustituye. Revocar dos veces devuelve la misma fila con el revoked_at original, porque una clave no muere dos veces. Un id que no es tuyo devuelve [], exactamente igual que uno que nunca existió.

6. Ámbitos

Una credencial lleva un conjunto de ámbitos. Existen tres:

ÁmbitoQué permite
read_supplyListar tus aparcamientos, sus horquillas de precio de Parkena y los periodos bloqueados de cada aparcamiento — incluido el recuento agregado overlapping_bookings de cada uno. Un recuento, nunca una reserva: no lo acompaña ninguna referencia, ningún nombre ni ninguna matrícula.
write_supplyCrear y modificar aparcamientos, sus precios de Parkena y sus listas de periodos bloqueados.
read_bookingsRecoger tus reservas de Parkena.

Concede lo mínimo que necesites. Una sincronización que solo envía inventario no necesita read_bookings; un proceso de informes que solo recoge reservas no debe tener write_supply.

La comprobación del ámbito no es orientativa. Es un argumento de la misma llamada a la base de datos que establece qué filas de qué operador puede tocar la petición, así que no hay ningún camino hasta tus datos que se la salte. Una credencial sin el ámbito que exige una ruta recibe el 401 uniforme: la misma respuesta que recibe un secreto equivocado.

Deliberadamente no existe write_bookings. Nada en Parkena permite que una máquina cree, modifique o cancele una reserva, así que un ámbito con ese nombre te parecería un límite y no sería nada de eso.

7. Rutas

MétodoRutaÁmbito necesario
GET/pingcualquier 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

{external_id} es TU PROPIO identificador del aparcamiento, el que use tu sistema. Para nosotros es opaco: nunca lo interpretamos y no tiene por qué ser un UUID. Codifícalo con porcentajes si contiene una / o un espacio. Los ids internos de Parkena nunca se te envían y nunca te los aceptamos.

Los parámetros de consulta desconocidos se rechazan en lugar de ignorarse, en todas las rutas. Un parámetro mal escrito que se descarta en silencio es un filtro que crees aplicado y no lo está.

GET /ping

Comprueba una credencial y te dice qué puede hacer. Esta es la ruta que hay que usar cuando una integración no funciona: cualquier otra negativa de esta API es deliberadamente incapaz de decirte cuál de seis cosas ha fallado. No establece contexto de operador y no lee ninguna fila.

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

server_time es nuestro reloj, en UTC. Es informativo: tú nunca nos envías una hora de vuelta.

GET /lots

Tus aparcamientos, cada uno con su horquilla de precio de Parkena.

Parámetro de consultaTipoPor defectoNotas
afterstringContinúa después de este external_id. Usa el next_after de la página anterior.
limitinteger de 1 a 20050Pedir más se rechaza, no se reduce en silencio.

La paginación va sobre tu propio external_id, en orden ascendente: no es un desplazamiento, así que el corte entre dos páginas no se mueve por una escritura simultánea.

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

next_after solo aparece cuando la página venía llena. Síguelo hasta que sea null y habrás visto todo tu parque exactamente una vez. rates es null en un aparcamiento que todavía no tiene precio de Parkena.

PUT /lots/{external_id}

Crea o actualiza UN aparcamiento. Una petición, un aparcamiento: no hay endpoint masivo, y es la forma de la ruta la que lo impone. Un cuerpo que sea un array se rechaza con too_many_lots.

PUT es una representación completa. Una clave ausente significa null, no «déjalo como estaba». Envía el aparcamiento entero cada vez. Lo contrario hace imposible vaciar un campo y convierte una clave mal escrita en una operación en vacío permanente y silenciosa.

CampoTipoObligatorioNotas
external_idstringNoSi aparece, tiene que ser igual al de la ruta. Se comprueba, no se usa.
namestring, máx. 200
timezonestringNombre IANA, p. ej. Europe/Berlin. Todo cálculo de cambio de día se resuelve a través de él.
handoverstringself o attended.
online_bookablebooleanTu interruptor de venta. Es obligatorio precisamente porque, si tuviera valor por defecto, el primer envío incompleto sacaría de la venta un aparcamiento que sí estaba vendiendo.
capacityinteger de 0 a 1000000NoPlazas TOTALES. No las plazas libres esta noche — mira el aviso de abajo.
latitudenumber o stringNoSe redondea a 6 decimales. Hay que enviarla junto con longitude.
longitudenumber o stringNoSe redondea a 6 decimales. Hay que enviarla junto con latitude.
citystring, máx. 120No
country_codestringNoISO 3166-1 alpha-2, p. ej. DE.
featuresarray de stringNoMira la lista de abajo. El orden da igual: los ordenamos nosotros.
arrivalobjectNoIndicaciones de llegada, de forma libre.
mediaobjectNoReferencias a material gráfico, de forma libre.
min_advance_daysinteger de 0 a 365NoPor defecto, 0.
min_stay_daysinteger de 1 a 365No
max_stay_daysinteger de 1 a 365NoLos tres se paran en 365, que es hasta cuántos días por delante cotiza Parkena una estancia. Un valor mayor se aceptaría y no se respetaría nunca.
cancellationobject o nullNoLas tres claves o ninguna — mira abajo.

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

cancellation es un único objeto anidado, y es todo o nada:

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

Enviar una o dos de las tres es 422 incomplete_cancellation_policy. Una política con una ventana gratuita y sin penalización declarada no es una política que se sepa a medias: es una pregunta sobre reembolsos que no tiene respuesta. Envía null u omite la clave para decir «sin política».

capacity son PLAZAS TOTALES, no disponibilidad

Si tu envío nocturno mete «plazas libres esta noche» en capacity, le dirás a Parkena que tu aparcamiento ha encogido Y anularás la revisión de tu ficha todas y cada una de las noches, porque capacity es contenido revisado.

La disponibilidad en tiempo real es un mecanismo distinto y no forma parte de la v1. Esta API no puede detectar el error, porque un número es un 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
        }
      }'
Un envío completo.
{
  "external_id": "LOT-1",
  "result": "created",
  "changed": ["name", "timezone", "handover", "…"],
  "listing_review_superseded": false,
  "lot": { "…": "the car park as it now stands" }
}
201 cuando el aparcamiento se ha creado, 200 en el resto de casos.
CampoSignificado
resultcreated, updated o unchanged.
changedLos nombres de los campos que de verdad difieren. Vacío en unchanged.
listing_review_supersededtrue si esta escritura anuló la revisión de tu ficha en Parkena — mira el §8.
lotEl aparcamiento guardado, releído.

result: "unchanged" significa que no se emitió ninguna sentencia: ni bloqueo de fila, ni escritura, ni disparador. Ese es el resultado normal y esperado de un reenvío nocturno, y es lo que impide que esta API te saque del escaparate cada noche.

PUT /lots/{external_id}/rates

Fija la horquilla de precios de Parkena para un aparcamiento. Un plan de tarifas no es un precio: es el RANGO dentro del cual estás dispuesto a vender en el canal de Parkena — un suelo, un techo y una base entre los dos.

Esta ruta escribe el canal parkena y solo ese canal. Tus propios precios directos son tuyos y esta API no puede tocarlos.

Se aceptan dos formas de cuerpo. Un rango:

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

O un precio fijo, que se expande a suelo = base = techo:

{ "fixed_price": "12.50" }
CampoTipoObligatorioNotas
fixed_pricedecimaluna forma o la otraNo se puede combinar con los campos del rango.
floor_pricedecimalcon el rangoTiene que ser ≤ base_price.
base_pricedecimalcon el rangoTiene que estar entre el suelo y el techo.
ceiling_pricedecimalcon el rangoTiene que ser ≥ base_price.
min_first_day_pricedecimal o nullNoTiene que quedar dentro de la horquilla.
currencystringNoISO 4217. Por defecto, tu moneda de liquidación, y no puede ser otra.
dynamicbooleanNoPor defecto, false.
valid_fromYYYY-MM-DD o nullNoUna fecha de calendario. Una marca de tiempo se rechaza, no se trunca.
valid_toYYYY-MM-DD o nullNoNo puede ser anterior a valid_from.

El dinero es una cadena, y se rechaza en lugar de redondearse. Envía "12.50", no 12.345. Los importes llevan dos decimales; un tercero es 422 invalid_body, porque un precio que no has tecleado no es un precio que hayas aceptado. Las coordenadas son lo contrario: son una medición, así que se redondean.

Enviar a la vez fixed_price y un rango es 422. Adivinar cuál de los dos querías decir es la forma en que un aparcamiento acaba con el precio en el extremo equivocado de su propio rango.

{
  "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 al crear, 200 en el resto de casos.

envelope_widened es true cuando esta escritura movió la horquilla HACIA FUERA —suelo abajo, techo arriba, cambio de moneda o dynamic invertido— o cuando antes no había horquilla de Parkena. Ensanchar es lo que te puede costar una revisión de la ficha.

Es una señal conservadora, y preferimos decirlo a exagerarla: comparamos con la horquilla que estaba viva hace un instante, no con la que aprobó un revisor, porque esta API deliberadamente no puede leer el estado de tu revisión. Así que puede informar true en un caso que no anula nada. Lo que no hará es informar false cuando algo sí se ha anulado.

GET /lots/{external_id}/blocked-periods

La lista de periodos bloqueados del aparcamiento: cada rango de fechas en el que está retirado de la venta en Parkena, lo haya escrito quien lo haya escrito — tus sincronizaciones y la consola escriben la misma lista. Un periodo bloqueado detiene las ventas NUEVAS de Parkena para las estancias que lo tocan, y no hace nada más: no cancela nada, y no toca tus propias ventas directas. Las dos fechas son inclusivas — ends_on es el último día bloqueado, no el día siguiente, y un periodo con starts_on igual a ends_on bloquea exactamente ese único día.

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 }
  ]
}
Los periodos vuelven con el más temprano primero. reason es tu propia etiqueta, null cuando no se dio ninguna.

overlapping_bookings es un recuento de transparencia: cuántas reservas de este aparcamiento —cualquier canal, cualquier estado salvo cancelled— tienen una estancia que toca el periodo. Las dos comparaciones son inclusivas, sobre las fechas de calendario del propio aparcamiento: una reserva que sale la primera mañana del periodo sigue contando, igual que una que llega su última tarde. Fíjate en qué incluye «salvo cancelled»: una estancia COMPLETADA también cuenta. El recuento responde a «qué se vendió en esas fechas», historia incluida — no es el número de coches por venir que un periodo bloqueado dejaría tirados, así que puede salir más alto que el panel de aviso de la consola, que hace esa pregunta más estrecha. Es un recuento y nada más: en la respuesta no hay referencia, nombre, matrícula ni fecha de ninguna reserva.

PUT /lots/{external_id}/blocked-periods

Sustituye la lista ENTERA de periodos bloqueados del aparcamiento por la del cuerpo. No hay una llamada «añadir un periodo» ni forma de dirigirse a un periodo aislado — una sincronización que solo sabe fusionar es una sincronización que nunca sabe borrar, y un periodo levantado en tu sistema se quedaría en Parkena para siempre. Como mucho 100 entradas; cada fecha, un día de calendario real entre 2020-01-01 y 2032-12-31; ends_on nunca antes de starts_on; y dos entradas no pueden solaparse — de forma inclusiva: un par que comparte un solo día choca.

La lista que envías es la lista que existe

PUT es aquí una representación completa, la misma regla que PUT /lots/{external_id} — y en esta ruta la regla tiene una consecuencia que merece mayúsculas: LOS PERIODOS ESCRITOS EN LA CONSOLA FORMAN PARTE DE LA MISMA LISTA. Si una persona apunta un periodo bloqueado en la consola el martes y tu sincronización nocturna envía solo sus propios periodos el martes por la noche, la sincronización elimina el periodo de esa persona — en silencio, correctamente, porque nos dijiste que la lista enviada era la lista entera.

Una máquina que posee esta ruta posee el calendario entero. O relees esta ruta y llevas en tu propio sistema los periodos escritos en la consola, o acuerdas con tu propia gente qué sistema posee los periodos bloqueados. La API no va a 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" }
        ]
      }'
Una sustitución completa. El segundo periodo no lleva reason, y eso está permitido.
CampoTipoObligatorioNotas
starts_onYYYY-MM-DDEl primer día bloqueado, inclusive. Una fecha de calendario — una marca de tiempo se rechaza, no se trunca.
ends_onYYYY-MM-DDEl ÚLTIMO día bloqueado, inclusive — no el día siguiente. No debe preceder a starts_on.
reasonstring ≤200, o nullnoUna etiqueta, que la consola muestra. Vacía se rechaza; null y ausente significan ambas «sin motivo». Se guarda tal cual, nunca recortada — un espacio cambiado es un periodo cambiado.

Cada rechazo de contenido en esta ruta lleva el mismo código, 422 {"error":"invalid_blocked_periods"}, con field nombrando la entrada culpable en las coordenadas de tu propio cuerpo — blocked_periods[3].ends_on. Los dos errores de sobre conservan los códigos que el resto de la API les da: una clave de primer nivel sobrante es unknown_field, una clave blocked_periods ausente es missing_field.

Qué estaba mal`field`
Más de 100 entradas, o blocked_periods no es un arrayblocked_periods
No es un día de calendario real (2026-02-30), es una marca de tiempo, o está fuera de 2020..2032el starts_on / ends_on de la entrada
ends_on antes de starts_onel ends_on de la entrada
Dos entradas se solapan (inclusive — basta con compartir un día)el starts_on de la entrada que empieza más tarde
reason vacío, no textual, o de más de 200 caracteresel reason de la entrada

Los periodos que se solapan con reservas vendidas NO se rechazan. Una sincronización de máquina no debe atascarse con un coche que se vendió la semana pasada; las reservas se mantienen — un periodo bloqueado solo detiene las ventas NUEVAS. En su lugar, la respuesta lleva el recuento overlapping_bookings de cada periodo (semántica arriba, estancias completadas incluidas), para que tu sistema, y el humano detrás de él, vean exactamente qué periodos tienen ya coches vendidos 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 cuando el aparcamiento no tenía antes ningún periodo, 200 en el resto de casos.
CampoSignificado
resultcreated (antes cero periodos, ahora alguno), updated (todo lo demás que escribió — incluido un PUT de [] que vació la lista), o unchanged.
added / removedLos recuentos de filas que la escritura movió de verdad. Ambos a 0 con unchanged.
blocked_periodsLa lista guardada, releída DESPUÉS de la escritura con recuentos overlapping_bookings frescos — lo que la base de datos contiene ahora, nunca un eco de tu cuerpo.

La regla de «compara antes de actualizar» del §2 vale aquí en forma de lista: result: "unchanged" significa que la lista guardada y tu cuerpo ya decían lo mismo, y que no se emitió ninguna instrucción en absoluto. Reenviar cada noche una lista de periodos sin cambios cuesta una lectura.

GET /bookings

Tus reservas de Parkena, como CURSOR DE RECOGIDA. Ese cursor es la fuente de la verdad: un endpoint de webhook registrado puede recibir una pista firmada booking.changed que te dice que lo recojas antes —el §13—, pero una pista no lleva datos de reserva, y nada de lo que construyas sobre este cursor se pierde jamás.

Parámetro de consultaTipoPor defectoNotas
sincestringEl token next de tu llamada anterior. Omítelo para decir «desde el principio».
limitinteger de 1 a 500200Pedir más se rechaza, no se reduce en silencio.
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
referenceLa referencia de reserva de PARKENA. Esta es la clave de deduplicación.
external_referenceTu propia cadena, si se puso alguna. Se devuelve tal cual, nunca se usa como clave.
check_in_local / check_out_localLa hora local del propio aparcamiento, SIN desfase de zona. Léelas en timezone.
totalUna cadena decimal, no un float.
statuspending, confirmed, checked_in, completed, cancelled.
payment_statuspending, paid, partial, refunded, expired, not_required.

Cómo consultar el cursor correctamente

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)

Tres reglas, y cada una sostiene algo:

  1. Deduplica por reference. La entrega es al menos una vez, nunca exactamente una vez. Verás la misma reserva más de una vez, y eso es correcto, no un fallo.
  2. Guarda next DESPUÉS de haber almacenado el lote de forma duradera, no antes. Si tu proceso muere a mitad de un lote, el cursor sin guardar lo vuelve a entregar, cosa que la regla 1 hace inofensiva.
  3. No construyas un cursor. Es un token que hemos emitido nosotros. No existe ?since=<un instante que yo elijo>, y esa ausencia es deliberada: un socio que nombrara un instante futuro dejaría de recibir sus propias reservas sin enterarse, y el primer síntoma sería un coche en una barrera del que su sistema no ha oído hablar nunca. Un cursor que va más allá de nuestra marca de avance se recorta hasta ella, así que lo peor que puede hacer un token manipulado es entregar filas dos veces.

Por qué puedes volver a ver una reserva enseguida: el cursor que te devolvemos nunca apunta más allá de now() − 5 minutes. Las reservas más nuevas que eso sí se devuelven —quieres tu reserva ahora, no dentro de cinco minutos—, simplemente no comprometemos el cursor con ellas. Esto no es prudencia porque sí. Una transacción de base de datos se sella con la hora en que EMPEZÓ, así que una transacción lenta puede confirmar una fila sellada antes que otra que ya te hemos entregado; sin ese retardo, un cursor que saltara directamente a la fila más nueva se la saltaría de forma permanente y silenciosa.

8. Qué te cuesta una nueva revisión

El §2 tiene la regla; esta es la lista de campos que hay detrás. Cambiar cualquiera de los siguientes en PUT /lots/{external_id} anula una ficha pendiente o aprobada, y la respuesta lo dice con "listing_review_superseded": true:

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

Qué no te cuesta ninguna

  • online_bookable. Es tu interruptor de venta, no contenido revisado. Apagarlo saca el aparcamiento de la venta de inmediato sin anular nada, que es exactamente por lo que es un campo obligatorio en cada PUT.
  • Cualquier cosa de la ruta de tarifas que no ensanche la horquilla. Subir tu suelo o bajar tu techo es quedarte dentro de una promesa que ya habías hecho. Bajar el suelo, subir el techo, cambiar de moneda o invertir dynamic es hacer una nueva, y puede anular la revisión: envelope_widened te dice cuándo.
  • valid_from / valid_to. La caducidad y la renovación se leen en vivo, así que un cambio de ventana saca el aparcamiento del escaparate y lo vuelve a poner sin que intervenga nadie.

Dos trampas que conviene conocer

  • El orden de las prestaciones te da igual a ti, pero antes nos importaba a nosotros. Ordenamos features antes de guardarlas, así que ["valet","indoor"] y ["indoor","valet"] son el mismo envío. No hace falta que las ordenes.
  • La precisión de las coordenadas da igual. Redondeamos a seis decimales antes de comparar, así que enviar 52.5200001 cada noche no hace que latitude figure como cambiada para siempre.

No hay borrado

Esta API no puede borrar un aparcamiento ni un precio, y no hay ningún permiso concedido que se lo permitiera. delete y luego insert es el patrón de upsert más común en el código de ingesta, y sobre estos datos es catastrófico: el aparcamiento tendría una identidad nueva y se llevaría con él su historial de ficha, sus precios y su disponibilidad, desde un proceso que nadie estaba mirando. Quitar un aparcamiento es algo que hace una persona en la consola, a propósito, donde la confirmación nombra todo lo que se va con él. Un aparcamiento que alguna vez ha recibido una reserva no se puede quitar en absoluto, ni por nadie: una reserva es un registro financiero, se conserva, y también se conserva el aparcamiento para el que se hizo. Para dejar de vender uno, pon "online_bookable": false.

9. Errores

Todo error es JSON con un código error estable y legible por una máquina:

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

field, cuando aparece, es TU PROPIO nombre de campo, devuelto tal cual. Nunca devolvemos un mensaje de la base de datos, ni el nombre de una restricción, ni el de una tabla: eso es nuestro y lo podemos renombrar, y tu integración no debería romperse cuando lo hagamos. Ramifica según error.

De credencial

EstadoCódigoSignificado
401unauthorizedMira más abajo: cubre seis situaciones distintas y se niega a decir cuál.

De la forma de la petición

EstadoCódigoSignificado
404not_foundNo existe esa RUTA. Nunca se usa para un recurso.
405method_not_allowedRuta correcta, verbo equivocado. La cabecera Allow nombra el verbo que esperábamos.
413payload_too_largeCuerpo de más de 64 KiB.
415unsupported_media_typeEl Content-Type no era application/json.
422invalid_jsonEl cuerpo no era JSON analizable.
422invalid_bodyJSON bien formado, con la forma equivocada o con un valor inservible.
422missing_fieldFaltaba un campo obligatorio.
422unknown_fieldUn campo que no reconocemos. Rechazado, no ignorado.
422field_belongs_to_another_routeUn campo que existe, pero que se fija en otro sitio. Hoy el único es rates — mira el §2.
422invalid_queryUn parámetro de consulta erróneo o desconocido.
422invalid_cursorEl token since no era de los nuestros.
422too_many_lotsUn cuerpo que es un array. Una petición, un aparcamiento.

De contenido

EstadoCódigoSignificado
422invalid_timezoneNo es un nombre de zona horaria IANA.
422invalid_coordinatesFuera de rango, o una latitud sin longitud.
422invalid_country_codeNo es ISO 3166-1 alpha-2.
422invalid_stay_boundsmin_stay_days / max_stay_days se contradicen.
422incomplete_cancellation_policyUna o dos de las tres claves de cancelación.
422invalid_price_envelopeEl suelo, la base y el techo no están en orden.
422invalid_first_day_pricemin_first_day_price fuera de la horquilla.
422invalid_validity_windowvalid_to es anterior a valid_from.
422currency_is_not_settlement_currencyNo es tu moneda de liquidación.
422invalid_blocked_periodsUna entrada de periodos bloqueados es inutilizable; field la nombra en las coordenadas de tu propio cuerpo (blocked_periods[3].ends_on). La tabla de rechazos está en el §7.
409conflictUna carrera de verdad: dos envíos crearon el mismo external_id a la vez. Reinténtalo.

Nuestros

EstadoCódigoSignificado
429rate_limitedPor encima de un límite. Respeta Retry-After.
503try_againUn conflicto transitorio de la base de datos. Reinténtalo; Retry-After viene puesto.
500internal_errorCulpa nuestra. Está entero en nuestros registros. Reintentar es razonable.

Por qué el 401 no te va a decir más

unauthorized cubre todos estos casos y se niega a distinguirlos:

  1. No hay cabecera Authorization, o hay una que no podemos interpretar.
  2. Un key_id que no nombra ninguna credencial.
  3. Un key_id que existe, con el secreto equivocado.
  4. Una credencial revocada, caducada, o cuya cuenta de operador está suspendida.
  5. Una credencial válida a la que le falta el ámbito que exige esta ruta.
  6. Una credencial válida que nombra un external_id que no es de sus aparcamientos.

Los casos del 2 al 5 no se distinguen ni siquiera dentro de nuestro propio proceso: la base de datos responde a todos de forma idéntica, con el mismo trabajo hecho, para que nadie pueda enumerar la lista de operadores ni averiguar qué capacidades de una clave robada siguen funcionando.

El caso 6 te cuesta algo de comodidad, y vamos a decir por qué en lugar de limitarnos a afirmarlo: una credencial que solo tiene write_supply no puede listar aparcamientos. Si un external_id desconocido respondiera lot_not_found, esa credencial tendría un oráculo de enumeración en funcionamiento sobre justo el parque que sus ámbitos le niegan, construido a partir de una negativa. Así que recibe el mismo 401.

GET /ping es la respuesta a esto. Te dice que tu clave está viva y qué puede hacer, sin que tengas que adivinar nada a partir de un 401. Si el ping funciona y una ruta responde 401, estás ante un ámbito que no tienes o un external_id que no es tuyo: comprueba los dos contra GET /lots.

10. Límites de uso, límites de tamaño y qué registramos

LímiteValor
Cuerpo de la petición64 KiB
Aparcamientos por petición1
Página de GET /lotsde 1 a 200, 50 por defecto
Página de GET /bookingsde 1 a 500, 200 por defecto
Periodos bloqueados por PUT …/blocked-periods100 — una lista, un aparcamiento

Los límites de uso son cubos de tokens: una capacidad de ráfaga que se rellena de forma continua.

CuboSe cuenta contraRáfagaRecarga
Todas las peticionesla dirección de origen2404 / segundo
Autenticaciones fallidasla dirección de origen201 cada 3 segundos
Lecturasla credencial1202 / segundo
Escrituras (ráfaga)la credencial601 / segundo
Escrituras (por hora)la credencial10001000 / hora

Una negativa es un 429 con una cabecera Retry-After en segundos enteros. Que te rechacen no gasta ningún token, así que reintentar no aleja todavía más tu propia recuperación.

Las escrituras están acotadas dos veces, y la ventana horaria es la que importa. Una clave de escritura filtrada que deja un parque a cero se parece exactamente a una sincronización nocturna legítima —misma credencial, misma ruta, misma forma, misma hora de la noche—, así que un límite de ráfaga por sí solo no puede distinguirlas, porque una sincronización de verdad también es una ráfaga. El techo por hora acota cuánto puede reescribir de un parque una clave robada antes de que sea plausible que alguien esté mirando. Un operador con 1000 aparcamientos que envía cada uno una vez por noche cabe dentro; si tu parque es mayor, pídenoslo y lo subiremos en lugar de hacerte buscar un rodeo.

Las rutas de periodos bloqueados gastan los mismos cubos que todo lo demás: el GET cuesta un token de lectura, y el PUT es una escritura, contada contra los dos cubos de escritura — una sincronización nocturna de periodos cuenta contra el mismo techo de 1000 por hora que tus envíos de aparcamientos, a propósito, porque una clave filtrada que retira un parque de la venta es exactamente la forma que ese techo existe para acotar.

Las autenticaciones fallidas se cuentan contra LA DIRECCIÓN DE ORIGEN, nunca contra el key_id que se presenta. Contarlas contra la clave sería una denegación de servicio dirigida a ti: key_id es la mitad no secreta y aparece a propósito en registros y ficheros de configuración, así que cualquiera que leyera uno podría dejarte fuera de tu propia integración con unas docenas de secretos equivocados.

Ten en cuenta que un external_id desconocido también gasta presupuesto de fallos, porque devuelve el mismo 401 que todo lo demás. Un limitador que tratara los dos casos de forma distinta sería un oráculo de existencia construido a partir de un 429. Si arrastras un mapeo obsoleto, reconcílialo contra GET /lots en lugar de ir probando.

Qué registramos

Cada petición produce una línea de registro de nuestro lado; cada escritura que cambió algo produce una segunda, que lleva los NOMBRES DE LOS CAMPOS que cambiaron y si el cambio te costó una revisión. Nombres de campo, nunca valores: el registro responde a «qué le pasó a este parque anoche», y tus precios no están en él. Tu key_id sí, que es como puedes preguntarnos cuál de tus integraciones hizo algo. Tu secreto no está nunca, de ninguna forma.

11. Antes de que tu aparcamiento pueda vender

Enviar un aparcamiento por esta API lo crea, pero un aparcamiento nuevo no empieza a venderse en parkena.com en el momento en que la API devuelve 201. Parte de lo que hace falta es contenido que esta API puede aportar, y parte es una decisión que una persona tiene que confirmar en la consola.

Un PUT /lots/{external_id} completo más un PUT …/rates cubre los requisitos de capacidad, precio, geografía, ciudad y país, y política de cancelación. Queda pendiente esto, y solo se puede hacer en la consola:

  • Confirmar la zona horaria y las reglas de reserva: son derivadas y con valores por defecto, y una respuesta equivocada pero plausible pone mal el precio de las reservas en silencio, así que una persona las confirma una vez.
  • Las indicaciones de llegada y una imagen principal, para la ficha pública.
  • Aceptar el acuerdo de publicación de Parkena, una vez para toda la cuenta.
  • Enviar el aparcamiento a revisión.

Lo último es deliberado: enviar un aparcamiento a un revisor humano es una declaración que haces sobre tu negocio, y una máquina que tiene una clave no debería poder hacerla en tu nombre.

La consola muestra todos los requisitos, si están cumplidos y qué protegen. Esto no es una limitación que pensemos quitar en la v1.

12. Qué no hace la v1

Dicho sin rodeos, porque una integración construida sobre una suposición que nunca hicimos es peor que una construida sobre una carencia documentada.

  • Sin números de disponibilidad en vivo. Ahora puedes retirar de la venta rangos de fechas enteros con PUT /lots/{external_id}/blocked-periods —el §7—, pero sigue sin haber forma de enviar «plazas libres esta noche» como número, y capacity son las plazas totales: poner ahí la disponibilidad en tiempo real informará mal de tu aparcamiento y lo mandará a revisión cada noche. Un calendario de disponibilidad basado en recuentos no está en la v1.
  • Sin cargas útiles en los webhooks. Un endpoint registrado recibe la PISTA firmada booking.changed del §13 —una referencia de reserva y nada más—, y el cursor de recogida sigue siendo la fuente de la verdad, exactamente como esta lista prometía antes de que existiera la pista. Lo que sigue sin existir: cargas útiles por evento (en un webhook nunca viajan datos de reserva), garantías de orden (las pistas se fusionan y se reintentan; la secuencia es asunto del cursor), o una API de reenvío (nada que reenviar — vuelve a recoger el cursor). Una integración tiene que funcionar con las pistas apagadas, porque una pista que falla cinco entregas muere sin ruido, a propósito.
  • Sin escritura de reservas. No puedes crear, modificar, cancelar, hacer el check-in ni reembolsar una reserva a través de esta API. No existe un ámbito para ello ni un permiso concedido detrás.
  • Sin pagos. Ni cobros, ni reembolsos, ni datos de liquidación, ni cifras de comisión. El feed de reservas lleva el total de la venta y la moneda, y nada sobre cómo se movió el dinero.
  • Sin distribución a OTA ni a gestores de canal. Esta API escribe solo el canal parkena. No es un gestor de canales y no envía nada a nadie más.
  • Sin borrados. Mira el §8.
  • Sin endpoint masivo. Una petición, un aparcamiento.
  • Sin envío a revisión ni estado de la revisión. No puedes enviar a revisión ni leer el estado de tu revisión a través de la API. listing_review_superseded y envelope_widened son las únicas señales cercanas a la revisión, y la segunda es deliberadamente conservadora.
  • Sin selección de tenant. No hay ningún tenant_id en ningún cuerpo ni en ninguna cadena de consulta que esta API interprete. Sobre qué operador actúa una petición se resuelve a partir de la credencial y de nada más: un id de operador enviado en la petición sería una clave de escritura entre tenants.
  • Sin marcas de tiempo puestas por quien llama, en ningún sitio. Ni as_of, ni watermark, ni updated_since. Las únicas fechas que puedes enviar son valid_from y valid_to, que son días de calendario que declaras sobre tu propio precio. Todo lo demás es nuestro reloj.

13. Webhooks: la pista `booking.changed`

Puedes registrar un endpoint HTTPS por cuenta de operador, y le enviaremos por POST una pista firmada cada vez que una reserva tuya se cree o cambie — cualquier canal, cualquier campo. Lee el aviso de abajo antes de diseñar nada alrededor.

Una pista no es datos. El cursor es los datos.

El cuerpo entero de una pista es un nombre de evento, una referencia de reserva y una marca de tiempo. Ni estado, ni fechas, ni viajero, ni importes — nada sobre lo que tu sistema pueda actuar directamente, y nada que caduque por el camino. La única respuesta correcta a una pista es lo que tu integración ya hace: recoger GET /bookings con tu cursor guardado.

Un socio cuyo endpoint está caído un día pierde latencia, nunca datos — el cursor lo reentrega todo en la siguiente recogida. Si tu integración no puede sobrevivir con las pistas apagadas, está mal construida.

Ese reparto de papeles es la razón por la que el §12 ya no dice «sin webhooks»: lo que nos negábamos a publicar era un webhook que LLEVARA la reserva, porque un webhook que falla en silencio es una reserva de la que nunca te enteras mientras el coche llega igual a tu barrera. Una pista puede fallar en silencio y no costarte nada.

La 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"}
Un solo evento en la v1 — booking.changed, un solo nombre para crear, modificar y cancelar por igual, porque la pista no dice qué pasó; eso lo dice el cursor.
  • booking_reference es la misma reference que lleva el feed de reservas — dásela a tu deduplicación, exactamente igual que deduplicas las filas del cursor.
  • occurred_at es cuándo se registró el cambio en nuestro lado, no cuándo se envió este intento. Los reintentos lo reenvían tal cual.

Los cambios sucesivos de una misma reserva SE FUSIONAN mientras una pista suya siga sin entregar: cinco ediciones en un minuto producen un solo aviso, y eso es correcto precisamente porque la pista no lleva estado — represente los escritos que represente, tu siguiente recogida del cursor ve la fila final. La entrega es al menos una vez, como todo lo demás en esta API: puedes recibir dos pistas por un mismo cambio, y deduplicar sobre booking_reference lo vuelve gratis.

Registrar un endpoint, y las reglas que debe cumplir

Los endpoints se registran en la consola, en la misma página Account → API keys donde se emiten las credenciales, por un propietario o un gestor — no a través de esta API, por la misma razón que las claves. El secreto de firma (whsec_ seguido de 64 caracteres hexadecimales) se genera en tu navegador y se muestra UNA VEZ: lo guardamos para firmar, pero ninguna pantalla de la consola ni ninguna consulta puede volver a leerlo. En la v1 puede haber un endpoint activo por cuenta. Un endpoint nunca se edita — una URL nueva o un secreto rotado es un endpoint nuevo (desarma antes el viejo); desarmar es el único interruptor que la consola ofrece después del nacimiento.

  • Solo HTTPS, solo el puerto 443. http://, y cualquier puerto explícito distinto de 443, no se intenta jamás.
  • Un nombre de host, no una dirección. Los literales IP (v4 o v6), localhost y todo lo que viva en los dominios de nuestra propia plataforma se rechazan.
  • Las redirecciones no se siguen nunca. Una redirección es una segunda URL que nadie examinó; el intento falla en su lugar.
  • Cinco segundos de límite, y más allá del código de estado tu respuesta no se lee jamás. Responde rápido y trabaja después — la forma correcta es «encolar y devolver 204».

El contrato del 2xx, los reintentos y la muerte

Cualquier 2xx dentro del límite de tiempo significa entregado. Todo lo demás —un 4xx, un 5xx, un tiempo agotado, una conexión rechazada— se reintenta con un backoff fijo, y tras el quinto intento fallido la pista está muerta: el último estado HTTP y la razón del fallo quedan registrados y visibles en la consola, y no se hace ningún intento más. La reserva, como siempre, espera en el cursor. Desarmar un endpoint mata sus pistas pendientes en el siguiente barrido, en lugar de entregarlas más tarde a un endpoint que apagaste.

Intentos fallidos hasta ahoraSiguiente intento
1al cabo de 1 minuto
2al cabo de 5 minutos
3al cabo de 30 minutos
4al cabo de 2 horas
5ninguno — la pista está muerta, con su último estado y su razón registrados

Verificar la firma

Cada entrega lleva una cabecera Parkena-Signature: t=<unix-seconds>,v1=<hex HMAC-SHA-256>. La carga firmada es la cadena literal t + . + el cuerpo CRUDO de la petición — firma los bytes que recibiste, nunca una reserialización del JSON ya interpretado. Es a propósito el esquema exacto que Stripe usa para sus webhooks, prefijo de secreto whsec_ incluido: cualquier verificador de webhooks de Stripe que ya tengas en marcha —o el publicado en el propio repositorio de Parkena— verifica estas entregas sin cambios.

# 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);
El conjunto, como boceto.

Tres detalles que una implementación rápida hace mal: compara con una igualdad de TIEMPO CONSTANTE, no ===, para que el tiempo de respuesta no delate cuánto de una firma falsificada estaba bien; rechaza un t más viejo que unos minutos —recomendamos 300 segundos—, que es lo que vuelve inútil reproducir más tarde una entrega capturada; y si analizas la cabecera con cuidado en vez de partirla ingenuamente, verifica contra CADA par v1= presente y acepta si alguno coincide — eso es lo que evita que una futura rotación del secreto de firma te rompa en mitad de la ventana.

Una pista que no pasa tu verificación no es una entrega de Parkena. Respóndele 401 y no hagas nada más — en particular, no recojas el cursor al ritmo que ella te dicte. Recoger el cursor siempre es SEGURO; negarse va de no dejar que un llamante sin autenticar dirija la cadencia de tu sistema.

14. Cómo conseguir acceso, y cómo conseguir ayuda

Las claves las emite en la consola un propietario o un gestor, en Account → API keys. No hay entorno de pruebas, y el acceso al piloto se acuerda con nosotros operador a operador: si lo que has leído aquí encaja con el sistema que ya llevas, eso es lo que hay que contarnos cuando escribas.

Escríbenos a [email protected]. Para soporte de una integración que ya está funcionando: cita tu key_id —nunca tu secreto— y el external_id y la marca de tiempo de la petición por la que preguntas. Los dos aparecen en nuestros registros y, juntos, identifican una única petición.

Pregúntanos por el piloto.

Cuéntanos qué sistema tienes y qué quieres que envíe. Te diremos con honestidad si la v1 lo cubre — el §12 es la lista completa de lo que no puede hacer, escrita por extenso para que puedas descartarla antes de construir nada.