openapi: 3.1.0 info: title: closercat-commerce-protocol v1 — lo que expone tu sistema version: 1.0.0 summary: Endpoints que implementa el sistema de ventas de la marca (ERP) y que CloserCat llama. description: | CloserCat llama estos endpoints para conocer tu catálogo y tu disponibilidad (si no los empujas), apartar stock mientras la compradora paga, liberarlo y crear el pedido con el pago ya confirmado. **Todas** las llamadas van firmadas con HMAC-SHA256 (ver `guia.md` §5) y llevan la cabecera `X-CloserCat-Protocol: commerce/1.0`. Todo `POST` lleva `Idempotency-Key`. Los montos viajan siempre como cadenas decimales (`"189900"`, `"12.50"`), nunca como números de punto flotante. Las fechas van en ISO-8601 con zona horaria. Cada operación declara en `x-capability` la capacidad a la que pertenece. Solo implementas las de las capacidades que declaras en `GET /health`. license: name: CC BY 4.0 url: https://creativecommons.org/licenses/by/4.0/ contact: name: CloserCat — integraciones url: https://closercat.pro/desarrolladores/comercio servers: - url: '{base_url}' description: URL base de tu servicio (solo https, IP pública, sin redirecciones). variables: base_url: default: https://erp.ejemplo.com/closercat security: - closercatSignature: [] tags: - name: Salud description: Verificar que tu servicio está en línea, su versión y las capacidades que declaras. - name: Catálogo description: Productos y variantes (SKU por variante). - name: Disponibilidad description: Unidades vendibles en línea por SKU. - name: Apartados description: Apartar stock mientras la compradora paga, liberarlo y extenderlo. - name: Pedidos description: Crear, consultar y cancelar el pedido con el pago confirmado. paths: /health: get: tags: [Salud] operationId: getHealth summary: Estado, versión y capacidades declaradas x-capability: base parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/KeyId' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Signature' responses: '200': description: Tu servicio está en línea y verificó la firma. content: application/json: schema: {$ref: '#/components/schemas/Health'} examples: nivel1: value: ok: true account_label: Calzado Ejemplo S.A.S. protocol_versions: [commerce/1.0] capabilities: [catalog.push, availability.push, holds, orders.create] '401': {$ref: '#/components/responses/Unauthorized'} '503': {$ref: '#/components/responses/Unavailable'} /catalog: get: tags: [Catálogo] operationId: getCatalog summary: Catálogo paginado (solo si declaras catalog.pull) x-capability: catalog.pull parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/KeyId' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Signature' - name: cursor in: query required: false description: Cursor opaco devuelto en la página anterior. Sin cursor = primera página. schema: {type: string} - name: updated_since in: query required: false description: Solo productos modificados desde esta fecha (delta). schema: {type: string, format: date-time} - name: limit in: query required: false schema: {type: integer, minimum: 1, maximum: 1000, default: 500} responses: '200': description: Una página del catálogo. content: application/json: schema: {$ref: '#/components/schemas/CatalogPage'} '401': {$ref: '#/components/responses/Unauthorized'} '429': {$ref: '#/components/responses/RateLimited'} '503': {$ref: '#/components/responses/Unavailable'} /availability/query: post: tags: [Disponibilidad] operationId: queryAvailability summary: Disponibilidad de una lista de SKUs (availability.pull o availability.live) description: | Con `availability.pull` CloserCat la llama en lotes cada pocos minutos. Con `availability.live` también la llama durante una conversación, con un corte de 1,5 s y sin reintento. x-capability: availability.pull x-capability-also: availability.live parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/KeyId' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Signature' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/AvailabilityQuery'} responses: '200': description: Disponible por SKU. Un SKU que no conoces se devuelve con `available` 0 y `known` false. content: application/json: schema: {$ref: '#/components/schemas/AvailabilityQueryResponse'} '400': {$ref: '#/components/responses/ValidationError'} '401': {$ref: '#/components/responses/Unauthorized'} '429': {$ref: '#/components/responses/RateLimited'} '503': {$ref: '#/components/responses/Unavailable'} /holds: post: tags: [Apartados] operationId: createHold summary: Apartar stock mientras la compradora paga description: | Responde en ≤ 3 s (p95); CloserCat corta a los 4 s. Si declaras `holds.async` puedes responder `202` y confirmar después con el evento `hold.confirmed` o `hold.rejected` (≤ 60 s). Idempotencia: la `reference` (también enviada como `Idempotency-Key`) identifica el apartado. Si recibes la misma `reference` con el mismo cuerpo, devuelve el mismo resultado. Con otro cuerpo, `409 idempotency_key_reused`. Tu sistema también vence el apartado por su cuenta en `expires_at` + 10 minutos. x-capability: holds parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/KeyId' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Signature' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/HoldRequest'} responses: '201': description: Apartado creado (`held`) o parcial (`partial`). En `partial`, `lines[].held` dice cuánto quedó apartado. content: application/json: schema: {$ref: '#/components/schemas/HoldResponse'} '202': description: Recibido; la confirmación llega por evento (solo con holds.async). content: application/json: schema: {$ref: '#/components/schemas/HoldAccepted'} '400': {$ref: '#/components/responses/ValidationError'} '401': {$ref: '#/components/responses/Unauthorized'} '409': description: Sin stock suficiente (`insufficient_stock`, con detalle por línea) o `idempotency_key_reused`. content: application/json: schema: {$ref: '#/components/schemas/Error'} '422': {$ref: '#/components/responses/ValidationError'} '429': {$ref: '#/components/responses/RateLimited'} '503': {$ref: '#/components/responses/Unavailable'} /holds/{reference}: get: tags: [Apartados] operationId: getHold summary: Estado de un apartado x-capability: holds parameters: - $ref: '#/components/parameters/Reference' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/KeyId' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Signature' responses: '200': description: Estado actual. content: application/json: schema: {$ref: '#/components/schemas/HoldResponse'} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} '503': {$ref: '#/components/responses/Unavailable'} /holds/{reference}/release: post: tags: [Apartados] operationId: releaseHold summary: Liberar un apartado (idempotente) description: Liberar dos veces responde 200. Un apartado que no conoces responde 404, y CloserCat lo toma como liberado. x-capability: holds parameters: - $ref: '#/components/parameters/Reference' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/KeyId' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Signature' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/HoldReleaseRequest'} responses: '200': description: Liberado. content: application/json: schema: {$ref: '#/components/schemas/HoldResponse'} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} '503': {$ref: '#/components/responses/Unavailable'} /holds/{reference}/extend: post: tags: [Apartados] operationId: extendHold summary: Extender un apartado (holds.extend) description: Se usa cuando el pago queda en verificación del banco. Si ya no hay stock, `409 insufficient_stock`. x-capability: holds.extend parameters: - $ref: '#/components/parameters/Reference' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/KeyId' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Signature' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/HoldExtendRequest'} responses: '200': description: Nuevo vencimiento. content: application/json: schema: {$ref: '#/components/schemas/HoldResponse'} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} '409': {$ref: '#/components/responses/Conflict'} '503': {$ref: '#/components/responses/Unavailable'} /orders: post: tags: [Pedidos] operationId: createOrder summary: Crear el pedido con el pago confirmado description: | CloserCat consulta `GET /orders/{reference}` **antes** de crear. Responde en ≤ 10 s (p95), o `202` y confirma con el evento `order.accepted`/`order.rejected`. CloserCat reintenta durante 24 h con la misma `reference`. Si el apartado venció y ya no hay stock, responde `409 hold_expired_no_stock`. Si el apartado venció pero hay stock, crea el pedido igual. x-capability: orders.create parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/KeyId' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Signature' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/OrderRequest'} responses: '201': description: Pedido creado. content: application/json: schema: {$ref: '#/components/schemas/OrderResponse'} '202': description: Recibido; la aceptación llega por evento. content: application/json: schema: {$ref: '#/components/schemas/OrderResponse'} '400': {$ref: '#/components/responses/ValidationError'} '401': {$ref: '#/components/responses/Unauthorized'} '409': {$ref: '#/components/responses/Conflict'} '422': {$ref: '#/components/responses/ValidationError'} '429': {$ref: '#/components/responses/RateLimited'} '503': {$ref: '#/components/responses/Unavailable'} /orders/{reference}: get: tags: [Pedidos] operationId: getOrder summary: Estado de un pedido (se consulta antes de crear) x-capability: orders.create parameters: - $ref: '#/components/parameters/Reference' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/KeyId' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Signature' responses: '200': description: El pedido existe. content: application/json: schema: {$ref: '#/components/schemas/OrderResponse'} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} '503': {$ref: '#/components/responses/Unavailable'} /orders/{reference}/cancel: post: tags: [Pedidos] operationId: cancelOrder summary: Cancelar un pedido (orders.cancel) x-capability: orders.cancel parameters: - $ref: '#/components/parameters/Reference' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/KeyId' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Signature' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/OrderCancelRequest'} responses: '200': description: Cancelado (cancelar dos veces responde 200). content: application/json: schema: {$ref: '#/components/schemas/OrderResponse'} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} '409': {$ref: '#/components/responses/Conflict'} '503': {$ref: '#/components/responses/Unavailable'} components: securitySchemes: closercatSignature: type: apiKey in: header name: X-CloserCat-Signature description: | Firma HMAC-SHA256 en hexadecimal con el prefijo `v1=`, calculada con el secreto de la llave `X-CloserCat-Key-Id` sobre la cadena `"{X-CloserCat-Timestamp}.{cuerpo crudo}"` (en GET el cuerpo es la cadena vacía). Verifícala con comparación de tiempo constante y rechaza (`401`) si |ahora − timestamp| > 300 s. Ver `guia.md` §5. parameters: Protocol: name: X-CloserCat-Protocol in: header required: true schema: {type: string, const: commerce/1.0} KeyId: name: X-CloserCat-Key-Id in: header required: true description: Identificador de la llave con la que se firmó (permite rotar con dos llaves activas). schema: {type: string, pattern: '^ck_(test|live)_[A-Za-z0-9]{4,32}$'} Timestamp: name: X-CloserCat-Timestamp in: header required: true description: Segundos Unix (UTC). schema: {type: string, pattern: '^[0-9]{10}$'} Signature: name: X-CloserCat-Signature in: header required: true schema: {type: string, pattern: '^v1=[0-9a-f]{64}$'} IdempotencyKey: name: Idempotency-Key in: header required: true description: Igual a la `reference` del cuerpo (o a la del recurso en la ruta). schema: {type: string, minLength: 1, maxLength: 128} Reference: name: reference in: path required: true schema: {$ref: '#/components/schemas/Reference'} responses: Unauthorized: description: Firma inválida, llave desconocida o timestamp fuera de la ventana de 300 s. content: application/json: schema: {$ref: '#/components/schemas/Error'} NotFound: description: Recurso desconocido. content: application/json: schema: {$ref: '#/components/schemas/Error'} Conflict: description: '`insufficient_stock`, `hold_expired_no_stock` o `idempotency_key_reused`.' content: application/json: schema: {$ref: '#/components/schemas/Error'} ValidationError: description: '`validation_error`: el cuerpo no cumple el esquema o una regla de negocio.' content: application/json: schema: {$ref: '#/components/schemas/Error'} RateLimited: description: '`rate_limited`. Incluye `Retry-After` en segundos.' headers: Retry-After: schema: {type: integer, minimum: 1} content: application/json: schema: {$ref: '#/components/schemas/Error'} Unavailable: description: '`unavailable`: tu sistema no puede responder ahora. CloserCat reintenta según la guía.' content: application/json: schema: {$ref: '#/components/schemas/Error'} schemas: Reference: type: string minLength: 1 maxLength: 128 pattern: '^[A-Za-z0-9_.:-]+$' Decimal: type: string description: Monto o cantidad decimal como cadena, con punto como separador y sin separador de miles. pattern: '^-?[0-9]+(\.[0-9]{1,4})?$' examples: ['189900', '12.50'] Currency: type: string pattern: '^[A-Z]{3}$' description: Código ISO 4217. examples: [COP, USD, MXN] Money: type: object additionalProperties: false required: [amount, currency] properties: amount: {$ref: '#/components/schemas/Decimal'} currency: {$ref: '#/components/schemas/Currency'} Capability: type: string enum: [catalog.push, catalog.pull, availability.push, availability.pull, availability.live, holds, holds.extend, holds.async, orders.create, orders.events, orders.cancel, invoice.in_order, invoice.events] Health: type: object additionalProperties: false required: [ok, account_label, protocol_versions, capabilities] properties: ok: {type: boolean} account_label: {type: string, maxLength: 120, description: 'Razón social o nombre de la cuenta.'} protocol_versions: type: array items: {type: string, examples: [commerce/1.0]} minItems: 1 capabilities: type: array items: {$ref: '#/components/schemas/Capability'} uniqueItems: true Variant: type: object additionalProperties: false required: [sku, attributes, price, active] properties: sku: type: string minLength: 1 maxLength: 64 description: SKU estable por variante (por ejemplo referencia + color + talla). Es la llave de todo el protocolo. attributes: type: object description: 'Ejes de la variante: talla, color, etc. Valores como texto.' additionalProperties: {type: string} price: {$ref: '#/components/schemas/Money'} compare_at: oneOf: - {$ref: '#/components/schemas/Money'} - {type: 'null'} description: Precio antes del descuento, si aplica. barcode: {type: [string, 'null'], maxLength: 64} active: {type: boolean} Product: type: object additionalProperties: false required: [external_id, name, active, variants] properties: external_id: {type: string, minLength: 1, maxLength: 64, description: 'Identificador estable del producto en tu sistema.'} name: {type: string, minLength: 1, maxLength: 200} description: {type: [string, 'null'], maxLength: 5000} active: {type: boolean} category_path: type: array items: {type: string, maxLength: 80} maxItems: 6 attributes: type: object description: Atributos del producto que no cambian entre variantes (material, género, uso). additionalProperties: {type: string} images: type: array items: {type: string, format: uri, pattern: '^https://'} maxItems: 20 variants: type: array items: {$ref: '#/components/schemas/Variant'} minItems: 1 maxItems: 500 CatalogPage: type: object additionalProperties: false required: [products, next_cursor] properties: products: type: array items: {$ref: '#/components/schemas/Product'} next_cursor: {type: [string, 'null'], description: 'Null en la última página.'} AvailabilityItem: type: object additionalProperties: false required: [sku, available] properties: sku: {type: string, minLength: 1, maxLength: 64} available: {type: integer, minimum: 0, description: 'Unidades disponibles para vender en línea (ya descontados tus propios apartados).'} warehouse: {type: [string, 'null'], maxLength: 64, description: 'Opcional. Sin bodega = disponible agregado.'} known: {type: boolean, default: true} AvailabilityQuery: type: object additionalProperties: false required: [skus] properties: skus: type: array items: {type: string, minLength: 1, maxLength: 64} minItems: 1 maxItems: 500 AvailabilityQueryResponse: type: object additionalProperties: false required: [as_of, items] properties: as_of: {type: string, format: date-time} items: type: array items: {$ref: '#/components/schemas/AvailabilityItem'} HoldLineRequest: type: object additionalProperties: false required: [sku, quantity] properties: sku: {type: string, minLength: 1, maxLength: 64} quantity: {type: integer, minimum: 1, maximum: 999} HoldRequest: type: object additionalProperties: false required: [protocol_version, reference, order_reference, expires_at, lines] properties: protocol_version: {type: string, const: commerce/1.0} reference: {$ref: '#/components/schemas/Reference'} order_reference: {$ref: '#/components/schemas/Reference'} expires_at: {type: string, format: date-time, description: 'Vencimiento que pide CloserCat. Puedes devolver uno menor.'} lines: type: array items: {$ref: '#/components/schemas/HoldLineRequest'} minItems: 1 maxItems: 100 HoldLine: type: object additionalProperties: false required: [sku, requested, held] properties: sku: {type: string} requested: {type: integer, minimum: 1} held: {type: integer, minimum: 0} available_after: {type: [integer, 'null'], minimum: 0, description: 'Disponible que queda después de apartar (opcional).'} HoldStatus: type: string enum: [held, partial, rejected, released, expired, committed] HoldResponse: type: object additionalProperties: false required: [reference, status, lines] properties: protocol_version: {type: string, const: commerce/1.0} reference: {$ref: '#/components/schemas/Reference'} erp_hold_id: {type: [string, 'null'], maxLength: 128} status: {$ref: '#/components/schemas/HoldStatus'} expires_at: {type: [string, 'null'], format: date-time} lines: type: array items: {$ref: '#/components/schemas/HoldLine'} HoldAccepted: type: object additionalProperties: false required: [reference, status] properties: protocol_version: {type: string, const: commerce/1.0} reference: {$ref: '#/components/schemas/Reference'} status: {type: string, const: pending} HoldReleaseRequest: type: object additionalProperties: false required: [reason] properties: reason: {type: string, enum: [expired, cancelled, replaced]} HoldExtendRequest: type: object additionalProperties: false required: [expires_at] properties: expires_at: {type: string, format: date-time} Buyer: type: object additionalProperties: false required: [person_type, name] description: Quien compra. El documento solo viaja si tu sistema lo exige para crear el pedido o si la compradora pidió factura a su nombre. properties: person_type: {type: string, enum: [natural, juridica]} name: {type: string, minLength: 1, maxLength: 200, description: 'Nombre de la persona o razón social.'} doc_type: {type: [string, 'null'], enum: [CC, CE, NIT, PP, PEP, PPT, NIT_EXT, null]} document: {type: [string, 'null'], maxLength: 32} phone: {type: [string, 'null'], pattern: '^\+[0-9]{7,15}$'} email: {type: [string, 'null'], format: email} Shipping: type: object additionalProperties: false required: [method] properties: method: {type: string, enum: [domicilio, recoge_titular, recoge_tercero, digital]} recipient: {type: [string, 'null'], maxLength: 200} city: {type: [string, 'null'], maxLength: 120} department: {type: [string, 'null'], maxLength: 120} address: {type: [string, 'null'], maxLength: 300} neighborhood: {type: [string, 'null'], maxLength: 120} notes: {type: [string, 'null'], maxLength: 500} pickup_person_name: {type: [string, 'null'], maxLength: 200} pickup_person_document: {type: [string, 'null'], maxLength: 32} amount: {$ref: '#/components/schemas/Decimal'} Billing: type: object additionalProperties: false required: [person_type, doc_type, document, name, email] description: Solo presente cuando la compradora pidió factura a su nombre. properties: person_type: {type: string, enum: [natural, juridica]} doc_type: {type: string, enum: [CC, CE, NIT, PP, PEP, PPT, NIT_EXT]} document: {type: string, minLength: 3, maxLength: 32} name: {type: string, minLength: 1, maxLength: 200, description: 'Nombre o razón social.'} email: {type: string, format: email} OrderLine: type: object additionalProperties: false required: [sku, quantity, unit_price, line_total] properties: sku: {type: string, minLength: 1, maxLength: 64} quantity: {type: integer, minimum: 1} unit_price: {$ref: '#/components/schemas/Decimal'} discount: {$ref: '#/components/schemas/Decimal'} line_total: {$ref: '#/components/schemas/Decimal'} Totals: type: object additionalProperties: false required: [subtotal, shipping, discount, total, currency] properties: subtotal: {$ref: '#/components/schemas/Decimal'} shipping: {$ref: '#/components/schemas/Decimal'} discount: {$ref: '#/components/schemas/Decimal'} total: {$ref: '#/components/schemas/Decimal'} currency: {$ref: '#/components/schemas/Currency'} Payment: type: object additionalProperties: false required: [provider, status, amount, currency] description: Soportes del pago. Nunca incluye datos de tarjeta. properties: provider: {type: string, examples: [mercadopago, contra_entrega, transferencia], maxLength: 40} transaction_id: {type: [string, 'null'], maxLength: 128, description: 'Id conciliable de la transacción en la pasarela.'} status: {type: string, enum: [captured, pending_on_delivery, confirmed_by_staff]} method: {type: [string, 'null'], maxLength: 40, examples: [card, pse, nequi, cash]} installments: {type: [integer, 'null'], minimum: 1} amount: {$ref: '#/components/schemas/Decimal'} currency: {$ref: '#/components/schemas/Currency'} captured_at: {type: [string, 'null'], format: date-time} proof_url: type: [string, 'null'] format: uri description: Comprobante que subió la compradora (transferencia), URL https firmada válida 7 días. CloserCatRef: type: object additionalProperties: false required: [order_id, is_test] properties: order_id: {type: string, maxLength: 64} is_test: {type: boolean, description: 'true en ambiente de prueba o pedidos de prueba; no son ventas reales.'} OrderRequest: type: object additionalProperties: false required: [protocol_version, reference, created_at, channel, buyer, shipping, billing, lines, totals, payment, closercat] properties: protocol_version: {type: string, const: commerce/1.0} reference: {$ref: '#/components/schemas/Reference'} hold_reference: oneOf: - {$ref: '#/components/schemas/Reference'} - {type: 'null'} created_at: {type: string, format: date-time} channel: {type: string, enum: [whatsapp, webchat, instagram, messenger, other]} buyer: {$ref: '#/components/schemas/Buyer'} shipping: {$ref: '#/components/schemas/Shipping'} billing: description: Null = la compradora no pidió factura a su nombre. oneOf: - {$ref: '#/components/schemas/Billing'} - {type: 'null'} lines: type: array items: {$ref: '#/components/schemas/OrderLine'} minItems: 1 maxItems: 100 totals: {$ref: '#/components/schemas/Totals'} payment: {$ref: '#/components/schemas/Payment'} notes: {type: [string, 'null'], maxLength: 500} closercat: {$ref: '#/components/schemas/CloserCatRef'} OrderStatus: type: string enum: [received, accepted, rejected, shipped, delivered, cancelled] OrderResponse: type: object additionalProperties: false required: [reference, status] properties: protocol_version: {type: string, const: commerce/1.0} reference: {$ref: '#/components/schemas/Reference'} erp_order_id: {type: [string, 'null'], maxLength: 128} status: {$ref: '#/components/schemas/OrderStatus'} OrderCancelRequest: type: object additionalProperties: false required: [reason] properties: reason: {type: string, enum: [retracto, devolucion, no_stock, buyer_request, payment_reversed]} ErrorCode: type: string enum: [validation_error, unauthorized, not_found, insufficient_stock, hold_expired_no_stock, idempotency_key_reused, rate_limited, unavailable] ErrorLine: type: object additionalProperties: false required: [sku, requested, available] properties: sku: {type: string} requested: {type: integer, minimum: 0} available: {type: integer, minimum: 0} Error: type: object additionalProperties: false required: [protocol_version, error] properties: protocol_version: {type: string, const: commerce/1.0} reference: {type: [string, 'null']} error: type: object additionalProperties: false required: [code, message] properties: code: {$ref: '#/components/schemas/ErrorCode'} message: {type: string, maxLength: 500, description: 'Texto para el equipo técnico; nunca se muestra a la compradora.'} fields: type: array items: {type: string} lines: type: array items: {$ref: '#/components/schemas/ErrorLine'}