openapi: 3.0.3 info: title: Distripak Partner API version: "1.0.0" description: | API pública M2M de Distripak para integradores que operan envíos de forma programática. Autenticación por API Key + firma HMAC-SHA256 (ver sección Auth). Namespace `/api/partner/v1`, separado del panel humano (`/api/v1`). Ver `api-docs.md` en este mismo directorio para la guía de uso completa, ejemplos de firma en 3 lenguajes y diagramas. Fuera de alcance de esta versión: sin self-service de generación de API Keys (las emite SuperAdmin, manual o vía aprobación de solicitud), sin OAuth2, sin pago automático del neto conciliado. servers: - url: https://{tenant-domain}/api/partner/v1 description: Producción (namespace autenticado) variables: tenant-domain: default: micuenta.distripak.com - url: https://{tenant-domain} description: Host para el endpoint público de tracking (fuera del namespace autenticado) variables: tenant-domain: default: micuenta.distripak.com tags: - name: Quotes - name: Shipments - name: PickupRequests - name: Webhooks - name: Wallet - name: Settlements - name: Billing - name: Tracking components: securitySchemes: ApiKeyAuth: type: apiKey in: header name: X-Api-Key description: > Combinado con X-Api-Timestamp y X-Api-Signature (HMAC-SHA256). Ver api-docs.md sección "Autenticación paso a paso". No es suficiente por sí solo — los 3 headers son obligatorios en cada request autenticada. parameters: IdempotencyKey: name: Idempotency-Key in: header required: false schema: type: string description: > Opcional. UUID (u otra cadena única) generada por el integrador antes del primer intento. Mismo (api_key_id, Idempotency-Key) con el mismo body devuelve la respuesta original sin reejecutar la operación. Mismo header con body distinto responde 409 PTN-VAL-003. PickupRequestId: name: id in: path required: true schema: { type: integer } description: Identificador devuelto por `POST /pickup-requests`. PeriodId: name: period_id in: path required: true schema: { type: integer } ShipmentId: name: id in: path required: true schema: { type: integer } headers: RetryAfter: description: Segundos a esperar antes de reintentar (solo en 429). schema: { type: integer } schemas: Error: type: object properties: error: { type: string, description: Mensaje legible del error. } code: type: string description: Código diagnóstico (PTN-*, o PKR-* para solicitudes de recogida). Ver runbook-diagnostico.md. request_id: { type: string, description: Identificador de la petición, útil para soporte. } example: error: "Firma HMAC inválida" code: "PTN-AUT-001" request_id: "req-6ac6a5cdefd0f5.89243598" QuoteRequest: type: object required: [origin_address, destination_address, weight_kg] properties: origin_address: { type: string, description: "Solo la dirección (calle, número, referencia) — sin el municipio/ciudad, que va en origin_city." } destination_address: { type: string, description: "Solo la dirección (calle, número, referencia) — sin el municipio/ciudad, que va en destination_city." } origin_city: { type: string, nullable: true, description: "Recomendado enviarlo siempre que no se envíen coordenadas — sin él, el riesgo de geolocalización incorrecta es alto y puede rechazar la cotización por 'fuera de zona de cobertura'." } destination_city: { type: string, nullable: true, description: "Recomendado enviarlo siempre que no se envíen coordenadas — sin él, el riesgo de geolocalización incorrecta es alto y puede rechazar la cotización por 'fuera de zona de cobertura'." } origin_lat: { type: number, format: float, nullable: true, description: "Recomendado. Si viene junto con origin_lng, tiene prioridad sobre geocodificar origin_address — la forma más confiable de indicar el punto exacto." } origin_lng: { type: number, format: float, nullable: true } destination_lat: { type: number, format: float, nullable: true, description: "Opcional. Si viene junto con destination_lng, tiene prioridad sobre geocodificar destination_address." } destination_lng: { type: number, format: float, nullable: true } weight_kg: { type: number, format: float } length_cm: { type: number, format: float, nullable: true } width_cm: { type: number, format: float, nullable: true } height_cm: { type: number, format: float, nullable: true } declared_value: { type: number, format: float, default: 0 } cash_on_delivery: { type: number, format: float, default: 0 } QuoteResponse: type: object properties: quote_id: { type: integer } origin_address: { type: string } destination_address: { type: string } zone_id: { type: integer, nullable: true } weight_kg: { type: number } vol_cm3: { type: integer, nullable: true } declared_value: { type: number } cash_on_delivery: { type: number } freight_cost: { type: number } expires_at: { type: string, format: date-time, description: "TTL de 15 minutos" } ShipmentCreateRequest: type: object description: > quote_id es completamente OPCIONAL — se puede crear un envío enviando todos los parámetros directamente, sin cotizar antes; el flete se calcula en el momento. Si SÍ se envía un quote_id vigente, freight_cost/origin_address/ destination_address/zone_id/weight_kg/declared_value/cash_on_delivery se heredan de la cotización (el body puede sobreescribir campos no cubiertos por la cotización, ej. datos del destinatario) y el precio cotizado se honra sin recalcular. client_id (opcional) dirige el envío a una sucursal del subárbol de la key; si se omite, aplica sobre la cuenta raíz. properties: quote_id: { type: integer, nullable: true, description: "Opcional — omitir para crear sin cotización previa." } client_id: { type: integer, nullable: true, description: "Sucursal del subárbol de la key" } origin_address: { type: string } origin_address_complement: { type: string, nullable: true, description: "Complemento de la dirección de origen (bodega, torre, referencia, etc.) para ESTE envío puntual." } origin_city: { type: string, nullable: true, description: "Recomendado enviarlo siempre que no se envíen coordenadas — ver QuoteRequest." } origin_lat: { type: number, format: float, nullable: true } origin_lng: { type: number, format: float, nullable: true } destination_address: { type: string } destination_address_complement: { type: string, nullable: true, description: "Complemento de la dirección de destino (apto, interior, torre, etc.)." } destination_city: { type: string, nullable: true, description: "Recomendado enviarlo siempre que no se envíen coordenadas — ver QuoteRequest." } destination_lat: { type: number, format: float, nullable: true } destination_lng: { type: number, format: float, nullable: true } weight_kg: { type: number } length_cm: { type: number, format: float, nullable: true } width_cm: { type: number, format: float, nullable: true } height_cm: { type: number, format: float, nullable: true } declared_value: { type: number } cash_on_delivery: { type: number } recipient_name: { type: string } recipient_phone: { type: string } Shipment: type: object description: > Lista blanca de campos — el envío puede tener más columnas internas en la base de datos (operativas, de facturación, datos de mensajeros) que deliberadamente NO se exponen a partners externos. properties: id: { type: integer } client_id: { type: integer } tracking_number: { type: string } status: { type: string } origin_address: { type: string } origin_address_complement: { type: string, nullable: true } origin_city: { type: string, nullable: true } origin_lat: { type: number, format: float, nullable: true } origin_lng: { type: number, format: float, nullable: true } destination_address: { type: string } destination_address_complement: { type: string, nullable: true } destination_city: { type: string, nullable: true } destination_zip: { type: string, nullable: true } destination_lat: { type: number, format: float, nullable: true } destination_lng: { type: number, format: float, nullable: true } recipient_name: { type: string, nullable: true } recipient_phone: { type: string, nullable: true } weight_kg: { type: number, nullable: true } length_cm: { type: number, nullable: true } width_cm: { type: number, nullable: true } height_cm: { type: number, nullable: true } package_category: { type: string, nullable: true } declared_value: { type: number } cash_on_delivery: { type: number } freight_cost: { type: number, nullable: true } zone_name: { type: string, nullable: true } scheduled_pickup_date: { type: string, format: date-time, nullable: true } not_delivered_reason: { type: string, nullable: true } delivered_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } history: type: array items: type: object properties: status: { type: string } timestamp: { type: string, format: date-time } PickupRequestCreate: type: object description: Todos los campos son opcionales. properties: client_id: type: integer nullable: true description: Sucursal del subárbol de la key. Si se omite, la cuenta raíz (403 PTN-AUT-005 si no pertenece al subárbol). notes: type: string nullable: true description: Indicación para el mensajero. requested_for: type: string format: date nullable: true description: > Fecha MÁS TEMPRANA deseada (YYYY-MM-DD, hora de Bogotá), entre hoy y los próximos 6 días. La fecha efectiva es la mayor entre esta y la primera que el portal aceptaría: hoy después de la hora de corte, con el corte de Flex ya ejecutado, o en domingo/festivo no habilitado, la solicitud pasa al siguiente día hábil (ver `rescheduled` en la respuesta). Si se omite, se usa la primera fecha disponible. Formato inválido, pasado o a más de 6 días: 422 PKR-CRE-001. PickupRequest: type: object required: [id, client_id, status, requested_for, rescheduled, shipments_count] properties: id: { type: integer } client_id: { type: integer, description: Cuenta (raíz o sucursal) para la que se programó. } status: type: string enum: [PENDING, ASSIGNED, IN_PROGRESS, COMPLETED, CANCELLED] description: PENDING sin mensajero; ASSIGNED con mensajero; IN_PROGRESS recogiendo; luego COMPLETED o CANCELLED. requested_for: { type: string, format: date, description: Fecha EFECTIVA de la recogida. } requested_for_original: type: string format: date nullable: true description: Fecha pedida, solo si la fecha fue corrida; si no, null. schedule_reason: type: string nullable: true enum: [CUTOFF, FLEX_CUTOFF, NON_BUSINESS_DAY, null] description: CUTOFF (pasada la hora de corte), FLEX_CUTOFF (el corte de Flex del día ya se ejecutó), NON_BUSINESS_DAY (domingo o festivo no habilitado); null si no hubo cambio. rescheduled: { type: boolean } schedule_message: { type: string, nullable: true, description: Explicación legible del cambio, o null. } notes: { type: string, nullable: true } shipments_count: { type: integer, description: Paquetes recogidos hasta ahora. } messenger: type: object nullable: true description: null mientras no hay mensajero. Solo el nombre de pila. properties: first_name: { type: string } created_at: { type: string, example: "2026-01-14 14:02:11" } started_at: { type: string, nullable: true } completed_at: { type: string, nullable: true } example: id: 2313 client_id: 55 status: PENDING requested_for: "2026-01-15" requested_for_original: "2026-01-14" schedule_reason: CUTOFF rescheduled: true schedule_message: "La solicitud se creó después de la hora de corte; quedó programada para el 2026-01-15." notes: null shipments_count: 0 messenger: null created_at: "2026-01-14 14:02:11" started_at: null completed_at: null PickupRequestList: type: object properties: data: type: array items: { $ref: '#/components/schemas/PickupRequest' } meta: type: object properties: page: { type: integer } per_page: { type: integer, description: Máximo 100. } total: { type: integer } last_page: { type: integer } WebhookCreateRequest: type: object required: [url, events] properties: url: { type: string, format: uri, description: "Solo http:// o https://" } events: type: array items: type: string enum: [shipment.status_changed, shipment.delivered] WebhookCreateResponse: type: object properties: id: { type: integer } url: { type: string } events: { type: array, items: { type: string } } status: { type: string, enum: [ACTIVE] } secret: type: string description: > Expuesto UNA sola vez en esta respuesta — usado para verificar X-Distripak-Signature en cada entrega. Distripak no vuelve a mostrarlo. WebhookDelivery: type: object properties: id: { type: integer } event_type: { type: string } http_status: { type: integer, nullable: true } error_message: { type: string, nullable: true } attempt_number: { type: integer } delivered_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } success: { type: boolean } Wallet: type: object properties: balance_available: { type: number } balance_pending: { type: number } balance_debt: { type: number } balance_frozen: { type: number } accounts: type: array items: type: object properties: client_id: { type: integer } business_name: { type: string, nullable: true } contact_name: { type: string } balance_available: { type: number } balance_pending: { type: number } balance_debt: { type: number } balance_frozen: { type: number } Settlement: type: object properties: period: type: object description: Fila completa de billing_periods summary: type: object properties: total_freight: { type: number } total_recaudo: { type: number, description: "RECAUDO_PENDING + RECAUDO_RELEASED sumados" } net_amount: type: number description: > Mismo valor y signo que "TOTAL ADEUDADO" en la cuenta de cobro (PDF) cuando el período ya fue facturado. Si el período sigue OPEN, es una aproximación en vivo desde el ledger. Positivo = el integrador le debe a la plataforma; negativo = saldo a favor del integrador. net_direction: { type: string, enum: [OWED_BY_INTEGRATOR, IN_FAVOR_OF_INTEGRATOR, BALANCED] } breakdown_by_type: type: array items: type: object properties: type: { type: string } total: { type: number } count: { type: integer } transactions: type: array items: type: object properties: shipment_id: { type: integer, nullable: true } tracking_number: { type: string, nullable: true } type: { type: string } amount: { type: number } date: { type: string, format: date-time } TrackingResponse: type: object properties: tracking_number: { type: string } status: { type: string } status_label: { type: string } destination_city: { type: string } created_at: { type: string, format: date-time } delivered_at: { type: string, format: date-time, nullable: true } estimated_delivery: { type: string, format: date-time, nullable: true } history: type: array items: type: object properties: status: { type: string } status_label: { type: string } timestamp: { type: string, format: date-time } responses: Unauthorized: description: Firma inválida (PTN-AUT-001) / timestamp expirado (PTN-AUT-002) / key desconocida (PTN-AUT-003) content: application/json: schema: { $ref: '#/components/schemas/Error' } Forbidden: description: Scope faltante (PTN-AUT-004) o client_id fuera del subárbol (PTN-AUT-005) content: application/json: schema: { $ref: '#/components/schemas/Error' } NotFound: description: Recurso no encontrado content: application/json: schema: { $ref: '#/components/schemas/Error' } TooManyRequests: description: Rate limit excedido (PTN-VAL-001) headers: Retry-After: { $ref: '#/components/headers/RetryAfter' } content: application/json: schema: { $ref: '#/components/schemas/Error' } Conflict: description: Conflicto de idempotencia — mismo Idempotency-Key con body distinto (PTN-VAL-003) content: application/json: schema: { $ref: '#/components/schemas/Error' } security: - ApiKeyAuth: [] paths: /quotes: post: tags: [Quotes] summary: Cotizar un envío (sin efectos secundarios de negocio) description: Requiere scope `quotes:read`. La cotización expira a los 15 minutos. requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/QuoteRequest' } responses: '201': description: Cotización creada content: application/json: schema: { $ref: '#/components/schemas/QuoteResponse' } '400': description: Datos inválidos (PTN-VAL-006) content: application/json: schema: { $ref: '#/components/schemas/Error' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '429': { $ref: '#/components/responses/TooManyRequests' } /shipments: post: tags: [Shipments] summary: Crear un envío (opcionalmente referenciando una cotización vigente) description: Requiere scope `shipments:write`. Soporta `Idempotency-Key`. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ShipmentCreateRequest' } responses: '201': description: Envío creado content: application/json: schema: { $ref: '#/components/schemas/Shipment' } '401': { $ref: '#/components/responses/Unauthorized' } '403': description: Scope faltante o client_id fuera del subárbol (PTN-AUT-005) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } '409': { $ref: '#/components/responses/Conflict' } '422': description: Cotización expirada (PTN-VAL-002) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } '429': { $ref: '#/components/responses/TooManyRequests' } /shipments/{id}: get: tags: [Shipments] summary: Consultar estado e historial de un envío description: Requiere scope `shipments:read`. Visible si el envío pertenece a cualquier cuenta del subárbol de la key. parameters: - $ref: '#/components/parameters/ShipmentId' responses: '200': description: Envío content: application/json: schema: { $ref: '#/components/schemas/Shipment' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': description: Envío no encontrado o fuera del subárbol (SHP-NOT-001) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } /shipments/{id}/cancel: post: tags: [Shipments] summary: Cancelar un envío (solo antes de recogida confirmada) description: Requiere scope `shipments:write`. Soporta `Idempotency-Key`. parameters: - $ref: '#/components/parameters/ShipmentId' - $ref: '#/components/parameters/IdempotencyKey' responses: '200': description: Cancelado content: application/json: schema: type: object properties: { success: { type: boolean } } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '422': description: Estado no elegible para cancelar (PTN-CON-003) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } /shipments/{id}/label: get: tags: [Shipments] summary: Descargar la etiqueta PDF del envío description: Requiere scope `shipments:read`. parameters: - $ref: '#/components/parameters/ShipmentId' - name: format in: query required: false schema: { type: string, enum: [thermal, a4], default: thermal } description: "thermal = 4x6 (101.6x152.4mm, la más usada); a4 = carta." responses: '200': description: PDF de etiqueta content: application/pdf: { schema: { type: string, format: binary } } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '422': description: Envío en estado final, sin etiqueta disponible (PTN-VAL-007) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } /pickup-requests: post: tags: [PickupRequests] summary: Solicitar recogida description: | Requiere scope `pickup-requests:write`. Soporta `Idempotency-Key`. Aplica las mismas reglas de programación que el portal (hora de corte, corte de Flex, domingos y festivos), SIN la ventana de bloqueo posterior al corte: siempre programa y responde la fecha en que quedó (`requested_for`); si fue corrida, `rescheduled` es `true` y `requested_for_original`/`schedule_reason`/`schedule_message` explican el cambio. Disponible solo si la empresa tiene activa la función «Solicitud de Recogida». parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/PickupRequestCreate' } responses: '201': description: Solicitud creada (puede traer la fecha corrida; ver `rescheduled`) content: { application/json: { schema: { $ref: '#/components/schemas/PickupRequest' } } } '401': { $ref: '#/components/responses/Unauthorized' } '403': description: Scope faltante (PTN-AUT-004), client_id fuera del subárbol (PTN-AUT-005) o servicio suspendido por deuda vencida (PTN-AUT-001, ver el mensaje) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } '409': description: Función «Solicitud de Recogida» desactivada (PKR-CON-004) o conflicto de idempotencia (PTN-VAL-003) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } '422': description: Fecha inválida, en el pasado o a más de 6 días (PKR-CRE-001), o ya existe una solicitud activa para la fecha efectiva (PKR-CON-005) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } get: tags: [PickupRequests] summary: Listar solicitudes de recogida description: | Requiere scope `pickup-requests:read` o `pickup-requests:write`. Lista las solicitudes de la cuenta raíz y de todas sus sucursales, de la más reciente a la más antigua (`requested_for` descendente), con paginación. parameters: - name: status in: query required: false schema: { type: string, enum: [PENDING, ASSIGNED, IN_PROGRESS, COMPLETED, CANCELLED] } - name: date_from in: query required: false schema: { type: string, format: date } description: Inicio del rango de `requested_for` (incluido). - name: date_to in: query required: false schema: { type: string, format: date } description: Fin del rango de `requested_for` (incluido). - name: client_id in: query required: false schema: { type: integer } description: Solo una sucursal del subárbol de la key (403 PTN-AUT-005 si no es tuya). - name: page in: query required: false schema: { type: integer, minimum: 1, default: 1 } - name: per_page in: query required: false schema: { type: integer, minimum: 1, maximum: 100, default: 20 } responses: '200': description: Página de solicitudes content: { application/json: { schema: { $ref: '#/components/schemas/PickupRequestList' } } } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '422': description: Filtro inválido — `status` desconocido o fecha que no es YYYY-MM-DD (PTN-VAL-012) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } /pickup-requests/{id}: get: tags: [PickupRequests] summary: Consultar una solicitud de recogida description: | Requiere scope `pickup-requests:read` o `pickup-requests:write`. Visible si la solicitud pertenece a cualquier cuenta del subárbol de la key. Úsalo para ver el estado, el mensajero asignado y cuántos paquetes se recogieron. parameters: - $ref: '#/components/parameters/PickupRequestId' responses: '200': description: Solicitud content: { application/json: { schema: { $ref: '#/components/schemas/PickupRequest' } } } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': description: No existe o pertenece a una cuenta fuera del subárbol de la key (PTN-NOT-008); es la misma respuesta en ambos casos content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } /webhooks: post: tags: [Webhooks] summary: Registrar una suscripción a webhooks salientes description: Requiere scope `webhooks:write`. El `secret` se muestra una única vez. requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/WebhookCreateRequest' } responses: '201': description: Suscripción creada content: application/json: schema: { $ref: '#/components/schemas/WebhookCreateResponse' } '400': description: URL inválida (PTN-VAL-008) o evento no soportado (PTN-VAL-009) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } /webhooks/{id}/deliveries: get: tags: [Webhooks] summary: Consultar bitácora de entregas de una suscripción description: Requiere scope `webhooks:read`. Paginado. parameters: - $ref: '#/components/parameters/ShipmentId' - name: page in: query schema: { type: integer, default: 1 } - name: per_page in: query schema: { type: integer, default: 20, maximum: 100 } responses: '200': description: Bitácora content: application/json: schema: type: object properties: data: { type: array, items: { $ref: '#/components/schemas/WebhookDelivery' } } meta: type: object properties: page: { type: integer } per_page: { type: integer } total: { type: integer } '401': { $ref: '#/components/responses/Unauthorized' } '404': description: Suscripción no encontrada o de otra key (PTN-NOT-005) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } /wallet: get: tags: [Wallet] summary: Saldo agregado del subárbol completo de la key description: Requiere scope `wallet:read`. responses: '200': description: Wallet agregado content: application/json: schema: { $ref: '#/components/schemas/Wallet' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } /settlements/{period_id}: get: tags: [Settlements] summary: Conciliación de un período (3 niveles — resumen/desglose/detalle) description: Requiere scope `billing:read`. Agrega TODO el subárbol de la key. parameters: - $ref: '#/components/parameters/PeriodId' - name: page in: query schema: { type: integer, default: 1 } - name: per_page in: query schema: { type: integer, enum: [25, 50, 100, 200], default: 50 } responses: '200': description: Conciliación content: application/json: schema: { $ref: '#/components/schemas/Settlement' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': description: Período no encontrado o fuera del subárbol (PTN-NOT-006) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } /billing/periods: get: tags: [Billing] summary: Histórico completo de períodos de facturación description: Requiere scope `billing:read`. Incluye OPEN/CLOSED/INVOICED/OVERDUE/BLOCKED/PAID/SUPERSEDED. parameters: - name: client_id in: query required: false schema: { type: integer } description: Sucursal específica del subárbol (default es la raíz de la key) - name: page in: query schema: { type: integer, default: 1 } responses: '200': description: Períodos content: application/json: schema: type: object properties: client_id: { type: integer } periods: { type: array, items: { type: object } } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } /billing/periods/{id}: get: tags: [Billing] summary: Detalle de un período de facturación description: Requiere scope `billing:read`. La raíz facturada agrega todo su árbol corporativo automáticamente. parameters: - $ref: '#/components/parameters/PeriodId' - name: client_id in: query required: false schema: { type: integer } responses: '200': description: Detalle content: { application/json: { schema: { type: object } } } '401': { $ref: '#/components/responses/Unauthorized' } '404': description: Período no encontrado (PTN-NOT-006) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } /billing/periods/{id}/pdf: get: tags: [Billing] summary: Estado de cuenta del período en PDF description: Requiere scope `billing:read`. parameters: - $ref: '#/components/parameters/PeriodId' - name: client_id in: query required: false schema: { type: integer } responses: '200': description: PDF content: application/pdf: { schema: { type: string, format: binary } } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /track/{tracking_number}: get: tags: [Tracking] summary: Tracking público (sin autenticación) description: > Fuera del namespace /api/partner/v1 y sin autenticación por API Key — cualquiera con el número de guía puede consultarlo. Excluye deliberadamente nombre/teléfono del destinatario, direcciones exactas, COD y valor declarado. security: [] parameters: - name: tracking_number in: path required: true schema: { type: string } responses: '200': description: Estado del envío content: application/json: schema: { $ref: '#/components/schemas/TrackingResponse' } '404': description: Genérico — no distingue formato válido pero inexistente de erróneo (PTN-NOT-004) content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }