# API de integración de Roombyte

Documentación completa de la API pública de Roombyte, en un solo archivo de texto plano.

- Base: `https://api.roombyte.co`
- Formato: JSON en las dos direcciones
- Autenticación: obligatoria en el 100% de las rutas
- Versión: `v1`
- Contacto: contacto@roombyte.co
- Especificación OpenAPI 3.1: https://roombyte.co/openapi.json
- Portal: https://roombyte.co/developers

Esta API es para comercios: sirve para recibir pedidos y comandas, mover el catálogo y consultar el
estado del comercio. Es **servidor a servidor**: el secreto no puede acabar en un navegador ni en una
app móvil.

---

## 1. Autenticación

Cada comercio tiene un par de credenciales, y son dos a propósito: la asimetría entre el coste de
filtrar una y la otra es la seguridad del conjunto.

| Valor | Formato | Dónde vive | Qué hace |
|---|---|---|---|
| Público | `pk_live_<24 hex>` | Puede viajar en claro: cabeceras, logs | Identifica la credencial. **No autentica nada por sí solo.** |
| Secreto | `sk_live_<64 hex>` | Solo en el servidor del integrador | Firma cada petición. Nunca viaja. |

Se crean y se rotan en el panel: **Ajustes → API e integraciones**, desde el panel de aliado o desde
RoombyComands.

La rotación del secreto mantiene el mismo identificador público y deja el secreto anterior válido
durante **24 horas**. La rotación completa cambia los dos valores y no da plazo.

### Cabeceras

```
X-Roombyte-Key-Id:    pk_live_...
X-Roombyte-Timestamp: <segundos unix>
X-Roombyte-Signature: v1=<64 hex>
Content-Type:         application/json   (solo en peticiones con cuerpo)
```

### Cálculo de la firma

**Paso 1.** Clave de firma, derivada de los dos valores que ya tienes:

```
clave = HMAC-SHA256(clave = sk_, mensaje = "roombyte:api:v1:" + pk_)      (bytes crudos)
```

**Paso 2.** Cadena canónica: cinco campos unidos por `\n`, **sin salto de línea final**:

```
v1
<MÉTODO en MAYÚSCULAS>
<ruta SIN querystring>
<timestamp, el mismo de la cabecera>
<sha256 en hex del cuerpo CRUDO>
```

En `GET` y `DELETE` no hay cuerpo y se firma el sha256 de la cadena vacía:
`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.

**Paso 3.** Firma:

```
X-Roombyte-Signature: "v1=" + HMAC-SHA256(clave = clave, mensaje = cadena canónica)   (hex)
```

El cuerpo se firma sobre los bytes que de verdad se envían. Serializar dos veces (una para firmar y
otra para enviar) produce bytes distintos y rompe la firma de forma intermitente.

Se acepta un desfase de reloj de ±300 segundos. La misma firma no se puede reutilizar (anti-replay de
600 segundos).

### Vector de prueba

Con estos datos, la clave de firma y la firma tienen que salir exactamente así. Sirve para comprobar
la implementación antes de tener credenciales reales.

```
pk_  = pk_live_0123456789abcdef01234567
sk_  = sk_live_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd
ts   = 1759000000
GET /v1/me, sin cuerpo

sha256("")     = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
clave de firma = 7c878d2a1da6a1d2c2db4c5fed532c8872dd01202d246d5a65023129f976db5f
firma          = 5ad8ac01b250e632687a540f3f1f13d6e26c77caea47b6a333e5eaf2b8ac485a
```

### Ejemplo en Node.js

```js
import crypto from "node:crypto";

const API = "https://api.roombyte.co";
const PK = process.env.ROOMBYTE_PK;
const SK = process.env.ROOMBYTE_SK;

const claveDeFirma = () => crypto.createHmac("sha256", SK).update(`roombyte:api:v1:${PK}`).digest();

async function roombyte(method, path, { query, cuerpo } = {}) {
  const crudo = cuerpo === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(cuerpo));
  const ts = String(Math.floor(Date.now() / 1000));
  const hash = crypto.createHash("sha256").update(crudo).digest("hex");
  const cadena = ["v1", method.toUpperCase(), path, ts, hash].join("\n");
  const firma = crypto.createHmac("sha256", claveDeFirma()).update(cadena).digest("hex");

  const url = API + path + (query ? `?${new URLSearchParams(query)}` : "");
  const res = await fetch(url, {
    method,
    headers: {
      "X-Roombyte-Key-Id": PK,
      "X-Roombyte-Timestamp": ts,
      "X-Roombyte-Signature": `v1=${firma}`,
      ...(cuerpo === undefined ? {} : { "Content-Type": "application/json" }),
    },
    body: cuerpo === undefined ? undefined : crudo,
  });
  const datos = await res.json();
  if (!res.ok) throw new Error(`${res.status}: ${datos.msg ?? "error"}`);
  return datos;
}
```

Los ejemplos de curl, Python y PHP están en https://roombyte.co/developers.

---

## 2. Alcance y permisos

Son **dos ejes independientes** y se comprueban por separado.

- **Permisos** (`scopes`): qué operaciones puede hacer la credencial. Todas las credenciales nacen con
  `["*"]`, así que en la práctica no hay que gestionarlos.
- **Alcance**: a qué datos llega. Sale de los servicios contratados por **la cuenta que creó la
  credencial** y se resuelve **en cada petición**.

| Cuenta que creó la credencial | Pedidos que alcanza | ¿Entra al resto de la API? |
|---|---|---|
| Solo RoombyComands | Las comandas del mostrador | Sí |
| Solo servicio de aliado (rol vigente) | Los pedidos de la app | Sí |
| Los dos | Los dos | Sí |
| Ninguno | Ninguno | No: 403 en todo (salvo `GET /v1/me`, que responde 200 con el alcance vacío) |

El alcance decide **qué pedidos** son alcanzables, y eso es lo único que filtra. Comercio, catálogo,
categorías, modificadores y webhooks son recursos **compartidos**: los dos paneles los tienen, así que
basta con alcanzar cualquiera de los dos servicios. Una cuenta solo de RoombyComands administra su
catálogo y sus webhooks por esta API sin problema; lo que no ve es un solo pedido de la app.

Se enciende solo el día que el servicio se activa (no hay que recrear la credencial) y se apaga solo si
el servicio se pierde. Si la cuenta que creó la credencial se borra o se suspende, sus credenciales
dejan de alcanzar nada.

`GET /v1/me` informa el alcance en `alcance.comandas` y `alcance.domicilios`. Es lo primero que hay
que mirar cuando algo responde 403.

Consecuencia en el listado: `GET /v1/pedidos` devuelve solo los pedidos alcanzables aunque no se mande
`tipo`. Si se manda `tipo=COMIDA` y la credencial no alcanza los pedidos de la app, la respuesta es
**400** con el motivo, no una lista parecida.

---

## 3. Endpoints

Todos exigen credencial válida. La columna "Permiso" indica el scope requerido.

### Credencial y comercio

| Método | Ruta | Permiso | Qué hace |
|---|---|---|---|
| GET | `/v1/me` | — | Credencial, comercio y alcance. La prueba de humo |
| GET | `/v1/comercio` | `comercio:read` | Nombre, estado y horario de hoy |
| PUT | `/v1/comercio/servicio` | `comercio:write` | Abrir o cerrar el comercio |
| PUT | `/v1/comercio/horario` | `comercio:write` | Reemplaza el horario completo |

### Catálogo

| Método | Ruta | Permiso | Qué hace |
|---|---|---|---|
| GET | `/v1/catalogo` | `catalogo:read` | Productos con el precio de promoción resuelto |
| GET | `/v1/catalogo/:id` | `catalogo:read` | Un producto con sus grupos de modificadores |
| GET | `/v1/categorias` | `catalogo:read` | Categorías del comercio |
| GET | `/v1/grupos-modificadores` | `catalogo:read` | Grupos y opciones; filtra con `producto_id` |
| POST | `/v1/catalogo` | `catalogo:write` | Crea un producto |
| PUT | `/v1/catalogo/:id` | `catalogo:write` | Edita un producto |
| PATCH | `/v1/catalogo/:id/activo` | `catalogo:write` | Activa o desactiva |
| DELETE | `/v1/catalogo/:id` | `catalogo:write` | Elimina un producto |
| PUT | `/v1/catalogo/precios` | `catalogo:write` | Precios en lote por `id`. Todo o nada |
| POST | `/v1/catalogo/sincronizar` | `catalogo:write` | Catálogo entero en JSON, con emparejamiento |

### Pedidos

| Método | Ruta | Permiso | Qué hace |
|---|---|---|---|
| GET | `/v1/pedidos` | `pedidos:read` | Listado con filtros |
| GET | `/v1/pedidos/:id` | `pedidos:read` | Un pedido con sus ítems |
| GET | `/v1/pedidos/:id/otp-recogida` | `pedidos:read` | Código de 4 dígitos para el domiciliario |
| POST | `/v1/pedidos/:id/aceptar` | `pedidos:write` | Acepta. **No cambia `estado`** |
| POST | `/v1/pedidos/:id/listo` | `pedidos:write` | Marca listo |
| POST | `/v1/pedidos/:id/cancelar` | `pedidos:write` | Cancela, devuelve stock y ajusta wallet |

### Webhooks

| Método | Ruta | Permiso | Qué hace |
|---|---|---|---|
| GET | `/v1/webhooks` | `webhooks:write` | Destinos configurados |
| POST | `/v1/webhooks` | `webhooks:write` | Crea un destino. Devuelve el secreto **una vez** |
| PUT | `/v1/webhooks/:id` | `webhooks:write` | Cambia `url`, `eventos` o `activo` |
| DELETE | `/v1/webhooks/:id` | `webhooks:write` | Borra el destino |
| GET | `/v1/webhooks/:id/secreto` | `webhooks:write` | Vuelve a leer el secreto |
| POST | `/v1/webhooks/:id/rotar-secreto` | `webhooks:write` | Secreto nuevo para **todos** los destinos |
| POST | `/v1/webhooks/:id/probar` | `webhooks:write` | Envía un `ping` firmado |
| GET | `/v1/webhooks/entregas` | `webhooks:write` | Log de entregas |

---

## 4. Detalle por endpoint

### GET /v1/me

Responde 200 con cualquier credencial viva, aunque el alcance esté vacío o falten permisos: es el
diagnóstico, y tiene que poder responder a "por qué lo demás me da 403".

```json
{
  "ok": true,
  "comercio": { "id": 217, "nombre": "Café La Esquina", "en_servicio": true, "dentro_de_horario": true },
  "credencial": {
    "public_id": "pk_live_...",
    "nombre": "Integración POS",
    "scopes": ["*"],
    "expira_at": null,
    "created_at": "2026-09-20T10:00:00.000Z",
    "ultimo_uso": "2026-09-28T14:02:55.117Z"
  },
  "alcance": { "comandas": true, "domicilios": false, "servicios": ["comanda"] }
}
```

### GET /v1/comercio

```json
{ "ok": true, "comercio": { "...": "nombre, estado, horario de hoy, ubicación pública" } }
```

No incluye cuenta bancaria, certificado, RUT ni estado de documentos: eso vive solo en el panel.

### PUT /v1/comercio/servicio

```json
{ "en_servicio": true }
```

`en_servicio` tiene que ser booleano. Pedir el estado en el que ya está responde
`{ "ok": true, "en_servicio": true, "sin_cambios": true }` **sin escribir**. Para encender, el comercio
necesita ubicación cargada; si no la tiene, **409**.

### PUT /v1/comercio/horario

```json
{
  "horario": {
    "lunes":   { "abre": "08:00", "cierra": "18:00", "activo": true },
    "domingo": { "activo": false }
  }
}
```

Reemplaza el horario completo: no hay cambios parciales. Devuelve lo que quedó guardado.

### GET /v1/catalogo

Filtros de consulta: `categoria` (comparación sin distinguir mayúsculas) y `activos=true`.

```json
{ "ok": true, "total": 128, "productos": [ { "id": 5521, "nombre": "Café molido 500 g", "precio": 18500 } ] }
```

El campo `costo` no se devuelve nunca: es interno del comercio.

### GET /v1/catalogo/:id

```json
{
  "ok": true,
  "producto": { "id": 5521, "nombre": "Café molido 500 g", "precio": 18500 },
  "grupos": [
    { "id": 91, "nombre": "Tamaño", "tipo": "opcion", "requerido": true, "min_sel": 1, "max_sel": 1,
      "opciones": [ { "id": 401, "nombre": "Grande", "precio_extra": 2000 } ] }
  ]
}
```

### GET /v1/grupos-modificadores

Igual que los grupos de arriba, para todos los productos del comercio. Filtro opcional `producto_id`.

### GET /v1/categorias

```json
{ "ok": true, "categorias": [ { "nombre": "Cafés", "total": 12 } ] }
```

### POST /v1/catalogo y PUT /v1/catalogo/:id

Campos aceptados:

| Campo | Tipo | Notas |
|---|---|---|
| `nombre` | texto | Obligatorio. Mínimo 2 caracteres |
| `precio` | número | Obligatorio. `0` solo en comercios de compra abierta |
| `descripcion` | texto | |
| `costo` | número | Se acepta al escribir, no se devuelve al leer |
| `categoria` | texto | |
| `variante` | texto | |
| `imagen_url` | texto | |
| `inventario` | número | |
| `codigo_barras` | texto | Clave de emparejamiento |
| `dias_semana` | — | Días en que se ofrece |
| `unidad_peso` | `lb` \| `kg` \| `g` | Solo categorías que venden por peso |
| `vendido_por_peso` | booleano | Solo categorías que venden por peso |
| `requiere_anticipacion` | booleano | |

`POST` responde **201** con `{ "ok": true, "producto": { … } }`. `PUT` responde 200 con la misma forma.

### PATCH /v1/catalogo/:id/activo

```json
{ "activo": false }
```

Tiene que ser booleano. Si el producto ya está como se pide, responde `sin_cambios: true` sin escribir.

### DELETE /v1/catalogo/:id

```json
{ "ok": true, "eliminado": 5521 }
```

### PUT /v1/catalogo/precios

```json
{ "precios": [ { "id": 5521, "precio": 18500 }, { "id": 5522, "precio": "$ 12.500" } ] }
```

- Identificación por `id` explícito.
- **Todo o nada**: un precio inválido, un id repetido o un id que no es del comercio detiene el lote
  entero y no se escribe nada.
- Un id de otro comercio o inexistente da **404** nombrando los ids que fallaron.
- El precio acepta número o texto en formato colombiano (`"$ 12.500"`).

```json
{ "ok": true, "actualizados": 2, "sinCambios": 0, "precios": [ { "id": 5521, "anterior": 17000, "nuevo": 18500 } ] }
```

### POST /v1/catalogo/sincronizar

```json
{
  "productos": [
    { "sku": "CAF-500", "nombre": "Café molido 500 g", "precio": 18500,
      "codigo_barras": "7701234567890", "categoria": "Cafés", "descripcion": "Molido medio" }
  ],
  "desactivar_faltantes": false,
  "categoria_nueva": "Cafés"
}
```

- Máximo **20.000** productos por petición.
- Campos canónicos de cada producto: `sku`, `codigo_barras`, `nombre`, `precio`, `categoria`,
  `descripcion`.
- Emparejamiento en cascada contra el catálogo actual: **`sku` → `codigo_barras` → `nombre`**.
- Todo el lote se escribe en **una transacción**.
- `desactivar_faltantes` viene apagado. Encendido, desactiva los productos que están en el catálogo y
  no vinieron en el JSON.

```json
{
  "ok": true,
  "total": 2, "actualizados": 1, "creados": 1, "desactivados": 0, "sinCambios": 0,
  "actualizaciones": [
    { "fila": 1, "id": 5521, "nombre": "Café molido 500 g", "anterior": 17000, "nuevo": 18500,
      "diferencia": 1500, "via": "sku" }
  ],
  "creados_detalle": [
    { "fila": 2, "nombre": "Café molido 250 g", "precio": 10500, "sku": "CAF-250",
      "codigo_barras": null, "categoria": "Cafés", "descripcion": null }
  ],
  "faltantes": [ { "id": 5400, "nombre": "Té verde" } ],
  "errores": []
}
```

Las posiciones `fila` son **1-based** sobre el arreglo enviado. `faltantes` se devuelve siempre, esté
`desactivar_faltantes` encendido o no.

Limitación conocida: la sincronización exige `precio > 0` con las mismas reglas que la importación por
archivo, así que un comercio de compra abierta no puede sincronizar sus productos de precio 0 por esta
vía.

### GET /v1/pedidos

| Parámetro | Valores | Notas |
|---|---|---|
| `estado` | estado del pedido | |
| `tipo` | `COMANDA` o el tipo de la app | Fuera del alcance de la credencial → **400** |
| `fecha` | `YYYY-MM-DD` | |
| `limit` | máximo **100** | Un valor mayor se recorta a 100 |
| `offset` | número | |

```json
{ "ok": true, "total": 12, "pedidos": [ { "id": 90124, "numero": "A-1043", "estado": "PAGADA", "total": 42500,
  "tipo_pedido": "COMANDA", "items": [ { "nombre": "Café molido 500 g", "cantidad": 2, "precio": 18500,
    "modificadores": [], "nota": null } ] } ] }
```

### GET /v1/pedidos/:id

Responde 200 con `{ "ok": true, "pedido": { … } }`, con los ítems y sus modificadores.

Si el pedido **es del comercio** pero el plan del titular no alcanza ese tipo de pedido: **403** con
el motivo. Si el pedido no es del comercio: **404**. La distinción es deliberada — un 404 sobre un
pedido propio que existe sería una mentira.

### GET /v1/pedidos/:id/otp-recogida

```json
{
  "ok": true,
  "pedido_id": 90124,
  "otp_recogida": "4821",
  "otp_recogida_at": "2026-09-28T14:08:00.000Z",
  "aviso": "Este código es único por comercio, rota cada 10 minutos y sirve para todos sus pedidos."
}
```

Es **un solo código por comercio**, cambia cada 10 minutos y vale para todos sus pedidos: hay que
pedirlo en el momento de entregarlo, nunca guardarlo en caché.

El código `otp_entrega` —el que el cliente le da al domiciliario al recibir— **no se expone** en
ninguna ruta: solo lo ve el domiciliario asignado, después de llegar y con el pedido en camino.

### POST /v1/pedidos/:id/aceptar

```json
{
  "ok": true,
  "pedido_id": 90124,
  "aceptada_at": "2026-09-28T14:05:00.000Z",
  "aviso": "El pedido quedó aceptado. El campo `estado` NO cambia con esta llamada."
}
```

**No cambia `estado`**: solo estampa `aceptada_at`. Es la señal que hay que mirar, y también viaja en
el webhook `pedido.aceptado`.

### POST /v1/pedidos/:id/listo

```json
{ "ok": true, "pedido_id": 90124, "listo_recogida_at": "2026-09-28T14:20:00.000Z" }
```

Con los pedidos de cocina responde `{ "ok": true, "pedido_id": 90124, "cocina": true, … }`.
No devuelve el código de recogida: se pide en su endpoint, que es donde vive el aviso de no cachearlo.

### POST /v1/pedidos/:id/cancelar

```json
{ "motivo": "Sin ingredientes" }
```

El cuerpo es opcional. Devuelve `{ "ok": true, "pedido_id": 90124, "estado": "CANCELADA", "motivo": "…" }`.
Cancela, devuelve stock y ajusta la wallet. Reintentar sobre un pedido ya cancelado da **409**.

### Webhooks

`POST /v1/webhooks`

```json
{ "url": "https://mi-sistema.example/webhooks/roombyte", "eventos": [], "descripcion": "POS principal" }
```

- `eventos` vacío u omitido significa **todos**.
- La URL tiene que ser `https`, con puerto estándar y con un host público: los rangos privados,
  loopback y link-local se rechazan.
- Responde **201** con `{ "ok": true, "webhook": { … }, "secreto": "whsec_…" }`. El secreto se devuelve
  **una sola vez** en el alta; después se lee con `GET /v1/webhooks/:id/secreto`.

`PUT /v1/webhooks/:id` acepta `url`, `eventos`, `activo` (booleano) y `descripcion`. Sin ningún campo
actualizable responde 400.

`POST /v1/webhooks/:id/probar` manda un `ping` real y espera la respuesta (hasta 10 segundos).

`GET /v1/webhooks/entregas` devuelve el log con el estado de cada entrega, su código de respuesta y su
error.

---

## 5. Webhooks: cuerpo, firma y reintentos

Cuerpo del POST:

```json
{
  "evento": "pedido.nuevo",
  "delivery_id": "8f14e45f-ceea-467a-9e5e-1b3f0f2b1a01",
  "creado_at": "2026-09-28T14:03:11.482Z",
  "comercio_id": 217,
  "datos": { "pedido": { "id": 90124, "numero": "A-1043", "estado": "PAGADA", "total": 42500, "tipo": "COMANDA" } }
}
```

Cabeceras:

```
X-Roombyte-Event:     pedido.nuevo
X-Roombyte-Delivery:  8f14e45f-ceea-467a-9e5e-1b3f0f2b1a01
X-Roombyte-Timestamp: 1759000000
X-Roombyte-Signature: v1=<64 hex>
```

**Firma (distinta a la de la API):**

```
X-Roombyte-Signature = "v1=" + HMAC-SHA256(clave = secreto del webhook (whsec_…),
                                           mensaje = "<timestamp>." + "<cuerpo CRUDO>")
```

Hay que verificar sobre el cuerpo crudo, antes de parsearlo, y comparar en tiempo constante. Con
`express.json()` delante, `JSON.stringify(req.body)` no reproduce los bytes recibidos.

`datos` solo lleva lo necesario para saber de qué pedido se trata y qué pasó. El detalle completo se
pide a la API con el id. En `pedido.aceptado`, `datos.pedido` añade `aceptada_at`; en
`comercio.servicio_cambiado`, `datos` es `{ "comercio": { "en_servicio": true } }`.

### Eventos

| Evento | Cuándo |
|---|---|
| `pedido.nuevo` | Entra un pedido o una comanda, o se confirma el pago |
| `pedido.aceptado` | Se acepta. Trae `aceptada_at` |
| `pedido.listo` | Se marca listo para recoger o para despachar |
| `pedido.asignado` | Un domiciliario toma el pedido |
| `pedido.en_camino` | El domiciliario confirma que recogió |
| `pedido.entregado` | Se cierra la entrega |
| `pedido.cancelado` | Se cancela, por el panel o por la API |
| `comercio.servicio_cambiado` | El comercio se abre o se cierra |
| `ping` | Prueba manual desde `POST /v1/webhooks/:id/probar` |

Un `pedido.nuevo` puede repetirse (un reintento de la oferta, o el webhook de pago y el camino en
línea coincidiendo). Hay que deduplicar por el id del pedido más el tipo de evento; `delivery_id` es
nuevo en cada emisión.

### Reintentos

Escalera: **10 s, 30 s, 2 min, 10 min, 1 h, 6 h**. Seis intentos; después la entrega queda como muerta.

Se reintenta: sin respuesta, más de 10 segundos de espera, `5xx` y `429` (respetando `Retry-After`).
No se reintenta un `4xx`: significa que el receptor rechazó el cuerpo.

El receptor tiene que responder **2xx en menos de 10 segundos** y procesar después.

---

## 6. Errores y límites

| Código | Significado |
|---|---|
| 200 | Correcto |
| 201 | Recurso creado |
| 400 | Petición mal formada. Suele traer `errores` con la posición del elemento que falló. No reintentar |
| 401 | Firma inválida, reloj desfasado o credencial desconocida. Es lo que responde también una petición sin firmar |
| 403 | Credencial válida sin permiso, sin alcance, o API apagada para ese comercio |
| 404 | El recurso no es del comercio. Un id ajeno y uno inventado dan lo mismo |
| 409 | Es del comercio y está al alcance, pero el estado no permite la operación |
| 429 | Demasiadas peticiones. Mirar `Retry-After` |
| 500 | Fallo del servidor |

Forma del error:

```json
{ "ok": false, "msg": "Mensaje en español, pensado para el integrador." }
```

Los límites son **por credencial, no por IP**:

| Ámbito | Límite |
|---|---|
| Lectura | 300 por minuto |
| Escritura | 120 por minuto |
| `POST /v1/catalogo/sincronizar` | 6 por minuto |
| `PUT /v1/catalogo/precios` | 12 por minuto |
| `GET /v1/pedidos/:id/otp-recogida` | 30 por minuto |
| `POST /v1/webhooks/:id/probar` | 10 por hora |

---

## 7. Idempotencia

`aceptar`, `listo` y `cancelar` son idempotentes por naturaleza: aceptar dos veces no duplica nada,
marcar listo dos veces tampoco, y cancelar un pedido ya cancelado responde 409 sin volver a devolver el
stock. `PUT /v1/comercio/servicio` y `PATCH /v1/catalogo/:id/activo` llevan el valor explícito en el
cuerpo y responden `sin_cambios: true` cuando ya están en el estado pedido.

La cabecera `Idempotency-Key` existe, pero su respaldo es Redis y no está garantizada si Redis cae. No
hay que apoyarse en ella: conviene apoyarse en que las operaciones son idempotentes.

---

## 8. Reglas que no se pueden romper

1. Es servidor a servidor: el `sk_` no puede acabar en un navegador ni en una app móvil.
2. Se firma el cuerpo exactamente como se envía.
3. La ruta firmada no lleva querystring.
4. El método va en mayúsculas dentro de la cadena canónica.
5. El reloj del servidor tiene que estar en hora (±5 minutos).
6. Los webhooks se verifican antes de procesarse, sobre el cuerpo crudo.
7. El código de recogida no se guarda en caché.
8. Un webhook se responde en menos de 10 segundos.
9. Los webhooks se deduplican por el id del pedido y el tipo de evento.
