# PietroCal API para aplicaciones de terceros

Versión funcional inicial: **8Q20.2**

## URL base

`https://pietrocal.com/api/v1`

## Autenticación

Enviar el token en cada solicitud:

```http
Authorization: Bearer pc_live_...
Accept: application/json
```

Los tokens son credenciales personales. La API actúa exclusivamente como el usuario propietario del token y conserva los permisos reales de calendarios, contactos, reservas y workspace.

No colocar tokens `pc_live_` en JavaScript público, aplicaciones web sin backend, URLs ni repositorios. Para integraciones web, el token debe permanecer en el servidor de la aplicación externa.

## Scopes

| Scope | Permite |
|---|---|
| `profile:read` | Leer perfil, workspace, plan y capacidades |
| `calendars:read` | Leer calendarios y suscripciones |
| `calendars:write` | Crear, editar y borrar calendarios y suscripciones |
| `events:read` | Leer eventos |
| `events:write` | Crear, editar y borrar eventos |
| `tasks:read` | Leer recordatorios/VTODO |
| `tasks:write` | Crear, editar y borrar recordatorios/VTODO |
| `contacts:read` | Leer contactos y libretas |
| `contacts:write` | Crear, editar y borrar contactos y libretas |
| `availability:read` | Leer links y consultar disponibilidad |
| `availability:write` | Crear, editar y borrar configuración de disponibilidad |
| `bookings:read` | Leer reservas |
| `bookings:write` | Administrar reservas |

Un scope de escritura no concede automáticamente el scope de lectura. Para una integración completa se deben seleccionar ambos.

## Endpoints canónicos

| Recurso | Endpoint | Métodos |
|---|---|---|
| Perfil | `/me_app.php` | `GET` |
| Calendarios | `/calendars.php` | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |
| Suscripciones | `/calendar_subscriptions.php` | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |
| Eventos | `/events.php` | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |
| Recordatorios | `/tasks.php` | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |
| Contactos | `/contacts.php` | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |
| Disponibilidad | `/availability_links.php` | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |
| Consulta Free/Busy | `/freebusy.php` | `POST` |
| Reservas | `/bookings.php` | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |

Estos son los mismos controladores usados por la aplicación PietroCal. No existe una segunda lógica de negocio para terceros.

## Ejemplos

### Perfil

```bash
curl -sS \
  -H "Authorization: Bearer $PIETROCAL_TOKEN" \
  -H "Accept: application/json" \
  "https://pietrocal.com/api/v1/me_app.php"
```

### Calendarios

```bash
curl -sS \
  -H "Authorization: Bearer $PIETROCAL_TOKEN" \
  -H "Accept: application/json" \
  "https://pietrocal.com/api/v1/calendars.php"
```

### Eventos de un calendario

```bash
curl -sS \
  -H "Authorization: Bearer $PIETROCAL_TOKEN" \
  -H "Accept: application/json" \
  "https://pietrocal.com/api/v1/events.php?calendarId=18"
```

### Crear un evento

```bash
curl -sS \
  -X POST \
  -H "Authorization: Bearer $PIETROCAL_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  --data '{
    "calendarId": 18,
    "title": "Reunión desde integración",
    "start": "2026-08-03T15:00:00-03:00",
    "end": "2026-08-03T16:00:00-03:00",
    "allDay": false
  }' \
  "https://pietrocal.com/api/v1/events.php"
```

## Respuestas de autenticación y autorización

- `401 unauthenticated`: token ausente, inválido, vencido, revocado o usuario inactivo.
- `403 api_scope_required`: el token es válido pero no tiene el scope solicitado.
- `403 api_method_not_enabled`: el método todavía no fue habilitado para Bearer.
- Los errores de permisos reales del recurso conservan el código y mensaje del backend de PietroCal/DAV.

## Reglas de seguridad

- Los calendarios compartidos respetan exactamente el permiso concedido.
- Los calendarios administrados por Reservas siguen bloqueados para edición directa.
- Los eventos, tareas y contactos continúan escribiéndose por la lógica DAV existente.
- Un token nunca permite acceder a información de otro usuario por enviar IDs ajenos.
- Revocar el token corta el acceso inmediatamente.
- Crear un token nuevo cuando cambien los scopes requeridos.

## Cuotas, límites de solicitudes y trazabilidad — 9Q05.1

Cada llamada que supera autenticación y validación de scope consume dos
contadores antes de ejecutar el controlador:

- una unidad del límite por minuto del token concreto;
- una unidad de la cuota mensual UTC del titular, compartida por todos sus
  Personal Access Tokens y access tokens OAuth.

Por eso crear o rotar tokens no multiplica la cuota mensual. Las respuestas
reproducidas mediante `Idempotency-Key`, los errores del controlador y los
`429` también representan solicitudes recibidas y consumen una unidad. Un
token inválido o un scope rechazado antes del guard no consume cuota.

Cada respuesta medida devuelve:

```http
X-Request-Id: req_...
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1785696060
X-PietroCal-Quota-Limit: -1
X-PietroCal-Quota-Remaining: -1
X-PietroCal-Quota-Used: 84
X-PietroCal-Quota-Reset: 1788217200
X-PietroCal-Token-Budget-Limit: 10000
X-PietroCal-Token-Budget-Remaining: 9916
X-PietroCal-Token-Budget-Used: 84
X-PietroCal-Token-Budget-Reset: 1788217200
```

Los campos `Reset` son Unix timestamps UTC. `-1` en límite o remanente
significa ilimitado; `0` significa bloqueado. Enterprise conserva el contrato
vigente de 600 solicitudes por minuto y cuota mensual ilimitada. 9Q05.1 no
crea cargos por excedentes ni habilita API en planes que hoy no la incluyen.

El límite efectivo es siempre el valor más restrictivo entre el plan y el
techo operativo global. Los topes globales se configuran con:

```text
PIETRO_API_RATE_LIMIT_PER_MINUTE=600
PIETRO_API_QUOTA_PER_MONTH=-1
```

También se admiten `api_rate_limit_per_minute` y `api_quota_per_month` en la
configuración de la aplicación. Cada plan puede declarar
`api_requests_per_minute` y `api_requests_per_month` en `limits_json`.

Cuando se supera el límite por minuto:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 27
Content-Type: application/json
```

```json
{
  "success": false,
  "error": {
    "code": "api_rate_limit_exceeded",
    "message": "Se superó el límite de solicitudes del token. Reintentá más tarde."
  }
}
```

Cuando se agota la cuota mensual, la respuesta mantiene los encabezados de
ambos contadores y cambia el error:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 1843200
```

```json
{
  "success": false,
  "error": {
    "code": "api_quota_exceeded",
    "message": "Se agotó la cuota mensual del titular del token. Reintentá después del reinicio indicado."
  }
}
```

Si el plan ya no incluye API, los tokens existentes quedan cerrados con
`403 plan_feature_disabled`; conservar un token no evita el plan actual.

Las integraciones deben conservar `X-Request-Id` para diagnóstico y respetar `Retry-After` antes de reintentar. No deben reintentar inmediatamente en bucle.

PietroCal registra para auditoría el identificador del token, usuario, scope, método, ruta sin query string, código HTTP, duración, IP y User-Agent. No registra el secreto Bearer ni el cuerpo enviado.


## Presupuestos y observabilidad por token — 9Q05.2

Cada Personal Access Token y access token OAuth puede heredar la política del
titular o declarar overrides más restrictivos para:

- solicitudes por minuto;
- solicitudes mensuales de esa credencial.

Un override nunca amplía el plan ni el techo operativo global. Para el límite
por minuto, un valor vacío hereda la política del titular. Para el presupuesto
mensual, vacío o `-1` significa que no se agrega un tope individual: la cuota
mensual compartida del titular continúa aplicándose. `0` pausa la credencial.
El consumo individual se contabiliza en un bucket propio y los tres contadores
se incrementan dentro de una misma transacción antes del controlador.

Las respuestas Bearer agregan:

```http
X-PietroCal-Token-Budget-Limit: 10000
X-PietroCal-Token-Budget-Remaining: 9916
X-PietroCal-Token-Budget-Used: 84
X-PietroCal-Token-Budget-Reset: 1788217200
```

Cuando se agota el presupuesto individual:

```json
{
  "success": false,
  "error": {
    "code": "api_token_budget_exceeded",
    "message": "Se agotó el presupuesto mensual asignado a esta credencial. Reintentá después del reinicio indicado o ajustá su presupuesto."
  }
}
```

La sesión web de **Configuración → Integraciones y API** permite crear o editar
los overrides y muestra por credencial: consumo mensual, solicitudes de las
últimas 24 horas, errores de 7 días, latencia media y último uso. La actividad
puede filtrarse por token, pero sigue sin exponerse a Bearer porque contiene IP
y User-Agent. Al rotar un access token OAuth, PietroCal hereda los overrides de
la credencial anterior para no perder el presupuesto operativo.


## Actividad y retención

La actividad de tokens se consulta únicamente desde la sesión web de PietroCal. No existe un scope Bearer que permita descargar direcciones IP o User-Agent.

PietroCal conserva por defecto 90 días de auditoría. El mantenimiento diario se ejecuta con:

```bash
php scripts/process_api_log_retention.php
```

Puede configurarse mediante `PIETRO_API_LOG_RETENTION_DAYS`. Los buckets técnicos de rate limiting se conservan durante 2 días. Los buckets mensuales por token se conservan durante 15 meses para observabilidad y se eliminan después mediante el mismo worker.

## Paginación de colecciones — 8Q20.5

Los listados consultados mediante Bearer aceptan:

```text
page=1
perPage=50
```

`perPage` tiene un máximo de 100. Los parámetros inválidos vuelven a los valores predeterminados.

Endpoints paginados:

- `GET /api/v1/events.php`
- `GET /api/v1/contacts.php`
- `GET /api/v1/bookings.php`

Las respuestas incluyen:

```text
X-Pagination-Page
X-Pagination-Per-Page
X-Pagination-Total
X-Pagination-Total-Pages
```

En Contactos y Reservas también se incluye un objeto `pagination` dentro del JSON. Eventos conserva una lista JSON para mantener el contrato existente del endpoint.

Ejemplo:

```bash
curl -i \
  -H "Authorization: Bearer pc_live_..." \
  -H "Accept: application/json" \
  "https://pietrocal.com/api/v1/events.php?page=2&perPage=25"
```

La paginación se aplica exclusivamente a solicitudes Bearer. La APP autenticada por sesión conserva las respuestas anteriores sin cambios.


## OpenAPI y Swagger — 8Q20.6

- Especificación oficial: `https://pietrocal.com/api/openapi.yaml`
- Explorador Swagger: `https://pietrocal.com/api/docs/`
- El botón **Authorize** acepta el token completo `pc_live_...`.
- Swagger no persiste la autorización al recargar la página.
- La especificación documenta el contrato vigente; los payloads complejos conservan campos adicionales porque los mismos controladores atienden a la APP.
- Todo método nuevo para Bearer debe actualizar primero `api/openapi.yaml`.


## API Explorer

Los usuarios con acceso a API disponen de un explorador integrado en
**Configuración → Integraciones y API**.

El Explorer:

- acepta únicamente rutas que comienzan con `/api/v1/`;
- utiliza un token activo seleccionado por el usuario;
- no muestra ni incorpora el secreto real en el cURL copiado;
- permite GET, POST, PATCH, PUT y DELETE;
- valida el cuerpo JSON antes de ejecutar;
- muestra rate limiting, paginación, Request ID, duración y respuesta.


## Encabezados de contrato y protección

Toda solicitud autenticada mediante Bearer publica:

```text
X-PietroCal-API-Version: 1
X-Request-Id: req_...
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1785696060
X-PietroCal-Quota-Limit: -1
X-PietroCal-Quota-Remaining: -1
X-PietroCal-Quota-Used: 84
X-PietroCal-Quota-Reset: 1788217200
X-PietroCal-Token-Budget-Limit: 10000
X-PietroCal-Token-Budget-Remaining: 9916
X-PietroCal-Token-Budget-Used: 84
X-PietroCal-Token-Budget-Reset: 1788217200
```

La ausencia de `X-Request-Id` o `X-RateLimit-Limit` indica que el guard de API
no está activo y debe ejecutarse `scripts/verify_api_third_party.sh`.

La aplicación web autenticada por sesión no consume el límite externo.


## Registro de aplicaciones OAuth

Desde **Configuración → Integraciones y API → Aplicaciones OAuth** se pueden
registrar clientes públicos o confidenciales.

- Cliente público: no posee secreto y utilizará PKCE.
- Cliente confidencial: recibe un Client Secret una sola vez; PietroCal guarda
  únicamente su hash SHA-256.
- Las Redirect URI se validan y, durante la autorización, deberán coincidir de
  forma exacta con una URI registrada.
- En 8Q21.1 sólo se administra el registro. Los endpoints de autorización y
  emisión de tokens se incorporan en 8Q21.2 y 8Q21.3.


## Authorization endpoint

```text
GET https://pietrocal.com/oauth/authorize.php
```

Parámetros obligatorios:

```text
response_type=code
client_id=pc_oauth_...
redirect_uri=https://app.ejemplo.com/oauth/callback
scope=profile:read events:read
state=valor-aleatorio-del-cliente
code_challenge=BASE64URL(SHA256(code_verifier))
code_challenge_method=S256
```

Después del consentimiento, PietroCal redirige a la Redirect URI exacta:

```text
?code=pc_code_...&state=...
```

El código vence a los 5 minutos, se almacena únicamente como SHA-256 y será
intercambiable una sola vez mediante `/oauth/token` a partir de 8Q21.3.


## Token endpoint

```text
POST https://pietrocal.com/oauth/token.php
Content-Type: application/x-www-form-urlencoded
```

Intercambio de Authorization Code:

```text
grant_type=authorization_code
client_id=pc_oauth_...
code=pc_code_...
redirect_uri=https://app.ejemplo.com/oauth/callback
code_verifier=...
```

Respuesta:

```json
{
  "access_token": "pc_live_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "pc_refresh_...",
  "refresh_token_expires_in": 2592000,
  "scope": "profile:read events:read"
}
```

Renovación:

```text
grant_type=refresh_token
client_id=pc_oauth_...
refresh_token=pc_refresh_...
```

Cada renovación rota el refresh token y revoca el access token anterior. Los
clientes confidenciales deben autenticarse mediante HTTP Basic o enviar
`client_secret`; los públicos dependen de PKCE.


## Revocación OAuth

```text
POST https://pietrocal.com/oauth/revoke.php
Content-Type: application/x-www-form-urlencoded
```

Parámetros:

```text
client_id=pc_oauth_...
token=pc_live_... | pc_refresh_...
token_type_hint=access_token | refresh_token
```

Los clientes confidenciales deben autenticarse mediante HTTP Basic o
`client_secret`.

Los refresh tokens son rotatorios y de un solo uso. Si PietroCal recibe otra
vez un refresh token ya consumido, revoca toda la conexión OAuth.


## Cierre OAuth 2.1

Documentación publicada:

```text
https://pietrocal.com/api/docs/
https://pietrocal.com/api/openapi.yaml
https://pietrocal.com/api/sdk/
https://pietrocal.com/docs/OAUTH-QUICKSTART.md
https://pietrocal.com/.well-known/oauth-authorization-server/
```

La API admite simultáneamente:

- Personal Access Tokens creados manualmente.
- Access tokens emitidos mediante OAuth 2.1.

Ambos usan los mismos endpoints, scopes, auditoría y rate limiting.


## Auditoría y límites de endpoints OAuth

- `/oauth/token.php`: 60 solicitudes por minuto.
- `/oauth/revoke.php`: 60 solicitudes por minuto.
- Decisión en `/oauth/authorize.php`: 30 intentos cada cinco minutos.

Los buckets combinan endpoint, Client ID, IP de conexión y ventana. Al superar
el límite se devuelve `429`, `Retry-After`, `X-RateLimit-Limit` y
`X-RateLimit-Remaining`.

En OpenAPI, las rutas OAuth utilizan el servidor raíz
`https://pietrocal.com`; los recursos siguen usando `/api/v1`.


## Webhooks — registro inicial

Los endpoints se administran desde:

```text
Configuración → Integraciones y API → Webhooks
```

En 9Q01.1 se registra la URL, eventos, estado y Signing Secret. El motor de
entrega todavía no está habilitado; se incorpora en 9Q01.2.

El Signing Secret se cifra en PietroCal para poder firmar futuras entregas HMAC.
No debe publicarse ni enviarse dentro del payload.


## Entrega de Webhooks

PietroCal realiza `POST` HTTPS con JSON y estos headers:

```text
X-PietroCal-Delivery: whd_...
X-PietroCal-Event: event.updated
X-PietroCal-Timestamp: 1785540000
X-PietroCal-Signature: v1=HEX_HMAC_SHA256
```

La firma se calcula sobre:

```text
timestamp + "." + raw_request_body
```

Nunca se debe reconstruir el JSON antes de verificar la firma. Usá los bytes
exactos recibidos.

Los Webhooks tienen semántica **at least once**. La aplicación receptora debe
guardar `X-PietroCal-Delivery` y responder idempotentemente ante duplicados.

Una respuesta `2xx` confirma la entrega. Se reintentan errores de red, `408`,
`409`, `425`, `429` y `5xx`. No se siguen redirects.


## Historial y reenvío

En Configuración → Integraciones y API → Webhooks, cada endpoint incluye
**Historial**. PietroCal muestra métricas de 30 días y los detalles de cada
intento.

El reenvío manual vuelve a colocar la misma entrega en la cola. Conserva el
mismo `X-PietroCal-Delivery`, por lo que el receptor debe continuar tratando la
operación como idempotente.


## Eventos reales disponibles

PietroCal genera Webhooks después de completar correctamente estas operaciones:

```text
calendar.created
calendar.updated
calendar.deleted
event.created
event.updated
event.deleted
event.rescheduled
event.cancelled
event.attendees_changed
task.created
task.updated
task.deleted
task.completed
task.reopened
task.due_date_changed
contact.created
contact.updated
contact.deleted
availability.link_created
availability.link_updated
availability.link_regenerated
availability.link_revoked
availability.resource_created
availability.resource_updated
availability.resource_archived
availability.pricing_updated
```

El campo `data` contiene identificadores compactos. La aplicación receptora
debe consultar la API para obtener el recurso completo cuando lo necesite.

Las modificaciones puramente locales de interfaz —por ejemplo, ocultar un
calendario o cambiar su color personal— no generan eventos.


## Webhooks semánticos de Eventos y Recordatorios — 9Q04.2

Los eventos generales continúan disponibles como fallback cuando una mutación
no tiene un tipo semántico más preciso:

```text
event.updated
task.updated
```

Desde 9Q04.4 una escritura significativa genera el evento semántico y no lo
duplica con el general:

```text
event.rescheduled
event.cancelled
event.attendees_changed
task.completed
task.reopened
task.due_date_changed
```

`event.rescheduled` se genera cuando cambia `start`, `end`, `all_day` o
`timezone`. Si una misma escritura entra en `CANCELLED`, la cancelación tiene
precedencia y se genera `event.cancelled`, no una reprogramación adicional.
Eliminar físicamente el VEVENT conserva `event.deleted`; no se presenta esa
eliminación como una transición de estado. Crear directamente un evento
cancelado genera únicamente `event.cancelled`.

`task.completed` se genera al entrar en `COMPLETED`. `task.reopened` se genera
al pasar de `COMPLETED` a `NEEDS-ACTION` o `IN-PROCESS`.
`task.due_date_changed` se genera al agregar, cambiar o quitar `due`; puede
acompañar al evento de estado si ambas dimensiones cambian en la misma
escritura. En ese caso son dos significados independientes, no una copia
genérica. Crear directamente un recordatorio completado genera únicamente
`task.completed`; el vencimiento inicial forma parte de la creación y no se
anuncia como un cambio adicional.

Ejemplo de reprogramación:

```json
{
  "type": "event.rescheduled",
  "data": {
    "event_id": 245,
    "recurrence_scope": "all",
    "previous": {
      "status": "CONFIRMED",
      "start": "2026-08-04T13:00:00Z",
      "end": "2026-08-04T14:00:00Z",
      "all_day": false,
      "timezone": "America/Argentina/Buenos_Aires"
    },
    "current": {
      "status": "CONFIRMED",
      "start": "2026-08-04T14:00:00Z",
      "end": "2026-08-04T15:00:00Z",
      "all_day": false,
      "timezone": "America/Argentina/Buenos_Aires"
    },
    "changed_fields": ["start", "end"]
  }
}
```

Cambiar asistentes genera únicamente `event.attendees_changed`, después de que
DAV persistió el nuevo VEVENT. `previous` y `current` publican
únicamente conteos totales, por respuesta y por rol. Reemplazar una persona por
otra se detecta aunque los conteos coincidan, pero el Webhook nunca incluye la
firma interna, emails, nombres, organizador ni contenido iCalendar.

Las respuestas recuperadas mediante `Idempotency-Key` no vuelven a ejecutar el
controlador y no duplican el Webhook lógico.


## Reservas y disponibilidad

### Webhooks semánticos de Reservas — 9Q04.1

PietroCal conserva los eventos generales para mutaciones sin un tipo más
preciso:

```text
booking.created
booking.updated
booking.cancelled
```

Además, una integración puede suscribirse directamente a cambios significativos:

```text
booking.confirmed
booking.completed
booking.expired
booking.no_show
booking.archived
booking.payment_recorded
booking.payment_cancelled
booking.collection_suspended
booking.collection_resumed
```

Desde 9Q04.4 una transición de estado publica exactamente un evento: el tipo
semántico cuando existe y `booking.updated` sólo como fallback. El siguiente es
un fragmento abreviado de una confirmación:

```json
{
  "type": "booking.confirmed",
  "data": {
    "booking_id": 123,
    "previous": {
      "status": "tentative"
    },
    "current": {
      "status": "confirmed"
    },
    "changed_fields": ["status"]
  }
}
```

Crear directamente una reserva confirmada genera únicamente
`booking.confirmed`. Archivar genera únicamente `booking.archived`.
Los pagos o reintegros nuevos generan `booking.payment_recorded`; su anulación
genera `booking.payment_cancelled`. Las revisiones de cobranza usan
`booking.collection_suspended` y `booking.collection_resumed`.

El vencimiento automático de una retención tentativa genera únicamente
`booking.expired`:

```json
{
  "action": "expired",
  "reason": "tentative_timeout",
  "previous": {
    "status": "tentative"
  },
  "current": {
    "status": "expired"
  },
  "changed_fields": ["status", "expired_at", "updated_at"]
}
```

Una repetición recuperada mediante `Idempotency-Key` devuelve la respuesta
guardada antes de ejecutar el controlador, por lo que no crea un segundo
Webhook. Los payloads no contienen nombre, email, teléfono, notas ni referencias
de pago del cliente; la aplicación receptora debe consultar el recurso
autorizado mediante la API.

### Webhooks semánticos de Disponibilidad — 9Q04.3

Como todavía no existen integraciones cliente suscriptas al contrato general,
9Q04.3 reemplaza `availability.updated` por eventos específicos. Cada mutación
exitosa publica exactamente uno de estos eventos:

```text
availability.link_created
availability.link_updated
availability.link_regenerated
availability.link_revoked
availability.resource_created
availability.resource_updated
availability.resource_archived
availability.pricing_updated
```

Los cuatro eventos `availability.link_*` cubren tanto links personales
(`availability_type=personal_link`) como links públicos de recursos
(`availability_type=resource_link`). Regenerar sólo comunica la rotación de la
credencial: el token nuevo, su hash y la URL pública se entregan exclusivamente
en la respuesta autenticada que realizó la operación.

`availability.resource_archived` se genera desde cualquiera de los dos
endpoints autenticados de baja lógica. La revocación y desactivación en cascada
de links o tarifas subordinados forman parte del archivado y no generan eventos
adicionales. `availability.pricing_updated` reúne los cambios de tarifa base y
la creación, modificación o desactivación de reglas por período; `action`
mantiene el detalle `base_updated`, `range_created`, `range_updated` o
`range_disabled`.

Ejemplo abreviado de creación de un link de recurso:

```json
{
  "type": "availability.link_created",
  "data": {
    "object_id": "resource_link:481",
    "availability_type": "resource_link",
    "action": "created",
    "link_id": 481,
    "resource_id": 77,
    "previous": {},
    "current": {
      "status": "active",
      "timezone": "America/Argentina/Buenos_Aires",
      "interaction_mode": "request"
    },
    "changed_fields": ["status", "timezone", "interaction_mode"]
  }
}
```

Los payloads semánticos aceptan únicamente identificadores compactos y estado
operativo permitido. No contienen títulos, nombres, descripciones, notas,
importes, tokens, hashes ni URLs. `revision_at` refleja el `updated_at`
persistido; los controladores lo avanzan de forma monótona para detectar incluso
mutaciones opacas realizadas dentro del mismo segundo —por ejemplo, regenerar una
URL— sin copiar la credencial. La aplicación receptora debe consultar la API
autorizada para obtener el objeto completo. Las respuestas recuperadas mediante
`Idempotency-Key` no reejecutan el controlador y no duplican el Webhook lógico.


## Contrato uniforme previous/current — 9Q04.4

Reservas, Eventos, Recordatorios y Disponibilidad comparten el mismo contrato
de transición. Cada `data` contiene siempre:

```json
{
  "previous": {},
  "current": {},
  "changed_fields": []
}
```

Las reglas son:

- alta: `previous` vacío, `current` con el estado persistido y todas sus claves
  en `changed_fields`;
- actualización, cancelación, revocación o archivado: estado persistido anterior
  y posterior, con diferencia estricta de la lista blanca publicada;
- baja: estado persistido anterior, `current` vacío y todas las claves previas
  en `changed_fields`;
- una clave ausente es distinta de una clave presente con `null`;
- `changed_fields` se calcula centralmente y el contexto no puede reemplazar
  ninguno de los tres campos.

Cuando existe un tipo semántico, ése reemplaza al evento general: se genera una sola entrega,
sin compatibilidad duplicada. Si una misma escritura de un
Recordatorio cambia dos dimensiones semánticas independientes —por ejemplo,
estado y vencimiento— puede producir una entrega por cada tipo específico, pero
nunca agrega `task.updated` como copia.

Los mapas son planos, admiten como máximo 64 claves escalares y se construyen
desde respuestas o filas ya persistidas. Cada dominio aplica una lista blanca:
no se publican datos personales de Reservas, identidad de asistentes, títulos,
descripciones, notas, credenciales, hashes, URLs ni contenido DAV/iCalendar.


## Garantías de entrega, orden y consistencia — 9Q04.5

Cuando una mutación persistida tiene un significado semántico y existe un
endpoint activo suscripto, PietroCal crea una sola entrega lógica para ese
endpoint. Las respuestas recuperadas mediante `Idempotency-Key` no vuelven a
ejecutar el controlador; las solicitudes rechazadas antes de persistir tampoco
encolan una entrega. Si una escritura de Recordatorio cambia dos dimensiones
independientes —estado y vencimiento— puede crear los dos eventos específicos
correspondientes, tal como define 9Q04.4.

Los cambios automáticos forman parte del mismo contrato. Por ejemplo, el worker
que vence una reserva tentativa publica `booking.expired` con el propietario
explícito. El fan-out selecciona únicamente endpoints activos del mismo usuario
y workspace cuyo filtro contiene el tipo de evento. El historial y el detalle
de una entrega sólo son consultables por ese propietario.

Cada intento HTTP de una entrega reutiliza exactamente:

- el mismo `X-PietroCal-Delivery`;
- el mismo Event ID del envelope;
- el mismo tipo;
- el mismo JSON persistido.

Un retry automático o manual actualiza la entrega existente y no crea otro
evento lógico. La semántica continúa siendo **at least once**: el receptor debe
deduplicar por `X-PietroCal-Delivery`, porque una respuesta perdida puede hacer
que reciba el mismo cuerpo más de una vez.

PietroCal procesa primero las entregas listas según `next_attempt_at` y su ID
interno, pero no garantiza un orden global de llegada. Dos endpoints se procesan
de forma independiente y un retry puede llegar después de un evento posterior.
`created_at` describe cuándo se creó el envelope; no es un número de secuencia.
El consumidor debe aplicar cada entrega idempotentemente y, cuando necesite el
estado más reciente, volver a consultar el recurso autorizado.

`previous`, `current` y `changed_fields` se construyen desde el estado realmente persistido antes y después de la operación. Nunca se completan desde una
solicitud que DAV o la base hayan rechazado. Las listas blancas de 9Q04.4 siguen
siendo obligatorias en todos los intentos y reenvíos.

La emisión es fail-open respecto de la mutación principal ya confirmada: si la
cola no está disponible o alcanzó su capacidad, PietroCal registra el incidente
y no convierte en fallida una escritura que ya fue aplicada. Por eso la
garantía **at least once** comienza cuando la entrega quedó efectivamente
encolada y visible en el historial.


## Contrato de payload Webhook

Desde 9Q01.5, cada entrega declara una versión estable:

```json
{
  "schema_version": 1,
  "id": "whevt_...",
  "type": "event.created",
  "created_at": "2026-07-31T23:53:14Z",
  "workspace_id": 12,
  "data": {}
}
```

La migración 9Q01.5 agrega esta versión a las entregas históricas que todavía no la tenían.

También se envía:

```text
X-PietroCal-Schema-Version: 1
```

Un receptor debe rechazar versiones que no soporte y deduplicar mediante
`X-PietroCal-Delivery`.

## Rate limiting de acciones manuales

La interfaz aplica límites por usuario e IP real de conexión:

```text
Enviar prueba:       10 por minuto
Reenviar entrega:    20 cada 5 minutos
```

Cuando se supera el límite, PietroCal responde `429 Too Many Requests` y
envía `Retry-After`.

## Retry-After del receptor

Para respuestas `429` y `503`, PietroCal respeta `Retry-After` en segundos o
fecha HTTP. El plazo se combina con el backoff normal y se limita a un máximo
de seis horas.

## Límites operativos

- Payload máximo: 64 KB.
- Cola abierta máxima: 5.000 entregas por endpoint.
- Respuesta almacenada: hasta 16 KB y sólo si es texto UTF-8.
- `Authorization`, `Proxy-Authorization`, `Set-Cookie` y
  `WWW-Authenticate` se guardan redactados en el historial.

Al alcanzar el máximo de cola, la operación principal de PietroCal no falla:
el incidente se registra en el log y no se agrega una entrega nueva.

## Retención

Valores predeterminados:

```text
Entregas exitosas: 90 días
Entregas fallidas: 180 días
Buckets de rate limit: 2 días
```

El proceso diario es:

```bash
php scripts/process_webhook_retention.php --limit=5000
```

Las variables opcionales son:

```text
PIETRO_WEBHOOK_SUCCESS_RETENTION_DAYS
PIETRO_WEBHOOK_FAILURE_RETENTION_DAYS
PIETRO_WEBHOOK_RATE_RETENTION_DAYS
```

## Escrituras idempotentes

Desde 9Q03.1, las creaciones realizadas con Personal Access Token u OAuth 2.1
pueden enviar:

```http
Idempotency-Key: orden-externa-20260801-000184
```

La clave es opcional y se admite inicialmente en:

```text
POST /calendars.php
POST /calendar_subscriptions.php
POST /events.php
POST /tasks.php
POST /contacts.php
POST /bookings.php
POST /availability_links.php
```

Debe contener entre 8 y 200 caracteres. Se permiten letras, números, punto,
guion, guion bajo y dos puntos.

### Repetición segura

La primera solicitud ejecuta el controlador normal y guarda su código HTTP y
respuesta JSON. Una repetición con la misma identidad, endpoint, clave y
contenido:

- no vuelve a ejecutar la escritura;
- reproduce el código HTTP y el cuerpo originales;
- agrega `Idempotency-Replayed: true`.

La respuesta inicial agrega `Idempotency-Replayed: false`.

El hash considera método, endpoint normalizado, query string, tipo de contenido
y payload. Para JSON, PietroCal normaliza el orden de las propiedades antes de
comparar, por lo que dos objetos JSON equivalentes no entran en conflicto sólo
por el orden de sus claves.

### Conflictos y concurrencia

Reutilizar la misma clave con contenido distinto devuelve:

```text
HTTP 409
error.code = idempotency_conflict
```

Si la primera ejecución todavía está activa, devuelve:

```text
HTTP 425
Retry-After: 2
error.code = idempotency_in_progress
```

Una ejecución que termina de forma fatal o pierde su resultado antes de poder
confirmarlo queda como `indeterminate`. PietroCal no la repite automáticamente,
porque hacerlo podría duplicar una operación ya aplicada:

```text
HTTP 409
error.code = idempotency_indeterminate
```

En ese caso, el integrador debe consultar el recurso y reconciliar su estado
antes de utilizar una nueva clave.

### Aislamiento

Las claves se aíslan por consumidor:

- los Personal Access Tokens usan la identidad del token;
- los access tokens OAuth usan la identidad estable del grant, incluso después
  de rotar el access token mediante refresh;
- las sesiones web no activan este mecanismo y mantienen el comportamiento
  histórico de la SPA.

### Persistencia y retención

Los registros viven por defecto 24 horas. Puede configurarse entre 1 y 168
horas mediante:

```text
PIETRO_API_IDEMPOTENCY_TTL_HOURS
```

La limpieza se ejecuta por CLI:

```bash
php scripts/process_api_idempotency_retention.php --limit=5000
```

Cron diario recomendado:

```cron
47 3 * * * /usr/bin/php /RUTA/pietrocal.com/scripts/process_api_idempotency_retention.php --limit=5000 >/dev/null 2>&1
```

Si el cliente envía `Idempotency-Key` pero la migración no está disponible,
PietroCal responde `503 api_idempotency_unavailable` y no ejecuta la escritura.

## SDKs e idempotencia automática — 9Q03.2

Los clientes oficiales PHP, JavaScript y Python generan una clave segura de
forma automática en todas las creaciones idempotentes publicadas en OpenAPI.
La clave también puede proporcionarse manualmente cuando el sistema integrador
necesita relacionarla con una orden o comando propio.

Ante un error de transporte sin respuesta HTTP confirmada, cada SDK realiza por
defecto un único reintento y reutiliza exactamente la misma
`Idempotency-Key`. No se generan claves nuevas entre intentos.

Los SDK no reintentan automáticamente respuestas HTTP, incluidos:

```text
409 idempotency_conflict
409 idempotency_indeterminate
425 idempotency_in_progress
```

`idempotency_indeterminate` exige reconciliar el estado del recurso antes de
usar otra clave. Para `idempotency_in_progress`, la aplicación puede esperar el
valor de `Retry-After` y repetir manualmente con la misma clave.

Las respuestas de los SDK publican:

```text
idempotency.key
idempotency.replayed
transportAttempts        # PHP y JavaScript
transport_attempts       # Python
```

La cobertura automática incluye:

```text
POST /calendars.php
POST /calendar_subscriptions.php
POST /events.php
POST /tasks.php
POST /contacts.php
POST /bookings.php
POST /availability_links.php
```

`POST /freebusy.php` queda excluido porque es una consulta sin creación ni
efecto persistente. Los endpoints OAuth mantienen sus contratos propios y no
usan la tabla de idempotencia de recursos API.


## Observabilidad de idempotencia — 9Q03.3

PietroCal registra actividad técnica de las solicitudes que utilizan
`Idempotency-Key` sin copiar la clave completa, el cuerpo enviado ni la respuesta
almacenada. El propietario puede revisar esta información desde:

```text
Configuración → Integraciones y API → Idempotencia
```

La pantalla distingue:

- **Original**: la clave fue adquirida y la operación comenzó por primera vez.
- **Reproducida**: PietroCal devolvió la respuesta confirmada anteriormente.
- **Conflicto**: la misma clave se presentó con otro contenido.
- **En ejecución**: otro request conserva el lock vigente.
- **Indeterminada**: PietroCal no puede asegurar si la operación produjo efectos.
- **Recuperada**: un lock abandonado fue convertido de forma segura en resultado
  indeterminado.

El historial puede filtrarse por prefijo de clave, endpoint, token personal,
aplicación OAuth y resultado. Sólo está disponible mediante la sesión web del
propietario; no existe un scope Bearer para consultar esta información.

### Recuperación de locks antiguos

“Resolver bloqueos” nunca repite la operación original. Únicamente marca como
`indeterminate` los registros `processing` cuyo lock lleva más de diez minutos
sin actividad. El integrador debe reconciliar el recurso antes de utilizar una
clave nueva.

El cron existente de idempotencia también realiza esta recuperación y elimina:

- registros de respuesta una vez vencido su TTL;
- movimientos de observabilidad más antiguos que la retención configurada.

La retención del historial es de 30 días de forma predeterminada y puede
configurarse entre 7 y 365 días:

```text
PIETRO_API_IDEMPOTENCY_ACTIVITY_RETENTION_DAYS=30
```

Cada ejecución deja estadísticas por propietario sobre locks recuperados,
claves vencidas e historial eliminado.


## Portal para desarrolladores — 9Q06.1

El punto de entrada público para una integración es:

```text
https://pietrocal.com/developers
```

El portal reúne, sin duplicar los contratos técnicos:

- inicio rápido con Personal Access Token;
- criterio para elegir PAT u OAuth 2.1 con PKCE;
- ejemplos equivalentes en cURL, PHP, JavaScript y Python;
- mapa de recursos de agenda, contactos, disponibilidad y reservas;
- acceso a Swagger UI, OpenAPI 3.1, SDKs y guías existentes;
- recordatorios sobre scopes, Request ID, idempotencia, cuotas y webhooks.

Los ejemplos usan exclusivamente tokens ficticios con prefijo
`pc_live_TU_TOKEN`. El portal nunca incorpora una credencial real, no persiste
secretos en el navegador y no ofrece un “try it” propio. Las pruebas interactivas
siguen centralizadas en Swagger UI.

La ruta limpia `/developers` se resuelve mediante el mismo mecanismo de páginas
públicas de PietroCal. No agrega endpoints, scopes, tablas ni contratos de API.

## Guías de integración por caso de uso — 9Q06.2

El portal público incorpora un índice de flujos completos:

- `https://pietrocal.com/developers-guides`
- `https://pietrocal.com/developer-guide-agenda`
- `https://pietrocal.com/developer-guide-contactos`
- `https://pietrocal.com/developer-guide-disponibilidad`
- `https://pietrocal.com/developer-guide-oauth`
- `https://pietrocal.com/developer-guide-webhooks`

Las guías no agregan endpoints ni contratos paralelos. Cada una deriva sus
scopes, métodos, payloads y errores del OpenAPI y de esta documentación:

- **Agenda:** lista calendarios y crea eventos con `Idempotency-Key`.
- **Contactos:** conserva libreta, URI y ETag antes de actualizar una vCard.
- **Disponibilidad:** consulta `POST /freebusy.php` y convierte la decisión en
  una creación idempotente de evento.
- **OAuth:** aplica Authorization Code con PKCE S256, refresh rotatorio y
  revocación.
- **Webhooks:** verifica el cuerpo crudo, deduplica por
  `X-PietroCal-Delivery` y consulta el recurso vigente cuando hace falta.

Los ejemplos usan únicamente credenciales y datos ficticios. No deben copiarse
con secretos reales dentro de repositorios, páginas públicas o JavaScript del
navegador.

## Changelog y política de versiones — 9Q06.3

El historial público de cambios está disponible en:

```text
https://pietrocal.com/developers-changelog
```

La superficie REST estable continúa bajo `/api/v1` y el contrato descargable
vigente identifica OpenAPI `1.2`. Dentro de `v1`, PietroCal puede agregar
endpoints, headers y campos opcionales. Los clientes deben ignorar campos
adicionales que no necesiten y no depender del orden de las propiedades JSON.

PietroCal no elimina, renombra ni cambia silenciosamente el significado de un
contrato documentado dentro de `v1`. Un cambio incompatible requiere una nueva
versión mayor y una guía de migración. Toda deprecación futura debe publicar el
reemplazo recomendado, la fecha del anuncio y la fecha prevista de retiro. Al
cerrar 9Q06.3 no existen deprecaciones activas.

Los Webhooks mantienen un versionado independiente mediante `schema_version`.
El consumidor debe rechazar versiones que no soporte, verificar la firma sobre
el cuerpo crudo y deduplicar por Delivery ID.

## Errores, reintentos y diagnóstico — 9Q06.4

La referencia pública central está disponible en:

```text
https://pietrocal.com/developers-errors
```

Toda respuesta de error mantiene el estado HTTP y el envelope:

```json
{
  "success": false,
  "error": {
    "code": "api_scope_required",
    "message": "El token no tiene el scope requerido."
  }
}
```

La lógica del cliente debe decidir por `error.code`; `error.message` se conserva
para mostrar contexto al operador. Cuando la solicitud atraviesa el guard, la
respuesta incluye `X-Request-Id`, que debe guardarse para diagnóstico.

Reglas mínimas:

- `401` y `403` no se reintentan hasta corregir token, scope, método, plan o
  permiso real.
- `425 idempotency_in_progress` se repite después de `Retry-After` con la misma
  `Idempotency-Key`.
- `429` se repite únicamente después de `Retry-After` o del reset publicado.
- `409 idempotency_indeterminate` exige reconciliar el recurso antes de emitir
  un comando nuevo.
- una creación sin respuesta HTTP sólo puede reintentarse de forma segura si
  estaba protegida y reutiliza exactamente la misma `Idempotency-Key`.
- una escritura no protegida con resultado ambiguo no debe repetirse a ciegas.

Para soporte se debe proporcionar Request ID, fecha y hora, método, ruta,
estado HTTP, `error.code`, tipo o ID visible de la credencial y pasos mínimos de
reproducción. Nunca se deben enviar Bearer tokens, refresh tokens, Signing
Secrets, contraseñas, cookies ni cuerpos con datos personales completos.

