{
  "openapi": "3.1.0",
  "info": {
    "title": "Enviadores Public API",
    "version": "1.0.0",
    "summary": "API REST pública de Enviadores: cotiza, genera guías, rastrea y cancela envíos.",
    "description": "API server-to-server autenticada por llave secreta (`Authorization: Bearer ek_live_…` / `ek_test_…`). Las llaves se administran desde la cuenta (una llave se muestra UNA sola vez al crearla). Nunca uses una llave secreta en un navegador o app móvil — no hay CORS en esta superficie por diseño.\n\n**Sobre (envelope):** toda respuesta JSON tiene la forma `{\"success\": true, \"data\": {…}, \"requestId\": \"…\"}` o `{\"success\": false, \"error\": {\"code\", \"message\", \"details\"?}, \"requestId\"}`. `requestId` también viaja en el header `X-Request-Id` — inclúyelo al reportar un problema.\n\n**Flujo cotiza→envía:** `POST /rates` devuelve tarifas con `id` (el `rate_id`). Ese `rate_id` es válido por **30 minutos** (`rate_id_expires_in_seconds`) y está ligado al usuario de la llave (y al PDV del header `X-PDV-ID`, si se usó): la creación del envío debe hacerse con la MISMA llave/usuario (y mismo PDV) o recibirás `RATE_NOT_FOUND` / `RATE_PDV_MISMATCH`.\n\n**Idempotencia:** manda un header `Idempotency-Key` (UUID fresco por operación lógica, 1–128 caracteres ASCII imprimibles) en `POST /shipments`. Un reintento con la MISMA llave y el MISMO cuerpo repite la respuesta original (header `Idempotent-Replay: true`) sin doble cargo, dentro de una ventana de **15 minutos**. La llave queda ligada al cuerpo exacto de la primera solicitud y al `X-PDV-ID` con que se envió: misma llave + cuerpo distinto → `409 IDEMPOTENCY_KEY_PAYLOAD_MISMATCH` (manda una llave nueva); misma llave pasados los 15 min → `409 IDEMPOTENCY_KEY_EXPIRED` (la llave sigue registrada 24 h pero ya no se repite — consulta `GET /shipments`); llave presente pero inválida (vacía, >128 chars, caracteres no imprimibles) → `400 IDEMPOTENCY_KEY_INVALID`; misma llave en otro endpoint → `409 IDEMPOTENCY_KEY_REUSED`. **Cambio 2026-08:** las llaves de `POST /shipments` ya NO comparten espacio de nombres con el carril web (`app.enviadores.com.mx`) de la misma cuenta — una llave usada en un carril y reusada en el otro responde `409 IDEMPOTENCY_KEY_REUSED` en lugar de repetir el envío del otro carril.\n\n**`POST_COMMIT_ERROR` (500): NO reintentes con una llave NUEVA.** El envío ES real y el cargo NO se revierte — la falla fue posterior al punto de commit. Consulta `GET /shipments` para recuperar la guía. Reintentar con el MISMO `Idempotency-Key` nunca crea un segundo envío: dentro de la ventana de 15 min repite este mismo `500 POST_COMMIT_ERROR` (con `Idempotent-Replay: true`) y pasada la ventana responde `409 IDEMPOTENCY_KEY_EXPIRED`. Una llave NUEVA sí crearía un segundo envío con un segundo cargo.\n\n**Límites de tasa:** por llave (token bucket). Cada respuesta incluye `X-RateLimit-Limit`, `X-RateLimit-Remaining` y `X-RateLimit-Reset`; un `429 RATE_LIMITED` incluye `Retry-After` (segundos) y `details.retry_after_ms`. Niveles: live standard 60 rpm sostenido / ráfaga 120; elevated 240/480; llaves de prueba 30/60.\n\n**Alcances (scopes):** cada llave porta un subconjunto de `rates:read`, `shipments:write`, `shipments:read`, `shipments:read:pdv`, `tracking:read`, `cancellations:write`, `cancellations:read`, `balance:read`, `labels:read`, `addresses:read`, `addresses:write`, `pickups:write`, `pickups:read`, `webhooks:write`, `webhooks:read`. Falta de scope → `403 INSUFFICIENT_SCOPE`. Una llave creada con el conjunto de permisos por omisión adquiere automáticamente los scopes por omisión nuevos (`scopes_mode: default`); una llave RECORTADA al crearla conserva exactamente su conjunto (`scopes_mode: explicit`) — crea una llave nueva para usar endpoints que no porte. Los scopes ampliados nunca se otorgan solos.\n\n`shipments:read:pdv` es un scope **ampliado**: es el único que deja ver filas que la llave no creó (los envíos de un PDV completo, bajo `X-PDV-ID`). Por eso NUNCA se otorga de forma implícita — hay que pedirlo por nombre al crear la llave, aunque se pidan \"todos\" los permisos.\n\n**`X-PDV-ID` (solo llaves de cuentas admin):** dirige el cargo (y el binding de cotización) a un punto de venta específico; requiere que la llave tenga una allowlist de PDVs configurada (sin allowlist, TODO `X-PDV-ID` real → `403 PDV_NOT_ALLOWED` — denegado por defecto). El valor `personal_account` equivale a omitir el header. Sin header, el principal cargado es el usuario de la llave.\n\n**Cobertura v1 (aplicada, no solo documentada):** envíos domésticos MX (CP de 5 dígitos) y UN paquete por solicitud. `country` distinto de `MX`, `packages`, `customs`, `ocurre`, `package.type` distinto de `paquete`, o cualquier campo desconocido a nivel raíz → `422 VALIDATION_ERROR` con el campo nombrado en `details.fields` — nunca se ignoran en silencio. Los campos `reference` y `metadata` de `POST /shipments` se aceptan pero están RESERVADOS (no se persisten en v1; `metadata.test_scenario` es un concepto del modo de prueba).\n\n**Catálogo de servicios bloqueado:** algunas cuentas (registro propio sin identidad verificada) ven TODAS las tarifas pero solo pueden generar guías con servicios **sin recolección** (entrega en sucursal; los cargos extra se pagan en mostrador). Cada tarifa de `POST /rates` trae `booking_locked`: si es `true`, `POST /shipments` responderá `403 SERVICE_UNLOCK_REQUIRED`. Filtra por `booking_locked === false` y nunca verás ese error; `unlock_notice` (a nivel de la respuesta) trae el texto en español que explica al usuario cómo desbloquearlo — verificar identidad (INE) o acumular $2,500 MXN en recargas. El **modo de prueba NO simula** esta restricción: en sandbox `booking_locked` siempre es `false`. Valida con una cotización live antes de salir a producción.\n\n**Límite de gasto por llave (opcional):** una llave puede portar un tope de gasto en ventana móvil de 24 h (`spend_cap_daily_mxn`, configurado por un administrador). Excederlo responde `429 RISK_LIMIT` con `details {daily_cap_mxn, spent_24h_mxn, attempted_mxn}`.\n\n**Webhooks (eventos salientes):** en lugar de hacer polling a `GET /shipments` o `GET /tracking/{guia}`, registra un endpoint `https://` público con `POST /webhooks` y recibe un `POST` por cada evento: `shipment.created`, `shipment.collected`, `shipment.in_transit`, `shipment.out_for_delivery`, `shipment.delivered`, `shipment.exception`, `shipment.returned`, `shipment.cancelled` y `pickup.resolved`. Máximo **5 webhooks activos** por cuenta (por modo). La respuesta de registro incluye el `secret` (`whsec_…`) **una sola vez** — no se almacena ni se vuelve a mostrar. Cada entrega lleva `X-Enviadores-Signature: t=<unix>,v1=<hex>` donde `v1 = HMAC-SHA256(secret, \"<t>.<cuerpo crudo>\")`, más `X-Enviadores-Event` (nombre del evento) y `X-Enviadores-Delivery` (id del evento, estable entre reintentos — úsalo para deduplicar). Verifica recomputando el HMAC sobre los bytes crudos del cuerpo, compara en tiempo constante y rechaza `t` con más de 5 minutos de antigüedad. Responde `2xx` en menos de 10 s; cualquier otra respuesta (redirecciones incluidas — no se siguen) se reintenta con espera creciente (1 min, 5 min, 30 min, 2 h, 12 h) y después se descarta; tras **20 fallos consecutivos** el webhook se desactiva solo (`status: disabled`, `disabled_reason`) — elimínalo y regístralo de nuevo. El cuerpo es `{id, event, mode, created_at, data}` donde `data.shipment` / `data.pickup` tienen EXACTAMENTE la forma de `GET /shipments/{id}` / `GET /pickups/{id}`. `POST /webhooks/{id}/test` entrega un `{\"event\":\"ping\"}` firmado de forma síncrona. Las llaves `ek_test_` administran webhooks de **sandbox**, que solo reciben `shipment.created` / `shipment.cancelled` del carril de pruebas y el ping (el sandbox no tiene rastreo ni resuelve recolecciones). Requiere `webhooks:write` / `webhooks:read`; las llaves creadas con el conjunto de permisos por omisión los adquieren automáticamente. **Postura de seguridad:** un webhook muere con la llave que lo registró: al revocar esa llave (o al reautorizar un conector OAuth, que retira la llave anterior) el webhook pasa a `disabled` con `disabled_reason: key_revoked` y sus eventos pendientes se descartan. Solo se aceptan los puertos 443 y 8443, y nunca una URL que resuelva al propio API.",
    "contact": { "name": "Enviadores", "url": "https://enviadores.com.mx" }
  },
  "servers": [
    { "url": "https://api.enviadores.com.mx/api/v1" }
  ],
  "security": [ { "bearerAuth": [] } ],
  "x-test-mode": {
    "status": "available",
    "key_format": "ek_test_ + 64 hex (72 chars)",
    "sandbox_allowance_mxn": 10000.0,
    "description": "Las llaves `ek_test_` autentican y consumen los MISMOS scopes y límites que las live, pero operan en un carril SANDBOX de persistencia paralela: escriben/leen únicamente en `sandbox_shipments` + `sandbox_rate_cache`, nunca tocan las tablas de dinero/reportes de producción, y NO mueven dinero real. Cada endpoint transaccional devuelve el MISMO esquema JSON que el carril live.\n\n**Diferencias honestas del sandbox (el esquema es idéntico; cambian los valores):** (1) alcance POR LLAVE, no por usuario — saldo, lista, consulta y cancelaciones se scopean por la llave de prueba; (2) saldo de prueba COMPUTADO = `PUBLIC_API_SANDBOX_ALLOWANCE` (10,000 MXN por llave) − el total de envíos de prueba NO cancelados; nunca es una fila de libro mayor, así que no puede desincronizarse; (3) guías con namespace `SBX` (SBX + 16 hex) para envíos simulados — los envíos del carril FedEx de prueba (abajo) usan como guía el número de rastreo REAL de prueba emitido por FedEx; (4) la cancelación es SÍNCRONA y terminal (sin flujo de staff): el reembolso es inmediato (libera el gasto), `GET /cancellations/{id}` es una vista sobre el envío cancelado direccionada por un id numérico determinista, y el motivo NO se persiste (reason_code/reason_text = null); create devuelve `status: cancelled` / `refund_status: refunded`; (5) `X-PDV-ID` en cualquier endpoint transaccional → `403 PDV_NOT_ALLOWED` (el sandbox no tiene fondos de PDV); `personal_account` equivale a omitir el header; (6) la etiqueta (`GET /labels/{id}`) es un PDF placeholder generado localmente — salvo en el carril FedEx de prueba, donde es una etiqueta REAL del sandbox de FedEx (PDF genuino, SIN validez de envío); `?format=json` devuelve un enlace PÚBLICO firmado y durable a esa copia (`/v1/sandbox-labels/{id}/{firma}.pdf`, sin bearer — la firma HMAC es la autorización), porque la URL de caché de FedEx caduca en horas; (7) `GET /tracking/{guia}` con llave de prueba resuelve PRIMERO los envíos sandbox de la propia llave (eventos mínimos: creado/cancelado); sin coincidencia, resuelve guías REALES igual que una llave live; (8) el tope de gasto por llave (`spend_cap_daily_mxn`) NO aplica al carril de prueba — el saldo computado del sandbox es el único límite de gasto de una llave `ek_test_`.\n\n**Carril FedEx de prueba (FedEx test environment):** cuando está habilitado, las cotizaciones del sandbox incluyen ofertas REALES del entorno de desarrollo de FedEx, y crear un envío con una de esas tarifas emite una etiqueta de PRUEBA real de FedEx: número de rastreo genuino (la guía), PDF genuino, sin costo y sin validez de envío. Fuera de ese carril el sandbox cotiza UNA sola oferta simulada interna (`Enviadores Sandbox` · `Estándar`, precio determinista): el sandbox nunca imita a otra paquetería real — solo muestra servicios con un sandbox real detrás. Todo el carril es fail-soft: si el sandbox de FedEx no responde, la cotización sirve el catálogo y el envío se crea con guía `SBX` + PDF placeholder — una petición de sandbox nunca falla por una falla del sandbox del proveedor.\n\n**Retención de datos de sandbox:** envíos 30 días, cotizaciones 30 min (misma TTL que el carril live → mismo momento de `RATE_EXPIRED`).",
    "forced_failures": {
      "description": "Escenarios forzados por `metadata.test_scenario` en `POST /shipments` — cada uno reproduce el código+status EXACTO del carril live. Aplican SOLO a ofertas simuladas del catálogo: sobre una tarifa del carril FedEx de prueba (entorno real de FedEx) la petición responde `422 TEST_SCENARIO_NOT_AVAILABLE` en lugar de forzar la falla.",
      "insufficient_funds": "402 CREDIT_ERROR con `details {required, available, shortfall}`, sin importar el saldo real (sintetizado — condición de crédito).",
      "rate_expired": "409 RATE_EXPIRED (sintetizado — condición a nivel de validación).",
      "vendor_error": "422 VENDOR_ERROR con `retryable: false` — enrutado por el proveedor de prueba y aplanado EXACTAMENTE como ShipmentExecutor (código literal `VENDOR_ERROR`, status HTTP de la subclase, retryable solo en fallas de red)."
    },
    "data_retention": { "shipments_days": 30, "rate_quotes_minutes": 30 }
  },
  "tags": [
    { "name": "rates", "description": "Cotización multi-paquetería" },
    { "name": "shipments", "description": "Creación y consulta de envíos" },
    { "name": "tracking", "description": "Rastreo público" },
    { "name": "cancellations", "description": "Solicitudes de cancelación" },
    { "name": "pickups", "description": "Recolecciones a domicilio (solicitudes; las confirma nuestro equipo)" },
    { "name": "balance", "description": "Saldo de créditos" },
    { "name": "labels", "description": "Etiquetas (guías)" },
    { "name": "addresses", "description": "Directorio: remitentes y destinatarios" },
    { "name": "webhooks", "description": "Webhooks de eventos (envíos y recolecciones) — alternativa al polling" },
    { "name": "account", "description": "La cuenta y la llave: identidad, capacidades y movimientos de saldo" }
  ],
  "paths": {
    "/rates": {
      "post": {
        "tags": ["rates"],
        "operationId": "createRates",
        "summary": "Cotizar en todas las paqueterías",
        "description": "Cotiza con todas las paqueterías activas y devuelve tarifas con `id` (`rate_id`) listas para `POST /shipments`. Requiere scope `rates:read`. Solo rutas domésticas MX (`country` ≠ MX → 422). **Bultos:** UN paquete (`package`) o VARIOS en un mismo envío (`packages`, 2–10 bultos; mandar ambos → 422). Con `packages` solo se devuelven ofertas reservables como envío multi-bulto (los servicios que no lo soportan no aparecen) y el peso facturable es la SUMA de los bultos. **Códigos postales:** deben existir en el catálogo SEPOMEX — un CP inexistente (p. ej. 99999) responde 422 nombrando `from.postal_code`/`to.postal_code` ANTES de consultar a las paqueterías (antes se cotizaba por zona a ciegas). Las tarifas de referencia de mostrador (market) NO se incluyen: todo `rate_id` devuelto es enviable. **Seguro:** `insurance.insured_value` (alias obsoleto `declared_value`) contrata cobertura para el envío: la póliza de la plataforma (prima incluida y firmada dentro de `pricing.total_price`) o, si esa póliza no está disponible, la cobertura de la propia paquetería — el precio mostrado ya la incluye y cada tarifa lo indica con `insurance_included` / `insurance_not_supported`. *Insurance: `insured_value` buys coverage; when the platform policy is unavailable the carrier's own coverage is used and the price shown already includes it.* **Vista agrupada:** `?view=grouped` devuelve cada servicio distinto UNA sola vez (paquetería + nivel de servicio) con su rango de precios y sus opciones reservables (ver `RatesGroupedResponse`); la forma plana por defecto no cambia. La respuesta es la MISMA para toda llave (los costos de proveedor nunca se exponen).",
        "parameters": [
          { "$ref": "#/components/parameters/XPdvId" },
          { "name": "view", "in": "query", "required": false, "schema": { "type": "string", "enum": ["grouped"] }, "description": "`grouped`: colapsa `services[]` en un elemento por servicio distinto (`RatesGroupedResponse`, con `view: \"grouped\"`). Omitido: la forma plana (`RatesResponse`)." }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/RatesRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cotización exitosa (forma autenticada multi-paquetería).",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": {
              "application/json": { "example": {"success": true, "data": {"services": [{"id": "e2etest_E2ETestCarrier_E2EStandard_ab12cd34", "carrier": "Estafeta", "service_name": "Terrestre", "service_type": "standard_economy", "tier": "standard_economy", "delivery_window": "ground", "pickup_included": false, "address_delivery": true, "pricing": {"total_price": 145.5, "currency": "MXN", "iva_included": true, "insurance_premium": 0, "insured_value": null}, "delivery": {"estimated_days": "3-5", "estimated_date": null, "min_days": 3, "max_days": 5}, "insurance_included": false, "insurance_not_supported": false, "zona_extendida": false}, {"id": "e2etest_E2ETestCarrier_E2EExpress_7f0a16b2", "carrier": "FedEx", "service_name": "Express Nacional", "service_type": "express", "tier": "express", "delivery_window": "next_day", "pickup_included": true, "address_delivery": true, "pricing": {"total_price": 289.0, "currency": "MXN", "iva_included": true, "insurance_premium": 0, "insured_value": null}, "delivery": {"estimated_days": "1-2", "estimated_date": null, "min_days": 1, "max_days": 2}, "insurance_included": false, "insurance_not_supported": false, "zona_extendida": false}], "fetched_at": "2026-07-13T18:42:05Z", "stale_after": "2026-07-13T18:47:05Z", "meta": {"billable_weight": 1, "volumetric_weight": 0.6, "zone": 5}, "rate_id_expires_in_seconds": 1800}, "requestId": "req_a1b2c3d4e5"},
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/SuccessEnvelope" },
                    { "type": "object", "properties": { "data": { "oneOf": [ { "$ref": "#/components/schemas/RatesResponse" }, { "$ref": "#/components/schemas/RatesGroupedResponse" } ] } } }
                  ]
                }
              }
            }
          },
          "400": { "description": "`INVALID_JSON` (cuerpo vacío/no-JSON) · `MISSING_ROUTE` · `MISSING_PACKAGE` · `WEIGHT_EXCEEDED` (peso facturable > 70 kg).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFoundDark" },
          "422": { "description": "`VALIDATION_ERROR` (details.fields lista los campos — incluye `country` ≠ MX, `package` y `packages` juntos, `packages` fuera de 2–10, un CP inexistente en SEPOMEX (`from.postal_code: el CP 99999 no existe en el catálogo SEPOMEX`) y campos desconocidos a nivel raíz) · `INSURANCE_VALUE_TOO_HIGH` (valor asegurado sobre el máximo asegurable).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "description": "`RATES_ERROR` — la cotización falló; reintenta.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }
        }
      }
    },
    "/shipments": {
      "post": {
        "tags": ["shipments"],
        "operationId": "createShipment",
        "summary": "Crear un envío (genera la guía y cobra créditos)",
        "description": "Crea el envío contra un `rate_id` vigente (≤ 30 min) cotizado con la MISMA llave (y mismo PDV, si aplica). Solo domésticos MX (`customs`/`ocurre`, `country` ≠ MX o campos desconocidos → 422). **Bultos:** los MISMOS que se cotizaron — `package` (uno) o `packages` (2–10 en un solo envío; ambos → 422); un conjunto distinto al cotizado responde 409 `RATE_PACKAGE_MISMATCH`. **CP:** origen/destino inline deben existir en SEPOMEX (422 nombrando `from.postal_code`/`to.postal_code`, antes de retener saldo). **Seguro vs. valor declarado (no son lo mismo):** `insurance.insured_value` es la póliza de plataforma que YA se cotizó — debe coincidir con la cotización (422 `INSURANCE_MISMATCH` si difiere o falta; el total firmado nunca se recalcula al crear); `declared_value` es el valor de la mercancía que se declara a la paquetería (MXN, informativo; algunos servicios lo exigen — `DECLARED_VALUE_REQUIRED`). Remitente/destinatario van inline (la plataforma crea o reutiliza los registros del directorio con dedupe exacto normalizado) o por referencia (`from_id`/`to_id` del directorio — la fila guardada se usa tal cual). El cargo sigue el ciclo autorizar→capturar/anular — un envío rechazado nunca deja fondos retenidos. **Usa `Idempotency-Key`** en todo intento.",
        "parameters": [
          { "$ref": "#/components/parameters/IdempotencyKey" },
          { "$ref": "#/components/parameters/XPdvId" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ShipmentCreateRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Envío creado (o repetido idempotentemente — header `Idempotent-Replay: true`; o deduplicado contra una guía existente — `duplicate: true`, en cuyo caso `carrier`/`service` pueden ser null).",
            "headers": {
              "Idempotent-Replay": { "$ref": "#/components/headers/IdempotentReplay" },
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": {
              "application/json": { "example": {"success": true, "data": {"shipment": {"id": "20260713-000123", "guia": "1234567890", "carrier": "Estafeta", "service": "Terrestre", "status": "created", "total": 145.5, "currency": "MXN", "label_url": "/api/v1/labels/20260713-000123", "tracking_pending": false, "created_at": "2026-07-13T18:45:12Z"}}, "requestId": "req_b2c3d4e5f6"},
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/SuccessEnvelope" },
                    { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/ShipmentCreateResponse" } } }
                  ]
                }
              }
            }
          },
          "400": { "description": "`INVALID_JSON` · `VALIDATION_FAILED` (rechazo del núcleo, details.validationErrors) · `INVALID_VENDOR` · `OCURRE_CARRIER_MISMATCH` · `IDEMPOTENCY_KEY_INVALID` (header presente pero vacío, >128 chars o con caracteres no imprimibles; omitirlo es válido, mandarlo mal no).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "description": "`CREDIT_ERROR` — créditos insuficientes. `details`: `{required, available, shortfall}`. Recarga y reintenta con el MISMO `Idempotency-Key`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFoundDark" },
          "409": { "description": "`RATE_NOT_FOUND` (cotiza de nuevo) · `RATE_EXPIRED` (>30 min) · `RATE_TAMPERED` · `RATE_PDV_MISMATCH` (se cotizó bajo otro PDV) · `RATE_PACKAGE_MISMATCH` (el `package`/`packages` enviado difiere del cotizado — medidas, peso o valor declarado — reenvía el MISMO conjunto que cotizaste, o cotiza de nuevo) · `GUIA_COLLISION` · `IDEMPOTENCY_KEY_REUSED` (misma llave en otro endpoint — desde 2026-08 el carril web cuenta como otro endpoint) · `IDEMPOTENCY_KEY_PAYLOAD_MISMATCH` (misma llave, cuerpo o `X-PDV-ID` distintos: manda una llave nueva) · `IDEMPOTENCY_KEY_EXPIRED` (la llave ya se completó hace más de 15 min: consulta `GET /shipments` o manda una llave nueva) · `RATE_ADDRESS_MISMATCH` (la cotización corresponde a OTRA ruta: los CP de origen/destino con los que se está creando el envío no son los que se cotizaron — cotiza de nuevo con las direcciones definitivas; desde 2026-08 esto rechaza en lugar de solo registrarse) · `REQUOTE_REQUIRED` (el precio del envío no coincide con el de la cotización; no se espera en este carril — el total se toma de la MISMA fila firmada que el ejecutor vuelve a verificar — y se documenta por completitud: cotiza de nuevo).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "422": { "description": "`VALIDATION_ERROR` (contrato público, details.fields — incluye CP inexistente en SEPOMEX) · `INSURANCE_NOT_AVAILABLE` (se mandó `insurance` sobre una tarifa cuya paquetería no ofrece cobertura — venía con `insurance_not_supported: true`; cotiza de nuevo y elige una con `insurance_included`, o crea sin `insurance`) · `INSURANCE_MISMATCH` (`insurance.insured_value` explícito distinto de la cobertura cotizada — `details {quoted_insured_value, requested_insured_value}`; omitirlo acepta la cobertura cotizada) · `DECLARED_VALUE_MISMATCH` (`declared_value` distinto del que se cotizó, o presente cuando la cotización no llevaba ninguno y la paquetería lo cobra — `details {quoted_declared_value, requested_declared_value}`; cotiza de nuevo) · `DECLARED_VALUE_REQUIRED` (el servicio exige `declared_value`; no se cobró) · `PRICING_CONFIG_ERROR` (margen mal configurado; contacta soporte) · `MISSING_PHONE` (remitente y/o destinatario sin teléfono válido de 10 dígitos; `details.missing` lista cuál).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "description": "`RISK_LIMIT` (tope de guías/gasto diario de la cuenta, o tope de gasto de la LLAVE en ventana móvil de 24 h — este último con `details {daily_cap_mxn, spent_24h_mxn, attempted_mxn}`) · `RATE_LIMITED` (límite por llave; ver `Retry-After`).", "headers": { "Retry-After": { "$ref": "#/components/headers/RetryAfter" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "500": { "description": "`SHIPMENT_ERROR` (falla pre-commit; el cargo se revirtió — `details.refunded`) · `ADDRESS_ERROR` (no se pudo registrar remitente/destinatario; reintenta) · `CREDIT_ERROR` (falla interna de créditos) · **`POST_COMMIT_ERROR` — EL ENVÍO ES REAL y el cargo NO se revierte (`details.committed: true`). NO reintentes con llave nueva: consulta `GET /shipments`.**", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "502": { "description": "`VENDOR_ERROR` — la paquetería rechazó/falló ANTES del commit; el cargo se revirtió (`details.refunded`). `details.vendorError: true`; el status HTTP refleja el de la paquetería (4xx/5xx). Reintenta solo si `retryable` aplica (falla de red).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }
        }
      },
      "get": {
        "tags": ["shipments"],
        "operationId": "listShipments",
        "summary": "Listar envíos (paginado)",
        "description": "Por defecto: solo envíos creados por el usuario de la llave, para TODOS los roles — una llave admin ve únicamente sus propios envíos de cuenta de servicio.\n\n**Alcance por PDV (opt-in).** Una llave admin que envía `X-PDV-ID` **y** tiene el scope `shipments:read:pdv` lista en cambio los envíos de ese punto de venta, sin importar qué operador los creó — el mismo fondo que ya responden `/senders`, `/recipients` y `/balance` bajo ese header. Sin el scope, el header no amplía nada y sigues viendo solo lo tuyo (los integradores que ya lo envían no cambian de resultados).\n\n**Cambio de contrato (2026-07):** `X-PDV-ID` ahora se **valida** en esta ruta aunque no amplíe nada. Antes se ignoraba por completo, así que un PDV inexistente o fuera de la allowlist de la llave respondía `200` con lista vacía mientras `/senders` respondía `403`; ahora responde `403 PDV_NOT_ALLOWED` como todas las rutas hermanas.\n\nOrden: más recientes primero. Requiere scope `shipments:read` (+ `shipments:read:pdv` para el alcance por PDV).",
        "parameters": [
          { "$ref": "#/components/parameters/Page" },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/ShipmentGuia" },
          { "$ref": "#/components/parameters/ShipmentStatus" },
          { "$ref": "#/components/parameters/ShipmentFrom" },
          { "$ref": "#/components/parameters/ShipmentTo" },
          { "$ref": "#/components/parameters/XPdvId" }
        ],
        "responses": {
          "200": {
            "description": "Página de envíos.",
            "content": {
              "application/json": { "example": {"success": true, "data": {"shipments": [{"id": "20260713-000123", "guia": "1234567890", "carrier": "Estafeta", "service": "Terrestre", "status": "in_transit", "status_label": "En tránsito", "total": 145.5, "currency": "MXN", "created_at": "2026-07-13T18:45:12Z", "tracking_pending": false, "label_url": "/api/v1/labels/20260713-000123", "packages": [{"weight_kg": 1.0, "length_cm": 20.0, "width_cm": 15.0, "height_cm": 10.0}], "billable_weight_kg": 1.0, "declared_value": null, "content": "Ropa", "sender": {"name": "Juan Pérez", "city": "Ciudad de México", "state": "CDMX", "postal_code": "01000"}, "recipient": {"name": "María López", "city": "Monterrey", "state": "Nuevo León", "postal_code": "64000"}}], "pagination": {"page": 1, "limit": 20, "total": 1}}, "requestId": "req_e5f6a7b8c9"},
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/SuccessEnvelope" },
                    { "type": "object", "properties": { "data": { "type": "object", "required": ["shipments", "pagination"], "properties": { "shipments": { "type": "array", "items": { "$ref": "#/components/schemas/Shipment" } }, "pagination": { "$ref": "#/components/schemas/Pagination" } } } } }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFoundDark" },
          "422": { "description": "`VALIDATION_ERROR` — filtro inválido (details.fields).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/shipments/{id}": {
      "get": {
        "tags": ["shipments"],
        "operationId": "getShipment",
        "summary": "Consultar un envío",
        "description": "Misma visibilidad que el listado: propios por defecto, o los del PDV con `X-PDV-ID` + scope `shipments:read:pdv`. Devuelve exactamente los mismos campos que cada elemento de `GET /shipments` (incluyendo `packages`, pesos y remitente) — el detalle no es más rico que la lista, por diseño. Si solo tienes el número de guía, usa `GET /shipments?guia=…`.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[A-Za-z0-9-]{1,32}$" }, "description": "Id del envío (p. ej. `20260712-000123`)." },
          { "$ref": "#/components/parameters/XPdvId" }
        ],
        "responses": {
          "200": {
            "description": "El envío.",
            "content": { "application/json": { "example": {"success": true, "data": {"shipment": {"id": "20260713-000123", "guia": "1234567890", "carrier": "Estafeta", "service": "Terrestre", "status": "in_transit", "status_label": "En tránsito", "total": 145.5, "currency": "MXN", "created_at": "2026-07-13T18:45:12Z", "tracking_pending": false, "label_url": "/api/v1/labels/20260713-000123", "packages": [{"weight_kg": 1.0, "length_cm": 20.0, "width_cm": 15.0, "height_cm": 10.0}], "billable_weight_kg": 1.0, "declared_value": null, "content": "Ropa", "sender": {"name": "Juan Pérez", "city": "Ciudad de México", "state": "CDMX", "postal_code": "01000"}, "recipient": {"name": "María López", "city": "Monterrey", "state": "Nuevo León", "postal_code": "64000"}}}, "requestId": "req_f6a7b8c9d0"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["shipment"], "properties": { "shipment": { "$ref": "#/components/schemas/Shipment" } } } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — no existe o no es visible para la llave (respuesta idéntica en ambos casos).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/tracking/{guia}": {
      "get": {
        "tags": ["tracking"],
        "operationId": "getTracking",
        "summary": "Rastrear cualquier guía",
        "description": "Datos públicos de rastreo (mismo payload PII-mínimo de la página pública: estatus canónico, ciudad/estado de origen y destino, línea de tiempo con nombres de receptor depurados). No requiere que la guía sea propia. Agnóstico al modo de la llave (funciona igual con `ek_test_`). Requiere scope `tracking:read`.",
        "parameters": [
          { "name": "guia", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1, "maxLength": 50 }, "description": "Número de guía (coincidencia EXACTA)." }
        ],
        "responses": {
          "200": {
            "description": "Estado de rastreo.",
            "content": { "application/json": { "example": {"success": true, "data": {"guia": "1234567890", "carrier": "Estafeta", "status": "in_transit", "status_label": "En tránsito", "origin": {"city": "Ciudad de México", "state": "CDMX"}, "destination": {"city": "Monterrey", "state": "Nuevo León"}, "created_at": "2026-07-13T18:45:12Z", "events": [{"timestamp": "2026-07-13T20:10:00Z", "description": "Recolectado", "location": "Ciudad de México, CDMX"}, {"timestamp": "2026-07-14T09:30:00Z", "description": "En tránsito al destino", "location": "Querétaro, QRO"}]}, "requestId": "req_c3d4e5f6a7"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Tracking" } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — genérico y de forma estable para toda guía desconocida/ inválida (sin oráculo de existencia).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/cancellations": {
      "post": {
        "tags": ["cancellations"],
        "operationId": "createCancellation",
        "summary": "Solicitar la cancelación de un envío propio",
        "description": "Crea una solicitud de cancelación (estado inicial `pending`). El avance del estado es operado por el staff/los procesos de la plataforma — consulta `GET /cancellations/{id}`. El reembolso esperado es lo que se cobró por el envío. Requiere scope `cancellations:write`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "example": {"shipment_id": "20260713-000123", "reason_code": "customer_changed_mind"},
              "schema": {
                "type": "object",
                "required": ["shipment_id"],
                "properties": {
                  "shipment_id": { "type": "string", "description": "Id del envío propio a cancelar." },
                  "reason_code": { "type": "string", "default": "other", "enum": ["wrong_address", "duplicate", "customer_changed_mind", "damaged", "never_shipped", "not_picked_up", "other"] },
                  "reason_text": { "type": "string", "maxLength": 2000 }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Solicitud creada.",
            "content": { "application/json": { "example": {"success": true, "data": {"id": 42, "display_id": "CR00000042", "status": "pending", "refund_status": "not_applicable", "refund_amount_expected": 145.5, "cancellation_deadline": "2026-07-14T18:45:12Z"}, "requestId": "req_a7b8c9d0e1"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/CancellationCreated" } } } ] } } }
          },
          "400": { "description": "`INVALID_JSON` · `REQUEST_FAILED` (rechazo del servicio).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "description": "`FORBIDDEN` (rechazo de permiso del servicio) · `INSUFFICIENT_SCOPE` · `PDV_NOT_ALLOWED`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "404": { "description": "`NOT_FOUND` — el envío no existe o no pertenece al usuario de la llave (respuesta idéntica).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "409": { "description": "`CONFLICT` — el envío ya está cancelado o ya existe una solicitud abierta (`details.existing_request_id`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "422": { "description": "`VALIDATION_ERROR` (details.fields).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "description": "`REQUEST_ERROR`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }
        }
      }
    },
    "/cancellations/{id}": {
      "get": {
        "tags": ["cancellations"],
        "operationId": "getCancellation",
        "summary": "Consultar una solicitud de cancelación",
        "description": "Visibilidad idéntica a la del envío que cancela (propios por defecto; los del PDV con `X-PDV-ID` + scope `shipments:read:pdv`) — deliberadamente en paralelo, para que un envío que puedes leer nunca tenga una cancelación que no.\n\nMáquina de estados: `pending → approved → in_progress → cancelled | rejected_by_carrier`; otros estados: `not_cancellable` (la guía no admite cancelación vía API — terminal), `reported_to_carrier` (reporte manual ante la paquetería, sigue abierta), `rejected`, `failed`, `expired`. Reembolso: `not_applicable → refund_in_progress → refunded | refund_denied` (los reembolsos son pass-through de la paquetería; `refunded` cubre también el reembolso manual por override administrativo). Requiere scope `cancellations:read`.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "minimum": 1 } },
          { "$ref": "#/components/parameters/XPdvId" }
        ],
        "responses": {
          "200": {
            "description": "La solicitud.",
            "content": { "application/json": { "example": {"success": true, "data": {"cancellation": {"id": 42, "display_id": "CR00000042", "shipment_id": "20260713-000123", "status": "cancelled", "refund_status": "refunded", "reason_code": "customer_changed_mind", "reason_text": null, "refund_amount_expected": 145.5, "refund_amount_actual": 145.5, "cancellation_deadline": "2026-07-14T18:45:12Z", "created_at": "2026-07-13T19:02:33Z", "updated_at": "2026-07-15T10:12:05Z", "resolved_at": "2026-07-15T10:12:05Z"}}, "requestId": "req_b8c9d0e1f2"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["cancellation"], "properties": { "cancellation": { "$ref": "#/components/schemas/Cancellation" } } } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — no existe o el envío subyacente no pertenece al usuario de la llave (respuesta idéntica).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "description": "`GET_ERROR`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }
        }
      }
    },
    "/pickups": {
      "post": {
        "tags": ["pickups"],
        "operationId": "createPickup",
        "summary": "Solicitar la recolección de un envío propio",
        "description": "**Solicita** que la paquetería pase por el paquete al domicilio del remitente, en lugar de que el remitente lo lleve a sucursal.\n\n**Esto es una solicitud, no una reserva.** No agendamos con la paquetería de forma automática: el equipo de Enviadores la gestiona y **avisa por correo** al solicitante cuando queda. Por eso la respuesta siempre regresa `status: awaiting_confirmation` — nunca reportes al usuario que ya hay mensajero confirmado. Consulta `GET /pickups/{id}` para el estado final, o espera el correo.\n\n**Por qué.** Un 200 de una paquetería no es prueba de que el mensajero vaya a llegar, y el peor error posible aquí es decirle a alguien que su recolección está confirmada y dejarlo esperando. Preferimos ser lentos y ciertos.\n\nDisponible sólo para **Estafeta, DHL, FedEx y Paquetexpress** (`422 PICKUP_CARRIER_NOT_ELIGIBLE` para el resto; el paquete siempre puede entregarse en sucursal). **Una sola recolección abierta por envío.**\n\n`pickup_date`, `ready_time` y `close_time` son **hora local de México (America/Mexico_City)**; la respuesta devuelve las ventanas en UTC (Z).\n\nLa recolección **no cobra saldo**; cualquier cargo de la paquetería llega después por la vía de sobrecargos, igual que en el carril web.\n\nLa dirección de recolección es la del propio envío — este endpoint no la cambia. Requiere scope `pickups:write`.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "example": {"shipment_id": "20260830-000123", "pickup_date": "2026-08-31", "ready_time": "10:00", "close_time": "17:00", "notes": "Timbre azul, preguntar por Ana"}, "schema": { "type": "object", "required": ["shipment_id", "pickup_date", "ready_time", "close_time"], "properties": { "shipment_id": { "type": "string", "description": "Id de un envío propio con guía, no entregado ni cancelado." }, "pickup_date": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": "Fecha local (America/Mexico_City). Hoy o después." }, "ready_time": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", "description": "HH:MM 24h local — desde cuándo está listo el paquete." }, "close_time": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", "description": "HH:MM 24h local — hasta cuándo puede pasar el mensajero. Debe ser posterior a ready_time." }, "notes": { "type": "string", "maxLength": 500, "description": "Indicación para el mensajero." } } } } }
        },
        "responses": {
          "201": {
            "description": "Solicitud registrada — `status` siempre es `awaiting_confirmation`. Un 201 significa que recibimos la solicitud, NO que haya mensajero confirmado.",
            "content": { "application/json": { "example": {"success": true, "data": {"pickup": {"id": 42, "display_id": "PR00000042", "shipment_id": "20260830-000123", "status": "awaiting_confirmation", "carrier": "Estafeta", "requested_window_start": "2026-08-31T16:00:00Z", "requested_window_end": "2026-08-31T23:00:00Z", "confirmed_window_start": null, "confirmed_window_end": null, "confirmation_number": null, "created_at": "2026-08-30T10:00:00Z", "updated_at": "2026-08-30T10:00:02Z"}}, "requestId": "req_a7b8c9d0e1"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["pickup"], "properties": { "pickup": { "$ref": "#/components/schemas/Pickup" } } } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "description": "`PICKUP_UNAVAILABLE` — la programación de recolecciones aún no está habilitada en esta cuenta (el paquete puede entregarse en sucursal; escríbenos para agendarla manualmente). · `PICKUP_UNLOCK_REQUIRED` — la cuenta todavía sólo puede usar servicios sin recolección; se desbloquea al verificar identidad (INE) en Mi cuenta o al acumular $2,500 MXN en recargas. · `FORBIDDEN` · `INSUFFICIENT_SCOPE` · `PDV_NOT_ALLOWED`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "404": { "description": "`NOT_FOUND` — el envío no existe o no es de esta llave (respuesta idéntica, sin oráculo de existencia).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "409": { "description": "`CONFLICT` — ya existe una recolección abierta para este envío (`details.existing_request_id`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "422": { "description": "`VALIDATION_ERROR` (campos en `details.fields`) · `PICKUP_CARRIER_NOT_ELIGIBLE` — la paquetería del envío no está en la lista (`details.eligible_carriers`); el paquete puede entregarse en sucursal · `REQUEST_FAILED` — el envío no admite recolección (sin guía, entregado, cancelado, o fecha/hora imposible).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "description": "`REQUEST_ERROR`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }
        }
      },
      "get": {
        "tags": [
          "pickups"
        ],
        "operationId": "listPickups",
        "summary": "Listar tus solicitudes de recolección",
        "description": "Las solicitudes de recolección de la cuenta, de la más reciente a la más antigua — qué pediste, para cuándo, y si nuestro equipo ya la gestionó.\n\nVisibilidad idéntica a la del listado de envíos: por omisión las recolecciones de los envíos que creó el usuario de la llave; con `X-PDV-ID` validado **y** el scope ampliado `shipments:read:pdv`, las de ese punto de venta (las dos ramas son EXCLUYENTES). Mientras la programación de recolecciones no esté habilitada en la cuenta, este listado responde **200 con una página vacía** — un listado de algo que no puedes usar no tiene nada que reportar. Requiere scope `pickups:read`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/PickupStatus"
          },
          {
            "$ref": "#/components/parameters/XPdvId"
          }
        ],
        "responses": {
          "200": {
            "description": "Página de solicitudes de recolección.",
            "content": {
              "application/json": {
                "example": {
                  "success": true,
                  "data": {
                    "pickups": [
                      {
                        "id": 42,
                        "display_id": "PR00000042",
                        "shipment_id": "20260830-000123",
                        "status": "awaiting_confirmation",
                        "carrier": "Estafeta",
                        "requested_window_start": "2026-08-31T16:00:00Z",
                        "requested_window_end": "2026-08-31T23:00:00Z",
                        "confirmed_window_start": null,
                        "confirmed_window_end": null,
                        "confirmation_number": null,
                        "created_at": "2026-08-30T10:00:00Z",
                        "updated_at": "2026-08-30T10:00:02Z"
                      }
                    ],
                    "pagination": {
                      "page": 1,
                      "limit": 20,
                      "total": 1
                    }
                  },
                  "requestId": "req_a7b8c9d0e1"
                },
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "pickups",
                            "pagination"
                          ],
                          "properties": {
                            "pickups": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Pickup"
                              }
                            },
                            "pagination": {
                              "$ref": "#/components/schemas/Pagination"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundDark"
          },
          "422": {
            "description": "`VALIDATION_ERROR` — `status` fuera del enum o paginación inválida (campos en `details.fields`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "`GET_ERROR`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/pickups/{id}": {
      "get": {
        "tags": ["pickups"],
        "operationId": "getPickup",
        "summary": "Consultar una recolección",
        "description": "Estado de una solicitud de recolección propia — úsalo para dar seguimiento mientras está en `awaiting_confirmation` (el solicitante también recibe un correo cuando se resuelve). Visibilidad idéntica a la del envío al que pertenece, en paralelo deliberado con `GET /cancellations/{id}`. Requiere scope `pickups:read`.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "minimum": 1 } },
          { "$ref": "#/components/parameters/XPdvId" }
        ],
        "responses": {
          "200": {
            "description": "La recolección.",
            "content": { "application/json": { "example": {"success": true, "data": {"pickup": {"id": 42, "display_id": "PR00000042", "shipment_id": "20260830-000123", "status": "awaiting_confirmation", "carrier": "Estafeta", "requested_window_start": "2026-08-31T16:00:00Z", "requested_window_end": "2026-08-31T23:00:00Z", "confirmed_window_start": null, "confirmed_window_end": null, "confirmation_number": null, "created_at": "2026-08-30T10:00:00Z", "updated_at": "2026-08-30T10:00:02Z"}}, "requestId": "req_a7b8c9d0e1"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["pickup"], "properties": { "pickup": { "$ref": "#/components/schemas/Pickup" } } } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — no existe o el envío subyacente no pertenece al usuario de la llave (respuesta idéntica).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "description": "`GET_ERROR`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }
        }
      }
    },
    "/balance": {
      "get": {
        "tags": ["balance"],
        "operationId": "getBalance",
        "summary": "Consultar el saldo que cargarían tus envíos",
        "description": "Sin `X-PDV-ID`: el saldo del usuario de la llave. Con `X-PDV-ID` (llaves admin, misma allowlist que `POST /shipments`): el fondo del punto de venta — exactamente el principal que un envío bajo ese header cargaría. `available` es lo gastable; `held` son autorizaciones pendientes; `balance` = available + held. Requiere scope `balance:read`.",
        "parameters": [ { "$ref": "#/components/parameters/XPdvId" } ],
        "responses": {
          "200": {
            "description": "Saldo del principal.",
            "content": { "application/json": { "example": {"success": true, "data": {"balance": 4820.5, "held": 145.5, "available": 4675.0, "currency": "MXN", "scope": {"type": "user", "id": "U0000001"}}, "requestId": "req_d4e5f6a7b8"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Balance" } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFoundDark" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/labels/{envio_id}": {
      "get": {
        "tags": ["labels"],
        "operationId": "getLabel",
        "summary": "Descargar la etiqueta de un envío propio",
        "description": "Transmite la copia almacenada por la plataforma (PDF/ZPL) cuando existe; si no, redirige (302) a la URL de la paquetería. Con `?format=json` responde JSON (`LabelLink`) en lugar de bytes. Solo envíos creados por el usuario de la llave (todos los roles). Requiere scope `labels:read`.",
        "parameters": [
          { "name": "envio_id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[A-Za-z0-9-]{1,32}$" } },
          { "name": "format", "in": "query", "required": false, "schema": { "type": "string", "enum": ["json"] }, "description": "`json` = en vez de transmitir bytes responde JSON con la URL vigente de la etiqueta (`LabelLink`). Es la vía para agentes (la herramienta MCP `get_label`): un agente no puede consumir el PDF transmitido, así que la URL es la única respuesta útil. Expone la URL de la paquetería bajo la misma consideración documentada del 302." }
        ],
        "responses": {
          "200": {
            "description": "Bytes de la etiqueta (copia de la plataforma). Con `?format=json`: `application/json` con `LabelLink`.",
            "content": {
              "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/LabelLink" } } } ] } },
              "application/pdf": { "schema": { "type": "string", "format": "binary" } },
              "application/zpl": { "schema": { "type": "string", "format": "binary" } },
              "application/octet-stream": { "schema": { "type": "string", "format": "binary" } }
            }
          },
          "302": { "description": "Redirección a la URL de etiqueta de la paquetería (fallback cuando no hay copia almacenada). `Cache-Control: private, no-store`.", "headers": { "Location": { "schema": { "type": "string" }, "description": "URL de la etiqueta en el proveedor." } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — no existe, no es del usuario de la llave, o no hay etiqueta disponible (respuesta idéntica en todos los casos).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/senders": {
      "get": {
        "tags": ["addresses"],
        "operationId": "listSenders",
        "summary": "Listar remitentes del directorio",
        "description": "Remitentes visibles para la llave. Visibilidad: llaves de cliente y llaves admin SIN `X-PDV-ID` ven solo los registros creados por el usuario de la llave; una llave admin con `X-PDV-ID` validado ve exactamente el directorio de ese punto de venta (el mismo que usa el personal de mostrador). Nunca hay lectura global en este carril. Requiere scope `addresses:read`. Este endpoint SÍ devuelve dirección completa y teléfono — el guardián es la visibilidad, no el recorte de campos.",
        "parameters": [
          { "$ref": "#/components/parameters/XPdvId" },
          { "$ref": "#/components/parameters/Page" },
          { "$ref": "#/components/parameters/Limit" },
          { "name": "postal_code", "in": "query", "schema": { "type": "string", "pattern": "^\\d{5}$" }, "description": "Filtro exacto por CP (5 dígitos; otro formato → 422)." },
          { "name": "q", "in": "query", "schema": { "type": "string", "maxLength": 100 }, "description": "Búsqueda de texto sobre nombre, razón social, RFC, teléfono, email y dirección (calle/colonia/municipio/estado/CP). Cada palabra debe aparecer en ALGÚN campo, no necesariamente en el mismo: `arturo meijueiro` encuentra un registro cuyo nombre es `ARTURO` y cuyo apellido solo aparece en el email. Insensible a acentos y mayúsculas; se consideran las primeras 5 palabras." }
        ],
        "responses": {
          "200": {
            "description": "Página de remitentes.",
            "content": { "application/json": { "example": {"success": true, "data": {"senders": [{"id": "C0012345", "name": "Juan", "apellido_paterno": "Pérez", "apellido_materno": null, "company": null, "rfc": null, "phone": "5512345678", "email": "juan@ejemplo.com", "street": "Av. Insurgentes Sur", "number": "600", "colonia": "Del Valle", "city": "Ciudad de México", "state": "CDMX", "postal_code": "01000", "created_at": "2026-07-13T18:40:00Z"}], "pagination": {"page": 1, "limit": 20, "total": 1}}, "requestId": "req_c9d0e1f2a3"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["senders", "pagination"], "properties": { "senders": { "type": "array", "items": { "$ref": "#/components/schemas/Sender" } }, "pagination": { "$ref": "#/components/schemas/Pagination" } } } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": { "description": "`VALIDATION_ERROR` — filtro inválido (details.fields).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "post": {
        "tags": ["addresses"],
        "operationId": "createSender",
        "summary": "Crear (o reutilizar) un remitente",
        "description": "Create-or-reuse con el MISMO dedupe exacto normalizado que usa `POST /shipments` al registrar direcciones inline, evaluado sobre las filas visibles para la llave: si ya existe un remitente idéntico visible, se devuelve ese (`created: false`, 200); si no, se crea (`created: true`, 201). El `postal_code` debe tener 5 dígitos y EXISTIR en el catálogo postal (SEPOMEX); si `city`/`state` vienen, deben ser coherentes con el CP (un desajuste responde 422 nombrando el valor esperado) y si se omiten se AUTOCOMPLETAN del catálogo — la misma validación de exactitud que `POST /contacts/import`. Requiere scope `addresses:write`. Con `X-PDV-ID` (llave admin), la fila creada pertenece al directorio de ese PDV y queda visible para su personal.",
        "parameters": [ { "$ref": "#/components/parameters/XPdvId" } ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SenderCreateRequest" } } } },
        "responses": {
          "201": { "description": "Remitente creado (`created: true`).", "content": { "application/json": { "example": {"success": true, "data": {"sender": {"id": "C0012345", "name": "Juan", "apellido_paterno": "Pérez", "apellido_materno": null, "company": null, "rfc": null, "phone": "5512345678", "email": "juan@ejemplo.com", "street": "Av. Insurgentes Sur", "number": "600", "colonia": "Del Valle", "city": "Ciudad de México", "state": "CDMX", "postal_code": "01000", "created_at": "2026-07-13T18:40:00Z"}, "created": true}, "requestId": "req_d0e1f2a3b4"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["sender", "created"], "properties": { "sender": { "$ref": "#/components/schemas/Sender" }, "created": { "type": "boolean" } } } } } ] } } } },
          "200": { "description": "Remitente idéntico ya existente reutilizado (`created: false`).", "content": { "application/json": { "example": {"success": true, "data": {"sender": {"id": "C0012345", "name": "Juan", "apellido_paterno": "Pérez", "apellido_materno": null, "company": null, "rfc": null, "phone": "5512345678", "email": "juan@ejemplo.com", "street": "Av. Insurgentes Sur", "number": "600", "colonia": "Del Valle", "city": "Ciudad de México", "state": "CDMX", "postal_code": "01000", "created_at": "2026-07-13T18:40:00Z"}, "created": false}, "requestId": "req_e1f2a3b4c5"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["sender", "created"], "properties": { "sender": { "$ref": "#/components/schemas/Sender" }, "created": { "type": "boolean" } } } } } ] } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": { "description": "`VALIDATION_ERROR` — campos faltantes/inválidos o desconocidos (details.fields).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "description": "`ADDRESS_ERROR` — no se pudo registrar; reintenta.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }
        }
      }
    },
    "/senders/{id}": {
      "get": {
        "tags": ["addresses"],
        "operationId": "getSender",
        "summary": "Consultar un remitente",
        "description": "Requiere scope `addresses:read`. 404 idéntico para inexistente y no-visible (sin oráculo).",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[A-Za-z0-9-]{1,32}$" } },
          { "$ref": "#/components/parameters/XPdvId" }
        ],
        "responses": {
          "200": { "description": "El remitente.", "content": { "application/json": { "example": {"success": true, "data": {"sender": {"id": "C0012345", "name": "Juan", "apellido_paterno": "Pérez", "apellido_materno": null, "company": null, "rfc": null, "phone": "5512345678", "email": "juan@ejemplo.com", "street": "Av. Insurgentes Sur", "number": "600", "colonia": "Del Valle", "city": "Ciudad de México", "state": "CDMX", "postal_code": "01000", "created_at": "2026-07-13T18:40:00Z"}}, "requestId": "req_f2a3b4c5d6"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["sender"], "properties": { "sender": { "$ref": "#/components/schemas/Sender" } } } } } ] } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFoundDark" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "patch": {
        "tags": ["addresses"],
        "operationId": "updateSender",
        "summary": "Editar un remitente",
        "description": "Actualización PARCIAL de un remitente visible. Requiere scope `addresses:write`. Solo los campos presentes cambian; se debe enviar al menos uno. Los requeridos pueden cambiarse pero no vaciarse; los opcionales aceptan `null` para limpiarse. Los apellidos NO forman parte del dedupe, así que `PATCH` es la forma de completarlos en una fila creada sin ellos. 404 idéntico para inexistente y no-visible (sin oráculo).",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[A-Za-z0-9-]{1,32}$" } },
          { "$ref": "#/components/parameters/XPdvId" }
        ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SenderUpdateRequest" } } } },
        "responses": {
          "200": { "description": "El remitente actualizado.", "content": { "application/json": { "example": {"success": true, "data": {"sender": {"id": "C0012345", "name": "Juan", "apellido_paterno": "Pérez", "apellido_materno": "García", "company": null, "rfc": null, "phone": "5512345678", "email": "juan@ejemplo.com", "street": "Av. Insurgentes Sur", "number": "600", "colonia": "Del Valle", "city": "Ciudad de México", "state": "CDMX", "postal_code": "01000", "created_at": "2026-07-13T18:40:00Z"}}, "requestId": "req_a3b4c5d6e7"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["sender"], "properties": { "sender": { "$ref": "#/components/schemas/Sender" } } } } } ] } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFoundDark" },
          "409": { "description": "`DUPLICATE_ADDRESS` — la edición dejaría este remitente idéntico a OTRO ya visible para la llave. La fila no se modifica.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "422": { "description": "`VALIDATION_ERROR` (details.fields) — campo desconocido, requerido vaciado, `country` ≠ MX, apellido > 50, o cuerpo sin campos editables.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/recipients": {
      "get": {
        "tags": ["addresses"],
        "operationId": "listRecipients",
        "summary": "Listar destinatarios del directorio",
        "description": "Misma visibilidad que `GET /senders`. Un destinatario siempre pertenece a un remitente (`sender_id`). Requiere scope `addresses:read`.",
        "parameters": [
          { "$ref": "#/components/parameters/XPdvId" },
          { "$ref": "#/components/parameters/Page" },
          { "$ref": "#/components/parameters/Limit" },
          { "name": "sender_id", "in": "query", "schema": { "type": "string", "maxLength": 32 }, "description": "Solo destinatarios de este remitente." },
          { "name": "postal_code", "in": "query", "schema": { "type": "string", "pattern": "^\\d{5}$" } },
          { "name": "q", "in": "query", "schema": { "type": "string", "maxLength": 100 }, "description": "Búsqueda de texto sobre nombre, alias, teléfono, email y dirección (calle/colonia/ciudad/estado/CP). Cada palabra debe aparecer en ALGÚN campo, no necesariamente en el mismo. Insensible a acentos y mayúsculas; se consideran las primeras 5 palabras." }
        ],
        "responses": {
          "200": {
            "description": "Página de destinatarios.",
            "content": { "application/json": { "example": {"success": true, "data": {"recipients": [{"id": "D0067890", "sender_id": "C0012345", "alias": "Oficina", "name": "María López", "phone": "8187654321", "email": null, "street": "Av. Constitución", "number": "400", "colonia": "Centro", "city": "Monterrey", "state": "Nuevo León", "postal_code": "64000", "referencia": "Edificio azul, junto a la farmacia", "delivery_instructions": "Entregar en recepción", "created_at": "2026-07-13T18:41:00Z"}], "pagination": {"page": 1, "limit": 20, "total": 1}}, "requestId": "req_b4c5d6e7f8"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["recipients", "pagination"], "properties": { "recipients": { "type": "array", "items": { "$ref": "#/components/schemas/Recipient" } }, "pagination": { "$ref": "#/components/schemas/Pagination" } } } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": { "description": "`VALIDATION_ERROR` — filtro inválido (details.fields).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "post": {
        "tags": ["addresses"],
        "operationId": "createRecipient",
        "summary": "Crear (o reutilizar) un destinatario",
        "description": "Create-or-reuse bajo el remitente `sender_id` (debe ser visible para la llave; si no → 404 ciego). Mismo dedupe normalizado que el carril de envíos; en una reutilización, `alias`/`referencia`/`delivery_instructions` NO se aplican (no forman parte de la identidad — la fila existente se devuelve intacta). El `postal_code` debe tener 5 dígitos y EXISTIR en el catálogo postal (SEPOMEX); si `city`/`state` vienen, deben ser coherentes con el CP (un desajuste responde 422 nombrando el valor esperado) y si se omiten se AUTOCOMPLETAN del catálogo — la misma validación de exactitud que `POST /contacts/import`. Requiere scope `addresses:write`.",
        "parameters": [ { "$ref": "#/components/parameters/XPdvId" } ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecipientCreateRequest" } } } },
        "responses": {
          "201": { "description": "Destinatario creado (`created: true`).", "content": { "application/json": { "example": {"success": true, "data": {"recipient": {"id": "D0067890", "sender_id": "C0012345", "alias": "Oficina", "name": "María López", "phone": "8187654321", "email": null, "street": "Av. Constitución", "number": "400", "colonia": "Centro", "city": "Monterrey", "state": "Nuevo León", "postal_code": "64000", "referencia": "Edificio azul, junto a la farmacia", "delivery_instructions": "Entregar en recepción", "created_at": "2026-07-13T18:41:00Z"}, "created": true}, "requestId": "req_c5d6e7f8a9"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["recipient", "created"], "properties": { "recipient": { "$ref": "#/components/schemas/Recipient" }, "created": { "type": "boolean" } } } } } ] } } } },
          "200": { "description": "Destinatario idéntico ya existente reutilizado (`created: false`).", "content": { "application/json": { "example": {"success": true, "data": {"recipient": {"id": "D0067890", "sender_id": "C0012345", "alias": "Oficina", "name": "María López", "phone": "8187654321", "email": null, "street": "Av. Constitución", "number": "400", "colonia": "Centro", "city": "Monterrey", "state": "Nuevo León", "postal_code": "64000", "referencia": "Edificio azul, junto a la farmacia", "delivery_instructions": "Entregar en recepción", "created_at": "2026-07-13T18:41:00Z"}, "created": false}, "requestId": "req_d6e7f8a9b0"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["recipient", "created"], "properties": { "recipient": { "$ref": "#/components/schemas/Recipient" }, "created": { "type": "boolean" } } } } } ] } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — `sender_id` inexistente o no visible (respuesta idéntica).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "422": { "description": "`VALIDATION_ERROR` — campos faltantes/inválidos o desconocidos (details.fields).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "description": "`ADDRESS_ERROR` — no se pudo registrar; reintenta.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }
        }
      }
    },
    "/recipients/{id}": {
      "get": {
        "tags": ["addresses"],
        "operationId": "getRecipient",
        "summary": "Consultar un destinatario",
        "description": "Requiere scope `addresses:read`. 404 idéntico para inexistente y no-visible (sin oráculo).",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[A-Za-z0-9-]{1,32}$" } },
          { "$ref": "#/components/parameters/XPdvId" }
        ],
        "responses": {
          "200": { "description": "El destinatario.", "content": { "application/json": { "example": {"success": true, "data": {"recipient": {"id": "D0067890", "sender_id": "C0012345", "alias": "Oficina", "name": "María López", "phone": "8187654321", "email": null, "street": "Av. Constitución", "number": "400", "colonia": "Centro", "city": "Monterrey", "state": "Nuevo León", "postal_code": "64000", "referencia": "Edificio azul, junto a la farmacia", "delivery_instructions": "Entregar en recepción", "created_at": "2026-07-13T18:41:00Z"}}, "requestId": "req_e7f8a9b0c1"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["recipient"], "properties": { "recipient": { "$ref": "#/components/schemas/Recipient" } } } } } ] } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFoundDark" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "patch": {
        "tags": ["addresses"],
        "operationId": "updateRecipient",
        "summary": "Editar un destinatario",
        "description": "Actualización PARCIAL de un destinatario visible. Requiere scope `addresses:write`. `sender_id` es inmutable (no se re-asigna de remitente). Solo los campos presentes cambian; al menos uno. Los requeridos pueden cambiarse pero no vaciarse; los opcionales aceptan `null`. 404 idéntico para inexistente y no-visible.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[A-Za-z0-9-]{1,32}$" } },
          { "$ref": "#/components/parameters/XPdvId" }
        ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecipientUpdateRequest" } } } },
        "responses": {
          "200": { "description": "El destinatario actualizado.", "content": { "application/json": { "example": {"success": true, "data": {"recipient": {"id": "D0067890", "sender_id": "C0012345", "alias": "Oficina", "name": "María López", "phone": "8187654322", "email": null, "street": "Av. Constitución", "number": "400", "colonia": "Centro", "city": "Monterrey", "state": "Nuevo León", "postal_code": "64000", "referencia": "Edificio azul, junto a la farmacia", "delivery_instructions": "Entregar en recepción", "created_at": "2026-07-13T18:41:00Z"}}, "requestId": "req_f8a9b0c1d2"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["recipient"], "properties": { "recipient": { "$ref": "#/components/schemas/Recipient" } } } } } ] } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFoundDark" },
          "409": { "description": "`DUPLICATE_ADDRESS` — la edición dejaría este destinatario idéntico a OTRO del MISMO remitente. La fila no se modifica.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "422": { "description": "`VALIDATION_ERROR` (details.fields) — campo desconocido, `sender_id` presente (inmutable), requerido vaciado, `country` ≠ MX, o cuerpo sin campos editables.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/postal-codes/{postal_code}": {
      "get": {
        "tags": ["addresses"],
        "operationId": "lookupPostalCode",
        "summary": "Validar un código postal y listar sus colonias",
        "description": "Resuelve un código postal mexicano (catálogo SEPOMEX) en su estado, municipio y el conjunto de colonias/asentamientos — justo lo que se necesita para llenar una dirección antes de `POST /senders` o `POST /shipments`. Requiere scope `addresses:read`. Datos de referencia públicos y de solo lectura: responde IDÉNTICo con llaves `ek_live_` y `ek_test_` (no hay nada que simular). `422` si el CP no son 5 dígitos; `404` si no existe en el catálogo; `200` con los datos si es válido.",
        "parameters": [
          { "name": "postal_code", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^\\d{5}$" }, "description": "Código postal de 5 dígitos." }
        ],
        "responses": {
          "200": { "description": "El código postal existe.", "content": { "application/json": { "example": {"success": true, "data": {"postal_code": "64000", "state": "Nuevo León", "state_code": "NL", "municipality": "Monterrey", "city": "Monterrey", "colonias": [{"name": "Centro", "settlement_type": "Colonia"}]}, "requestId": "req_a9b0c1d2e3"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/PostalCode" } } } ] } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — el código postal (bien formado) no está en el catálogo SEPOMEX.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "422": { "description": "`VALIDATION_ERROR` — el código postal debe ser exactamente 5 dígitos.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/contacts/import": {
      "post": {
        "tags": ["addresses"],
        "operationId": "importContacts",
        "summary": "Importar contactos al directorio en bloque (remitentes y destinatarios)",
        "description": "Importación masiva del directorio — pensada para migrar contactos desde una hoja de cálculo, otra plataforma o una lista fotografiada, típicamente a través de un asistente de IA. Requiere scope `addresses:write` (es el mismo alcance de `POST /senders` / `POST /recipients`: una importación son esos dos creates en bloque).\n\n**Dos carriles de entrada, mutuamente excluyentes:**\n- `contacts`: arreglo de filas `{type: 'sender'|'recipient', name, phone, street, number?, colonia, city?, state?, postal_code, email?, company?, rfc?}` — el mismo vocabulario de campos que `POST /senders` / `POST /recipients` (los destinatarios además aceptan `sender_id?`, `alias?`, `referencia?`, `delivery_instructions?`). El cliente (p. ej. el modelo) debe mapear los datos del usuario TAL CUAL, nunca inventar valores.\n- `raw_csv`: el contenido del CSV del usuario, VERBATIM (UTF-8, BOM opcional, máx. 256KB). El servidor lo interpreta de forma determinista: delimitador coma/punto y coma/tabulador (detectado), fila de encabezado obligatoria con las columnas de la plantilla `tipo,nombre,telefono,calle,numero,colonia,ciudad,estado,codigo_postal,email,empresa,rfc` (coincidencia insensible a mayúsculas y acentos, cualquier orden; columnas desconocidas → 422, nunca adivinadas). `tipo` acepta `remitente`/`destinatario` (o `sender`/`recipient`).\n\n**Dos fases sin estado:** `dry_run: true` (el DEFAULT — el modo seguro) valida todo y devuelve la vista previa normalizada sin escribir nada; `dry_run: false` re-valida e inserta. La seguridad ante reintentos viene del DEDUPE POR CONTENIDO, no de claims: una fila idéntica (tupla normalizada nombre+teléfono+dirección+CP — el MISMO dedupe de `POST /senders`) a un contacto existente visible se reporta `duplicate` y se omite, así que repetir un commit jamás duplica el directorio.\n\n**Validación por fila (nunca aborta el lote completo):** `codigo_postal` de 5 dígitos y existente en el catálogo SEPOMEX; si `ciudad`/`estado` vienen, deben ser coherentes con el CP (un desajuste nombra el valor esperado) y si faltan se AUTOCOMPLETAN del catálogo; `telefono` se normaliza a 10 dígitos (se toleran espacios, guiones y el prefijo +52/521); `email` con formato válido cuando venga. Cada fila responde `status: ok|duplicate|invalid` con `issues[]` en español nombrando campo y motivo.\n\n**Padre de los destinatarios:** un destino siempre pertenece a un remitente. Orden de resolución: `sender_id` explícito de la fila → el PRIMER remitente válido del mismo lote → el remitente más reciente del directorio. Sin candidato, la fila es `invalid` con instrucciones.\n\n**Topes:** máximo 200 filas por llamada (más → `422` pidiendo trocear el lote) y 256KB de `raw_csv`.\n\n**Conteos (invariantes):** `received = valid + invalid`, `valid = filas ok + duplicates`; en commit además `created` y `skipped` con `received = created + skipped + invalid` — imposible perder filas en silencio.\n\nCon una llave `ek_test_` la importación escribe en el directorio del sandbox (mismas reglas, mismos formatos de respuesta). La visibilidad/destino de las filas es la misma de `POST /senders`: llaves de cliente y llaves admin sin `X-PDV-ID` escriben como el usuario de la llave; una llave admin con `X-PDV-ID` validado escribe en el directorio de ese punto de venta.",
        "parameters": [
          { "$ref": "#/components/parameters/XPdvId" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "examples": {
                "dryRunContacts": {
                  "summary": "Fase 1 — vista previa (dry_run default)",
                  "value": { "contacts": [ { "type": "sender", "name": "Juan Pérez", "phone": "55 1234 5678", "street": "Av. Insurgentes Sur", "number": "600", "colonia": "San Ángel", "postal_code": "01000" }, { "type": "recipient", "name": "Ana López", "phone": "8112345678", "street": "Calle Hidalgo", "colonia": "Centro", "postal_code": "64000" } ] }
                },
                "commitCsv": {
                  "summary": "Fase 2 — commit del CSV verbatim",
                  "value": { "raw_csv": "tipo,nombre,telefono,calle,numero,colonia,ciudad,estado,codigo_postal,email,empresa,rfc\nremitente,Juan Pérez,5512345678,Av. Insurgentes Sur,600,San Ángel,,,01000,,,\n", "dry_run": false }
                }
              },
              "schema": {
                "type": "object",
                "properties": {
                  "contacts": {
                    "type": "array",
                    "maxItems": 200,
                    "description": "Filas mapeadas tal cual de los datos del usuario. Mutuamente excluyente con raw_csv.",
                    "items": {
                      "type": "object",
                      "required": ["type", "name", "phone", "street", "colonia", "postal_code"],
                      "properties": {
                        "type": { "type": "string", "enum": ["sender", "recipient"] },
                        "name": { "type": "string" },
                        "phone": { "type": "string", "description": "10 dígitos MX; se normalizan espacios/guiones/+52." },
                        "email": { "type": "string" },
                        "company": { "type": "string", "description": "Solo remitentes (razón social)." },
                        "rfc": { "type": "string", "description": "Solo remitentes." },
                        "apellido_paterno": { "type": "string", "description": "Solo remitentes." },
                        "apellido_materno": { "type": "string", "description": "Solo remitentes." },
                        "street": { "type": "string" },
                        "number": { "type": "string" },
                        "colonia": { "type": "string" },
                        "city": { "type": "string", "description": "Opcional — se autocompleta desde el CP; si viene, se valida contra él." },
                        "state": { "type": "string", "description": "Opcional — se autocompleta desde el CP; si viene, se valida contra él." },
                        "postal_code": { "type": "string", "pattern": "^\\d{5}$" },
                        "country": { "type": "string", "description": "Opcional; solo MX." },
                        "sender_id": { "type": "string", "description": "Solo destinatarios: remitente dueño; default = primer remitente del lote o el más reciente del directorio." },
                        "alias": { "type": "string", "description": "Solo destinatarios." },
                        "referencia": { "type": "string", "description": "Solo destinatarios." },
                        "delivery_instructions": { "type": "string", "description": "Solo destinatarios." }
                      }
                    }
                  },
                  "raw_csv": { "type": "string", "maxLength": 262144, "description": "CSV del usuario, verbatim (UTF-8, máx. 256KB). Mutuamente excluyente con contacts." },
                  "dry_run": { "type": "boolean", "default": true, "description": "true (default): valida y previsualiza sin escribir. false: re-valida e inserta." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado por fila + resumen. En dry_run nada se escribió; en commit, `created`/`skipped` reportan lo insertado/omitido.",
            "content": {
              "application/json": {
                "example": {
                  "success": true,
                  "data": {
                    "dry_run": false,
                    "rows": [
                      { "index": 0, "line": 2, "type": "sender", "name": "Juan Pérez", "status": "ok", "issues": [], "contact": { "type": "sender", "name": "Juan Pérez", "phone": "5512345678", "street": "Av. Insurgentes Sur", "number": "600", "colonia": "San Ángel", "city": "Ciudad de México", "state": "Ciudad de México", "postal_code": "01000" }, "id": "C0012399" },
                      { "index": 1, "line": 3, "type": "recipient", "name": "Ana López", "status": "duplicate", "issues": ["ya existe un contacto idéntico en tu directorio (se omite)"], "contact": null, "id": "D0045012" },
                      { "index": 2, "line": 4, "type": "sender", "name": "Mal Teléfono", "status": "invalid", "issues": ["telefono: debe tener 10 dígitos después de quitar espacios, guiones y el prefijo +52 (recibido «123»)"], "contact": null, "id": null }
                    ],
                    "summary": { "received": 3, "valid": 2, "invalid": 1, "duplicates": 1, "created": 1, "skipped": 1 }
                  },
                  "requestId": "req_f1e2d3c4b5"
                },
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/SuccessEnvelope" },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": ["dry_run", "rows", "summary"],
                          "properties": {
                            "dry_run": { "type": "boolean" },
                            "rows": {
                              "type": "array",
                              "items": {
                                "type": "object",
                                "required": ["index", "type", "name", "status", "issues"],
                                "properties": {
                                  "index": { "type": "integer", "description": "Posición 0-based de la fila en la solicitud." },
                                  "line": { "type": ["integer", "null"], "description": "Línea del archivo en el carril raw_csv (encabezado = línea 1); null en el carril contacts." },
                                  "type": { "type": ["string", "null"], "enum": ["sender", "recipient", null] },
                                  "name": { "type": ["string", "null"] },
                                  "status": { "type": "string", "enum": ["ok", "duplicate", "invalid"] },
                                  "issues": { "type": "array", "items": { "type": "string" }, "description": "Motivos en español, nombrando el campo." },
                                  "contact": { "type": ["object", "null"], "description": "La fila normalizada (teléfono a 10 dígitos, ciudad/estado autocompletados) cuando status=ok." },
                                  "id": { "type": ["string", "null"], "description": "Id creado (commit) o del contacto existente (duplicate)." }
                                }
                              }
                            },
                            "summary": {
                              "type": "object",
                              "required": ["received", "valid", "invalid", "duplicates"],
                              "properties": {
                                "received": { "type": "integer" },
                                "valid": { "type": "integer", "description": "Filas ok + duplicates (received = valid + invalid)." },
                                "invalid": { "type": "integer" },
                                "duplicates": { "type": "integer" },
                                "created": { "type": "integer", "description": "Solo en commit. received = created + skipped + invalid." },
                                "skipped": { "type": "integer", "description": "Solo en commit; = duplicates." }
                              }
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": { "description": "`VALIDATION_ERROR` — error de contrato (ambos carriles a la vez, ninguno, campo desconocido, más de 200 filas → trocear el lote, CSV >256KB) o de forma del CSV (encabezado con columnas desconocidas/faltantes, no-UTF-8), con los motivos en `details.fields`. Los problemas POR FILA nunca son 422: viajan como `status: invalid` dentro de `rows`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/webhooks": {
      "post": {
        "tags": ["webhooks"],
        "operationId": "createWebhook",
        "summary": "Registrar un webhook",
        "description": "Registra un endpoint `https://` público que recibirá un `POST` firmado por cada evento suscrito. **El `secret` se devuelve UNA sola vez** en esta respuesta: no se almacena (se deriva bajo una llave del servidor) y no vuelve a mostrarse; guárdalo al recibirlo. Máximo 5 webhooks activos por cuenta y modo (`409 WEBHOOK_LIMIT_REACHED`). La URL se valida contra SSRF: solo `https://`, hostname público (se rechazan `localhost`, `.local`, `.internal`, literales IP privadas/loopback/link-local y hosts que resuelvan a ellas) y sin credenciales embebidas; en cada entrega se vuelve a resolver el host y la conexión se fija a la IP pública verificada. Con llave `ek_test_` el webhook es de sandbox (solo `shipment.created`/`shipment.cancelled` del carril de pruebas + ping). Requiere scope `webhooks:write`.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "example": {"url": "https://hooks.mitienda.com/enviadores", "events": ["shipment.created", "shipment.delivered", "shipment.exception"]}, "schema": { "$ref": "#/components/schemas/WebhookCreateRequest" } } }
        },
        "responses": {
          "201": {
            "description": "Webhook registrado. `secret` aparece SOLO aquí.",
            "content": { "application/json": { "example": {"success": true, "data": {"webhook": {"id": "wh_0f3a9c1d2e4b5a6978c0d1e2", "url": "https://hooks.mitienda.com/enviadores", "events": ["shipment.created", "shipment.delivered", "shipment.exception"], "mode": "live", "status": "active", "failure_count": 0, "last_delivery_at": null, "last_delivery_status": null, "created_at": "2026-09-04T16:00:00Z", "disabled_at": null, "disabled_reason": null}, "secret": "whsec_9f2c…(64 hex)"}, "requestId": "req_a7b8c9d0e1"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/WebhookCreated" } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — los webhooks no están habilitados en este despliegue (respuesta idéntica a una ruta inexistente).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "409": { "description": "`WEBHOOK_LIMIT_REACHED` — ya hay 5 webhooks activos en este modo (`details {max_active, mode}`). Elimina uno con `DELETE /webhooks/{id}`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "422": { "description": "`VALIDATION_ERROR` — `details.fields` nombra el problema: URL no `https://`, puerto distinto de 443/8443, host privado/no resoluble/propio del API, credenciales embebidas, `events` vacío o con un evento desconocido, campo desconocido.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "get": {
        "tags": ["webhooks"],
        "operationId": "listWebhooks",
        "summary": "Listar webhooks",
        "description": "Todos los webhooks de la cuenta en el modo de la llave (live o sandbox), incluidos los desactivados automáticamente — `status`, `failure_count` (fallos consecutivos) y `last_delivery_*` explican por qué dejaron de llegar eventos. Nunca incluye secretos. Requiere scope `webhooks:read`.",
        "responses": {
          "200": {
            "description": "Lista + el catálogo de eventos y el tope activo.",
            "content": { "application/json": { "example": {"success": true, "data": {"webhooks": [{"id": "wh_0f3a9c1d2e4b5a6978c0d1e2", "url": "https://hooks.mitienda.com/enviadores", "events": ["shipment.created", "shipment.delivered"], "mode": "live", "status": "active", "failure_count": 0, "last_delivery_at": "2026-09-04T16:05:12Z", "last_delivery_status": 200, "created_at": "2026-09-04T16:00:00Z", "disabled_at": null, "disabled_reason": null}], "max_active": 5, "events": ["shipment.created", "shipment.collected", "shipment.in_transit", "shipment.out_for_delivery", "shipment.delivered", "shipment.exception", "shipment.returned", "shipment.cancelled", "pickup.resolved"]}, "requestId": "req_a7b8c9d0e1"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["webhooks", "max_active", "events"], "properties": { "webhooks": { "type": "array", "items": { "$ref": "#/components/schemas/Webhook" } }, "max_active": { "type": "integer", "description": "Tope de webhooks activos por cuenta y modo." }, "events": { "type": "array", "items": { "type": "string" }, "description": "Catálogo completo de eventos suscribibles." } } } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFoundDark" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/webhooks/{id}": {
      "get": {
        "tags": ["webhooks"],
        "operationId": "getWebhook",
        "summary": "Consultar un webhook",
        "description": "Un webhook propio (misma cuenta y mismo modo que la llave). Requiere scope `webhooks:read`.",
        "parameters": [ { "$ref": "#/components/parameters/WebhookId" } ],
        "responses": {
          "200": { "description": "El webhook.", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["webhook"], "properties": { "webhook": { "$ref": "#/components/schemas/Webhook" } } } } } ] } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — no existe, es de otra cuenta o de otro modo (respuesta idéntica).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "delete": {
        "tags": ["webhooks"],
        "operationId": "deleteWebhook",
        "summary": "Eliminar un webhook",
        "description": "Elimina el webhook. Las entregas se detienen de inmediato y los eventos pendientes en cola para él se descartan. Volver a registrar la misma URL genera un `id` y un `secret` NUEVOS. Requiere scope `webhooks:write`.",
        "parameters": [ { "$ref": "#/components/parameters/WebhookId" } ],
        "responses": {
          "200": { "description": "Eliminado.", "content": { "application/json": { "example": {"success": true, "data": {"deleted": true, "id": "wh_0f3a9c1d2e4b5a6978c0d1e2"}, "requestId": "req_a7b8c9d0e1"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "type": "object", "required": ["deleted", "id"], "properties": { "deleted": { "type": "boolean", "const": true }, "id": { "type": "string" } } } } } ] } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — no existe, es de otra cuenta o de otro modo (respuesta idéntica).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/webhooks/{id}/test": {
      "post": {
        "tags": ["webhooks"],
        "operationId": "testWebhook",
        "summary": "Probar un webhook (ping)",
        "description": "Entrega un `{\"event\":\"ping\"}` firmado **de forma síncrona** (mismos headers y misma firma que un evento real; `data` = `{webhook_id}`) y reporta el resultado. Un ping fallido NO cuenta para la desactivación automática — repítelo con libertad mientras ajustas el endpoint. Sin cuerpo. Requiere scope `webhooks:write`.",
        "parameters": [ { "$ref": "#/components/parameters/WebhookId" } ],
        "responses": {
          "200": {
            "description": "Resultado del ping (200 aunque el endpoint haya fallado — mira `delivered`).",
            "content": { "application/json": { "example": {"success": true, "data": {"delivered": false, "response_status": 500, "error": "HTTP 500", "event_id": "evt_4a5b6c7d8e9f0a1b2c3d4e5f"}, "requestId": "req_a7b8c9d0e1"}, "schema": { "allOf": [ { "$ref": "#/components/schemas/SuccessEnvelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/WebhookTestResult" } } } ] } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "description": "`NOT_FOUND` — no existe, es de otra cuenta o de otro modo (respuesta idéntica).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "409": { "description": "`WEBHOOK_DISABLED` — el webhook fue desactivado automáticamente (`details.disabled_reason`); elimínalo y regístralo de nuevo.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/me": {
      "get": {
        "tags": [
          "account"
        ],
        "operationId": "getMe",
        "summary": "Identificar la llave y la cuenta (whoami)",
        "description": "Quién eres desde el punto de vista de la API: la cuenta, si la conexión es de **prueba** (`ek_test_`) o de **producción** (`ek_live_`), los scopes que la llave realmente porta en ESTA petición, el saldo disponible y las capacidades de la superficie.\n\nEs el endpoint de autodiagnóstico: cuando algo responde `403 INSUFFICIENT_SCOPE`, compara `key.scopes` con el scope que nombró el error; cuando dudes si una guía es real, mira `account.mode`.\n\n**Cualquier llave válida puede llamarlo, sin importar sus scopes** — un whoami que pudiera responder 403 por falta de permiso no serviría para su único propósito. Aun así no expone nada nuevo: `balance.available` es el mismo número de `GET /balance` (misma lectura), y el resto son hechos sobre tu propia llave. **Nunca** devuelve el secreto ni su hash ni su prefijo, ni la allowlist de PDVs, ni correo/teléfono/rol.\n\n`key.scopes` es el conjunto EFECTIVO: una llave creada con \"los permisos por omisión\" (`scopes_mode: default`) se re-resuelve contra los scopes por omisión vigentes en cada petición, así que aquí ves lo que el gate acaba de aplicar. Una llave que alguien restringió a mano (`scopes_mode: explicit`) queda congelada como se creó.\n\n`account.tier_label` es el escalón de verificación de las cuentas de registro propio (`T1`…`T4`); es `null` para cuentas creadas por nuestro equipo, que no están en esa escalera, y también si la consulta de verificación no está disponible.\n\n`capabilities` describe la superficie, no la cuenta: `pickups` sigue el interruptor de recolecciones (siempre `true` en sandbox), y `international`/`multi_package` se derivan de las mismas validaciones que aplica `POST /shipments`, así que no pueden desincronizarse de la realidad.\n\nEn modo de prueba, `balance.available` es el saldo COMPUTADO del sandbox.",
        "parameters": [
          {
            "$ref": "#/components/parameters/XPdvId"
          }
        ],
        "responses": {
          "200": {
            "description": "Identidad de la llave y la cuenta.",
            "content": {
              "application/json": {
                "example": {
                  "success": true,
                  "data": {
                    "account": {
                      "id": "U0000001",
                      "name": "Comercializadora Ejemplo SA de CV",
                      "mode": "live",
                      "account_type": "customer",
                      "tier_label": "T3"
                    },
                    "key": {
                      "id": "AK0123456789abcdef",
                      "label": "Servidor de producción",
                      "mode": "live",
                      "scopes": [
                        "rates:read",
                        "shipments:write",
                        "shipments:read",
                        "tracking:read",
                        "cancellations:write",
                        "cancellations:read",
                        "balance:read",
                        "labels:read",
                        "addresses:read",
                        "addresses:write",
                        "pickups:write",
                        "pickups:read"
                      ],
                      "scopes_mode": "default",
                      "spend_cap_daily_mxn": 5000.0,
                      "rate_limit_tier": "standard"
                    },
                    "balance": {
                      "available": 4675.0,
                      "currency": "MXN"
                    },
                    "capabilities": {
                      "pickups": true,
                      "international": false,
                      "multi_package": false,
                      "webhooks": false
                    }
                  },
                  "requestId": "req_d4e5f6a7b8"
                },
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Me"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundDark"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/transactions": {
      "get": {
        "tags": [
          "account"
        ],
        "operationId": "listTransactions",
        "summary": "Listar los movimientos de saldo",
        "description": "El libro mayor detrás de `GET /balance`: cargos por guías, reembolsos por cancelación, recargas y ajustes manuales, del más reciente al más antiguo. Es la respuesta a \"¿en qué se fue el saldo?\".\n\n**Mismo principal que `GET /balance`, por construcción.** Sin header: el saldo PERSONAL del usuario de la llave. Con `X-PDV-ID` validado (llaves admin, misma allowlist): el libro mayor de ese fondo de punto de venta. Las consultas cruzadas por `user_id` del endpoint interno NO existen aquí: una llave lee exactamente el fondo que cargarían sus envíos.\n\n`amount` viene con SIGNO en MXN (negativo = salió dinero) y `balance_after` es el saldo tras ese movimiento. `type` usa un vocabulario público de cuatro palabras (`charge`, `refund`, `topup`, `adjustment`) que resume el enum interno; los detalles internos (qué reventa, qué pierna de una corrección, qué programa) no se exponen.\n\nEn modo de prueba el listado se DERIVA de los envíos sandbox — un cargo al crear, un reembolso al cancelar — de modo que la suma cuadra exactamente con el saldo computado del sandbox. Requiere scope `balance:read`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/TransactionType"
          },
          {
            "$ref": "#/components/parameters/TransactionFrom"
          },
          {
            "$ref": "#/components/parameters/TransactionTo"
          },
          {
            "$ref": "#/components/parameters/XPdvId"
          }
        ],
        "responses": {
          "200": {
            "description": "Página de movimientos.",
            "content": {
              "application/json": {
                "example": {
                  "success": true,
                  "data": {
                    "transactions": [
                      {
                        "id": "TX00012345",
                        "type": "charge",
                        "amount": -184.5,
                        "balance_after": 4675.0,
                        "shipment_id": "20260830-000123",
                        "description": "Guía Estafeta Terrestre",
                        "created_at": "2026-08-30T10:00:02Z"
                      },
                      {
                        "id": "TX00012344",
                        "type": "topup",
                        "amount": 2000.0,
                        "balance_after": 4859.5,
                        "shipment_id": null,
                        "description": "Recarga en línea",
                        "created_at": "2026-08-29T18:12:00Z"
                      }
                    ],
                    "pagination": {
                      "page": 1,
                      "limit": 20,
                      "total": 2
                    }
                  },
                  "requestId": "req_b1c2d3e4f5"
                },
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "transactions",
                            "pagination"
                          ],
                          "properties": {
                            "transactions": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Transaction"
                              }
                            },
                            "pagination": {
                              "$ref": "#/components/schemas/Pagination"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFoundDark"
          },
          "422": {
            "description": "`VALIDATION_ERROR` — `type` fuera del enum, fecha mal formada o `from` posterior a `to` (campos en `details.fields`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "ek_live_/ek_test_ + 64 hex (72 chars)",
        "description": "Llave secreta de API (`ek_live_…` producción, `ek_test_…` sandbox). Solo por header `Authorization: Bearer` — NUNCA por query string."
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": { "type": "string", "minLength": 1, "maxLength": 128 },
        "description": "UUID fresco por operación lógica (1–128 caracteres ASCII imprimibles; presente pero inválida → `400 IDEMPOTENCY_KEY_INVALID`). Un reintento con la misma llave Y el mismo cuerpo repite la respuesta 2xx original (`Idempotent-Replay: true`) sin doble cargo, durante **15 minutos**. La llave queda ligada al cuerpo exacto de la primera solicitud y al `X-PDV-ID`: cuerpo distinto → `409 IDEMPOTENCY_KEY_PAYLOAD_MISMATCH`; pasados los 15 min → `409 IDEMPOTENCY_KEY_EXPIRED`. **Cambio 2026-08:** ya NO se comparte espacio de nombres con el carril web del mismo usuario (antes una llave se repetía entre `/v1/shipments` y la app web; ahora eso responde `409 IDEMPOTENCY_KEY_REUSED`). Fuertemente recomendado en `POST /shipments`."
      },
      "XPdvId": {
        "name": "X-PDV-ID",
        "in": "header",
        "required": false,
        "schema": { "type": "string" },
        "description": "Solo llaves de cuentas admin: el punto de venta cuyo fondo se carga/consulta. Requiere que la llave tenga una allowlist de PDVs configurada por un administrador (`pdv_allowlist`); sin allowlist, todo PDV real es denegado por defecto. Validado además contra los PDV activos. Cualquier violación → `403 PDV_NOT_ALLOWED`. El valor `personal_account` equivale a omitir el header."
      },
      "Page": { "name": "page", "in": "query", "schema": { "type": "integer", "minimum": 1, "default": 1 } },
      "Limit": { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
      "ShipmentGuia": {
        "name": "guia",
        "in": "query",
        "required": false,
        "schema": { "type": "string", "maxLength": 50, "pattern": "^[A-Za-z0-9\\-]+$" },
        "description": "Búsqueda exacta por número de guía. Es la forma de llegar a un envío cuando solo tienes el número que ve el cliente (el `id` interno no es adivinable). Devuelve una página de 0 o 1 elementos, sujeta a la misma visibilidad que el resto del listado."
      },
      "ShipmentStatus": {
        "name": "status",
        "in": "query",
        "required": false,
        "schema": { "type": "string", "enum": ["created", "collected", "in_transit", "out_for_delivery", "delivered", "exception", "returned", "cancelled", "unknown"] },
        "description": "Filtra por estatus canónico — exactamente el mismo valor que devuelve el campo `status` de cada envío. Un valor fuera del enum es `422 VALIDATION_ERROR`, nunca una lista vacía silenciosa."
      },
      "ShipmentFrom": {
        "name": "from",
        "in": "query",
        "required": false,
        "schema": { "type": "string", "format": "date", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
        "description": "Día calendario INCLUSIVO (zona horaria de negocio America/Mexico_City) desde el cual listar, por fecha de creación. Solo `YYYY-MM-DD`."
      },
      "ShipmentTo": {
        "name": "to",
        "in": "query",
        "required": false,
        "schema": { "type": "string", "format": "date", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
        "description": "Día calendario INCLUSIVO (America/Mexico_City) hasta el cual listar — el día completo, hasta las 23:59:59 locales. `from` posterior a `to` es `422`."
      },
      "WebhookId": { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^wh_[a-f0-9]{24}$" }, "description": "Id del webhook (`wh_` + 24 hex), devuelto por `POST /webhooks`." },
      "TransactionType": {
        "name": "type",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string",
          "enum": [
            "charge",
            "refund",
            "topup",
            "adjustment"
          ]
        },
        "description": "Filtra por tipo público de movimiento — exactamente el mismo valor que devuelve el campo `type` de cada fila. `charge` = cargo (salió saldo), `refund` = devolución, `topup` = recarga en línea, `adjustment` = cualquier otro abono (alta manual, promoción, liquidación). Un valor fuera del enum es `422 VALIDATION_ERROR`, nunca una lista vacía silenciosa."
      },
      "TransactionFrom": {
        "name": "from",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string",
          "format": "date",
          "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$"
        },
        "description": "Día calendario INCLUSIVO (zona horaria de negocio America/Mexico_City) desde el cual listar movimientos. Solo `YYYY-MM-DD`."
      },
      "TransactionTo": {
        "name": "to",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string",
          "format": "date",
          "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$"
        },
        "description": "Día calendario INCLUSIVO (America/Mexico_City) hasta el cual listar — el día completo. `from` posterior a `to` es `422`."
      },
      "PickupStatus": {
        "name": "status",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string",
          "enum": [
            "pending",
            "scheduled",
            "awaiting_confirmation",
            "failed",
            "cancelled"
          ]
        },
        "description": "Filtra por estatus público de la recolección — el mismo valor que devuelve el campo `status`. `awaiting_confirmation` = la registramos y nuestro equipo aún la está gestionando; `scheduled` = ya quedó con la paquetería. Un valor fuera del enum es `422 VALIDATION_ERROR`."
      }
    },
    "headers": {
      "XRateLimitLimit": { "schema": { "type": "integer" }, "description": "Tamaño de ráfaga del bucket de la llave." },
      "XRateLimitRemaining": { "schema": { "type": "integer" }, "description": "Tokens restantes." },
      "XRateLimitReset": { "schema": { "type": "integer" }, "description": "Epoch (s) en que el bucket vuelve a estar lleno." },
      "RetryAfter": { "schema": { "type": "integer" }, "description": "Segundos a esperar antes de reintentar (el hint fino viaja en `details.retry_after_ms`)." },
      "IdempotentReplay": { "schema": { "type": "string", "const": "true" }, "description": "Presente cuando la respuesta es la repetición de una operación previa con el mismo `Idempotency-Key`." }
    },
    "responses": {
      "Unauthorized": {
        "description": "`UNAUTHORIZED` — credencial Bearer ausente/mal formada o llave desconocida/revocada (respuesta idéntica en ambos casos).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
      },
      "Forbidden": {
        "description": "`INSUFFICIENT_SCOPE` — a la llave le falta el scope requerido · `PDV_NOT_ALLOWED` — violación del carril X-PDV-ID. Nota de orden: el limitador por llave corre ANTES del gate de scope, así que una petición con scope incorrecto y el bucket agotado responde `429 RATE_LIMITED` (el 429 tiene precedencia sobre este 403).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
      },
      "NotFoundDark": {
        "description": "`NOT_FOUND` — ruta desconocida, recurso ajeno/inexistente, o la API pública no está habilitada (respuestas indistinguibles por diseño).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
      },
      "RateLimited": {
        "description": "`RATE_LIMITED` — bucket de la llave agotado. Espera `Retry-After` s (`details.retry_after_ms` para el hint fino). El limitador corre antes del gate de scope, por lo que un 429 tiene precedencia sobre un 403 `INSUFFICIENT_SCOPE`.",
        "headers": { "Retry-After": { "$ref": "#/components/headers/RetryAfter" } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
      },
      "ServerError": {
        "description": "`SERVER_ERROR` — falla interna (p. ej. lectura de base de datos). Es transitoria; reintenta con backoff.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
      }
    },
    "schemas": {
      "SuccessEnvelope": {
        "type": "object",
        "required": ["success", "data", "requestId"],
        "properties": {
          "success": { "const": true },
          "data": { "type": "object" },
          "requestId": { "type": "string", "description": "Correlación de logs; también en el header `X-Request-Id`." }
        }
      },
      "ErrorEnvelope": { "example": {"success": false, "error": {"code": "VALIDATION_ERROR", "message": "Validation failed", "details": {"fields": ["from.postal_code: must be a 5-digit Mexican postal code"]}}, "requestId": "req_b0c1d2e3f4"},
        "type": "object",
        "required": ["success", "error", "requestId"],
        "properties": {
          "success": { "const": false },
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": { "type": "string", "description": "Código de máquina estable (p. ej. RATE_EXPIRED, CREDIT_ERROR)." },
              "message": { "type": "string" },
              "details": { "type": "object", "description": "Contexto adicional por código (p. ej. {required, available, shortfall} en CREDIT_ERROR; {fields} en VALIDATION_ERROR; {committed: true} en POST_COMMIT_ERROR)." }
            }
          },
          "requestId": { "type": "string" }
        }
      },
      "RatesRequest": {
        "type": "object",
        "description": "Origen: exactamente UNO de `from` (inline) o `from_id` (remitente del directorio). Destino: exactamente UNO de `to` o `to_id`. Mandar ambos miembros de un par → 422; un id no visible para la llave → 404 (respuesta ciega). Cotizar por id usa los datos completos guardados (ciudad/estado/colonia), así que la resolución de zona es igual o mejor que con CP suelto. Bultos: exactamente UNO de `package` (una caja) o `packages` (2–10 cajas en un mismo envío).",
        "properties": {
          "from": { "$ref": "#/components/schemas/RatesEndpointParty" },
          "from_id": { "type": "string", "maxLength": 32, "description": "Id de un remitente del directorio (`GET /senders`). Mutuamente excluyente con `from`." },
          "to": { "$ref": "#/components/schemas/RatesEndpointParty" },
          "to_id": { "type": "string", "maxLength": 32, "description": "Id de un destinatario del directorio (`GET /recipients`). Mutuamente excluyente con `to`." },
          "package": {
            "type": "object",
            "required": ["weight_kg"],
            "properties": {
              "weight_kg": { "type": "number", "exclusiveMinimum": 0, "description": "Peso real en kg. El facturable es max(real, volumétrico L*W*H/5000); máximo 70 kg." },
              "length_cm": { "type": "number", "exclusiveMinimum": 0, "default": 10 },
              "width_cm": { "type": "number", "exclusiveMinimum": 0, "default": 10 },
              "height_cm": { "type": "number", "exclusiveMinimum": 0, "default": 10 }
            }
          },
          "packages": { "$ref": "#/components/schemas/PackagesList" },
          "insurance": { "$ref": "#/components/schemas/InsuranceRequest" }
        },
        "example": {
          "from": { "postal_code": "01000" },
          "to": { "postal_code": "64000" },
          "package": { "weight_kg": 1, "length_cm": 20, "width_cm": 15, "height_cm": 10 }
        }
      },
      "PackagesList": {
        "type": "array",
        "minItems": 2,
        "maxItems": 10,
        "description": "VARIOS bultos que viajan como UN solo envío (un cargo, una cotización). Mutuamente excluyente con `package`. La lista cotizada y la reservada deben ser IDÉNTICAS (mismo orden, mismas medidas) o `POST /shipments` responde 409 `RATE_PACKAGE_MISMATCH`. Solo se cotizan/reservan servicios que soportan multi-bulto.",
        "items": {
          "type": "object",
          "required": ["weight_kg"],
          "properties": {
            "weight_kg": { "type": "number", "exclusiveMinimum": 0, "description": "Peso real del bulto (kg)." },
            "length_cm": { "type": "number", "exclusiveMinimum": 0, "default": 10 },
            "width_cm": { "type": "number", "exclusiveMinimum": 0, "default": 10 },
            "height_cm": { "type": "number", "exclusiveMinimum": 0, "default": 10 }
          }
        }
      },
      "InsuranceRequest": {
        "type": "object",
        "description": "Cobertura para el envío. `insured_value` contrata la póliza de la PLATAFORMA (prima incluida en `pricing.total_price`) o, cuando esa póliza no está disponible, la cobertura de la propia paquetería — el precio mostrado ya la incluye; cada tarifa indica `insurance_included` / `insurance_not_supported`. NO es el valor declarado a la paquetería (`declared_value` en `POST /shipments`). *Insurance: `insured_value` buys coverage for the shipment; when the platform policy is unavailable the carrier's own coverage is used and the price shown already includes it.*",
        "properties": {
          "insured_value": { "type": "number", "exclusiveMinimum": 0, "description": "Valor a asegurar (MXN). La prima queda incluida en `pricing.total_price` de cada tarifa." },
          "declared_value": { "type": "number", "exclusiveMinimum": 0, "deprecated": true, "description": "OBSOLETO — alias de `insured_value` (compatibilidad con integradores previos). Si mandas ambos deben coincidir." }
        }
      },
      "RatesGroupedResponse": {
        "type": "object",
        "required": ["services", "view", "fetched_at", "stale_after", "meta", "rate_id_expires_in_seconds"],
        "description": "Respuesta de `POST /rates?view=grouped`: cada servicio distinto (paquetería + nivel de servicio; para `priority` también la ventana horaria) aparece UNA vez con su rango de precios y sus opciones reservables. Mismo `unlock_notice`/`fetched_at`/`stale_after`/`meta`/`rate_id_expires_in_seconds` que la forma plana.",
        "properties": {
          "services": { "type": "array", "items": { "$ref": "#/components/schemas/RateGroup" }, "description": "Ordenados por `price_from` ascendente." },
          "view": { "type": "string", "const": "grouped" },
          "unlock_notice": { "type": ["string", "null"] },
          "fetched_at": { "type": ["string", "null"] },
          "stale_after": { "type": ["string", "null"] },
          "meta": { "type": "object" },
          "rate_id_expires_in_seconds": { "type": "integer", "const": 1800 }
        }
      },
      "RateGroup": {
        "type": "object",
        "required": ["service_key", "carrier", "tier", "delivery_window", "service_label", "estimated_days", "pickup_available", "booking_locked", "price_from", "price_to", "currency", "options"],
        "properties": {
          "service_key": { "type": "string", "description": "Clave estable del servicio: `carrier|tier` (+ `|ventana` para priority)." },
          "carrier": { "type": "string", "description": "Nombre canónico de la paquetería." },
          "tier": { "type": "string", "enum": ["priority", "express", "standard_economy", "international"] },
          "delivery_window": { "type": ["string", "null"] },
          "service_label": { "type": "string", "description": "Etiqueta legible, p. ej. `DHL · Express`, `Estafeta · Prioritario 10:30 AM`." },
          "estimated_days": { "type": ["string", "number", "null"], "description": "De la opción más barata." },
          "pickup_available": { "type": "boolean", "description": "true si ALGUNA opción incluye recolección." },
          "booking_locked": { "type": "boolean", "description": "true solo si TODAS las opciones están bloqueadas para esta cuenta." },
          "price_from": { "type": ["number", "null"] },
          "price_to": { "type": ["number", "null"] },
          "currency": { "type": "string", "const": "MXN" },
          "options": {
            "type": "array",
            "description": "Precios reservables de este servicio, de menor a mayor. Reserva `options[0].rate_id` salvo que el usuario pida otra.",
            "items": {
              "type": "object",
              "required": ["rate_id", "service_name", "total_price", "estimated_days", "pickup_included", "booking_locked", "insurance_included", "insurance_not_supported", "insurance_premium", "insured_value"],
              "properties": {
                "rate_id": { "type": "string", "description": "Para `POST /shipments`. OPACO." },
                "service_name": { "type": ["string", "null"], "description": "Nombre comercial del servicio tal como lo publica la paquetería." },
                "total_price": { "type": ["number", "null"] },
                "estimated_days": { "type": ["string", "number", "null"] },
                "pickup_included": { "type": "boolean" },
                "booking_locked": { "type": "boolean" },
                "insurance_included": { "type": "boolean", "description": "La opción incluye cobertura (ya dentro de total_price)." },
                "insurance_not_supported": { "type": "boolean", "description": "La paquetería no ofrece cobertura para esta opción." },
                "insurance_premium": { "type": "number", "description": "Prima de plataforma itemizada (0 sin póliza)." },
                "insured_value": { "type": ["number", "null"] }
              }
            }
          }
        }
      },
      "RatesEndpointParty": {
        "type": "object",
        "required": ["postal_code"],
        "properties": {
          "postal_code": { "type": "string", "description": "CP de 5 dígitos (MX)." },
          "country": { "type": "string", "enum": ["MX"], "default": "MX", "description": "Opcional. Solo `MX` (sin distinción de mayúsculas/minúsculas; vacío = MX) — cualquier otro valor responde 422 (v1 es doméstico MX)." },
          "state": { "type": "string" },
          "city": { "type": "string" },
          "neighborhood": { "type": "string" }
        }
      },
      "RatesResponse": {
        "type": "object",
        "required": ["services", "fetched_at", "stale_after", "meta", "rate_id_expires_in_seconds"],
        "properties": {
          "services": { "type": "array", "items": { "$ref": "#/components/schemas/Rate" } },
          "unlock_notice": { "type": ["string", "null"], "description": "Mensaje es-MX para mostrar al usuario cuando su catálogo está limitado (hay tarifas con `booking_locked`) o cuando se desbloqueó por recargas acumuladas y la verificación de identidad sigue pendiente. `null` para cuentas sin restricción." },
          "fetched_at": { "type": ["string", "null"], "description": "ISO-8601." },
          "stale_after": { "type": ["string", "null"], "description": "ISO-8601 (fetched_at + 5 min): re-cotiza para PRECIOS frescos; el rate_id sigue siendo enviable hasta los 30 min." },
          "meta": {
            "type": "object",
            "properties": {
              "billable_weight": { "type": ["number", "null"], "description": "Peso facturable (kg): max(real, volumétrico)." },
              "volumetric_weight": { "type": ["number", "null"], "description": "L×W×H / 5000 (kg)." },
              "zone": { "type": ["integer", "null"] }
            }
          },
          "rate_id_expires_in_seconds": { "type": "integer", "const": 1800, "description": "TTL del rate_id: crea el envío dentro de esta ventana o recibirás 409 RATE_EXPIRED." }
        }
      },
      "Rate": {
        "type": "object",
        "description": "Una tarifa enviable. `id` es el `rate_id` para `POST /shipments`. La forma es IDÉNTICA para toda llave (los costos internos de proveedor nunca se incluyen) y es una whitelist estricta: no aparecerán campos no documentados.",
        "required": ["id", "carrier", "service_name", "pricing"],
        "properties": {
          "id": { "type": "string", "description": "rate_id — pásalo tal cual a POST /shipments. OPACO: su formato no es parte del contrato (difiere entre el carril de prueba y el live); no lo interpretes ni lo valides." },
          "carrier": { "type": ["string", "null"] },
          "service_name": { "type": ["string", "null"], "description": "Nombre comercial del servicio tal como lo publica la paquetería." },
          "service_type": { "type": ["string", "null"], "description": "Clasificación por niveles: priority | express | standard_economy…" },
          "tier": { "type": ["string", "null"], "description": "Igual a service_type (alias de compatibilidad)." },
          "delivery_window": { "type": ["string", "null"], "description": "Ventana de entrega normalizada (p. ej. next_day, ground)." },
          "pickup_included": { "type": "boolean", "description": "La tarifa incluye recolección." },
          "address_delivery": { "type": "boolean", "description": "Entrega a domicilio (false = entrega en sucursal/ocurre)." },
          "pricing": {
            "type": "object",
            "required": ["total_price", "currency"],
            "properties": {
              "total_price": { "type": ["number", "null"], "description": "Total a cobrar en MXN, 2 decimales (IVA incluido; incluye la prima de seguro de plataforma cuando aplique)." },
              "currency": { "type": "string", "const": "MXN" },
              "iva_included": { "type": "boolean" },
              "insurance_premium": { "type": "number", "description": "Prima itemizada ya incluida en total_price (0 sin póliza)." },
              "insured_value": { "type": ["number", "null"], "description": "Valor declarado asegurado (null sin póliza)." }
            }
          },
          "delivery": {
            "type": "object",
            "properties": {
              "estimated_days": { "type": ["string", "number", "null"] },
              "estimated_date": { "type": ["string", "null"] },
              "min_days": { "type": ["integer", "null"] },
              "max_days": { "type": ["integer", "null"] }
            }
          },
          "insurance_included": { "type": "boolean" },
          "insurance_not_supported": { "type": "boolean", "description": "La paquetería no acepta seguro para esta tarifa." },
          "zona_extendida": { "type": "boolean", "description": "Destino en zona extendida: puede implicar cargos de reexpedición. Cuando la paquetería los cotiza por adelantado ya vienen dentro de total_price; si no, pueden facturarse después como cargo de paquetería." },
          "booking_locked": { "type": "boolean", "description": "La tarifa es VISIBLE pero aún no reservable para esta cuenta: `POST /shipments` responde `403 SERVICE_UNLOCK_REQUIRED`. Solo afecta a cuentas de registro propio sin identidad verificada (INE) y con menos de $2,500 MXN en recargas acumuladas; los servicios sin recolección (entrega en sucursal, cargos extra en mostrador) nunca se bloquean. Las rutas de desbloqueo vienen en `unlock_notice`." }
        }
      },
      "ShipmentCreateRequest": {
        "type": "object",
        "required": ["rate_id"],
        "description": "Remitente: exactamente UNO de `from` (inline) o `from_id` (directorio). Destinatario: exactamente UNO de `to` o `to_id`. Ambos miembros de un par → 422; id no visible → 404 ciego. `to_id` requiere `from_id` y el destinatario debe pertenecer a ese remitente (un destinatario de otro remitente responde 404). Un lado referenciado usa la fila guardada tal cual (sin create-or-reuse). Bultos: exactamente UNO de `package` o `packages` — los MISMOS que se cotizaron.",
        "properties": {
          "rate_id": { "type": "string", "description": "El `id` de una tarifa de POST /rates cotizada con esta misma llave (y mismo X-PDV-ID, si se usó), con menos de 30 minutos. Trátalo como OPACO: cópialo tal cual; su formato no es parte del contrato (difiere entre el carril de prueba y el live) y no debe interpretarse ni validarse." },
          "from": { "$ref": "#/components/schemas/ShipmentParty" },
          "from_id": { "type": "string", "maxLength": 32, "description": "Id de un remitente del directorio. Mutuamente excluyente con `from`." },
          "to": { "$ref": "#/components/schemas/ShipmentParty" },
          "to_id": { "type": "string", "maxLength": 32, "description": "Id de un destinatario del directorio (debe pertenecer al `from_id` enviado). Mutuamente excluyente con `to`; requiere `from_id`." },
          "package": {
            "type": "object",
            "required": ["weight_kg"],
            "properties": {
              "weight_kg": { "type": "number", "exclusiveMinimum": 0 },
              "length_cm": { "type": "number", "exclusiveMinimum": 0 },
              "width_cm": { "type": "number", "exclusiveMinimum": 0 },
              "height_cm": { "type": "number", "exclusiveMinimum": 0 },
              "type": { "type": "string", "enum": ["paquete"], "default": "paquete", "description": "Opcional. Solo `paquete` en v1 (sin distinción de mayúsculas; vacío/null = paquete). Cualquier otro valor — p. ej. `sobre` — responde 422 tanto en /rates como en /shipments (envelope aún no soportado)." }
            }
          },
          "packages": { "$ref": "#/components/schemas/PackagesList" },
          "insurance": {
            "type": "object",
            "description": "La cobertura que YA se cotizó (`insured_value` buys coverage; when the platform policy is unavailable the carrier's own coverage is used and the price shown already includes it). Omitir `insurance` acepta la cobertura cotizada; mandar un `insured_value` DISTINTO → 422 `INSURANCE_MISMATCH` (el total firmado nunca se recalcula al crear). Una tarifa cuya paquetería no ofrece cobertura (`insurance_not_supported: true`) → 422 `INSURANCE_NOT_AVAILABLE`.",
            "required": ["insured_value"],
            "properties": {
              "insured_value": { "type": "number", "exclusiveMinimum": 0, "description": "Valor asegurado (MXN) — el mismo de la cotización." }
            }
          },
          "declared_value": { "type": "number", "exclusiveMinimum": 0, "description": "Valor de la mercancía DECLARADO A LA PAQUETERÍA (MXN). NO es seguro, pero la paquetería puede cobrarlo: debe ser EXACTAMENTE el valor con el que se cotizó (el asegurado) — un valor que la cotización no llevaba responde 422 `DECLARED_VALUE_MISMATCH`, salvo en servicios que lo tratan como informativo (la recuperación de `DECLARED_VALUE_REQUIRED`). Si se omite se declara el valor cotizado; si la cotización no llevaba ninguno, no se inventa. *declared_value must equal the value the rate was quoted with; a carrier may price it.*" },
          "contenido": { "type": "string", "maxLength": 255, "description": "Descripción del contenido (aparece en la guía cuando la paquetería lo soporta)." },
          "reference": { "type": "string", "description": "RESERVADO — aceptado pero no persistido en v1." },
          "metadata": {
            "type": "object",
            "description": "RESERVADO en el carril live (aceptado, no persistido). En el carril SANDBOX, `metadata.test_scenario` fuerza una falla para rehearsal (ver `x-test-mode`).",
            "properties": {
              "test_scenario": {
                "type": "string",
                "enum": ["insufficient_funds", "rate_expired", "vendor_error"],
                "description": "Solo llaves `ek_test_`, y solo sobre ofertas SIMULADAS del catálogo: sobre una tarifa del carril FedEx de prueba (que va al entorno real de FedEx) responde `422 TEST_SCENARIO_NOT_AVAILABLE`. `insufficient_funds` → 402 CREDIT_ERROR (details {required, available, shortfall}) sin importar el saldo; `rate_expired` → 409 RATE_EXPIRED; `vendor_error` → 422 VENDOR_ERROR (retryable=false)."
              }
            },
            "additionalProperties": true
          }
        },
        "example": {
          "rate_id": "e2etest_E2ETestCarrier_E2EStandard_ab12cd34",
          "from": { "name": "Juan Pérez", "phone": "5512345678", "street": "Av. Insurgentes Sur", "number": "600", "colonia": "Del Valle", "city": "Ciudad de México", "state": "CDMX", "postal_code": "01000" },
          "to": { "name": "María López", "phone": "8187654321", "street": "Av. Constitución", "number": "400", "colonia": "Centro", "city": "Monterrey", "state": "Nuevo León", "postal_code": "64000" },
          "package": { "weight_kg": 1 },
          "contenido": "Ropa"
        }
      },
      "ShipmentParty": {
        "type": "object",
        "required": ["name", "phone", "street", "colonia", "city", "state", "postal_code"],
        "properties": {
          "name": { "type": "string", "maxLength": 100 },
          "apellido_paterno": { "type": "string", "maxLength": 50, "description": "Opcional; solo remitente (ignorado en destinatario)." },
          "apellido_materno": { "type": "string", "maxLength": 50, "description": "Opcional; solo remitente (ignorado en destinatario)." },
          "company": { "type": "string", "maxLength": 100, "description": "Razón social (solo remitente; ignorado en destinatario)." },
          "phone": { "type": "string", "maxLength": 15 },
          "email": { "type": "string", "format": "email" },
          "street": { "type": "string", "maxLength": 100 },
          "number": { "type": "string", "maxLength": 20, "description": "Número exterior." },
          "colonia": { "type": "string", "maxLength": 100 },
          "city": { "type": "string", "maxLength": 100 },
          "state": { "type": "string", "maxLength": 50 },
          "postal_code": { "type": "string", "pattern": "^\\d{5}$" },
          "country": { "type": "string", "enum": ["MX"], "default": "MX", "description": "Opcional. Solo `MX` (sin distinción de mayúsculas/minúsculas; vacío = MX) — cualquier otro valor responde 422 (v1 es doméstico MX)." },
          "rfc": { "type": "string", "maxLength": 13, "description": "Solo remitente; usado por algunas paqueterías." }
        },
        "description": "Los datos se registran en el directorio del usuario de la llave con dedupe exacto (normalizado por espacios/mayúsculas), así que repetir el mismo remitente/destinatario no crea filas nuevas. Los campos exceden-longitud se recortan al límite de columna."
      },
      "ShipmentCreateResponse": {
        "type": "object",
        "required": ["shipment"],
        "properties": {
          "shipment": {
            "type": "object",
            "required": ["id", "status", "currency", "label_url", "tracking_pending"],
            "properties": {
              "id": { "type": "string" },
              "guia": { "type": ["string", "null"], "description": "Null cuando la paquetería publica la guía con retraso (`tracking_pending: true`); consúltala después vía GET /shipments/{id}." },
              "carrier": { "type": ["string", "null"], "description": "Null posible en respuestas `duplicate: true`." },
              "service": { "type": ["string", "null"] },
              "status": { "type": "string", "description": "Estatus canónico (created | collected | in_transit | out_for_delivery | ready_for_pickup | delivered | exception | returned | cancelled | unknown)." },
              "total": { "type": ["number", "null"], "description": "Monto cobrado (MXN)." },
              "currency": { "type": "string", "const": "MXN" },
              "label_url": { "type": ["string", "null"], "description": "Ruta de NUESTRO endpoint autenticado de etiquetas (/api/v1/labels/{id}) — nunca la URL cruda del proveedor." },
              "tracking_pending": { "type": "boolean" },
              "created_at": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z)." }
            }
          },
          "duplicate": { "type": "boolean", "description": "Presente (true) cuando la paquetería devolvió una guía ya registrada y la plataforma deduplicó sin doble cargo." },
          "recovered": { "type": "boolean", "description": "Presente (true) cuando la etiqueta se recuperó tras un error transitorio del proveedor." }
        }
      },
      "Shipment": {
        "type": "object",
        "required": ["id", "guia", "carrier", "service", "status", "status_label", "total", "currency", "created_at", "tracking_pending", "label_url", "packages", "billable_weight_kg", "declared_value", "content", "sender", "recipient"],
        "properties": {
          "id": { "type": "string" },
          "guia": { "type": ["string", "null"] },
          "carrier": { "type": ["string", "null"], "description": "Nombre canónico de la paquetería física (SSOT de la plataforma)." },
          "service": { "type": ["string", "null"] },
          "status": { "type": "string", "enum": ["created", "collected", "in_transit", "out_for_delivery", "delivered", "exception", "returned", "cancelled", "unknown"] },
          "status_label": { "type": "string", "description": "Etiqueta en español del estatus canónico." },
          "total": { "type": ["number", "null"] },
          "currency": { "type": "string", "const": "MXN" },
          "created_at": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z)." },
          "tracking_pending": { "type": "boolean" },
          "label_url": { "type": "string" },
          "packages": {
            "type": "array",
            "description": "Lo que se registró al crear el envío, en el mismo vocabulario que usa `POST /shipments` (`weight_kg`, `length_cm`, …). Para envíos multi-paquete es la lista completa por bulto, no el agregado. Lista vacía si el envío no tiene medidas registradas.\n\n**Envíos anteriores al 2026-07-28:** las dimensiones no se persistían (solo se usaban para calcular el peso volumétrico y se descartaban), así que `length_cm`/`width_cm`/`height_cm` llegan en `null` en todo envío creado antes de esa fecha. `weight_kg` sí está disponible en todo el histórico. Un `null` significa \"no registrado\", nunca \"cero\": las dimensiones no declaradas tampoco se rellenan con un valor por defecto.",
            "items": {
              "type": "object",
              "required": ["weight_kg", "length_cm", "width_cm", "height_cm"],
              "properties": {
                "weight_kg": { "type": ["number", "null"] },
                "length_cm": { "type": ["number", "null"] },
                "width_cm": { "type": ["number", "null"] },
                "height_cm": { "type": ["number", "null"] }
              }
            }
          },
          "billable_weight_kg": { "type": ["number", "null"], "description": "Peso facturable con el que se cotizó — `max(peso real, peso volumétrico)`. Es el número contra el que hay que comparar cuando la paquetería aplica un sobrepeso; el peso real por sí solo no explica el cargo. El repeso de la paquetería NO vive en esta API (está en su reporte de facturación)." },
          "declared_value": { "type": ["number", "null"], "description": "Valor declarado (MXN)." },
          "content": { "type": ["string", "null"], "description": "Descripción del contenido capturada al crear el envío." },
          "sender": {
            "type": "object",
            "required": ["name", "city", "state", "postal_code"],
            "properties": {
              "name": { "type": ["string", "null"] },
              "city": { "type": ["string", "null"] },
              "state": { "type": ["string", "null"] },
              "postal_code": { "type": ["string", "null"] }
            },
            "description": "Remitente, con la misma forma mínima que `recipient`: solo nombre + ciudad/estado/CP — nunca la dirección completa ni teléfonos."
          },
          "recipient": {
            "type": "object",
            "required": ["name", "city", "state", "postal_code"],
            "properties": {
              "name": { "type": ["string", "null"] },
              "city": { "type": ["string", "null"] },
              "state": { "type": ["string", "null"] },
              "postal_code": { "type": ["string", "null"] }
            },
            "description": "Solo nombre + ciudad/estado/CP — nunca la dirección completa ni teléfonos."
          }
        }
      },
      "Pagination": {
        "type": "object",
        "required": ["page", "limit", "total"],
        "properties": {
          "page": { "type": "integer" },
          "limit": { "type": "integer" },
          "total": { "type": "integer" }
        }
      },
      "Tracking": {
        "type": "object",
        "required": ["guia", "carrier", "status", "status_label", "origin", "destination", "created_at", "events"],
        "properties": {
          "guia": { "type": "string" },
          "carrier": { "type": ["string", "null"] },
          "status": { "type": "string" },
          "status_label": { "type": "string" },
          "origin": { "type": "object", "properties": { "city": { "type": ["string", "null"] }, "state": { "type": ["string", "null"] } } },
          "destination": { "type": "object", "properties": { "city": { "type": ["string", "null"] }, "state": { "type": ["string", "null"] } } },
          "created_at": { "type": ["string", "null"] },
          "events": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "timestamp": { "type": ["string", "null"] },
                "description": { "type": ["string", "null"], "description": "Nombres de receptor (POD) depurados." },
                "location": { "type": ["string", "null"] }
              }
            }
          }
        }
      },
      "CancellationCreated": {
        "type": "object",
        "required": ["id", "display_id", "status", "refund_status"],
        "properties": {
          "id": { "type": "integer" },
          "display_id": { "type": "string", "description": "Live: formato `CR%08d`. Sandbox: `SBX-CR-<id>`." },
          "status": { "type": "string", "enum": ["pending", "cancelled"], "description": "Live devuelve `pending` (la solicitud entra a la cola de staff). Sandbox devuelve `cancelled` (cancelación síncrona y terminal — ver `x-test-mode`)." },
          "refund_status": { "type": "string", "enum": ["not_applicable", "refunded"], "description": "Live: `not_applicable` (reembolso lo resuelve el staff). Sandbox: `refunded` (reembolso inmediato)." },
          "refund_amount_expected": { "type": ["number", "null"] },
          "cancellation_deadline": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z). Siempre null en sandbox." }
        }
      },
      "Pickup": {
        "type": "object",
        "required": ["id", "display_id", "shipment_id", "status"],
        "properties": {
          "id": { "type": "integer", "description": "Id numérico de la recolección." },
          "display_id": { "type": "string", "description": "Live: formato `PR%08d`. Sandbox: `SBX-PU-<id>`." },
          "shipment_id": { "type": ["string", "null"] },
          "status": { "type": "string", "enum": ["pending", "scheduled", "awaiting_confirmation", "failed", "cancelled"], "description": "Una solicitud SIEMPRE nace en `awaiting_confirmation` (se está gestionando; NO la presentes como confirmada). Pasa a `scheduled` cuando el equipo la confirma con la paquetería — ahí llegan `confirmed_window_*` y `confirmation_number`, y se envía el correo al solicitante. `failed` = no se pudo agendar (entregar en sucursal). `cancelled` = la canceló quien la pidió. `pending` no se usa. El sandbox devuelve `awaiting_confirmation`, igual que producción." },
          "carrier": { "type": ["string", "null"], "description": "Paquetería física que hará la recolección." },
          "requested_window_start": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z). Inicio de la ventana solicitada." },
          "requested_window_end": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z). Fin de la ventana solicitada." },
          "confirmed_window_start": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z). Ventana confirmada por la paquetería, cuando la devuelve." },
          "confirmed_window_end": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z)." },
          "confirmation_number": { "type": ["string", "null"], "description": "Folio/referencia de recolección de la paquetería." },
          "created_at": { "type": ["string", "null"] },
          "updated_at": { "type": ["string", "null"] }
        }
      },
      "Cancellation": {
        "type": "object",
        "required": ["id", "display_id", "shipment_id", "status", "refund_status", "reason_code", "reason_text", "refund_amount_expected", "refund_amount_actual", "cancellation_deadline", "created_at", "updated_at", "resolved_at"],
        "properties": {
          "id": { "type": "integer" },
          "display_id": { "type": "string" },
          "shipment_id": { "type": ["string", "null"] },
          "status": { "type": "string", "enum": ["pending", "approved", "in_progress", "cancelled", "rejected_by_carrier", "not_cancellable", "reported_to_carrier", "rejected", "failed", "expired"] },
          "refund_status": { "type": "string", "enum": ["not_applicable", "refund_in_progress", "refunded", "refund_denied"] },
          "reason_code": { "type": ["string", "null"] },
          "reason_text": { "type": ["string", "null"] },
          "refund_amount_expected": { "type": ["number", "null"] },
          "refund_amount_actual": { "type": ["number", "null"] },
          "cancellation_deadline": { "type": ["string", "null"] },
          "created_at": { "type": ["string", "null"] },
          "updated_at": { "type": ["string", "null"] },
          "resolved_at": { "type": ["string", "null"] }
        }
      },
      "Balance": {
        "type": "object",
        "required": ["balance", "held", "available", "currency", "scope"],
        "properties": {
          "balance": { "type": "number", "description": "available + held (total del principal en el libro mayor)." },
          "held": { "type": "number", "description": "Autorizaciones pendientes (holds)." },
          "available": { "type": "number", "description": "Lo gastable por un envío nuevo." },
          "currency": { "type": "string", "const": "MXN" },
          "scope": {
            "type": "object",
            "required": ["type", "id"],
            "properties": {
              "type": { "type": "string", "enum": ["user", "pdv"] },
              "id": { "type": "string" }
            }
          }
        }
      },
      "SenderCreateRequest": { "example": {"name": "Juan", "apellido_paterno": "Pérez", "phone": "5512345678", "email": "juan@ejemplo.com", "street": "Av. Insurgentes Sur", "number": "600", "colonia": "Del Valle", "city": "Ciudad de México", "state": "CDMX", "postal_code": "01000"},
        "type": "object",
        "required": ["name", "phone", "street", "colonia", "postal_code"],
        "properties": {
          "name": { "type": "string", "maxLength": 100, "description": "Nombre de pila (`nombre`)." },
          "apellido_paterno": { "type": "string", "maxLength": 50, "description": "Opcional. Apellido paterno. NO forma parte del dedupe (como `company`/`rfc`/`email`): un segundo alta con el mismo nombre+domicilio reutiliza la fila y los apellidos nuevos se ignoran — usa `PATCH /senders/{id}` para corregir." },
          "apellido_materno": { "type": "string", "maxLength": 50, "description": "Opcional. Apellido materno." },
          "company": { "type": "string", "maxLength": 100, "description": "Razón social." },
          "rfc": { "type": "string", "maxLength": 13 },
          "phone": { "type": "string", "maxLength": 15 },
          "email": { "type": "string", "format": "email" },
          "street": { "type": "string", "maxLength": 100 },
          "number": { "type": "string", "maxLength": 20, "description": "Número exterior." },
          "colonia": { "type": "string", "maxLength": 100 },
          "city": { "type": "string", "maxLength": 100, "description": "Opcional: si se omite se autocompleta del catálogo SEPOMEX según el CP; si viene, debe coincidir con el CP (si no → 422 nombrando la ciudad esperada)." },
          "state": { "type": "string", "maxLength": 50, "description": "Opcional: si se omite se autocompleta del catálogo SEPOMEX según el CP; si viene, debe coincidir con el CP (si no → 422 nombrando el estado esperado)." },
          "postal_code": { "type": "string", "pattern": "^\\d{5}$", "description": "5 dígitos; debe EXISTIR en el catálogo postal (SEPOMEX)." },
          "country": { "type": "string", "enum": ["MX"], "default": "MX", "description": "Opcional. Solo `MX` — cualquier otro valor responde 422." }
        },
        "description": "El mismo vocabulario de campos que el `from` de `POST /shipments`, con `city`/`state` opcionales aquí (se autocompletan del catálogo SEPOMEX según el CP — la misma validación de exactitud que `POST /contacts/import`). `apellido_paterno`/`apellido_materno` son opcionales (solo remitente; el destinatario usa un único `name`). Campos desconocidos → 422. Los campos exceden-longitud se recortan al límite de columna."
      },
      "SenderUpdateRequest": { "example": {"apellido_materno": "García", "email": null},
        "type": "object",
        "minProperties": 1,
        "properties": {
          "name": { "type": "string", "maxLength": 100 },
          "apellido_paterno": { "type": ["string", "null"], "maxLength": 50, "description": "`null` limpia el campo." },
          "apellido_materno": { "type": ["string", "null"], "maxLength": 50, "description": "`null` limpia el campo." },
          "company": { "type": ["string", "null"], "maxLength": 100 },
          "rfc": { "type": ["string", "null"], "maxLength": 13 },
          "phone": { "type": "string", "maxLength": 15 },
          "email": { "type": ["string", "null"], "format": "email" },
          "street": { "type": "string", "maxLength": 100 },
          "number": { "type": ["string", "null"], "maxLength": 20 },
          "colonia": { "type": "string", "maxLength": 100 },
          "city": { "type": "string", "maxLength": 100 },
          "state": { "type": "string", "maxLength": 50 },
          "postal_code": { "type": "string", "pattern": "^\\d{5}$" },
          "country": { "type": "string", "enum": ["MX"], "default": "MX" }
        },
        "description": "Actualización PARCIAL: solo los campos presentes cambian. Se debe enviar al menos uno. Un campo requerido (`name`/`phone`/`street`/`colonia`/`city`/`state`/`postal_code`) puede cambiarse pero NO vaciarse. Campos opcionales aceptan `null` para limpiarse. Campos desconocidos → 422. Si la edición dejaría la dirección idéntica a OTRA visible → 409 `DUPLICATE_ADDRESS`."
      },
      "RecipientCreateRequest": { "example": {"sender_id": "C0012345", "alias": "Oficina", "name": "María López", "phone": "8187654321", "street": "Av. Constitución", "number": "400", "colonia": "Centro", "city": "Monterrey", "state": "Nuevo León", "postal_code": "64000", "referencia": "Edificio azul, junto a la farmacia", "delivery_instructions": "Entregar en recepción"},
        "type": "object",
        "required": ["sender_id", "name", "phone", "street", "colonia", "postal_code"],
        "properties": {
          "sender_id": { "type": "string", "maxLength": 32, "description": "Remitente dueño (visible para la llave; si no → 404 ciego)." },
          "alias": { "type": "string", "maxLength": 50, "description": "Nombre descriptivo (\"Casa\", \"Oficina\")." },
          "name": { "type": "string", "maxLength": 100 },
          "phone": { "type": "string", "maxLength": 15 },
          "email": { "type": "string", "format": "email" },
          "street": { "type": "string", "maxLength": 100 },
          "number": { "type": "string", "maxLength": 20 },
          "colonia": { "type": "string", "maxLength": 100 },
          "city": { "type": "string", "maxLength": 100, "description": "Opcional: si se omite se autocompleta del catálogo SEPOMEX según el CP; si viene, debe coincidir con el CP (si no → 422 nombrando la ciudad esperada)." },
          "state": { "type": "string", "maxLength": 50, "description": "Opcional: si se omite se autocompleta del catálogo SEPOMEX según el CP; si viene, debe coincidir con el CP (si no → 422 nombrando el estado esperado)." },
          "postal_code": { "type": "string", "pattern": "^\\d{5}$", "description": "5 dígitos; debe EXISTIR en el catálogo postal (SEPOMEX)." },
          "country": { "type": "string", "enum": ["MX"], "default": "MX" },
          "referencia": { "type": "string", "maxLength": 1000, "description": "Referencias del domicilio." },
          "delivery_instructions": { "type": "string", "maxLength": 1000 }
        },
        "description": "El mismo vocabulario de campos que el `to` de `POST /shipments` + `sender_id`, con `city`/`state` opcionales aquí (se autocompletan del catálogo SEPOMEX según el CP — la misma validación de exactitud que `POST /contacts/import`). `alias`/`referencia`/`delivery_instructions` solo se aplican al CREAR (una reutilización devuelve la fila existente intacta)."
      },
      "RecipientUpdateRequest": { "example": {"phone": "8187654322", "delivery_instructions": null},
        "type": "object",
        "minProperties": 1,
        "properties": {
          "alias": { "type": ["string", "null"], "maxLength": 50 },
          "name": { "type": "string", "maxLength": 100 },
          "phone": { "type": "string", "maxLength": 15 },
          "email": { "type": ["string", "null"], "format": "email" },
          "street": { "type": "string", "maxLength": 100 },
          "number": { "type": ["string", "null"], "maxLength": 20 },
          "colonia": { "type": "string", "maxLength": 100 },
          "city": { "type": "string", "maxLength": 100 },
          "state": { "type": "string", "maxLength": 50 },
          "postal_code": { "type": "string", "pattern": "^\\d{5}$" },
          "country": { "type": "string", "enum": ["MX"], "default": "MX" },
          "referencia": { "type": ["string", "null"], "maxLength": 1000 },
          "delivery_instructions": { "type": ["string", "null"], "maxLength": 1000 }
        },
        "description": "Actualización PARCIAL. `sender_id` NO puede cambiarse (los destinatarios no se re-asignan de remitente) → 422 si se envía. Misma disciplina que `SenderUpdateRequest`: al menos un campo, no vaciar requeridos, `null` limpia opcionales, unificar con OTRO destinatario del MISMO remitente → 409 `DUPLICATE_ADDRESS`."
      },
      "Sender": {
        "type": "object",
        "required": ["id", "name", "apellido_paterno", "apellido_materno", "company", "rfc", "phone", "email", "street", "number", "colonia", "city", "state", "postal_code", "created_at"],
        "properties": {
          "id": { "type": "string" },
          "name": { "type": ["string", "null"] },
          "apellido_paterno": { "type": ["string", "null"] },
          "apellido_materno": { "type": ["string", "null"] },
          "company": { "type": ["string", "null"] },
          "rfc": { "type": ["string", "null"] },
          "phone": { "type": ["string", "null"] },
          "email": { "type": ["string", "null"] },
          "street": { "type": ["string", "null"] },
          "number": { "type": ["string", "null"] },
          "colonia": { "type": ["string", "null"] },
          "city": { "type": ["string", "null"] },
          "state": { "type": ["string", "null"] },
          "postal_code": { "type": ["string", "null"] },
          "created_at": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z)." }
        },
        "description": "Recurso remitente. Su `id` sirve como `from_id` en `POST /rates` y `POST /shipments`."
      },
      "Recipient": {
        "type": "object",
        "required": ["id", "sender_id", "alias", "name", "phone", "email", "street", "number", "colonia", "city", "state", "postal_code", "referencia", "delivery_instructions", "created_at"],
        "properties": {
          "id": { "type": "string" },
          "sender_id": { "type": "string" },
          "alias": { "type": ["string", "null"] },
          "name": { "type": ["string", "null"] },
          "phone": { "type": ["string", "null"] },
          "email": { "type": ["string", "null"] },
          "street": { "type": ["string", "null"] },
          "number": { "type": ["string", "null"] },
          "colonia": { "type": ["string", "null"] },
          "city": { "type": ["string", "null"] },
          "state": { "type": ["string", "null"] },
          "postal_code": { "type": ["string", "null"] },
          "referencia": { "type": ["string", "null"] },
          "delivery_instructions": { "type": ["string", "null"] },
          "created_at": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z)." }
        },
        "description": "Recurso destinatario. Su `id` sirve como `to_id` en `POST /rates` y `POST /shipments` (requiere `from_id` del remitente dueño)."
      },
      "Colonia": {
        "type": "object",
        "required": ["name", "settlement_type"],
        "properties": {
          "name": { "type": "string", "description": "Nombre del asentamiento (`d_asenta` de SEPOMEX). Úsalo como `colonia`/`neighborhood`." },
          "settlement_type": { "type": ["string", "null"], "description": "Tipo de asentamiento SEPOMEX: `Colonia`, `Fraccionamiento`, `Pueblo`, `Barrio`, etc." }
        }
      },
      "PostalCode": {
        "type": "object",
        "required": ["postal_code", "state", "state_code", "municipality", "city", "colonias"],
        "properties": {
          "postal_code": { "type": "string", "description": "El CP consultado (5 dígitos)." },
          "state": { "type": ["string", "null"], "description": "Estado (`d_estado`). Úsalo como `state`." },
          "state_code": { "type": ["string", "null"], "description": "Código de estado normalizado (mismo esquema que `state_code` del directorio; p. ej. `DF`, `JAL`, `NL`)." },
          "municipality": { "type": ["string", "null"], "description": "Municipio/alcaldía (`d_mnpio`). Suele ser el mejor valor para `city`." },
          "city": { "type": ["string", "null"], "description": "Ciudad (`d_ciudad`) cuando SEPOMEX la distingue del municipio; `null` en la mayoría de los CP." },
          "colonias": { "type": "array", "items": { "$ref": "#/components/schemas/Colonia" }, "description": "Asentamientos únicos del CP, ordenados por nombre. Un CP mapea a un estado/municipio pero a muchas colonias." }
        },
        "description": "Resultado de `GET /postal-codes/{postal_code}`. Datos de referencia SEPOMEX (públicos, solo lectura)."
      },
      "LabelLink": {
        "type": "object",
        "required": ["shipment_id", "label_url", "format", "sandbox"],
        "description": "Respuesta de `GET /labels/{envio_id}?format=json`: enlace a la etiqueta en vez de los bytes.",
        "properties": {
          "shipment_id": { "type": "string" },
          "guia": { "type": "string", "description": "Solo en sandbox (la vía en vivo omite el campo)." },
          "label_url": { "type": ["string", "null"], "description": "URL vigente de la etiqueta (PDF). En vivo es la URL más fresca de la paquetería; `null` cuando solo existe la copia almacenada (rara vez) — entonces `note` lo explica y el endpoint binario sigue sirviéndola." },
          "format": { "type": "string", "const": "pdf" },
          "sandbox": { "type": "boolean", "description": "`true` con llaves `ek_test_`: etiqueta de prueba sin validez." },
          "note": { "type": "string", "description": "Presente solo cuando hay algo que aclarar (etiqueta sandbox, o sin URL directa)." }
        }
      },
      "WebhookEvent": {
        "type": "string",
        "enum": ["shipment.created", "shipment.collected", "shipment.in_transit", "shipment.out_for_delivery", "shipment.delivered", "shipment.exception", "shipment.returned", "shipment.cancelled", "pickup.resolved"],
        "description": "Eventos suscribibles. `shipment.created` se emite al comprar la guía; `shipment.collected`…`shipment.exception` cuando el rastreo observa una transición REAL del estatus canónico — un estatus no terminal puede repetirse legítimamente (`exception` → `in_transit` → `exception` produce dos `shipment.exception`), pero la misma transición observada dos veces se entrega una sola vez; `shipment.delivered`, `shipment.returned` y `shipment.cancelled` son terminales y se entregan a lo sumo una vez por envío (`cancelled` también cuando la cancelación queda en firme); `pickup.resolved` cuando nuestro equipo cierra una solicitud de recolección (`scheduled` o `failed`). El evento `ping` solo existe para `POST /webhooks/{id}/test` y no es suscribible."
      },
      "WebhookCreateRequest": {
        "type": "object",
        "required": ["url", "events"],
        "additionalProperties": false,
        "properties": {
          "url": { "type": "string", "format": "uri", "maxLength": 2048, "description": "Endpoint `https://` público en el puerto 443 u 8443 (cualquier otro puerto se rechaza). Se rechazan http, credenciales embebidas, fragmentos, `localhost`/`.local`/`.internal`, literales IP privadas, hosts que resuelvan a rangos privados y hosts que apunten al propio API de Enviadores." },
          "events": { "type": "array", "minItems": 1, "items": { "$ref": "#/components/schemas/WebhookEvent" }, "description": "Eventos a recibir (duplicados se descartan)." }
        }
      },
      "Webhook": {
        "type": "object",
        "required": ["id", "url", "events", "mode", "status", "failure_count"],
        "properties": {
          "id": { "type": "string", "description": "`wh_` + 24 hex." },
          "url": { "type": "string" },
          "events": { "type": "array", "items": { "$ref": "#/components/schemas/WebhookEvent" } },
          "mode": { "type": "string", "enum": ["live", "test"], "description": "Lane de la llave que lo registró. `live` recibe eventos reales; `test` solo `shipment.created`/`shipment.cancelled` del sandbox y el ping." },
          "status": { "type": "string", "enum": ["active", "disabled"], "description": "`disabled` = desactivado automáticamente: tras 20 fallos consecutivos, o porque la llave API que lo registró fue revocada (`disabled_reason: key_revoked` — un webhook muere con su llave). Al desactivarse, sus eventos pendientes se descartan. No cuenta para el tope; elimínalo y regístralo de nuevo." },
          "failure_count": { "type": "integer", "description": "Fallos CONSECUTIVOS de entrega (cualquier respuesta no 2xx o error de transporte). Vuelve a 0 con cada 2xx. Los pings de prueba no lo afectan." },
          "last_delivery_at": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z). Último intento (éxito o fallo), pings incluidos." },
          "last_delivery_status": { "type": ["integer", "null"], "description": "Status HTTP del último intento; `0` = fallo de transporte (timeout/TLS/DNS)." },
          "created_at": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z)." },
          "disabled_at": { "type": ["string", "null"], "description": "ISO-8601 UTC (Z)." },
          "disabled_reason": { "type": ["string", "null"], "description": "`key_revoked` cuando la llave que lo registró fue revocada; texto explicativo cuando fue por fallos consecutivos." }
        },
        "description": "Recurso webhook. NUNCA incluye el secreto: solo existe en la respuesta de `POST /webhooks`."
      },
      "WebhookCreated": {
        "type": "object",
        "required": ["webhook", "secret"],
        "properties": {
          "webhook": { "$ref": "#/components/schemas/Webhook" },
          "secret": { "type": "string", "pattern": "^whsec_[0-9a-f]{64}$", "description": "Secreto de firma — se muestra SOLO aquí. Guárdalo: no se almacena y no se puede recuperar; para obtener otro hay que eliminar el webhook y registrarlo de nuevo." }
        }
      },
      "WebhookTestResult": {
        "type": "object",
        "required": ["delivered", "response_status", "error", "event_id"],
        "properties": {
          "delivered": { "type": "boolean", "description": "`true` si el endpoint respondió 2xx dentro de 10 s." },
          "response_status": { "type": ["integer", "null"], "description": "Status HTTP recibido; `null` si no hubo respuesta (timeout, TLS, DNS)." },
          "error": { "type": ["string", "null"], "enum": ["timeout", "connection failed", "non-2xx status", "endpoint rejected", null], "description": "Clase del fallo, deliberadamente gruesa (el detalle de red no se expone para que el ping no sirva como sonda de puertos): `timeout`, `connection failed` (rechazo/reset/TLS/DNS), `non-2xx status` (ver `response_status`), `endpoint rejected` (la URL ya no pasa la política); `null` si se entregó." },
          "event_id": { "type": "string", "description": "`evt_` + 24 hex — el `id` del cuerpo enviado y el header `X-Enviadores-Delivery`." }
        }
      },
      "WebhookPayload": {
        "type": "object",
        "required": ["id", "event", "mode", "created_at", "data"],
        "properties": {
          "id": { "type": "string", "description": "`evt_` + 24 hex. Estable entre reintentos de la misma entrega — deduplica con él." },
          "event": { "type": "string", "description": "Nombre del evento (`WebhookEvent`, o `ping`)." },
          "mode": { "type": "string", "enum": ["live", "test"] },
          "created_at": { "type": "string", "description": "ISO-8601 UTC (Z). Momento en que se emitió el evento." },
          "data": { "type": "object", "description": "`{shipment: Shipment}` para `shipment.*` (misma forma que `GET /shipments/{id}`), `{pickup: Pickup}` para `pickup.resolved` (misma forma que `GET /pickups/{id}`), `{webhook_id}` para `ping`." }
        },
        "description": "Cuerpo de cada `POST` a tu endpoint. Headers: `Content-Type: application/json`, `User-Agent: enviadores-webhooks/1.0`, `X-Enviadores-Signature: t=<unix>,v1=<hex>` (`v1 = HMAC-SHA256(secret, \"<t>.<cuerpo crudo>\")`), `X-Enviadores-Event`, `X-Enviadores-Delivery` (= `id`). No se siguen redirecciones; responde 2xx en <10 s."
      },
      "Me": {
        "type": "object",
        "required": [
          "account",
          "key",
          "balance",
          "capabilities"
        ],
        "properties": {
          "account": {
            "type": "object",
            "required": [
              "id",
              "name",
              "mode",
              "account_type",
              "tier_label"
            ],
            "properties": {
              "id": {
                "type": "string",
                "description": "Id del usuario dueño de la llave."
              },
              "name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Nombre visible de la cuenta (razón social, nombre de la persona, o el usuario). Nunca correo ni teléfono."
              },
              "mode": {
                "type": "string",
                "enum": [
                  "test",
                  "live"
                ],
                "description": "`test` = sandbox (nada es real); `live` = producción."
              },
              "account_type": {
                "type": "string",
                "enum": [
                  "customer",
                  "staff"
                ],
                "description": "Dos valores públicos: cuenta de cliente, o cuenta de nuestro mostrador. El rol interno no se expone."
              },
              "tier_label": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "T0",
                  "T1",
                  "T2",
                  "T3",
                  "T4",
                  null
                ],
                "description": "Escalón de verificación de una cuenta de registro propio. `null` si la cuenta no está en la escalera (creada por nuestro equipo) o si la consulta no está disponible."
              }
            }
          },
          "key": {
            "type": "object",
            "required": [
              "id",
              "label",
              "mode",
              "scopes",
              "scopes_mode",
              "spend_cap_daily_mxn",
              "rate_limit_tier"
            ],
            "properties": {
              "id": {
                "type": "string",
                "description": "Id público de la llave (no es el secreto)."
              },
              "label": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Etiqueta que le puso su dueño."
              },
              "mode": {
                "type": "string",
                "enum": [
                  "test",
                  "live"
                ]
              },
              "scopes": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Los scopes EFECTIVOS de esta petición — lo que el gate acaba de aplicar."
              },
              "scopes_mode": {
                "type": "string",
                "enum": [
                  "default",
                  "explicit"
                ],
                "description": "`default`: la llave se creó con los permisos por omisión y se actualiza sola cuando aparecen nuevos. `explicit`: alguien la restringió al crearla; queda congelada así."
              },
              "spend_cap_daily_mxn": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "Tope de gasto en ventana móvil de 24 h, o `null` si no tiene."
              },
              "rate_limit_tier": {
                "type": "string",
                "enum": [
                  "standard",
                  "elevated"
                ]
              }
            }
          },
          "balance": {
            "type": "object",
            "required": [
              "available",
              "currency"
            ],
            "properties": {
              "available": {
                "type": "number",
                "description": "Lo gastable ahora mismo — el mismo número que `GET /balance`. Para el desglose (`held`, total, `scope`) usa ese endpoint."
              },
              "currency": {
                "type": "string",
                "const": "MXN"
              }
            }
          },
          "capabilities": {
            "type": "object",
            "required": [
              "pickups",
              "international",
              "multi_package",
              "webhooks"
            ],
            "properties": {
              "pickups": {
                "type": "boolean",
                "description": "Si esta conexión puede solicitar recolecciones. Siempre `true` en sandbox."
              },
              "international": {
                "type": "boolean",
                "description": "Si la API acepta destinos fuera de México. Hoy `false` (v1 es doméstico MX)."
              },
              "multi_package": {
                "type": "boolean",
                "description": "Si una solicitud puede llevar más de un paquete. Se deriva de la misma validación que aplica `POST /shipments`."
              },
              "webhooks": {
                "type": "boolean",
                "description": "Si existen webhooks salientes en esta superficie. Hoy `false`: consulta con `GET /shipments` o `GET /tracking/{guia}`."
              }
            }
          }
        }
      },
      "Transaction": {
        "type": "object",
        "required": [
          "id",
          "type",
          "amount",
          "balance_after",
          "shipment_id",
          "description",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Id del movimiento (estable; el que conviene citar a soporte)."
          },
          "type": {
            "type": "string",
            "enum": [
              "charge",
              "refund",
              "topup",
              "adjustment"
            ],
            "description": "Vocabulario público: cargo, devolución, recarga en línea, o cualquier otro abono."
          },
          "amount": {
            "type": "number",
            "description": "Importe en MXN CON SIGNO: negativo si salió saldo, positivo si entró."
          },
          "balance_after": {
            "type": [
              "number",
              "null"
            ],
            "description": "Saldo del principal después de este movimiento."
          },
          "shipment_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Envío relacionado, cuando el movimiento nació de uno."
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Texto del movimiento. Las correcciones de cargo (cuando reasignamos un cobro al fondo correcto) se reportan siempre como `Ajuste`: el detalle interno describe nuestra propia contabilidad y nombra al operador que la hizo, y nada de eso viaja a esta superficie."
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO-8601 en UTC (Z)."
          }
        }
      }
    }
  }
}
