{
  "openapi": "3.1.0",
  "info": {
    "title": "API de integración de Roombyte",
    "version": "1.0.0",
    "summary": "Pedidos, comandas, catálogo y disponibilidad del comercio.",
    "description": "API pública de Roombyte para POS, ERP e integraciones a medida de un comercio.\n\nPermite recibir pedidos y comandas por webhook, atenderlos (aceptar, marcar listo, cancelar), entregar el código de recogida al domiciliario, leer y escribir el catálogo, y consultar o cambiar la disponibilidad del comercio.\n\n## Autenticación\n\nEs **servidor a servidor** y **toda** la API está autenticada: sin credencial válida no se consulta ni se cambia nada. El secreto (`sk_live_…`) firma cada petición y no puede viajar a un navegador ni a una app móvil.\n\nCada petición lleva tres cabeceras:\n\n```\nX-Roombyte-Key-Id:    pk_live_<24 hex>\nX-Roombyte-Timestamp: <segundos unix>\nX-Roombyte-Signature: v1=<64 hex>\n```\n\nLa firma se calcula así:\n\n1. Clave de firma: `HMAC-SHA256(clave = sk_, mensaje = \"roombyte:api:v1:\" + pk_)` en bytes crudos.\n2. Cadena canónica: cinco campos unidos por `\\n`, sin salto final:\n\n```\nv1\n<MÉTODO en MAYÚSCULAS>\n<ruta SIN querystring>\n<timestamp>\n<sha256 en hex del cuerpo CRUDO>\n```\n\n3. Firma: `\"v1=\" + HMAC-SHA256(clave = clave de firma, mensaje = cadena canónica)` en hex.\n\nEn `GET` y `DELETE` no hay cuerpo: se firma `sha256(\"\")`. Se acepta un desfase de reloj de ±300 segundos y la misma firma no se puede reutilizar.\n\nImplementaciones copiables en curl, Node.js, Python y PHP, con un vector de prueba para comprobar el cliente, en https://roombyte.co/developers\n\n## Alcance\n\nLos **permisos** (el scope de cada operación) dicen qué puede hacer la credencial; el **alcance** dice a qué datos llega, y sale de los servicios contratados por la cuenta que creó la credencial, resuelto en cada petición. Una cuenta solo de RoombyComands alcanza las comandas del mostrador; una solo de aliado alcanza los pedidos de la app; una sin ninguno de los dos recibe 403 en toda la API.\n\nEl alcance decide **qué pedidos** son alcanzables —solo eso—. 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. `GET /v1/me` informa el alcance y siempre responde 200 con una credencial viva, aunque el alcance esté vacío.\n\n## Webhooks\n\nRoombyte avisa con un POST a la URL configurada. El cuerpo va firmado con `\"v1=\" + HMAC-SHA256(clave = secreto del webhook, mensaje = \"<timestamp>.<cuerpo crudo>\")`. Se reintenta con la escalera 10 s, 30 s, 2 min, 10 min, 1 h y 6 h ante timeouts, 5xx y 429; nunca ante un 4xx.",
    "contact": {
      "name": "Soporte de integraciones",
      "email": "contacto@roombyte.co"
    },
    "license": {
      "name": "Uso sujeto a los Términos y Condiciones de Roombyte",
      "url": "https://roombyte.co/terminos"
    }
  },
  "servers": [
    {
      "url": "https://api.roombyte.co",
      "description": "Producción"
    }
  ],
  "tags": [
    { "name": "Credencial", "description": "Diagnóstico de la credencial y del comercio." },
    { "name": "Comercio", "description": "Disponibilidad y horario." },
    { "name": "Catálogo", "description": "Productos, categorías y modificadores. Lectura y escritura." },
    { "name": "Pedidos", "description": "Listado, detalle, código de recogida y transiciones de estado." },
    { "name": "Webhooks", "description": "Destinos, secreto, prueba manual y log de entregas. Como el resto de recursos compartidos, este bloque exige alcanzar alguno de los dos servicios: es la API del comercio, no la de un lado de sus pedidos." }
  ],
  "security": [{ "credencial": [] }],
  "paths": {
    "/v1/me": {
      "get": {
        "tags": ["Credencial"],
        "operationId": "getMe",
        "summary": "Credencial, comercio y alcance",
        "description": "La prueba de humo: si esto responde 200, la credencial, el reloj, la firma y el comercio están bien.\n\nResponde 200 con cualquier credencial viva, aunque falten permisos o el alcance esté vacío. Es deliberado: si esta llamada también fallara, no quedaría ninguna que explicara por qué el resto responde 403. No exige ni permiso ni alcance.\n\nNo se expone el id interno de la cuenta que creó la credencial.",
        "responses": {
          "200": {
            "description": "Credencial válida.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/RespuestaMe" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/comercio": {
      "get": {
        "tags": ["Comercio"],
        "operationId": "getComercio",
        "summary": "Estado del comercio",
        "description": "Nombre, estado, horario de hoy y datos públicos. No incluye cuenta bancaria, certificado, RUT ni estado de documentos: eso vive solo en el panel.\n\nBasta con alcanzar cualquiera de los dos servicios, porque el comercio es un recurso compartido por los dos paneles.",
        "responses": {
          "200": {
            "description": "Comercio encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "comercio": { "$ref": "#/components/schemas/Comercio" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/comercio/servicio": {
      "put": {
        "tags": ["Comercio"],
        "operationId": "putServicio",
        "summary": "Abrir o cerrar el comercio",
        "description": "El cuerpo lleva el estado deseado, no un interruptor: un reintento tras un timeout tiene que poder reenviarse sin cambiar el resultado. Pedir el estado en el que ya está responde 200 con `sin_cambios: true` y **no escribe**.\n\nPara encender, el comercio necesita tener su ubicación cargada; si no la tiene, responde 409.\n\nEmite el webhook `comercio.servicio_cambiado`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["en_servicio"],
                "properties": {
                  "en_servicio": {
                    "type": "boolean",
                    "description": "Tiene que ser booleano de verdad: la cadena `\"false\"` sería verdadera y encendería el comercio que se quería apagar."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Estado aplicado (o ya vigente).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok", "en_servicio"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "en_servicio": { "type": "boolean" },
                    "sin_cambios": { "type": "boolean" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "409": { "$ref": "#/components/responses/Conflicto" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/comercio/horario": {
      "put": {
        "tags": ["Comercio"],
        "operationId": "putHorario",
        "summary": "Reemplazar el horario",
        "description": "Reemplaza el horario completo: no hay cambios parciales, igual que en el panel. Devuelve lo que quedó guardado, para que se pueda comprobar lo que realmente se guardó y no lo que se creía haber mandado.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["horario"],
                "properties": {
                  "horario": {
                    "type": "object",
                    "description": "Un objeto con una entrada por día. Cada día activo declara `abre` y `cierra` en formato HH:MM; un día inactivo se marca con `activo: false`.",
                    "additionalProperties": {
                      "type": "object",
                      "properties": {
                        "abre": { "type": "string", "pattern": "^[0-2][0-9]:[0-5][0-9]$", "examples": ["08:00"] },
                        "cierra": { "type": "string", "pattern": "^[0-2][0-9]:[0-5][0-9]$", "examples": ["18:00"] },
                        "activo": { "type": "boolean" }
                      }
                    },
                    "examples": [
                      {
                        "lunes": { "abre": "08:00", "cierra": "18:00", "activo": true },
                        "domingo": { "activo": false }
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Horario guardado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "horario": { "type": ["object", "null"], "additionalProperties": true }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/catalogo": {
      "get": {
        "tags": ["Catálogo"],
        "operationId": "getCatalogo",
        "summary": "Catálogo del comercio",
        "description": "Los productos con el precio de promoción ya resuelto por el mismo motor que usa el panel, así que no hay dos precios circulando. El campo `costo` no se devuelve nunca: es interno del comercio.\n\nLos filtros se aplican en memoria sobre el catálogo del comercio.",
        "parameters": [
          {
            "name": "categoria",
            "in": "query",
            "required": false,
            "schema": { "type": "string" },
            "description": "Comparación sin distinguir mayúsculas ni espacios sobrantes."
          },
          {
            "name": "activos",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "enum": ["true"] },
            "description": "Con el valor `true`, devuelve solo los productos activos."
          }
        ],
        "responses": {
          "200": {
            "description": "Catálogo del comercio.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok", "total"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "total": { "type": "integer" },
                    "productos": { "type": "array", "items": { "$ref": "#/components/schemas/Producto" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      },
      "post": {
        "tags": ["Catálogo"],
        "operationId": "postProducto",
        "summary": "Crear un producto",
        "description": "Crea un producto en el catálogo del comercio. Los campos aceptados son los mismos que en la edición.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/ProductoEntrada" } }
          }
        },
        "responses": {
          "201": {
            "description": "Producto creado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "producto": { "$ref": "#/components/schemas/Producto" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/catalogo/precios": {
      "put": {
        "tags": ["Catálogo"],
        "operationId": "putPrecios",
        "summary": "Cambiar precios en lote",
        "description": "Cambia el precio de una lista de productos, identificados por `id` explícito. No hay emparejamiento ni altas: solo el cambio.\n\n**Todo o nada.** Un precio inválido, un id repetido o un id que no es del comercio detiene el lote entero y no escribe nada; escribir los buenos y avisar de los malos dejaría el catálogo a medias con una respuesta que parece un éxito. El lote se valida por completo antes de pedir conexión a la base.\n\nUn id inexistente o de otro comercio da 404 nombrando los que fallaron. El precio acepta número o texto en formato colombiano (`\"$ 12.500\"`). El `0` solo es válido en comercios de compra abierta.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["precios"],
                "properties": {
                  "precios": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "object",
                      "required": ["id", "precio"],
                      "properties": {
                        "id": { "type": "integer", "minimum": 1 },
                        "precio": { "oneOf": [{ "type": "number" }, { "type": "string" }] }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Precios actualizados.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "actualizados": { "type": "integer" },
                    "sinCambios": { "type": "integer", "description": "Productos cuyo precio ya era el pedido." },
                    "precios": {
                      "type": "array",
                      "description": "Solo los productos que cambiaron, con el valor anterior para poder registrar el cambio hacia atrás.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": "integer" },
                          "anterior": { "type": "number" },
                          "nuevo": { "type": "number" }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "429": { "$ref": "#/components/responses/DemasiadasPeticiones" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/catalogo/sincronizar": {
      "post": {
        "tags": ["Catálogo"],
        "operationId": "postSincronizarCatalogo",
        "summary": "Sincronizar el catálogo entero",
        "description": "Recibe un arreglo de productos y los empareja con el catálogo actual en cascada: `sku` → `codigo_barras` → `nombre`. Es el mismo criterio que la importación por archivo.\n\nTodo el lote se escribe en **una sola transacción**: o entra entero o no entra nada. Máximo 20.000 productos por petición.\n\n`desactivar_faltantes` viene apagado y conviene dejarlo así en el uso normal: encendido, un export a medias desactiva medio catálogo. `faltantes` se devuelve siempre, esté encendido o no.\n\nLas posiciones `fila` son 1-based sobre el arreglo enviado. 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 por aquí sus productos de precio 0.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["productos"],
                "properties": {
                  "productos": {
                    "type": "array",
                    "maxItems": 20000,
                    "description": "Cada elemento tiene que ser un objeto. Un elemento nulo o un arreglo se rechaza con 400 nombrando las posiciones.",
                    "items": { "$ref": "#/components/schemas/ProductoSincronizar" }
                  },
                  "desactivar_faltantes": {
                    "type": "boolean",
                    "default": false,
                    "description": "Con `true`, desactiva los productos que están en el catálogo y no vinieron en el JSON."
                  },
                  "categoria_nueva": {
                    "type": ["string", "null"],
                    "description": "Categoría por defecto para los productos que se creen sin categoría propia."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resumen de la sincronización.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RespuestaSincronizacion" } }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "429": { "$ref": "#/components/responses/DemasiadasPeticiones" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/catalogo/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": { "type": "integer", "minimum": 1 },
          "description": "Identificador del producto."
        }
      ],
      "get": {
        "tags": ["Catálogo"],
        "operationId": "getProducto",
        "summary": "Un producto con sus modificadores",
        "description": "El producto y sus grupos de modificadores activos con sus opciones. Es lo que necesita una pantalla de pedido.",
        "responses": {
          "200": {
            "description": "Producto encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "producto": { "$ref": "#/components/schemas/Producto" },
                    "grupos": {
                      "type": "array",
                      "items": {
                        "allOf": [
                          { "$ref": "#/components/schemas/GrupoModificador" },
                          {
                            "type": "object",
                            "properties": {
                              "opciones": { "type": "array", "items": { "$ref": "#/components/schemas/Modificador" } }
                            }
                          }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      },
      "put": {
        "tags": ["Catálogo"],
        "operationId": "putProducto",
        "summary": "Editar un producto",
        "description": "Mismos campos que en la creación. Un id de otro comercio responde 404, nunca 403: no se enumeran ids ajenos.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/ProductoEntrada" } }
          }
        },
        "responses": {
          "200": {
            "description": "Producto actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "producto": { "$ref": "#/components/schemas/Producto" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      },
      "delete": {
        "tags": ["Catálogo"],
        "operationId": "deleteProducto",
        "summary": "Eliminar un producto",
        "responses": {
          "200": {
            "description": "Producto eliminado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "eliminado": { "type": "integer" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/catalogo/{id}/activo": {
      "patch": {
        "tags": ["Catálogo"],
        "operationId": "patchProductoActivo",
        "summary": "Activar o desactivar",
        "description": "El valor explícito va en el cuerpo, no es un interruptor: si el producto ya está como se pide, responde `sin_cambios: true` **sin escribir**, de forma que un reintento no cambia el resultado.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["activo"],
                "properties": { "activo": { "type": "boolean" } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Estado aplicado (o ya vigente).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok", "activo"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "activo": { "type": "boolean" },
                    "sin_cambios": { "type": "boolean" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/categorias": {
      "get": {
        "tags": ["Catálogo"],
        "operationId": "getCategorias",
        "summary": "Categorías del comercio",
        "description": "Las categorías que de verdad están en uso, con cuántos productos tiene cada una. Se excluyen las vacías.",
        "responses": {
          "200": {
            "description": "Categorías en uso.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "categorias": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "nombre": { "type": "string" },
                          "total": { "type": "integer" }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/grupos-modificadores": {
      "get": {
        "tags": ["Catálogo"],
        "operationId": "getGruposModificadores",
        "summary": "Grupos de modificadores",
        "description": "Todos los grupos del comercio con sus opciones, para traerlos de una vez en lugar de producto a producto.",
        "parameters": [
          {
            "name": "producto_id",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 1 },
            "description": "Limita la respuesta a los grupos de un producto."
          }
        ],
        "responses": {
          "200": {
            "description": "Grupos con sus opciones.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "grupos": {
                      "type": "array",
                      "items": {
                        "allOf": [
                          { "$ref": "#/components/schemas/GrupoModificador" },
                          {
                            "type": "object",
                            "properties": {
                              "opciones": { "type": "array", "items": { "$ref": "#/components/schemas/Modificador" } }
                            }
                          }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/pedidos": {
      "get": {
        "tags": ["Pedidos"],
        "operationId": "getPedidos",
        "summary": "Listado de pedidos",
        "description": "El alcance de la credencial entra como un filtro más: si la credencial alcanza un solo lado, la consulta se fija a ese lado aunque no se mande `tipo`. Si se manda `tipo` pidiendo el lado que no se alcanza, la respuesta es **400** con el motivo, y no una lista parecida que no es la que se pidió.\n\nEl tope de `limit` es 100 y se aplica en el servidor: un valor mayor se recorta, y se ve en el número de filas devueltas.",
        "parameters": [
          {
            "name": "estado",
            "in": "query",
            "required": false,
            "schema": { "type": "string" },
            "description": "Filtra por estado del pedido."
          },
          {
            "name": "tipo",
            "in": "query",
            "required": false,
            "schema": { "type": "string" },
            "description": "`COMANDA` para las comandas del mostrador, o el tipo de la app para los pedidos de domicilio. Fuera del alcance de la credencial responde 400."
          },
          {
            "name": "fecha",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "format": "date" },
            "description": "Día en formato `YYYY-MM-DD`."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 },
            "description": "Máximo 100."
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 0, "default": 0 }
          }
        ],
        "responses": {
          "200": {
            "description": "Pedidos alcanzables por la credencial.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok", "total"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "total": { "type": "integer" },
                    "pedidos": { "type": "array", "items": { "$ref": "#/components/schemas/Pedido" } }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/pedidos/{id}": {
      "get": {
        "tags": ["Pedidos"],
        "operationId": "getPedido",
        "summary": "Un pedido con sus ítems",
        "description": "Si el pedido **es del comercio** pero el plan del titular de la credencial no alcanza ese tipo de pedido, responde **403** con el motivo. Si el pedido no es del comercio, responde **404**, igual que un id inventado.\n\nLa distinción es deliberada: un 404 sobre un pedido propio que existe sería afirmar que no existe.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Pedido encontrado y alcanzado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "pedido": { "$ref": "#/components/schemas/Pedido" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/pedidos/{id}/otp-recogida": {
      "get": {
        "tags": ["Pedidos"],
        "operationId": "getOtpRecogida",
        "summary": "Código de recogida",
        "description": "El código de 4 dígitos que el comercio le dice al domiciliario cuando llega a recoger.\n\nEs **un solo código por comercio**, cambia cada 10 minutos y vale para todos sus pedidos a la vez: hay que pedirlo en el momento de entregarlo y no guardarlo en caché. La propia respuesta trae el aviso.\n\nEl alcance se comprueba **antes** de pedir el código, porque obtenerlo escribe en la base.\n\nEl otro código —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.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Código vigente.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok", "otp_recogida"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "pedido_id": { "type": "integer" },
                    "otp_recogida": { "type": "string", "pattern": "^[0-9]{4}$" },
                    "otp_recogida_at": { "type": ["string", "null"], "format": "date-time" },
                    "aviso": { "type": "string" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "429": { "$ref": "#/components/responses/DemasiadasPeticiones" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/pedidos/{id}/aceptar": {
      "post": {
        "tags": ["Pedidos"],
        "operationId": "postAceptarPedido",
        "summary": "Aceptar el pedido",
        "description": "El comercio toma el pedido.\n\n**No cambia `estado`**: solo estampa `aceptada_at`. La señal que hay que mirar es esa, y también viaja en el webhook `pedido.aceptado`. Quien solo mire `estado` concluirá que nadie acepta nada cuando el comercio lo está haciendo.\n\nEs idempotente: aceptar dos veces no duplica nada ni cambia el resultado. Emite el webhook `pedido.aceptado`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Pedido aceptado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok", "pedido_id"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "pedido_id": { "type": "integer" },
                    "aceptada_at": { "type": ["string", "null"], "format": "date-time" },
                    "aviso": { "type": "string" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "409": { "$ref": "#/components/responses/Conflicto" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/pedidos/{id}/listo": {
      "post": {
        "tags": ["Pedidos"],
        "operationId": "postMarcarListo",
        "summary": "Marcar el pedido como listo",
        "description": "Marca el pedido listo para recoger o para despachar, con los mismos avisos al cliente y al domiciliario que hace el panel. Con los pedidos de cocina la respuesta lo indica con `cocina: true`.\n\nNo devuelve el código de recogida: se pide en su endpoint, que es donde vive el aviso de no guardarlo. Emite el webhook `pedido.listo`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Pedido marcado como listo.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok", "pedido_id"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "pedido_id": { "type": "integer" },
                    "listo_recogida_at": { "type": "string", "format": "date-time" },
                    "cocina": { "type": "boolean" },
                    "cocina_listo_at": { "type": "string", "format": "date-time" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "409": { "$ref": "#/components/responses/Conflicto" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/pedidos/{id}/cancelar": {
      "post": {
        "tags": ["Pedidos"],
        "operationId": "postCancelarPedido",
        "summary": "Cancelar el pedido",
        "description": "Cancela el pedido, devuelve el stock y ajusta la wallet, igual que desde el panel. El motivo es opcional y queda registrado.\n\nReintentar sobre un pedido ya cancelado responde 409, no 404 —el pedido existe— ni 400 —la petición está bien—: es un conflicto con el estado. Así, un reintento no vuelve a devolver el stock. Emite el webhook `pedido.cancelado`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "motivo": { "type": ["string", "null"] } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Pedido cancelado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok", "pedido_id"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "pedido_id": { "type": "integer" },
                    "estado": { "type": "string", "const": "CANCELADA" },
                    "motivo": { "type": ["string", "null"] }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "409": { "$ref": "#/components/responses/Conflicto" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/webhooks": {
      "get": {
        "tags": ["Webhooks"],
        "operationId": "getWebhooks",
        "summary": "Destinos configurados",
        "description": "Los webhooks activos e inactivos del comercio. Las lecturas de webhooks piden el permiso de escritura: quien mira la configuración es quien la cambia.",
        "responses": {
          "200": {
            "description": "Destinos del comercio.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "webhooks": { "type": "array", "items": { "$ref": "#/components/schemas/Webhook" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      },
      "post": {
        "tags": ["Webhooks"],
        "operationId": "postWebhook",
        "summary": "Crear un destino",
        "description": "La URL tiene que ser `https` con puerto estándar y host público: se rechazan los rangos privados, el loopback y los enlaces locales, y la comprobación se repite en cada intento de entrega.\n\n`eventos` vacío u omitido significa **todos**: un destino recién creado con solo su URL recibe el ciclo completo, no se queda mudo esperando configuración.\n\nEl secreto se devuelve **una sola vez**, en el alta. Después se lee con `GET /v1/webhooks/{id}/secreto`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["url"],
                "properties": {
                  "url": { "type": "string", "format": "uri", "examples": ["https://mi-sistema.example/webhooks/roombyte"] },
                  "eventos": { "type": "array", "items": { "$ref": "#/components/schemas/EventoWebhook" } },
                  "descripcion": { "type": ["string", "null"] }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Destino creado. El secreto solo se devuelve aquí.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "webhook": { "$ref": "#/components/schemas/Webhook" },
                    "secreto": { "type": "string", "description": "Secreto con el que se firman las entregas. Empieza por `whsec_`." }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/webhooks/entregas": {
      "get": {
        "tags": ["Webhooks"],
        "operationId": "getEntregasWebhook",
        "summary": "Log de entregas",
        "description": "El estado de cada entrega: pendiente, entregada, fallida o muerta, con el código de respuesta y el error cuando lo hubo. Es donde se ve la escalera de reintentos en acción.",
        "responses": {
          "200": {
            "description": "Entregas del comercio.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "entregas": { "type": "array", "items": { "$ref": "#/components/schemas/EntregaWebhook" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/webhooks/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": { "type": "integer", "minimum": 1 }
        }
      ],
      "put": {
        "tags": ["Webhooks"],
        "operationId": "putWebhook",
        "summary": "Editar un destino",
        "description": "Acepta `url`, `eventos`, `activo` y `descripcion`. Sin ningún campo actualizable responde 400.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": { "type": "string", "format": "uri" },
                  "eventos": { "type": "array", "items": { "$ref": "#/components/schemas/EventoWebhook" } },
                  "activo": { "type": "boolean" },
                  "descripcion": { "type": ["string", "null"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Destino actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "webhook": { "$ref": "#/components/schemas/Webhook" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      },
      "delete": {
        "tags": ["Webhooks"],
        "operationId": "deleteWebhook",
        "summary": "Borrar un destino",
        "responses": {
          "200": {
            "description": "Destino borrado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "msg": { "type": "string" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/webhooks/{id}/secreto": {
      "get": {
        "tags": ["Webhooks"],
        "operationId": "getSecretoWebhook",
        "summary": "Leer el secreto",
        "description": "Devuelve el secreto del comercio, el mismo para todos sus destinos, para poder configurar el verificador. Está cifrado y no hasheado precisamente por esto: el receptor lo necesita en claro para verificar, y obligar a rotarlo en cada despiste de configuración rompería las entregas en curso.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Secreto del comercio.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "secreto": { "type": "string" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/webhooks/{id}/rotar-secreto": {
      "post": {
        "tags": ["Webhooks"],
        "operationId": "postRotarSecretoWebhook",
        "summary": "Rotar el secreto",
        "description": "Genera un secreto nuevo. Es del comercio, así que **afecta a todos sus destinos**: hay que desplegar el valor nuevo en todos los receptores a la vez. Rotar una credencial de la API no rompe un webhook que funciona, y esto tampoco toca las credenciales.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Secreto rotado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "secreto": { "type": "string" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    },
    "/v1/webhooks/{id}/probar": {
      "post": {
        "tags": ["Webhooks"],
        "operationId": "postProbarWebhook",
        "summary": "Enviar un ping de prueba",
        "description": "Hace un POST real y firmado al destino, con el evento `ping`, y espera la respuesta hasta 10 segundos. Sirve para depurar el verificador sin esperar a que entre un pedido.\n\nTiene su propio límite, muy bajo, porque cada llamada es una petición saliente a un tercero.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Resultado de la prueba.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok"],
                  "properties": {
                    "ok": { "type": "boolean" },
                    "delivery_id": { "type": "string", "format": "uuid" },
                    "estado": { "type": "string", "description": "Estado de la entrega del ping." }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "401": { "$ref": "#/components/responses/NoAutorizado" },
          "403": { "$ref": "#/components/responses/SinPermiso" },
          "404": { "$ref": "#/components/responses/NoEncontrado" },
          "429": { "$ref": "#/components/responses/DemasiadasPeticiones" },
          "500": { "$ref": "#/components/responses/ErrorServidor" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "credencial": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Roombyte-Key-Id",
        "description": "Identificador público de la credencial, con la forma `pk_live_<24 hex>`.\n\nNo autentica nada por sí solo: es el índice con el que el servidor localiza la credencial antes de verificar la firma, y por eso puede viajar en claro.\n\nCada petición necesita además estas dos cabeceras, que no se pueden expresar en el esquema de seguridad y hay que calcular en el cliente:\n\n- `X-Roombyte-Timestamp`: segundos unix.\n- `X-Roombyte-Signature`: `v1=<64 hex>`, calculada con el procedimiento descrito en la descripción de la API."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["ok", "msg"],
        "properties": {
          "ok": { "type": "boolean", "const": false },
          "msg": { "type": "string", "description": "Mensaje en español, pensado para que lo lea quien integra." }
        }
      },
      "ErrorConDetalle": {
        "type": "object",
        "required": ["ok", "msg"],
        "properties": {
          "ok": { "type": "boolean", "const": false },
          "msg": { "type": "string" },
          "errores": {
            "type": "array",
            "description": "Detalle por elemento, con su posición o su id, para no tener que buscar a mano cuál de las mil entradas era la mala.",
            "items": {
              "type": "object",
              "properties": {
                "posicion": { "type": "integer" },
                "id": { "type": "integer" },
                "nombre": { "type": "string" },
                "error": { "type": "string" }
              }
            }
          }
        }
      },
      "Comercio": {
        "type": "object",
        "description": "Datos públicos del comercio. La cuenta bancaria, el certificado, el RUT y el estado de documentos no salen por la API.",
        "additionalProperties": true,
        "properties": {
          "id": { "type": "integer" },
          "nombre": { "type": "string" },
          "en_servicio": { "type": "boolean" },
          "dentro_de_horario": { "type": "boolean" }
        }
      },
      "Alcance": {
        "type": "object",
        "description": "A qué datos llega la credencial. Se resuelve en cada petición a partir de los servicios contratados por la cuenta que la creó.",
        "properties": {
          "comandas": { "type": "boolean" },
          "domicilios": { "type": "boolean" },
          "servicios": { "type": "array", "items": { "type": "string" } }
        }
      },
      "RespuestaMe": {
        "type": "object",
        "required": ["ok"],
        "properties": {
          "ok": { "type": "boolean" },
          "comercio": { "$ref": "#/components/schemas/Comercio" },
          "credencial": {
            "type": "object",
            "properties": {
              "public_id": { "type": "string" },
              "nombre": { "type": ["string", "null"] },
              "scopes": { "type": "array", "items": { "type": "string" } },
              "expira_at": { "type": ["string", "null"], "format": "date-time" },
              "created_at": { "type": ["string", "null"], "format": "date-time" },
              "ultimo_uso": { "type": ["string", "null"], "format": "date-time" }
            }
          },
          "alcance": { "$ref": "#/components/schemas/Alcance" }
        }
      },
      "Producto": {
        "type": "object",
        "description": "Producto del catálogo. `precio` ya lleva la promoción resuelta, igual que en el panel. El campo `costo` no se devuelve nunca.",
        "additionalProperties": true,
        "properties": {
          "id": { "type": "integer" },
          "nombre": { "type": "string" },
          "precio": { "type": "number" },
          "categoria": { "type": ["string", "null"] },
          "activo": { "type": "boolean" }
        }
      },
      "ProductoEntrada": {
        "type": "object",
        "required": ["nombre", "precio"],
        "properties": {
          "nombre": { "type": "string", "minLength": 2 },
          "precio": { "type": "number", "minimum": 0, "description": "El `0` solo es válido en comercios de compra abierta." },
          "descripcion": { "type": ["string", "null"] },
          "costo": { "type": ["number", "null"], "description": "Se acepta al escribir, no se devuelve al leer: es interno del comercio." },
          "categoria": { "type": ["string", "null"] },
          "variante": { "type": ["string", "null"] },
          "imagen_url": { "type": ["string", "null"] },
          "inventario": { "type": ["integer", "null"] },
          "codigo_barras": { "type": ["string", "null"], "description": "Clave de emparejamiento para la sincronización." },
          "dias_semana": { "description": "Días en que se ofrece el producto." },
          "unidad_peso": { "type": "string", "enum": ["lb", "kg", "g"], "default": "lb" },
          "vendido_por_peso": { "type": "boolean", "description": "Solo aplica a las categorías que venden por peso." },
          "requiere_anticipacion": { "type": "boolean" }
        }
      },
      "ProductoSincronizar": {
        "type": "object",
        "description": "Producto dentro de una sincronización. El emparejamiento es en cascada: primero `sku`, si no `codigo_barras`, y si no `nombre`.",
        "properties": {
          "sku": { "type": ["string", "null"] },
          "codigo_barras": { "type": ["string", "null"] },
          "nombre": { "type": ["string", "null"] },
          "precio": { "oneOf": [{ "type": "number" }, { "type": "string" }], "description": "Acepta número o texto en formato colombiano, como `\"$ 12.500\"`." },
          "categoria": { "type": ["string", "null"] },
          "descripcion": { "type": ["string", "null"] }
        }
      },
      "RespuestaSincronizacion": {
        "type": "object",
        "required": ["ok"],
        "properties": {
          "ok": { "type": "boolean" },
          "total": { "type": "integer" },
          "actualizados": { "type": "integer" },
          "creados": { "type": "integer" },
          "desactivados": { "type": "integer" },
          "sinCambios": { "type": "integer" },
          "actualizaciones": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "fila": { "type": "integer", "description": "Posición 1-based sobre el arreglo enviado." },
                "id": { "type": "integer" },
                "nombre": { "type": "string" },
                "anterior": { "type": "number" },
                "nuevo": { "type": "number" },
                "diferencia": { "type": "number" },
                "via": { "type": "string", "enum": ["sku", "codigo_barras", "nombre"] }
              }
            }
          },
          "creados_detalle": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "fila": { "type": "integer" },
                "nombre": { "type": ["string", "null"] },
                "precio": { "type": "number" },
                "sku": { "type": ["string", "null"] },
                "codigo_barras": { "type": ["string", "null"] },
                "categoria": { "type": ["string", "null"] },
                "descripcion": { "type": ["string", "null"] }
              }
            }
          },
          "faltantes": {
            "type": "array",
            "description": "Productos que están en el catálogo y no vinieron en el JSON. Se devuelve siempre, esté `desactivar_faltantes` encendido o no.",
            "items": { "$ref": "#/components/schemas/Producto" }
          },
          "errores": { "type": "array", "items": { "type": "object", "additionalProperties": true } }
        }
      },
      "GrupoModificador": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "id": { "type": "integer" },
          "producto_id": { "type": "integer" },
          "nombre": { "type": "string" },
          "requerido": { "type": "boolean" },
          "min_sel": { "type": ["integer", "null"] },
          "max_sel": { "type": ["integer", "null"] },
          "incluidas_gratis": { "type": ["integer", "null"] },
          "orden": { "type": ["integer", "null"] }
        }
      },
      "Modificador": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "id": { "type": "integer" },
          "grupo_id": { "type": "integer" },
          "nombre": { "type": "string" },
          "precio_extra": { "type": "number" },
          "imagen_url": { "type": ["string", "null"] },
          "orden": { "type": ["integer", "null"] }
        }
      },
      "ItemPedido": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "nombre": { "type": "string" },
          "cantidad": { "type": "number" },
          "precio": { "type": "number" },
          "modificadores": { "type": ["array", "null"], "items": { "type": "object", "additionalProperties": true } },
          "nota": { "type": ["string", "null"] }
        }
      },
      "Pedido": {
        "type": "object",
        "description": "Pedido o comanda del comercio. `aceptada_at` es la señal de que el comercio lo tomó: aceptar NO cambia `estado`.",
        "additionalProperties": true,
        "properties": {
          "id": { "type": "integer" },
          "numero": { "type": ["string", "null"] },
          "estado": { "type": ["string", "null"] },
          "total": { "type": ["number", "null"] },
          "tipo_pedido": { "type": ["string", "null"] },
          "aceptada_at": { "type": ["string", "null"], "format": "date-time" },
          "items": { "type": "array", "items": { "$ref": "#/components/schemas/ItemPedido" } }
        }
      },
      "EventoWebhook": {
        "type": "string",
        "enum": [
          "pedido.nuevo",
          "pedido.aceptado",
          "pedido.listo",
          "pedido.asignado",
          "pedido.en_camino",
          "pedido.entregado",
          "pedido.cancelado",
          "comercio.servicio_cambiado",
          "ping"
        ]
      },
      "Webhook": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "id": { "type": "integer" },
          "url": { "type": "string", "format": "uri" },
          "eventos": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/EventoWebhook" },
            "description": "Vacío significa todos."
          },
          "activo": { "type": "boolean" },
          "descripcion": { "type": ["string", "null"] }
        }
      },
      "EntregaWebhook": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "id": { "type": "integer" },
          "webhook_id": { "type": "integer" },
          "evento": { "$ref": "#/components/schemas/EventoWebhook" },
          "delivery_id": { "type": "string", "format": "uuid" },
          "estado": { "type": "string", "enum": ["pendiente", "entregada", "fallida", "muerta"] },
          "intentos": { "type": "integer" },
          "respuesta_status": { "type": ["integer", "null"] },
          "error": { "type": ["string", "null"] },
          "proximo_intento_at": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "EventoEntregado": {
        "type": "object",
        "description": "Cuerpo del POST que recibe el receptor.",
        "required": ["evento", "delivery_id", "creado_at", "comercio_id", "datos"],
        "properties": {
          "evento": { "$ref": "#/components/schemas/EventoWebhook" },
          "delivery_id": { "type": "string", "format": "uuid" },
          "creado_at": { "type": "string", "format": "date-time" },
          "comercio_id": { "type": "integer" },
          "datos": {
            "type": "object",
            "description": "Lo justo para saber de qué pedido se trata y qué pasó. El detalle completo se pide a la API con el id. En `pedido.aceptado` el pedido añade `aceptada_at`; en `comercio.servicio_cambiado` viene `{ comercio: { en_servicio } }`.",
            "properties": {
              "pedido": {
                "type": "object",
                "additionalProperties": true,
                "properties": {
                  "id": { "type": "integer" },
                  "numero": { "type": ["string", "null"] },
                  "estado": { "type": ["string", "null"] },
                  "total": { "type": ["number", "null"] },
                  "tipo": { "type": ["string", "null"] },
                  "aceptada_at": { "type": ["string", "null"], "format": "date-time" }
                }
              },
              "comercio": {
                "type": "object",
                "properties": { "en_servicio": { "type": "boolean" } }
              }
            }
          }
        }
      }
    },
    "responses": {
      "PeticionInvalida": {
        "description": "La petición está mal formada: falta un campo, un precio no es un número, un arreglo viene vacío o un id no es un entero positivo. No reintentar.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ErrorConDetalle" } }
        }
      },
      "NoAutorizado": {
        "description": "Firma inválida, reloj desfasado más de 5 minutos, firma repetida o credencial desconocida o revocada. Es lo que responde también una petición sin firmar.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "SinPermiso": {
        "description": "La credencial es válida pero no le corresponde: le falta el permiso, el servicio del titular no alcanza ese dato, o la API está apagada para ese comercio.\n\nCuando el recurso **sí es del comercio** y lo que falla es el alcance, la respuesta lo explica con un 403 en vez de un 404: un 404 sobre un pedido propio que existe afirmaría que no existe.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "NoEncontrado": {
        "description": "El recurso no es de este comercio. Un id de otro comercio y un id inventado dan exactamente lo mismo: no se enumeran ids ajenos.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "Conflicto": {
        "description": "El recurso es del comercio y está al alcance, pero su estado no permite la operación: cancelar un pedido ya cancelado, marcar listo algo que no está en el estado adecuado, o abrir un comercio sin ubicación cargada.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "DemasiadasPeticiones": {
        "description": "Se superó el límite de la credencial. El límite es por credencial, no por IP. Mirar `Retry-After`.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "ErrorServidor": {
        "description": "Fallo del servidor. Reintentar con espera es razonable si se repite.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      }
    }
  }
}
