{
  "openapi": "3.1.0",
  "info": {
    "title": "Facta — API de facturación electrónica (DTE) de El Salvador",
    "version": "0.1.0-esqueleto",
    "summary": "Emite documentos tributarios electrónicos sellados por el Ministerio de Hacienda.",
    "description": "Una llamada HTTP con una llave de API produce un DTE firmado y sellado por\nel MH. El cliente describe la venta —quién, qué, cuánto cada cosa— y el\nservidor hace todo lo fiscal: correlativo transaccional, totales, IVA,\nmonto en letras, firma JWS RS512 y transmisión.\n\n**El cliente nunca calcula dinero y nunca firma.** Un SDK que calculara IVA\nsería un segundo motor fiscal, y dos motores se desincronizan el primer\nmartes (`docs/plan/tareas/sdks-de-la-api.md` §5).\n\n**La llave decide el ambiente, y tiene que coincidir con el de su empresa.**\nUna llave `facta_test_` emite en pruebas (00) contra `apitest`; una\n`facta_live_` emite documentos fiscales reales (01). Las dos usan la MISMA\nURL base: lo que cambia es la llave.\n\nUna llave de producción solo se puede acuñar para una empresa que YA pasó su\npropia ceremonia de paso a producción —la que puso su certificado y su\ncredencial de transmisión de verdad—, y el servidor compara los dos\nambientes antes de firmar nada. Cruzarlos es `403 environment_not_allowed`:\nuna llave de pruebas no emite un documento real, y una de producción no\nensucia el ambiente de pruebas.\n\n**La ruta decide, y el método se comprueba después.** Una ruta que existe\ncon el método equivocado responde `405 method_not_allowed`; una ruta que no\nexiste, `404 not_found`. Ese orden no es un detalle de implementación: si\ndecidiera el método, un `POST` a una ruta de consulta caería en el emisor y\nquemaría un correlativo.\n\n**Cada ruta exige un alcance de la llave**, y la llave los lleva escritos:\n`issue` para las tres que emiten, `query` para la consulta, `download` para\nel área de retención. `/v1/status` no exige ninguno. Un alcance que falta es\n`403 forbidden_scope`, y `details` dice cuál hacía falta y cuáles tiene.\n\n**El cuerpo no puede pasar de 1 MB** (`400 invalid_request`). Dos mil\nlíneas de detalle caben de sobra; un archivo adjunto no es asunto de esta\nAPI.\n\n**Toda respuesta dice cuánto cupo queda**, en `RateLimit-Limit`,\n`RateLimit-Remaining` y `RateLimit-Reset`, más `RateLimit-Policy` con las\ndos ventanas. Los números describen la que está MÁS cerca de morder: con\n5 llamadas libres en la hora y 1 en el día, lo que se anuncia es 1 — decir\n5 sería invitar a un 429 en la segunda. Y cuando toca esperar —429, 409 en\nvuelo, 503— la respuesta trae además `Retry-After` en segundos.\n\n**Consultar el estado no gasta el techo que reporta.** `/v1/status` se\ncuenta en una ventana propia y generosa (240 por hora de fábrica), porque\nun monitor que pregunta cada minuto se comía el cupo de emitir. Sigue\ncontándose: una ruta sin medir es un martillo gratis para una llave\nrobada.\n",
    "contact": {
      "name": "Facta",
      "url": "https://factadte.com"
    },
    "license": {
      "name": "Propietaria"
    }
  },
  "servers": [
    {
      "url": "https://hcnvknpsbadplnfcflxx.supabase.co/functions/v1/api-v1",
      "description": "URL pública única de Facta. La llave selecciona el ambiente del\nMinisterio de Hacienda: `facta_test_` transmite a `apitest.dtes.mh.gob.sv`\ny `facta_live_` al servicio de producción. Las pruebas se ejecutan en\nesta misma URL con el certificado y la llave de pruebas; no se publica\nuna segunda URL de staging.\n\nEl prefijo `/functions/v1/api-v1` se normaliza en el servidor, así que\n`…/functions/v1/api-v1/v1/dte` y `…/v1/dte` llegan a la misma ruta. Los\ncaminos de abajo son los que se escriben DESPUÉS de esta URL base.\n"
    }
  ],
  "security": [
    {
      "FactaApiKey": []
    }
  ],
  "tags": [
    {
      "name": "Emisión",
      "description": "Las rutas que gastan un correlativo. Todas exigen `Idempotency-Key`."
    },
    {
      "name": "Consulta",
      "description": "Leer lo emitido. No gastan nada."
    },
    {
      "name": "Salud",
      "description": "Estado de la llave y techos restantes."
    },
    {
      "name": "Almacenamiento",
      "description": "El área de retención (`api-almacenamiento-y-contingencia.md` §4/§5): una\ncopia cifrada de una hora del documento firmado, para el caso en que la\nescritura a TU destino real falle. Exige el alcance `download`, además\nde `X-Facta-Key`.\n"
    }
  ],
  "paths": {
    "/v1/openapi.json": {
      "get": {
        "tags": [
          "Salud"
        ],
        "operationId": "contrato",
        "summary": "El contrato de esta API, servido por ella misma",
        "description": "**Alcance: ninguno — y tampoco hace falta llave.**\n\nEs la única ruta que no autentica, y la razón es el orden de las cosas:\nlo primero que necesita quien va a integrar es saber qué hay, y eso\nocurre antes de tener una llave. No gasta cupo, no deja fila en la\nbitácora y se puede leer desde un navegador (`Access-Control-Allow-Origin: *`).\n\nSon los mismos bytes que sirve `/api/openapi.json` en el sitio, salidos\ndel mismo archivo. Si el sitio estuviera caído, la API sigue sabiendo\ndescribirse.\n",
        "security": [],
        "responses": {
          "200": {
            "description": "Este mismo documento, en JSON.",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "`public, max-age=3600`."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "Un documento OpenAPI 3.1."
                }
              }
            }
          },
          "405": {
            "description": "Solo GET (y HEAD).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/status": {
      "get": {
        "tags": [
          "Salud"
        ],
        "operationId": "status",
        "summary": "Salud, ambiente y techos restantes",
        "description": "**Alcance: ninguno.** Cualquier llave viva contesta aquí.\n\nLo primero que llama un integrador. Confirma que la llave sirve, dice\npara qué empresa emite, en qué ambiente y cuánto cupo le queda.\n\nOjo: la petición se cuenta a sí misma. Llamar a `/v1/status` gasta una\nunidad de los techos por hora y por día, porque el contador se escribe\nANTES de trabajar (ver `Techos`).\n",
        "responses": {
          "200": {
            "description": "La llave es válida.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Status"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutorizado"
          },
          "403": {
            "$ref": "#/components/responses/Prohibido"
          },
          "429": {
            "$ref": "#/components/responses/Techo"
          },
          "503": {
            "$ref": "#/components/responses/NoDisponible"
          }
        }
      }
    },
    "/v1/dte": {
      "post": {
        "tags": [
          "Emisión"
        ],
        "operationId": "emitirDte",
        "summary": "Emitir un DTE en una sola llamada",
        "description": "**Alcance: `issue`.** Y `X-Facta-Sign-Key`, que es el segundo factor.\n\n`prepare` + `sign` en un solo viaje. Es la ruta normal: reserva el\ncorrelativo, construye y valida contra el esquema oficial, firma con el\ncertificado del emisor, transmite al MH y escribe la fila de índice.\n\n**El orden importa y no es negociable**: se construye, se valida y se\ncomprueba el techo de monto ANTES de reservar. Nada por debajo de la\nreserva se puede devolver.\n\nDevuelve el JWS firmado además del documento: es lo que el cliente\narchiva, porque volver a serializar el JSON después no reproduce los\nmismos bytes que Hacienda validó.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/SignKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SolicitudDte"
              },
              "examples": {
                "creditoFiscalPorClienteGuardado": {
                  "summary": "CCF a un cliente que ya vive en Facta",
                  "value": {
                    "tipoDte": "03",
                    "receptor": {
                      "customerId": "374114b6-e957-4c7a-8911-dd6381b1e0ea"
                    },
                    "items": [
                      {
                        "descripcion": "Integración de la API de facturación electrónica",
                        "cantidad": 1,
                        "precioUni": 25
                      }
                    ]
                  }
                },
                "facturaAConsumidorFinal": {
                  "summary": "FE sin receptor (consumidor final anónimo)",
                  "value": {
                    "tipoDte": "01",
                    "items": [
                      {
                        "descripcion": "Café",
                        "cantidad": 2,
                        "precioUni": 1.5
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sellado por el MH. `Idempotency-Replayed: true` si esta respuesta ya\nestaba guardada de un intento anterior con la misma llave.\n",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DteSellado"
                }
              }
            }
          },
          "202": {
            "description": "El documento quedó FIRMADO pero el MH no respondió. No es un fallo\nque se reintente: los bytes existen y se le deben a Hacienda. La\nreserva queda retenida en contingencia y se retransmite después; los\nplazos (24 h / 72 h) son de la ley, no nuestros.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DteEnContingencia"
                }
              }
            }
          },
          "400": {
            "description": "Cuerpo mal formado, ausente, de más de 1 MB, o falta\n`Idempotency-Key`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutorizado"
          },
          "403": {
            "$ref": "#/components/responses/Prohibido"
          },
          "404": {
            "description": "El `customerId` no existe en la empresa de la llave.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/EnVueloOSinVault"
          },
          "422": {
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "description": "El documento no pasó — y hay dos razones muy distintas:\n\n· `validation_failed`: no cumple el esquema oficial. **No se gastó\n  correlativo**; `details.issues` dice qué campo.\n· `mh_rejected`: Hacienda lo leyó y lo negó. **Sí se gastó**, y\n  `details` nombra `codigoGeneracion` y `numeroControl` para que la\n  corrección los reuse (§167).\n· `idempotency_key_reuse`: la misma llave con otro cuerpo.\n· `no_storage_destination`: la empresa no tiene ningún destino de\n  almacenamiento conectado y verificado en los últimos 30 días.\n  **No se gastó correlativo** — es la puerta de\n  `api-almacenamiento-y-contingencia.md` §2.1, y corre antes que\n  nada más: firmar sin un sitio donde el documento pueda aterrizar\n  deja al cliente sin ninguna copia salvo la de esta misma\n  respuesta.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "rechazoDelMh": {
                    "value": {
                      "error": {
                        "code": "mh_rejected",
                        "message": "[receptor.nit] NIT CONTRIBUYENTE NO EXISTE",
                        "details": {
                          "estado": "RECHAZADO",
                          "descripcionMsg": "[receptor.nit] NIT CONTRIBUYENTE NO EXISTE",
                          "observaciones": [],
                          "codigoGeneracion": "E4311553-DADF-4168-829E-B6B7E50F1D41",
                          "numeroControl": "DTE-03-M001P001-000000000000177"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/Techo"
          },
          "500": {
            "$ref": "#/components/responses/ErrorInterno"
          },
          "502": {
            "$ref": "#/components/responses/MhCaido"
          },
          "503": {
            "$ref": "#/components/responses/NoDisponible"
          }
        }
      },
      "get": {
        "tags": [
          "Consulta"
        ],
        "operationId": "listarDte",
        "summary": "Listar lo emitido, por rango de fechas",
        "description": "**Alcance: `query`.**\n\nLa pregunta que hace un sistema que se cayó y volvió: qué alcancé a\nemitir. Devuelve el libro de lo sellado (`dte_index`) de la empresa de\nla llave, del más nuevo al más viejo.\n\n**La paginación es por cursor, no por página.** `?pagina=2` sobre una\ntabla que crece repite un documento y se salta otro, porque entre las\ndos llamadas entraron filas. El `siguiente` que devuelve esta ruta es la\nposición exacta de la última fila entregada; páselo tal cual y la\nfrontera no se mueve pase lo que pase. Es opaco a propósito: quien lo\ndesarme se rompe el día que cambie el orden.\n\n**Un documento RECHAZADO no está aquí**, y no es un olvido: un rechazo\nno es un documento fiscal y su fila dejaría el `numeroControl` retenido\npara siempre. Si al reconciliar aparece un hueco en su numeración,\npregunte por ese código con `GET /v1/dte/{codigoGeneracion}`, que sí\ncontesta por los rechazados.\n",
        "parameters": [
          {
            "name": "desde",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Fecha de emisión, inclusive.",
            "example": "2026-09-01"
          },
          {
            "name": "hasta",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Fecha de emisión, inclusive."
          },
          {
            "name": "estado",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "contingencia",
                "firmado",
                "invalidado",
                "rechazado",
                "sellado"
              ]
            },
            "description": "En español, el mismo vocabulario que devuelve la consulta."
          },
          {
            "name": "tipoDte",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^\\d{2}$"
            },
            "example": "03"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            },
            "description": "Pedir más de 100 no es un error: se sirven 100."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "El `siguiente` de la página anterior, tal cual."
          }
        ],
        "responses": {
          "200": {
            "description": "La página, de la más nueva a la más vieja.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaginaDeDocumentos"
                }
              }
            }
          },
          "400": {
            "description": "Una fecha mal escrita, un estado que no existe, un cursor inventado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutorizado"
          },
          "403": {
            "$ref": "#/components/responses/Prohibido"
          },
          "429": {
            "$ref": "#/components/responses/Techo"
          },
          "503": {
            "$ref": "#/components/responses/NoDisponible"
          }
        }
      }
    },
    "/v1/dte/prepare": {
      "post": {
        "tags": [
          "Emisión"
        ],
        "operationId": "prepararDte",
        "summary": "Reservar el correlativo y obtener el documento canónico",
        "description": "**Alcance: `issue`.** Aquí NO se abre el vault de firma, así que esta\nruta no pide `X-Facta-Sign-Key`: reservar y construir es todo lo que un\ntoken robado puede hacer, y firmar no está entre esas dos cosas.\n\nPara quien quiere ver el documento —y sus totales— antes de firmarlo.\n\n**Reserva el correlativo**, así que un `prepare` sin su `sign` deja un\nnúmero entregado. Eso es un hueco que alguien tiene que explicar, y por\neso `prepareToken` vence a los 15 minutos.\n\n`prepareToken` no es una credencial: sola no firma nada. Es un MAC sobre\nel hash canónico del documento, y su único trabajo es garantizar que lo\nque se firma es lo que obtuvo ese número. El hash se calcula sobre el\nJSON con las claves ORDENADAS, así que un cliente que reordene el objeto\nal deserializarlo (PHP, Go, Python viejo) sigue verificando.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SolicitudDte"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Correlativo reservado; el documento todavía no está firmado.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DtePreparado"
                }
              }
            }
          },
          "400": {
            "description": "Cuerpo mal formado, de más de 1 MB, o falta `Idempotency-Key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutorizado"
          },
          "403": {
            "$ref": "#/components/responses/Prohibido"
          },
          "409": {
            "$ref": "#/components/responses/EnVuelo"
          },
          "422": {
            "description": "`validation_failed` o `no_storage_destination` — ninguno gasta correlativo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/Techo"
          },
          "503": {
            "$ref": "#/components/responses/NoDisponible"
          }
        }
      }
    },
    "/v1/dte/sign": {
      "post": {
        "tags": [
          "Emisión"
        ],
        "operationId": "firmarDte",
        "summary": "Firmar y transmitir un documento preparado",
        "description": "**Alcance: `issue`.** Y `X-Facta-Sign-Key`.\n\nFirma con el certificado del emisor y transmite al MH. El cuerpo\ndevuelve el documento tal cual salió de `prepare`, junto con su\n`prepareToken`.\n\nSi el documento cambió aunque sea un centavo, la respuesta es 422\n`prepare_token_invalid` y no se firma nada.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/SignKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SolicitudFirma"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sellado por el MH.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/IdempotencyReplayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DteSellado"
                }
              }
            }
          },
          "202": {
            "description": "Firmado sin respuesta del MH; queda en contingencia.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DteEnContingencia"
                }
              }
            }
          },
          "400": {
            "description": "Cuerpo mal formado, de más de 1 MB, o falta `Idempotency-Key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutorizado"
          },
          "403": {
            "$ref": "#/components/responses/Prohibido"
          },
          "409": {
            "$ref": "#/components/responses/EnVueloOSinVault"
          },
          "422": {
            "description": "`prepare_token_invalid` (vencido, de otra llave, o el documento cambió) o `mh_rejected`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/Techo"
          },
          "502": {
            "$ref": "#/components/responses/MhCaido"
          },
          "503": {
            "$ref": "#/components/responses/NoDisponible"
          }
        }
      }
    },
    "/v1/dte/{codigoGeneracion}": {
      "get": {
        "tags": [
          "Consulta"
        ],
        "operationId": "consultarDte",
        "summary": "Consultar un documento por su código de generación",
        "description": "**Alcance: `query`.**\n\nResponde para un documento sellado **y también para uno rechazado**, que\nno tiene fila de índice pero sí reserva. Devolver 404 para un número que\nHacienda negó mandaría al integrador a buscar un bug que no existe.\n",
        "parameters": [
          {
            "name": "codigoGeneracion",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "7875BC7A-9580-441D-94E4-FA455E9D8BD0"
          }
        ],
        "responses": {
          "200": {
            "description": "El documento, en el estado en que esté.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DteConsultado"
                }
              }
            }
          },
          "400": {
            "description": "El código no tiene forma de UUID.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutorizado"
          },
          "403": {
            "$ref": "#/components/responses/Prohibido"
          },
          "404": {
            "description": "No existe en la empresa de la llave.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/Techo"
          },
          "503": {
            "$ref": "#/components/responses/NoDisponible"
          }
        }
      }
    },
    "/v1/dte/{codigoGeneracion}/invalidate": {
      "post": {
        "tags": [
          "Emisión"
        ],
        "operationId": "anularDte",
        "summary": "Anular un documento que Hacienda ya selló",
        "description": "**Alcance: `issue`.** Y `X-Facta-Sign-Key`: la anulación se firma con el\ncertificado del emisor, igual que el documento que anula.\n\n**Una anulación no es un borrado.** Es un EVENTO: su propio documento\nfirmado, con su propio código de generación y su propio sello, enviado a\notro servicio del Ministerio. El documento anulado no desaparece de\nningún registro — cambia de estado, y esta ruta devuelve el sello que lo\nprueba. No hay forma de deshacerla.\n\n**No gasta correlativo, y aun así exige `Idempotency-Key`**: un reintento\npor timeout mandaría un segundo evento contra un documento que el\nprimero ya anuló.\n\n**Los tres tipos no son intercambiables** (CAT-024):\n\n· `1` error en el documento — lo anula JUNTO CON el que lo reemplaza,\n  así que `codigoGeneracionReemplazo` es obligatorio, y `motivo` también.\n  Emita primero el documento correcto.\n· `2` rescisión de la operación — no nombra reemplazo, y nombrarlo es un\n  error (el campo 110 tiene que ir vacío).\n· `3` otro — como el 1: reemplazo y motivo.\n\nSolo se anula un documento **con sello**. Un rechazo nunca existió para\nHacienda, y su correlativo lo reutiliza el documento corregido (§167).\n\nPedir la anulación de algo **ya anulado** contesta 200 con\n`yaEstabaInvalidado: true`. Un reintento que recibe un error enseña a\nhacer algo peor.\n",
        "parameters": [
          {
            "name": "codigoGeneracion",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/SignKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SolicitudAnulacion"
              },
              "examples": {
                "rescision": {
                  "summary": "El cliente desistió de la compra",
                  "value": {
                    "tipoAnulacion": 2,
                    "responsable": {
                      "nombre": "Ana Rivas",
                      "tipoDocumento": "36",
                      "numDocumento": "06142803901121"
                    },
                    "solicita": {
                      "nombre": "Ana Rivas",
                      "tipoDocumento": "36",
                      "numDocumento": "06142803901121"
                    }
                  }
                },
                "errorConReemplazo": {
                  "summary": "Salió con el monto equivocado y ya se emitió el correcto",
                  "value": {
                    "tipoAnulacion": 1,
                    "motivo": "El precio unitario iba sin el descuento pactado",
                    "codigoGeneracionReemplazo": "7875BC7A-9580-441D-94E4-FA455E9D8BD0",
                    "responsable": {
                      "nombre": "Ana Rivas",
                      "tipoDocumento": "36",
                      "numDocumento": "06142803901121"
                    },
                    "solicita": {
                      "nombre": "Beto Cruz",
                      "tipoDocumento": "13",
                      "numDocumento": "012345678"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Anulado, con el sello del evento.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DteAnulado"
                }
              }
            }
          },
          "400": {
            "description": "Cuerpo mal formado, o falta `Idempotency-Key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutorizado"
          },
          "403": {
            "$ref": "#/components/responses/Prohibido"
          },
          "404": {
            "description": "No existe ese documento en la empresa de la llave.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/EnVueloOSinVault"
          },
          "422": {
            "description": "`validation_failed` (el documento no tiene sello, o el evento no\ncumple el esquema) o `mh_rejected` (Hacienda leyó la anulación y la\nnegó — el plazo vencido es la razón más común).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/Techo"
          },
          "502": {
            "description": "`mh_unreachable` — no llegamos a Hacienda, así que **no se anuló\nnada** y el documento sigue vivo. Reintente con la MISMA\n`Idempotency-Key`.\n\nAquí sí ocurre, a diferencia de la emisión: un DTE firmado existe y\nse le debe a Hacienda, pero un evento que no llegó no anuló nada.\n",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/NoDisponible"
          }
        }
      }
    },
    "/v1/dte/{codigoGeneracion}/file": {
      "get": {
        "tags": [
          "Almacenamiento"
        ],
        "operationId": "descargarDocumentoRetenido",
        "summary": "Bajar el JSON firmado (o el PDF) del área de retención",
        "description": "**Alcance: `download`.**\n\nEl área de retención dura **una hora** desde que el documento se\nfirmó — nunca es el archivo fiscal de nadie, es la red para cuando su\npropia escritura al destino falla. **Pasada esa hora esta ruta sigue\ncontestando**: el documento se rearma desde la reserva, que guarda el\nJWS sellado. El JSON son los mismos bytes; la hoja se vuelve a dibujar\ncon el sello que ya está en el índice.\n\nLo que NO puede rearmar son los documentos emitidos desde la app: esos\nviajan cifrados con una llave que vive en un navegador y que ningún\nservidor tiene. La respuesta entonces es la misma que si no existieran. Los bytes que devuelve son\nEXACTAMENTE los que se cifraron al guardarlos: un JSON con\n`codigoGeneracion`, `ambiente` y el `jws` compacto (el mismo artefacto\nque ya recibiste en la respuesta de `/v1/dte/sign` o `/v1/dte`), o el\nPDF una vez que la representación gráfica exista.\n\nLa ruta al bucket la deriva el servidor de la fila — company_id y\nenvironment salen de TU llave, nunca del cuerpo ni de la URL — así\nque no hay forma de pedir el documento de otra empresa nombrando otro\n`codigoGeneracion`: si no es tuyo, la respuesta es la MISMA que si no\nexistiera (ver `NoEnElArea`).\n",
        "security": [
          {
            "FactaApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "codigoGeneracion",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "7875BC7A-9580-441D-94E4-FA455E9D8BD0"
          },
          {
            "name": "kind",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "json",
                "pdf"
              ],
              "default": "json"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Los bytes decifrados, tal como se guardaron.",
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string"
                },
                "description": "attachment; filename=\"<codigoGeneracion>.<kind>\""
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "codigoGeneracion": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "ambiente": {
                      "type": "string",
                      "enum": [
                        "00",
                        "01"
                      ]
                    },
                    "jws": {
                      "type": "string"
                    }
                  }
                }
              },
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "El código no tiene forma de UUID.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutorizado"
          },
          "403": {
            "$ref": "#/components/responses/Prohibido"
          },
          "404": {
            "$ref": "#/components/responses/NoEnElArea"
          },
          "429": {
            "$ref": "#/components/responses/Techo"
          },
          "503": {
            "$ref": "#/components/responses/NoDisponible"
          }
        }
      }
    },
    "/v1/dte/holding": {
      "get": {
        "tags": [
          "Almacenamiento"
        ],
        "operationId": "listarAreaDeRetencion",
        "summary": "Qué hay en tu área de retención ahora mismo",
        "description": "**Alcance: `download`.**\n\nPaginado, y **sin ningún filtro de empresa aceptado**: la única\nempresa posible es la de la llave. No devuelve rutas de bucket ni\ncontenido — solo evidencia de estado (`whereLanded`, intentos,\ncuándo vence, cuántas veces se descargó).\n",
        "security": [
          {
            "FactaApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "La lista, más reciente primero.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "documentos": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/DocumentoRetenido"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NoAutorizado"
          },
          "403": {
            "$ref": "#/components/responses/Prohibido"
          },
          "429": {
            "$ref": "#/components/responses/Techo"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "FactaApiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Facta-Key",
        "description": "`facta_test_<id>.<secreto>` en pruebas, `facta_live_<id>.<secreto>` en\nproducción. La parte antes del punto es pública e identifica la fila; la\nde después se muestra UNA vez al acuñarla y no se guarda (solo su\nSHA-256 con pimienta).\n\nEl prefijo y el ambiente de la fila tienen que coincidir: una llave\n`facta_test_` que apuntara a una fila de producción es 403, jamás una\nemisión.\n\n**Nunca en `Authorization`.** El gateway lee esa cabecera por su cuenta.\n\n**Esta llave sola no firma.** Abre nada: identifica y autentica. Para\nfirmar hace falta además `X-Facta-Sign-Key` (ver los dos vaults del\nplan, §6).\n\n**Lista de IP autorizadas** (opcional, se define al acuñar la llave):\ncada entrada es una dirección —`190.53.1.2`, `2001:db8::1`— o un rango\nen notación CIDR —`190.53.1.0/24`, `2001:db8::/32`—, IPv4 o IPv6. Un\nintegrador detrás de un NAT con varias salidas pone su prefijo, no cada\ndirección. La lista vacía significa «desde cualquier parte». Si la llave\ntiene lista y tu IP no está en ninguna de sus entradas, la respuesta es\n`ip_not_allowed` (403) antes de cualquier trabajo. La IP que se compara\nes la del extremo que nos conecta, y una cabecera `X-Forwarded-For`\npuesta por el cliente no la cambia.\n"
      }
    },
    "parameters": {
      "SignKey": {
        "name": "X-Facta-Sign-Key",
        "in": "header",
        "required": true,
        "schema": {
          "type": "string",
          "pattern": "^factask_[A-Za-z0-9_-]{43}$"
        },
        "description": "La contraseña que abre **el vault de firma**, donde vive el certificado\ndel emisor. 256 bits generados por el navegador en la ceremonia de\nacuñado y mostrados UNA vez.\n\nPor qué existe: en la web hay dos secretos independientes (la sesión y\nla llave del vault, que nunca viaja) y robar uno no basta. En una API\nhay un solo token, y si ese token bastara para firmar, un `.env`\nfiltrado sería poder de emitir DTE reales con el certificado del\ncliente. Con esta separación, **el token robado no firma**.\n\nQué hacemos con ella: se convierte a bytes al entrar, se deriva la KEK\npor HKDF-SHA-256, se abre la envoltura y el arreglo se pone a cero. No\nse guarda, no se registra en ninguna bitácora y no hay hash de ella en\nnuestra base — el verificador es la propia etiqueta GCM de la\nenvoltura. Lo que sí guardamos es el CONTEO de intentos fallidos: cinco\nseguidos en una hora suspenden la llave.\n\n**No es `FACTA_UNLOCK_KEY`.** Esa otra (prefijo `factauk_`) abre sus\ncredenciales de almacenamiento en SU servidor y no viaja nunca a Facta.\nSi la manda aquí, la respuesta se lo dice en vez de intentarlo.\n"
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "schema": {
          "type": "string",
          "maxLength": 200
        },
        "example": "venta-2026-09-02-00417",
        "description": "Identificador que el CLIENTE elige para esta operación. Obligatorio en\ntoda ruta que no se pueda deshacer: las tres que gastan correlativo y la\nanulación. Sin él, un reintento por timeout quemaría un segundo número —\no mandaría un segundo evento contra un documento que el primero ya\nanuló.\n\nReglas, todas verificables:\n\n· **Alcance** `(llave, Idempotency-Key)`. Dos integradores pueden usar\n  la misma cadena sin chocar, y ninguno puede leer la respuesta del otro.\n· **Repetir la misma llave con el MISMO cuerpo** devuelve la respuesta\n  guardada, con `Idempotency-Replayed: true`. Eso incluye un rechazo del\n  MH: el reintento devuelve el mismo 422, sin quemar otro número.\n· **Repetir con OTRO cuerpo** es 422 `idempotency_key_reuse`. Devolver\n  la factura de ayer para la venta de hoy es peor que fallar.\n· **Mientras la primera está en vuelo**, la segunda es 409\n  `idempotency_in_flight` — no se encola. El cliente reintenta.\n· **TTL 24 h.** Pasado eso la cadena queda libre: un reintento un día\n  después es una venta nueva, no un reintento.\n"
      }
    },
    "headers": {
      "RateLimitLimit": {
        "schema": {
          "type": "integer"
        },
        "description": "El techo de la ventana más cercana a morder."
      },
      "RateLimitRemaining": {
        "schema": {
          "type": "integer"
        },
        "description": "Lo que queda de ESA ventana, con esta petición ya descontada."
      },
      "RateLimitReset": {
        "schema": {
          "type": "integer"
        },
        "description": "Segundos hasta que esa ventana se libera del todo. Es la longitud\nentera de la ventana deslizante (3600 u 86400), no un instante\ncalculado: el momento exacto en que se libera un turno es cuando la\npetición más vieja de la ventana cumple una hora, y saberlo costaría\notra consulta. Redondear hacia arriba nunca dice «vuelva» antes de\ntiempo.\n"
      },
      "RateLimitPolicy": {
        "schema": {
          "type": "string"
        },
        "description": "Las dos ventanas, `hour;q=60;w=3600, day;q=300;w=86400`."
      },
      "RetryAfter": {
        "schema": {
          "type": "integer"
        },
        "description": "Segundos que conviene esperar. Solo en los errores donde esperar arregla algo."
      },
      "IdempotencyReplayed": {
        "schema": {
          "type": "boolean"
        },
        "description": "Presente y `true` cuando la respuesta salió de la caché de idempotencia.\n\nSale también sobre un **error** guardado: un rechazo del MH se repite\ncon su mismo 422 y su mismo correlativo gastado, que es justo lo que\nevita que el reintento queme un segundo número.\n"
      }
    },
    "responses": {
      "NoAutorizado": {
        "description": "`unauthorized` (no vino llave) o `invalid_api_key` (no sirve). Las dos\ntardan lo mismo, a propósito.\n\nEn las rutas que firman se suman las dos del segundo factor:\n`sign_key_required` (falta `X-Facta-Sign-Key`, o vino la de apertura por\nerror) y `sign_key_invalid` (no abre el vault). `details.intentosRestantes`\ndice cuántos quedan antes de que la llave se suspenda sola.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Prohibido": {
        "description": "La llave existe pero no puede: `key_revoked`, `key_expired`,\n`key_inactive`, `forbidden_scope`, `dte_type_not_allowed`,\n`ip_not_allowed`, `environment_not_allowed` o `sign_vault_locked`\n(demasiados intentos fallidos de abrir el vault: esperar no lo arregla,\nhay que reactivar la llave desde la app).\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "EnVuelo": {
        "description": "`idempotency_in_flight` — la primera petición con esa llave todavía\ntrabaja. `details.enVueloSegundos` dice desde cuándo.\n\n**Si esa primera petición ya había empezado algo que no se deshace**\n—un correlativo reservado, o una anulación camino de Hacienda— el\nmensaje NOMBRA el documento (`details.codigoGeneracion`) para que se\npueda preguntar `GET /v1/dte/{codigoGeneracion}` qué fue de él, en vez\nde esperar las 24 h del reclamo. Si no había llegado a eso, no pasó nada\nirreversible y el barrido de cinco minutos suelta el reclamo solo a la\nmedia hora (`details.seLiberaSola`).\n",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "EnVueloOSinVault": {
        "description": "`idempotency_in_flight` (la primera petición con esa llave todavía\ntrabaja — reintenta) o `sign_vault_missing` (la llave no está\nprovisionada para firmar — reintentar no la arregla, hay que acuñarla\nde nuevo desde la app). El `code` los distingue.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Techo": {
        "description": "`rate_limited` (por hora, por día, o la ventana propia de `/v1/status`)\no `amount_limit` (el documento pasa del monto máximo de la llave). El de\nmonto se comprueba ANTES de reservar: no gasta correlativo.\n\n`details.window` dice cuál de las tres fue.\n",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "MhCaido": {
        "description": "`mh_unreachable` — no llegamos a Hacienda. Nadie juzgó nada.\n\n**RESERVADO: la versión actual nunca lo devuelve.** Todo fallo de\ntransporte al MH —caída, timeout, respuesta ilegible, «PROCESADO» sin\nsello— sale como **202 contingencia**, porque para entonces el documento\nYA está firmado y se le debe a Hacienda; un 502 invitaría a reintentar y\nel reintento firmaría un segundo documento. El código se queda en la\ntaxonomía para el día en que exista un fallo ANTES de firmar que sí se\npueda reintentar.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NoDisponible": {
        "description": "`service_unavailable` o `correlative_unavailable` — algo nuestro no está\nlisto. Puede salir en CUALQUIER ruta, incluidas las de consulta: el\nregistro de peticiones se escribe antes de trabajar y falla cerrado, así\nque si esa tabla no contesta, nada contesta.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ErrorInterno": {
        "description": "`internal_error`. El mensaje nunca describe nuestras entrañas.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NoEnElArea": {
        "description": "`not_found` — igual para \"ese documento no existe en el área de\nretención\" que para \"existe, pero es de otra empresa o de otro\nambiente\". **Nunca 403**: distinguir los dos casos confirmaría\ncódigos de generación ajenos, y el código va impreso en cada factura.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Estable. Es sobre esto que un cliente decide qué hacer.",
                "enum": [
                  "unauthorized",
                  "invalid_api_key",
                  "key_revoked",
                  "key_expired",
                  "key_inactive",
                  "forbidden_scope",
                  "dte_type_not_allowed",
                  "ip_not_allowed",
                  "environment_not_allowed",
                  "sign_key_required",
                  "sign_key_invalid",
                  "sign_vault_locked",
                  "sign_vault_missing",
                  "invalid_request",
                  "validation_failed",
                  "not_found",
                  "method_not_allowed",
                  "idempotency_key_required",
                  "idempotency_key_reuse",
                  "idempotency_in_flight",
                  "prepare_token_invalid",
                  "rate_limited",
                  "amount_limit",
                  "mh_rejected",
                  "mh_unreachable",
                  "correlative_unavailable",
                  "no_storage_destination",
                  "service_unavailable",
                  "internal_error"
                ]
              },
              "message": {
                "type": "string",
                "description": "Español, para el humano que lee el log. Puede cambiar."
              },
              "details": {
                "description": "Depende del código. `validation_failed` trae `issues`;\n`mh_rejected` trae `descripcionMsg`, `observaciones` y el\ncorrelativo gastado; `rate_limited` trae la ventana y\n`retryAfterSeconds`.\n"
              }
            }
          }
        }
      },
      "Direccion": {
        "type": "object",
        "description": "Códigos del catálogo oficial del MH (CAT-012/013), no nombres.",
        "required": [
          "departamento",
          "municipio",
          "complemento"
        ],
        "properties": {
          "departamento": {
            "type": "string",
            "example": "06"
          },
          "municipio": {
            "type": "string",
            "example": "20"
          },
          "distrito": {
            "type": "string",
            "example": "01"
          },
          "complemento": {
            "type": "string",
            "example": "CC Metrocentro San Miguel, Local 105"
          }
        }
      },
      "Receptor": {
        "type": "object",
        "description": "O se nombra un cliente ya guardado en Facta (`customerId`) o se escribe\nentero. Se pueden combinar: lo explícito gana sobre lo guardado, para\nque una venta con otro correo no obligue a editar la ficha del cliente.\n\nUn CCF (03) exige `numDocumento` (NIT), `nrc`, `nombre`, `codActividad`,\n`direccion` y `correo`. Una FE (01) admite el receptor entero, parcial o\nninguno — sin receptor es el consumidor final anónimo, que es legal.\n",
        "properties": {
          "customerId": {
            "type": "string",
            "format": "uuid",
            "description": "Un cliente de la empresa de la llave. Nunca de otra empresa."
          },
          "nombre": {
            "type": "string"
          },
          "tipoDocumento": {
            "type": "string",
            "description": "CAT-022. 36 = NIT, 13 = DUI.",
            "example": "36"
          },
          "numDocumento": {
            "type": "string",
            "example": "05110606161016"
          },
          "nrc": {
            "type": "string",
            "example": "2513113"
          },
          "codActividad": {
            "type": "string",
            "example": "46510"
          },
          "descActividad": {
            "type": "string"
          },
          "direccion": {
            "$ref": "#/components/schemas/Direccion"
          },
          "telefono": {
            "type": "string",
            "nullable": true
          },
          "correo": {
            "type": "string",
            "format": "email"
          }
        }
      },
      "DocumentoRelacionado": {
        "type": "object",
        "description": "El documento sellado que una nota ajusta. Dos formas:\n\n· **Por código** — `{ \"codigoGeneracion\": \"…\" }` y nada más. El servidor\n  lee el tipo y la fecha del índice de su empresa. Es la forma que usa\n  quien emitió el original por esta misma API.\n· **Entera** — `tipoDocumento`, `numeroDocumento` y `fechaEmision`, para\n  ajustar algo que no está en nuestro índice.\n\nUn código que no está en su índice es `404`; uno que está pero sin sello\nes `400` — Hacienda no selló nada que ajustar.\n",
        "properties": {
          "codigoGeneracion": {
            "type": "string",
            "format": "uuid"
          },
          "tipoDocumento": {
            "type": "string",
            "example": "03"
          },
          "numeroDocumento": {
            "type": "string",
            "maxLength": 36
          },
          "fechaEmision": {
            "type": "string",
            "format": "date"
          },
          "tipoGeneracion": {
            "type": "integer",
            "enum": [
              1,
              2
            ],
            "default": 2,
            "description": "1 en papel, 2 electrónico."
          }
        }
      },
      "ReceptorExportacion": {
        "type": "object",
        "description": "Quien compra desde fuera del país. No tiene NRC ni dirección con códigos\ndel catálogo: tiene país, una dirección en texto y si es persona o\nempresa. Su `descActividad` es texto libre — el registro del Ministerio\nsolo cubre a contribuyentes salvadoreños.\n",
        "required": [
          "nombre",
          "numDocumento",
          "codPais",
          "nombrePais",
          "complemento",
          "tipoPersona",
          "descActividad",
          "correo"
        ],
        "properties": {
          "nombre": {
            "type": "string"
          },
          "numDocumento": {
            "type": "string",
            "description": "Identificación fiscal extranjera o pasaporte."
          },
          "tipoDocumento": {
            "type": "string",
            "default": "37",
            "description": "CAT-022; por defecto «otro»."
          },
          "codPais": {
            "type": "string",
            "minLength": 2,
            "maxLength": 2,
            "description": "CAT-020.",
            "example": "US"
          },
          "nombrePais": {
            "type": "string",
            "minLength": 3,
            "maxLength": 50
          },
          "complemento": {
            "type": "string",
            "description": "La dirección",
            "en texto libre.": null
          },
          "tipoPersona": {
            "type": "integer",
            "enum": [
              1,
              2
            ],
            "description": "1 natural",
            "2 jurídica.": null
          },
          "descActividad": {
            "type": "string",
            "minLength": 5,
            "maxLength": 150
          },
          "correo": {
            "type": "string",
            "format": "email"
          },
          "telefono": {
            "type": "string",
            "nullable": true
          },
          "nombreComercial": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "Exportacion": {
        "type": "object",
        "required": [
          "tipoItemExpor"
        ],
        "description": "Lo que hace de una venta una exportación. La tasa es **cero**, y tanto la\nlínea como el resumen llevan el tributo C3 con valor 0 — un `null` ahí lo\nrechaza Hacienda con «DEBE PROPORCIONAR UN VALOR».\n",
        "properties": {
          "tipoItemExpor": {
            "type": "integer",
            "enum": [
              1,
              2,
              3
            ],
            "description": "1 bienes",
            "2 servicios": null,
            "3 ambos.": null
          },
          "incoterms": {
            "type": "string",
            "description": "**Solo el código** de CAT-031 (`01`..`11`). La descripción la pone el\nservidor desde su copia del catálogo: pedirle a un integrador que\nescriba la redacción del Ministerio es pedirle que la escriba mal.\nSin incoterm, el código y su descripción salen `null` juntos.\n",
            "example": "09"
          },
          "recintoFiscal": {
            "type": "string",
            "nullable": true,
            "description": "CAT-027; null en exportación de servicios."
          },
          "tipoRegimen": {
            "type": "string",
            "nullable": true,
            "description": "CAT-033."
          },
          "regimen": {
            "type": "string",
            "nullable": true,
            "description": "CAT-028."
          },
          "flete": {
            "type": "number",
            "minimum": 0,
            "description": "Monto pactado, no un cálculo. El servidor lo suma al total de la operación."
          },
          "seguro": {
            "type": "number",
            "minimum": 0,
            "description": "Íd."
          }
        }
      },
      "ReceptorSujetoExcluido": {
        "type": "object",
        "description": "**El VENDEDOR**, no el comprador — el 14 es el único documento del\ncatálogo que corre al revés: lo emite quien compra, para documentar una\ncompra a alguien que no está en el registro de IVA. Por eso no tiene NRC\n(si lo manda, se ignora: el esquema no tiene dónde ponerlo) y por eso no\nhay IVA en ninguna parte del documento.\n",
        "required": [
          "numDocumento",
          "nombre",
          "direccion"
        ],
        "properties": {
          "numDocumento": {
            "type": "string",
            "description": "Un DUI se normaliza a `########-#` y un NIT a solo dígitos. El\nMinisterio rechaza en vuelo el otro formato.\n"
          },
          "tipoDocumento": {
            "type": "string",
            "default": "13",
            "description": "CAT-022; por defecto DUI."
          },
          "nombre": {
            "type": "string"
          },
          "direccion": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Direccion"
              }
            ],
            "description": "No es opcional aquí: el esquema de la FSE la declara objeto llano."
          },
          "codActividad": {
            "type": "string",
            "nullable": true,
            "description": "CAT-019. Con menos de 5 caracteres sale null, junto con su descripción — no se inventa un código."
          },
          "telefono": {
            "type": "string",
            "nullable": true
          },
          "correo": {
            "type": "string",
            "format": "email",
            "nullable": true
          }
        }
      },
      "Item": {
        "type": "object",
        "required": [
          "descripcion",
          "cantidad",
          "precioUni"
        ],
        "properties": {
          "descripcion": {
            "type": "string",
            "minLength": 1
          },
          "cantidad": {
            "type": "number",
            "exclusiveMinimum": 0,
            "description": "Hasta 8 decimales, los que la Normativa admite en el cuerpo. Se\nfirma tal cual llega (nada se redondea a dos), y el importe de la\nlínea y su IVA salen también con hasta 8 decimales; solo el\nresumen va a dos.\n"
          },
          "precioUni": {
            "type": "number",
            "minimum": 0,
            "description": "Hasta 8 decimales, como `cantidad`.\n\n**Depende del tipo, y el servidor no adivina.** En una FE (01)\nINCLUYE el IVA. En un CCF (03) y en las notas (05/06) lo EXCLUYE. En\nuna exportación (11) no hay IVA que incluir. En una FSE (14) el\nprecio es el precio: no se le suma ni se le quita nada. Así lo\ndefinen los esquemas oficiales.\n"
          },
          "codigo": {
            "type": "string",
            "nullable": true
          },
          "tipoItem": {
            "type": "integer",
            "description": "CAT-011. 1 bien, 2 servicio, 3 ambos, 4 otro.",
            "default": 2
          },
          "uniMedida": {
            "type": "integer",
            "description": "CAT-014. 59 = unidad.",
            "default": 59
          },
          "numeroDocumento": {
            "type": "string",
            "nullable": true,
            "description": "En una nota (05/06), a cuál de los `documentosRelacionados`\npertenece esta línea. Opcional con uno solo; **obligatorio en cuanto\nhaya más de uno**, porque si no todas las líneas caerían sobre el\nprimero sin decirlo.\n"
          }
        }
      },
      "SolicitudDte": {
        "type": "object",
        "required": [
          "tipoDte",
          "items"
        ],
        "description": "Un solo cuerpo para los seis tipos, y cada tipo exige lo suyo. La tabla\nde quién pide qué:\n\n| | 01 | 03 | 05 / 06 | 11 | 14 |\n|---|---|---|---|---|---|\n| **precio de la línea** | con IVA | sin IVA | sin IVA | sin IVA (tasa cero) | el precio, tal cual |\n| **receptor** | opcional | inscrito | inscrito | extranjero | el VENDEDOR |\n| **campos propios** | — | — | `documentosRelacionados`, y `numPagoElectronico` solo en 06 | `exportacion` | `aplicarReteRenta` |\n\nMandar el campo propio de un tipo en otro es `400`, no un campo\nignorado en silencio: si alguien manda `aplicarReteRenta` en una\nfactura, cree que está reteniendo y no está reteniendo nada.\n",
        "properties": {
          "tipoDte": {
            "type": "string",
            "enum": [
              "01",
              "03",
              "05",
              "06",
              "11",
              "14"
            ],
            "description": "`01` factura · `03` crédito fiscal · `05` nota de crédito · `06` nota\nde débito · `11` exportación · `14` sujeto excluido. Los demás del\ncatálogo llegan con su tanda.\n"
          },
          "receptor": {
            "description": "Su forma depende del tipo: `Receptor` para 01/03/05/06,\n`ReceptorExportacion` para el 11 y `ReceptorSujetoExcluido` para el\n14. Una FE (01) puede no llevarlo — es el consumidor final anónimo.\n",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Receptor"
              },
              {
                "$ref": "#/components/schemas/ReceptorExportacion"
              },
              {
                "$ref": "#/components/schemas/ReceptorSujetoExcluido"
              },
              {
                "type": "null"
              }
            ]
          },
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 2000,
            "items": {
              "$ref": "#/components/schemas/Item"
            }
          },
          "documentosRelacionados": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "description": "**Obligatorio en 05 y 06, y prohibido en los demás.** Qué documento\nsellado ajusta esta nota. Cada entrada se escribe entera o se nombra\npor su `codigoGeneracion`, que el servidor completa desde el índice\nde SU empresa.\n",
            "items": {
              "$ref": "#/components/schemas/DocumentoRelacionado"
            }
          },
          "numPagoElectronico": {
            "type": "string",
            "maxLength": 100,
            "nullable": true,
            "description": "Solo en la nota de débito (06). En otro tipo es `400`."
          },
          "exportacion": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Exportacion"
              }
            ],
            "description": "Obligatorio en el 11, y prohibido en los demás."
          },
          "aplicarReteRenta": {
            "type": "boolean",
            "default": false,
            "description": "Solo en el 14. Retención de renta del 10 % (art. 156 CT) sobre\nservicios de una persona natural. **Nunca automática**: se pide.\n"
          },
          "condicionOperacion": {
            "type": "integer",
            "enum": [
              1,
              2,
              3
            ],
            "default": 1,
            "description": "1 contado",
            "2 crédito": null,
            "3 otro.": null
          },
          "plazo": {
            "type": "string",
            "enum": [
              "01",
              "02",
              "03"
            ],
            "description": "CAT-018 — días, meses o años. Solo se escribe si la operación es a crédito."
          },
          "periodo": {
            "type": "integer",
            "minimum": 1,
            "description": "Cuántos de esos plazos. Solo con `condicionOperacion` 2."
          },
          "formaPago": {
            "type": "string",
            "description": "CAT-017. 01 = efectivo.",
            "example": "01"
          },
          "observaciones": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "SolicitudFirma": {
        "type": "object",
        "required": [
          "prepareToken",
          "documento"
        ],
        "properties": {
          "prepareToken": {
            "type": "string",
            "description": "Tal cual lo devolvió `prepare`. Vence a los 15 min."
          },
          "documento": {
            "type": "object",
            "description": "El documento canónico de `prepare`",
            "sin tocar un centavo.": null
          }
        }
      },
      "PersonaDeLaAnulacion": {
        "type": "object",
        "required": [
          "nombre",
          "numDocumento"
        ],
        "properties": {
          "nombre": {
            "type": "string"
          },
          "numDocumento": {
            "type": "string",
            "description": "Sin guiones. Un NIT son 14 dígitos (o 9, si está homologado con el\nDUI) y un DUI son 9 dígitos: la Normativa 2.0 lo valida así y el\nambiente de pruebas rechaza `05308546-5` desde el 16-sep-2026. Si\nmanda el guion se lo quitamos antes de firmar, según el tipo.\n",
            "example": "012345678"
          },
          "tipoDocumento": {
            "type": "string",
            "description": "CAT-022 — 36 NIT, 13 DUI, 03 pasaporte, 02 carné de residente, 37\notro. **Mándelo siempre.** El tipo se elige junto al número, donde\nalguien lo escribe, y viaja con él; no es algo que se pueda leer de\nla forma del número, porque nueve dígitos son a la vez un DUI y un\nNIT homologado con ese DUI, y la misma persona puede ser cualquiera\nde los dos.\n\nSi lo omite lo deducimos, y es solo una red: 14 dígitos es NIT (36),\n9 dígitos o `########-#` es DUI (13) —porque aquí se nombran\nPERSONAS— y cualquier otra cosa es «otro» (37). Una deducción\nequivocada no la atrapa el esquema; la atrapa el Ministerio, en\nvuelo, y entonces el evento ya gastó su intento.\n",
            "example": "13"
          }
        }
      },
      "SolicitudAnulacion": {
        "type": "object",
        "required": [
          "tipoAnulacion",
          "responsable",
          "solicita"
        ],
        "properties": {
          "tipoAnulacion": {
            "type": "integer",
            "enum": [
              1,
              2,
              3
            ],
            "description": "1 error en el documento, 2 rescisión, 3 otro."
          },
          "motivo": {
            "type": "string",
            "nullable": true,
            "description": "Obligatorio en los tipos 1 y 3; el campo 111 describe el error."
          },
          "codigoGeneracionReemplazo": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "El documento que reemplaza al anulado. Obligatorio en los tipos 1 y\n3, y prohibido en el 2.\n"
          },
          "responsable": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PersonaDeLaAnulacion"
              }
            ],
            "description": "Quien responde por la anulación ante Hacienda."
          },
          "solicita": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PersonaDeLaAnulacion"
              }
            ],
            "description": "Quien la pidió. Puede ser la misma persona."
          }
        }
      },
      "DteAnulado": {
        "type": "object",
        "properties": {
          "estado": {
            "type": "string",
            "const": "invalidado"
          },
          "codigoGeneracion": {
            "type": "string",
            "format": "uuid",
            "description": "El del documento anulado."
          },
          "numeroControl": {
            "type": "string"
          },
          "tipoDte": {
            "type": "string"
          },
          "ambiente": {
            "type": "string"
          },
          "yaEstabaInvalidado": {
            "type": "boolean",
            "description": "Presente y `true` cuando el documento ya estaba anulado y no se mandó nada."
          },
          "evento": {
            "type": "object",
            "description": "El documento de anulación, que es un documento aparte.",
            "properties": {
              "codigoGeneracion": {
                "type": "string",
                "format": "uuid"
              },
              "selloRecibido": {
                "type": "string",
                "description": "La prueba de que quedó anulado."
              },
              "fhProcesamiento": {
                "type": "string"
              },
              "observaciones": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "tipoAnulacion": {
                "type": "integer",
                "enum": [
                  1,
                  2,
                  3
                ]
              }
            }
          },
          "documento": {
            "type": "object",
            "description": "El evento canónico",
            "tal como se firmó.": null
          },
          "jws": {
            "type": "string",
            "description": "El evento firmado. Archívelo como archiva el DTE."
          },
          "anotadoEnElIndice": {
            "type": "boolean",
            "description": "Si además pudimos anotarlo en nuestro libro. `false` no cambia que el\ndocumento está anulado —el sello de Hacienda es lo que manda— pero\nmerece una mirada en la bitácora de peticiones.\n"
          }
        }
      },
      "Totales": {
        "type": "object",
        "description": "Los calcula `dte-core` en el servidor. **El cliente los transporta y no\nlos recalcula nunca.**\n\n`totalIva` es el IVA de verdad, venga de donde venga: una FE lo lleva en\n`resumen.totalIva` y un CCF lo desglosa en `resumen.tributos` código 20\ndejando aquel campo vacío. Leer un solo sitio acierta la mitad de las\nveces, y la mitad en la que falla es cada crédito fiscal.\n",
        "properties": {
          "totalNoSuj": {
            "type": "number"
          },
          "totalExenta": {
            "type": "number"
          },
          "totalGravada": {
            "type": "number"
          },
          "totalDescu": {
            "type": "number"
          },
          "totalIva": {
            "type": "number"
          },
          "montoTotalOperacion": {
            "type": "number"
          },
          "totalPagar": {
            "type": "number"
          },
          "totalLetras": {
            "type": "string",
            "example": "VEINTIOCHO 25/100 DOLARES"
          }
        }
      },
      "DteSellado": {
        "type": "object",
        "required": [
          "estado",
          "codigoGeneracion",
          "numeroControl",
          "selloRecibido",
          "documento",
          "jws"
        ],
        "properties": {
          "estado": {
            "type": "string",
            "const": "sellado"
          },
          "codigoGeneracion": {
            "type": "string",
            "format": "uuid"
          },
          "numeroControl": {
            "type": "string",
            "example": "DTE-03-M001P001-000000000000175"
          },
          "tipoDte": {
            "type": "string"
          },
          "ambiente": {
            "type": "string",
            "enum": [
              "00",
              "01"
            ],
            "description": "El de la llave y su empresa",
            "que son el mismo.": null
          },
          "fecEmi": {
            "type": "string",
            "format": "date"
          },
          "horEmi": {
            "type": "string",
            "example": "15:59:27"
          },
          "selloRecibido": {
            "type": "string",
            "description": "La prueba. Sin sello no hay documento",
            "aunque el MH diga PROCESADO.": null
          },
          "fhProcesamiento": {
            "type": "string"
          },
          "observaciones": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Un sello CON observaciones sigue siendo un sello",
            "pero conviene leerlas.": null
          },
          "totales": {
            "$ref": "#/components/schemas/Totales"
          },
          "documento": {
            "type": "object",
            "description": "El DTE canónico",
            "tal como se firmó.": null
          },
          "jws": {
            "type": "string",
            "description": "El JWS compacto RS512. **Es esto lo que hay que archivar**: volver a\nserializar `documento` no reproduce los bytes cuya firma Hacienda\nvalidó.\n"
          },
          "representacionGrafica": {
            "type": "string",
            "format": "byte",
            "nullable": true,
            "description": "El PDF del documento, en base64 — la misma hoja que imprime la app,\ndel catálogo de 20 plantillas de `@facta/rg`. Lleva el sello y el\nQR, así que solo existe DESPUÉS de que el MH conteste.\n\nLa plantilla, la franja del plan gratuito y el logo salen de la\nempresa EMISORA; no se piden en la petición.\n\n`null` no es un error: el artefacto fiscal es el `jws` y el sello ya\nestá dado. Un PDF que no se pudo dibujar no invalida una emisión que\nsí ocurrió.\n"
          },
          "almacenamiento": {
            "type": "string",
            "enum": [
              "retencion",
              "ninguno"
            ],
            "description": "Si además del documento que lleva en esta respuesta quedó una copia\nen el área de retención (`retencion`) o no (`ninguno`). El\nalmacenamiento nunca bloquea el sello: un fallo aquí no cambia que\nel documento está sellado.\n"
          }
        }
      },
      "DteEnContingencia": {
        "type": "object",
        "description": "Firmado y sin respuesta del MH. Lleva los mismos campos que el sellado\nsalvo los que solo existen con sello: no hay `selloRecibido`, no hay\n`fhProcesamiento` y no hay `representacionGrafica` — la hoja necesita el\nsello y el QR, así que todavía no se puede dibujar.\n",
        "properties": {
          "estado": {
            "type": "string",
            "const": "contingencia"
          },
          "codigoGeneracion": {
            "type": "string",
            "format": "uuid"
          },
          "numeroControl": {
            "type": "string"
          },
          "tipoDte": {
            "type": "string"
          },
          "ambiente": {
            "type": "string"
          },
          "fecEmi": {
            "type": "string",
            "format": "date"
          },
          "horEmi": {
            "type": "string"
          },
          "detalle": {
            "type": "string",
            "description": "Por qué no se alcanzó al MH."
          },
          "documento": {
            "type": "object"
          },
          "jws": {
            "type": "string"
          },
          "almacenamiento": {
            "type": "string",
            "enum": [
              "retencion",
              "ninguno"
            ],
            "description": "Si quedó copia en el área de retención. La red es la misma que en el\nsellado, y aquí importa más: es el único sitio, aparte de esta\nrespuesta, donde existe el documento firmado mientras Hacienda no\nconteste.\n"
          }
        }
      },
      "DtePreparado": {
        "type": "object",
        "properties": {
          "estado": {
            "type": "string",
            "const": "preparado"
          },
          "codigoGeneracion": {
            "type": "string",
            "format": "uuid"
          },
          "numeroControl": {
            "type": "string"
          },
          "tipoDte": {
            "type": "string"
          },
          "ambiente": {
            "type": "string"
          },
          "totales": {
            "$ref": "#/components/schemas/Totales"
          },
          "documento": {
            "type": "object"
          },
          "prepareToken": {
            "type": "string"
          }
        }
      },
      "DteConsultado": {
        "type": "object",
        "properties": {
          "estado": {
            "type": "string",
            "description": "Un solo vocabulario, en español, responda el índice o la reserva.",
            "enum": [
              "sellado",
              "firmado",
              "rechazado",
              "contingencia",
              "invalidado",
              "reservado",
              "liberado",
              "descartado"
            ]
          },
          "codigoGeneracion": {
            "type": "string",
            "format": "uuid"
          },
          "numeroControl": {
            "type": "string"
          },
          "tipoDte": {
            "type": "string"
          },
          "ambiente": {
            "type": "string"
          },
          "fecEmi": {
            "type": "string",
            "format": "date"
          },
          "horEmi": {
            "type": "string",
            "nullable": true
          },
          "selloRecibido": {
            "type": "string",
            "nullable": true
          },
          "observaciones": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "motivo": {
            "description": "Las palabras del MH cuando el documento fue rechazado.",
            "nullable": true
          },
          "totales": {
            "type": "object",
            "description": "Del documento sellado salen `totalGravada`, `totalIva` y\n`totalPagar`. De uno **rechazado** —que no tiene fila de índice, y\nse contesta desde su reserva— sale solo `totalPagar`, y tampoco hay\n`horEmi`, `observaciones` ni `receptor`.\n"
          },
          "receptor": {
            "type": "object",
            "nullable": true
          }
        }
      },
      "PaginaDeDocumentos": {
        "type": "object",
        "required": [
          "documentos",
          "siguiente"
        ],
        "properties": {
          "documentos": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DteEnLista"
            }
          },
          "siguiente": {
            "type": "string",
            "nullable": true,
            "description": "El cursor de la página siguiente, o `null` cuando no hay más. Opaco:\npáselo tal cual en `?cursor=`. Nunca una cadena vacía — un cliente\nla pediría igual.\n"
          }
        }
      },
      "DteEnLista": {
        "type": "object",
        "description": "Lo que hace falta para reconciliar: qué número, qué estado, cuánto y a\nquién. El documento entero se pide por su código con\n`GET /v1/dte/{codigoGeneracion}` o `…/file`.\n",
        "properties": {
          "estado": {
            "type": "string",
            "enum": [
              "sellado",
              "firmado",
              "contingencia",
              "invalidado",
              "rechazado"
            ]
          },
          "codigoGeneracion": {
            "type": "string",
            "format": "uuid"
          },
          "numeroControl": {
            "type": "string"
          },
          "tipoDte": {
            "type": "string"
          },
          "fecEmi": {
            "type": "string",
            "format": "date"
          },
          "horEmi": {
            "type": "string"
          },
          "selloRecibido": {
            "type": "string",
            "nullable": true
          },
          "totales": {
            "type": "object",
            "properties": {
              "totalGravada": {
                "type": "number"
              },
              "totalIva": {
                "type": "number"
              },
              "totalPagar": {
                "type": "number"
              }
            }
          },
          "receptor": {
            "type": "object",
            "properties": {
              "nombre": {
                "type": "string",
                "nullable": true
              },
              "numDocumento": {
                "type": "string",
                "nullable": true
              }
            }
          }
        }
      },
      "DocumentoRetenido": {
        "type": "object",
        "description": "Una fila del área de retención — evidencia de estado, nunca una ruta\nde bucket ni contenido.\n",
        "properties": {
          "codigoGeneracion": {
            "type": "string",
            "format": "uuid"
          },
          "ambiente": {
            "type": "string",
            "enum": [
              "00",
              "01"
            ]
          },
          "whereLanded": {
            "type": "string",
            "enum": [
              "holding",
              "synced"
            ],
            "description": "\"holding\": solo existe aquí. \"synced\": ya confirmaste que aterrizó en tu destino real."
          },
          "gaveUp": {
            "type": "boolean",
            "description": "Se agotaron los tres intentos del barrido de cinco minutos."
          },
          "attempts": {
            "type": "integer"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "description": "Una hora después de firmado."
          },
          "downloadedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "downloadCount": {
            "type": "integer",
            "description": "Evidencia",
            "no un cupo — descargar dos veces no se niega.": null
          },
          "syncedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Status": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "version": {
            "type": "string",
            "const": "v1"
          },
          "ambiente": {
            "type": "string",
            "enum": [
              "00",
              "01"
            ],
            "description": "El de la LLAVE. `/v1/status` es la única ruta que no exige ambiente\nde pruebas, así que una llave `facta_live_` puede contestar `01`\naquí y ser rechazada en cuanto intente emitir.\n"
          },
          "emisor": {
            "type": "object",
            "nullable": true,
            "properties": {
              "nit": {
                "type": "string"
              },
              "nombre": {
                "type": "string"
              },
              "ambiente": {
                "type": "string"
              }
            }
          },
          "llave": {
            "type": "object",
            "properties": {
              "keyId": {
                "type": "string"
              },
              "label": {
                "type": "string",
                "nullable": true
              },
              "modo": {
                "type": "string",
                "enum": [
                  "custodian",
                  "byok"
                ]
              },
              "alcances": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "issue",
                    "query",
                    "download"
                  ]
                }
              },
              "tiposDte": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "venceEl": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              }
            }
          },
          "firma": {
            "type": "object",
            "description": "Si esta llave puede firmar, y con qué certificado. **Existencia\nnada más**: nada del contenido del vault de firma se puede leer por\nninguna ruta, en ninguna forma — es la invariante de §6.1 del plan,\ny hay un test de arquitectura que falla si alguien la rompe.\n",
            "properties": {
              "vaultDeFirma": {
                "type": "boolean",
                "description": "La llave tiene su propio vault con el certificado del emisor."
              },
              "origenDeLaFirma": {
                "type": "string",
                "enum": [
                  "vault",
                  "plataforma",
                  "sin-provisionar"
                ],
                "description": "`vault`: el certificado del emisor, abierto con\n`X-Facta-Sign-Key`. `plataforma`: llave anterior al diseño de\nlos dos vaults, que todavía firma con el certificado de Facta —\ncamino con fecha de retiro. `sin-provisionar`: no puede firmar.\n"
              },
              "cabecera": {
                "type": "string",
                "const": "x-facta-sign-key"
              }
            }
          },
          "limites": {
            "type": "object",
            "description": "Los techos se cuentan ANTES de trabajar, con el patrón de\n`email_relay_log`: una petición que muere a medio camino igual gastó\nsu cupo. Es conservador a propósito — con trabajo irreversible al\notro lado de la puerta, el que pierde un turno es el integrador\nhonesto, no el ladrón.\n",
            "properties": {
              "hora": {
                "$ref": "#/components/schemas/Ventana"
              },
              "dia": {
                "$ref": "#/components/schemas/Ventana"
              },
              "estado": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/Ventana"
                  }
                ],
                "description": "La ventana propia de `/v1/status`, contada aparte de las dos de\narriba. Preguntar por el estado ya no gasta el techo de emitir\n— que era justo lo que hacía que un monitor cada minuto se\ncomiera la hora entera.\n"
              },
              "montoMaximoPorDocumentoCentavos": {
                "type": "integer"
              }
            }
          }
        }
      },
      "Ventana": {
        "type": "object",
        "nullable": true,
        "properties": {
          "limit": {
            "type": "integer"
          },
          "used": {
            "type": "integer"
          },
          "remaining": {
            "type": "integer"
          }
        }
      }
    }
  },
  "x-facta-errores": {
    "unauthorized": {
      "status": 401,
      "cuando": "No llegó la cabecera `X-Facta-Key`.",
      "hacer": "Mande la llave. Y nunca en `Authorization` — esa la lee el gateway."
    },
    "invalid_api_key": {
      "status": 401,
      "cuando": "La llave no existe, está mal escrita o el secreto no coincide.",
      "hacer": "Los tres casos tardan lo mismo y dicen lo mismo, a propósito. Revise que copió la llave entera, con el punto."
    },
    "key_revoked": {
      "status": 403,
      "cuando": "Alguien la revocó desde la app.",
      "hacer": "Una llave revocada no vuelve. Acuñe otra."
    },
    "key_expired": {
      "status": 403,
      "cuando": "Pasó la fecha de vencimiento que se le puso al acuñarla.",
      "hacer": "Acuñe otra desde Configuración → Llaves de la API."
    },
    "key_inactive": {
      "status": 403,
      "cuando": "La llave está desactivada.",
      "hacer": "Vuelva a activarla desde la app."
    },
    "forbidden_scope": {
      "status": 403,
      "cuando": "A la llave le falta el alcance que la ruta exige.",
      "hacer": "`details.required` dice cuál hacía falta y `details.granted` los que tiene. El alcance se elige al acuñar y no se edita después."
    },
    "dte_type_not_allowed": {
      "status": 403,
      "cuando": "La llave no tiene permitido ese `tipoDte`.",
      "hacer": "`details.allowed` trae la lista. Acuñe una llave que incluya el tipo."
    },
    "ip_not_allowed": {
      "status": 403,
      "cuando": "La llave tiene lista de direcciones y la suya no está en ninguna entrada.",
      "hacer": "Añada su dirección o su rango CIDR al acuñar. La dirección que se compara es la del extremo que conecta; una cabecera puesta por el cliente no la cambia."
    },
    "environment_not_allowed": {
      "status": 403,
      "cuando": "El prefijo de la llave y su fila no coinciden, o el ambiente de la llave no es el de su empresa.",
      "hacer": "Si su empresa ya pasó a producción, acuñe una llave de producción; una de pruebas no puede emitir documentos reales."
    },
    "sign_key_required": {
      "status": 401,
      "cuando": "Falta `X-Facta-Sign-Key` en una ruta que firma — o llegó la de apertura de almacenamiento (`factauk_`), que no es esta.",
      "hacer": "Mande la contraseña del vault de firma, la que empieza con `factask_` y se mostró una vez al acuñar."
    },
    "sign_key_invalid": {
      "status": 401,
      "cuando": "Esa contraseña no abre el vault de firma.",
      "hacer": "`details.intentosRestantes` dice cuántos quedan antes de que la llave se suspenda sola."
    },
    "sign_vault_locked": {
      "status": 403,
      "cuando": "Cinco intentos fallidos en una hora. La llave queda suspendida.",
      "hacer": "Esperar no lo arregla. Hay que reactivarla desde la app."
    },
    "sign_vault_missing": {
      "status": 409,
      "cuando": "La llave no está provisionada para firmar, o el certificado que guarda es de otro NIT que el de la empresa.",
      "hacer": "Reintentar no la arregla. Acuñe la llave de nuevo, que es lo que provisiona su vault de firma."
    },
    "invalid_request": {
      "status": 400,
      "cuando": "Cuerpo ausente, que no es JSON, de más de 1 MB, un campo con la forma equivocada, o un código de generación que no tiene forma de UUID.",
      "hacer": "`details.field` señala el campo cuando lo hay. No gasta correlativo."
    },
    "validation_failed": {
      "status": 422,
      "cuando": "El documento no cumple el esquema oficial del Ministerio de Hacienda.",
      "hacer": "`details.issues` lista cada campo. **No gasta correlativo**: la validación corre antes de reservar."
    },
    "not_found": {
      "status": 404,
      "cuando": "No existe el documento, o el `customerId`, o la ruta.",
      "hacer": "Un documento de otra empresa contesta exactamente lo mismo que uno que no existe. Es a propósito."
    },
    "method_not_allowed": {
      "status": 405,
      "cuando": "La ruta existe, pero no con ese método.",
      "hacer": "El mensaje dice cuál es el bueno."
    },
    "idempotency_key_required": {
      "status": 400,
      "cuando": "Falta `Idempotency-Key` en una de las tres rutas que gastan correlativo.",
      "hacer": "Elija una cadena por operación. Sin ella, un reintento por timeout quemaría un segundo número."
    },
    "idempotency_key_reuse": {
      "status": 422,
      "cuando": "Ya usó esa `Idempotency-Key` con un cuerpo distinto.",
      "hacer": "Use una nueva. Devolver la factura de ayer para la venta de hoy sería peor que fallar."
    },
    "idempotency_in_flight": {
      "status": 409,
      "cuando": "La primera petición con esa llave todavía se está procesando.",
      "hacer": "Reintente en unos segundos. No se encola a propósito: la primera puede estar gastando un correlativo ahora mismo."
    },
    "prepare_token_invalid": {
      "status": 422,
      "cuando": "El `prepareToken` venció, es de otra llave, o el documento cambió aunque sea un centavo.",
      "hacer": "Vuelva a llamar a `/v1/dte/prepare`. Lo que se firma tiene que ser lo que obtuvo el número."
    },
    "rate_limited": {
      "status": 429,
      "cuando": "La llave alcanzó su techo por hora o por día.",
      "hacer": "`details.window`, `details.remaining` y `details.retryAfterSeconds` dicen cuál y cuánto esperar."
    },
    "amount_limit": {
      "status": 429,
      "cuando": "El documento pasa del monto máximo por documento de la llave.",
      "hacer": "Se comprueba antes de reservar, así que **no gasta correlativo**. Suba el techo de la llave o divida la operación."
    },
    "mh_rejected": {
      "status": 422,
      "cuando": "Hacienda leyó el documento y lo negó.",
      "hacer": "**Sí gastó correlativo**, y `details` nombra `codigoGeneracion` y `numeroControl` para que la corrección reuse ESE número (§167). No es 502 porque es un veredicto sobre los datos, no una caída."
    },
    "mh_unreachable": {
      "status": 502,
      "cuando": "Reservado: la versión actual nunca lo devuelve.",
      "hacer": "Todo fallo de transporte sale como **202 contingencia**, porque para entonces el documento ya está firmado y se le debe a Hacienda."
    },
    "correlative_unavailable": {
      "status": 503,
      "cuando": "No se pudo reservar el correlativo.",
      "hacer": "Reintente con la MISMA `Idempotency-Key`. No se gastó número."
    },
    "no_storage_destination": {
      "status": 422,
      "cuando": "La empresa no tiene ningún destino de almacenamiento conectado y verificado en los últimos 30 días.",
      "hacer": "Conecte un destino desde la app. Corre antes que nada más, así que **no gasta correlativo**: firmar un documento que no tiene dónde aterrizar sería dejarlo sin ninguna copia."
    },
    "service_unavailable": {
      "status": 503,
      "cuando": "Algo nuestro no contestó — la base, el registro de peticiones, el de idempotencia.",
      "hacer": "Reintente con la misma `Idempotency-Key`."
    },
    "internal_error": {
      "status": 500,
      "cuando": "Un fallo que no estaba previsto.",
      "hacer": "El mensaje nunca describe nuestras entrañas. Si se repite, escriba a soporte con la hora y el `keyId`."
    }
  }
}
