factadesarrolladores API 0.1.0-esqueleto

Emitir un DTE

POST /v1/dte

Alcance: issue. Y X-Facta-Sign-Key, que es el segundo factor.

Alcance issueGasta correlativoFirma · X-Facta-Sign-KeyIdempotency-Key obligatoria

Parámetros

NombreDóndeQué es
X-Facta-Keyrequerido cabecera

La llave de la API. Nunca en Authorization.

X-Facta-Sign-Keyrequerido cabecera

La contraseña que abre el vault de firma, durante esta petición.

Idempotency-Keyrequerido cabecera

Un identificador por operación, elegido por usted. La misma en cada reintento.

X-Facta-Request-Fingerprintopcional cabecera

Opcional. SHA-256 en hexadecimal minúscula de la petición TAL COMO LA ESCRIBIÓ quien llama, antes de que ninguna biblioteca la complete. Solo se lee en POST /v1/dte y POST /v1/dte/prepare; en cualquier otra ruta se ignora. Con ella, la comparación de Idempotency-Key usa esta huella en lugar del cuerpo: un cliente que resuelve customerId o productId desde su copia del catálogo puede reintentar la misma venta después de que el catálogo cambió y obtiene la respuesta guardada, no un 422 idempotency_key_reuse. Una huella distinta con la misma llave sigue siendo 422, y una cabecera que no sea 64 caracteres hexadecimales en minúscula es 400 invalid_request. El alcance no cambia: (llave, Idempotency-Key). Si el primer intento no la mandó y el reintento sí, y el cuerpo del reintento es idéntico byte a byte al del primero, se trata como la misma petición (así una biblioteca que se actualiza a mitad de un reintento no recibe 422); si el cuerpo difiere, es 422. Si el primer intento la mandó y el reintento no, la respuesta es 422.

X-Facta-Copiesopcional cabecera

Opcional, para quien prefiere la respuesta rápida. Con X-Facta-Copies: deferred la respuesta sale en cuanto el documento está sellado, el índice escrito y la hoja dibujada, y las dos copias (área de retención y almacenamiento administrado) se escriben después. La respuesta lo dice con la verdad: almacenamiento: "pendiente" y un recibo storage en estado pending con errorCode: "write_deferred". Nada se pierde: el documento y la hoja ya vienen en la respuesta, y GET /v1/storage/copies/{codigoGeneracion} muestra cuándo aterrizan las copias (si una falla, el barrido de reparación la reintenta). Sin la cabecera todo sigue como siempre: la respuesta espera las copias.

X-Facta-Debugopcional cabecera

Función de soporte, no de uso normal. Con X-Facta-Debug: timings, y solo si la llave está autorizada, la respuesta trae además un miembro debug con códigos de diagnóstico para el equipo de soporte de Facta, y la cabecera Server-Timing con el tiempo total.

``json "debug": { "ref": "k3q9m0x7ptd2", "t": "A1:12,V1:40,H4:812" } ``

  • Disponible para las llaves de pruebas (facta_test_) y para las llaves de producción que Facta haya habilitado. Con cualquier otra llave, o si la llave no es válida, la respuesta no cambia en un byte y la cabecera se ignora.
  • ref es una referencia aleatoria: dígasela a soporte junto con la hora de la petición.
  • t son pares código:milisegundos. El significado de cada código no es público y puede cambiar sin aviso; no construya lógica sobre él. Soporte lo interpreta con su propio catálogo.
  • Server-Timing lleva solo total;dur=<ms>.
  • Aplica a POST /v1/dte, POST /v1/dte/prepare, POST /v1/dte/sign, GET /v1/dte y a la descarga GET /v1/dte/{codigoGeneracion}/file (esta última solo con Server-Timing, porque la respuesta son los bytes del archivo). En otras rutas se ignora.
  • Una repetición por Idempotency-Key también lo acepta; el informe describe esa repetición y no se guarda.

Qué se manda

CampoTipoQué es
tipoDterequerido 01 · 03 · 05 · 06 · 11 · 14

01 factura · 03 crédito fiscal · 05 nota de crédito · 06 nota de débito · 11 exportación · 14 sujeto excluido. Los demás del catálogo llegan con su tanda.

entregaopcional Entrega

receptoropcional Receptor o ReceptorExportacion o ReceptorSujetoExcluido o null

Su forma depende del tipo: Receptor para 01/03/05/06, ReceptorExportacion para el 11 y ReceptorSujetoExcluido para el 14. Una FE (01) puede no llevarlo — es el consumidor final anónimo.

itemsrequerido lista de Item

documentosRelacionadosopcional lista de DocumentoRelacionado

Obligatorio en 05 y 06, y prohibido en los demás. Qué documento sellado ajusta esta nota. Cada entrada se escribe entera o se nombra por su codigoGeneracion, que el servidor completa desde el índice de SU empresa.

numPagoElectronicoopcional texto

Solo en la nota de débito (06). En otro tipo es 400.

exportacionopcional cualquiera

Obligatorio en el 11, y prohibido en los demás.

aplicarReteRentaopcional sí/no

Solo en el 14. Retención de renta del 10 % (art. 156 CT) sobre servicios de una persona natural. Nunca automática: se pide. No se puede combinar con líneas exenta o no_sujeta (422 retention_mixed_class_unsupported).

aplicarRetencionIvaopcional sí/no

Solo en el 01 y el 03. Retención de IVA del 1 % que practica el receptor (art. 162 CT) sobre el monto neto gravado después de descuentos. Depende de quién es el comprador, algo que la API no puede ver, así que nunca es automática: se pide. En otro tipo es 400.

aplicarPercepcionIvaopcional sí/no

Solo en el 03. Percepción de IVA del 1 % (art. 163 CT) sobre el monto neto gravado de las líneas de bienes (tipoItem 1) después de la parte proporcional del descuento global; los servicios no entran en la base. Se suma al total a pagar (montoTotalOperacion + ivaPerci − ivaRete) y no forma parte de montoTotalOperacion. Nunca es automática: se pide. En otro tipo es 400.

condicionOperacionopcional 1 · 2 · 3

1 contado

plazoopcional 01 · 02 · 03

CAT-018 — días, meses o años. Solo se escribe si la operación es a crédito.

periodoopcional entero

Cuántos de esos plazos. Solo con condicionOperacion 2.

formaPagoopcional texto

CAT-017. 01 = efectivo.

observacionesopcional texto

apendiceopcional lista de FilaApendice

La sección Apéndice del propio documento: hasta 10 filas campo / etiqueta / valor que el esquema oficial admite en los seis tipos (01, 03, 05, 06, 11 y 14). Forma parte del DTE firmado y transmitido a Hacienda —por eso se valida contra el esquema antes de gastar un correlativo— y la hoja la imprime como «etiqueta: valor» bajo las observaciones. Úselo para lo que sí pertenece al documento (orden de compra, vendedor, referencia). Si lo que quiere es solo decorar la hoja, use representacion.

Una lista vacía es 400: omita el campo o mande null. Se aplican los mínimos más estrictos de los esquemas a todos los tipos (campo ≥ 2, etiqueta ≥ 3) para que una fila válida lo sea en cualquiera. Sin saltos de línea ni caracteres de control.

representacionopcional Representacion

Ejemplos

Solo la primera pestaña usa una librería de Facta; las demás son peticiones HTTP normales, porque no existe un cliente oficial para esos lenguajes.

curl

curl
# -D - vuelca las cabeceras de la respuesta, con RateLimit-Remaining (y, si aplica, Retry-After).
curl --fail-with-body -X POST "$FACTA_API_BASE_URL/v1/dte" \
  -H "X-Facta-Key: $FACTA_API_KEY" \
  -H "X-Facta-Sign-Key: $FACTA_SIGN_KEY" \
  -H "Idempotency-Key: $PEDIDO_ID" \
  -H "Content-Type: application/json" \
  -D - \
  --max-time 60 \
  -d '{
  "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
    }
  ]
}'

JavaScript

javascript
// JavaScript puro, sin ninguna librería de Facta. Node 22 o 24; fetch es global.
const base = process.env.FACTA_API_BASE_URL;

const headers = {
  "X-Facta-Key": process.env.FACTA_API_KEY,
  "X-Facta-Sign-Key": process.env.FACTA_SIGN_KEY,
  // Un identificador que USTED elige y REUTILIZA en cada reintento de
  // esta misma operación — su número de pedido, su ticket de caja.
  "Idempotency-Key": pedidoId,
  "Content-Type": "application/json",
};

const controlador = new AbortController();
const plazo = setTimeout(() => controlador.abort(), 60_000);

const respuesta = await fetch(base + "/v1/dte", {
  method: "POST",
  headers,
  body: JSON.stringify({
    "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
      }
    ]
  }),
  signal: controlador.signal,
}).finally(() => clearTimeout(plazo));

console.log("cupo restante:", respuesta.headers.get("RateLimit-Remaining"));

const cuerpo = await respuesta.json();

if (respuesta.status === 200) {
  // Resultado correcto.
  console.log(cuerpo);
}
else if (respuesta.status === 202) {
  // 202: firmado, sin respuesta del Ministerio todavía. NO lo reintente.
  console.log("en contingencia:", cuerpo.numeroControl);
}
else if (cuerpo.error?.code === "mh_rejected") {
  console.error("rechazado:", cuerpo.error.message, "· número gastado:", cuerpo.error.details.numeroControl);
}
else {
  throw new Error(cuerpo.error?.code ?? String(respuesta.status));
}

TypeScript (SDK)

typescript
import { Facta, FactaError } from "@facta-dte/api";

const facta = new Facta({
  apiKey: process.env.FACTA_API_KEY!,
  signKey: process.env.FACTA_SIGN_KEY!,
});

try {
  const dte = await facta.issue(
    {
  "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
    }
  ]
},
    // Si su sistema ya tiene un identificador para esta venta —el número de
    // pedido, el ticket de caja—, páselo: así la operación es idempotente
    // entre reinicios del proceso, no solo entre reintentos de una llamada.
    { idempotencyKey: pedidoId },
  );

  console.log(dte.estado, dte.numeroControl);
  if (dte.estado === "sellado") console.log(dte.selloRecibido, dte.totales.totalPagar);
} catch (error) {
  if (error instanceof FactaError && error.isRejection) {
    console.error("rechazado:", error.message, "· gastó:", error.spent?.numeroControl);
  } else throw error;
}

Python

python
import json
import os
import urllib.error
import urllib.request

base = os.environ["FACTA_API_BASE_URL"]

cabeceras = {
    "X-Facta-Key": os.environ["FACTA_API_KEY"],
    "X-Facta-Sign-Key": os.environ["FACTA_SIGN_KEY"],
    "Idempotency-Key": pedido_id,  # el mismo en cada reintento de esta operación
    "Content-Type": "application/json",
}

peticion = urllib.request.Request(
    base + "/v1/dte",
    data=json.dumps({
      "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
        }
      ]
    }).encode("utf-8"),
    method="POST",
    headers=cabeceras,
)

try:
    # El Ministerio puede tardar unos cuarenta segundos en contestar.
    with urllib.request.urlopen(peticion, timeout=60) as respuesta:
        print("cupo restante:", respuesta.headers.get("RateLimit-Remaining"))
        cuerpo = json.load(respuesta)
        if respuesta.status == 200:
            print(cuerpo)
        elif respuesta.status == 202:
            # Firmado, sin respuesta del Ministerio todavía. NO lo reintente.
            print("en contingencia:", cuerpo["numeroControl"])
except urllib.error.HTTPError as fallo:
    error = json.load(fallo).get("error", {})
    if error.get("code") == "mh_rejected":
        detalle = error.get("details", {})
        print("rechazado:", error.get("message"), "· número gastado:", detalle.get("numeroControl"))
    else:
        raise

PHP

php
<?php
// PHP 8 con la extensión cURL. No hay librería de Facta para PHP.
$base = getenv("FACTA_API_BASE_URL");

$cabeceras = [
    "X-Facta-Key: " . getenv("FACTA_API_KEY"),
    "X-Facta-Sign-Key: " . getenv("FACTA_SIGN_KEY"),
    "Idempotency-Key: " . $pedidoId,  // el mismo en cada reintento de esta operación
    "Content-Type: application/json",
];

$conexion = curl_init($base . "/v1/dte");
$cuerpo = json_encode([
    "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,
        ],
    ],
], JSON_UNESCAPED_UNICODE);
curl_setopt_array($conexion, [
    CURLOPT_CUSTOMREQUEST => "POST",
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADER => true,
    CURLOPT_TIMEOUT => 60,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => $cuerpo,
]);

$crudo = curl_exec($conexion);
$estado = curl_getinfo($conexion, CURLINFO_RESPONSE_CODE);
$tamCabeceras = curl_getinfo($conexion, CURLINFO_HEADER_SIZE);
curl_close($conexion);

$cabecerasRespuesta = substr($crudo, 0, $tamCabeceras);
echo "cupo restante: " . (preg_match("/RateLimit-Remaining: (\d+)/i", $cabecerasRespuesta, $m) ? $m[1] : "?") . "\n";

$datos = json_decode(substr($crudo, $tamCabeceras), true);

if ($estado === 200) {
    echo json_encode($datos) . "\n";
}
elseif ($estado === 202) {
    // Firmado, sin respuesta del Ministerio todavía. NO lo reintente.
    echo "en contingencia: {$datos['numeroControl']}\n";
}
elseif (($datos['error']['code'] ?? null) === 'mh_rejected') {
    echo "rechazado: {$datos['error']['message']}\n";
    echo "número gastado: {$datos['error']['details']['numeroControl']}\n";
} else {
    throw new RuntimeException($datos['error']['code'] ?? "HTTP $estado");
}

C#

csharp
// .NET 8 con HttpClient. No hay librería de Facta para C#.
using System.Net.Http.Json;
using System.Text.Json;

var base_ = Environment.GetEnvironmentVariable("FACTA_API_BASE_URL");
using var cliente = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };

var peticion = new HttpRequestMessage(HttpMethod.Post, base_ + "/v1/dte");
peticion.Content = JsonContent.Create(new { tipoDte = "03", receptor = new { customerId = "374114b6-e957-4c7a-8911-dd6381b1e0ea" }, items = new[] { new { descripcion = "Integración de la API de facturación electrónica", cantidad = 1, precioUni = 25 } } });
peticion.Headers.Add("X-Facta-Key", Environment.GetEnvironmentVariable("FACTA_API_KEY"));
peticion.Headers.Add("X-Facta-Sign-Key", Environment.GetEnvironmentVariable("FACTA_SIGN_KEY"));
peticion.Headers.Add("Idempotency-Key", pedidoId); // el mismo en cada reintento de esta operación

var respuesta = await cliente.SendAsync(peticion);
Console.WriteLine($"cupo restante: {respuesta.Headers.GetValues("RateLimit-Remaining").FirstOrDefault()}");

using var datos = JsonDocument.Parse(await respuesta.Content.ReadAsStringAsync());
var raiz = datos.RootElement;

if ((int)respuesta.StatusCode == 200)
    Console.WriteLine(raiz);
else if ((int)respuesta.StatusCode == 202)
    // Firmado, sin respuesta del Ministerio todavía. NO lo reintente.
    Console.WriteLine($"en contingencia: {raiz.GetProperty("numeroControl")}");
else if (raiz.TryGetProperty("error", out var error) && error.GetProperty("code").GetString() == "mh_rejected")
    Console.WriteLine($"rechazado: {error.GetProperty("message")} · gastó {error.GetProperty("details").GetProperty("numeroControl")}");
else
    throw new InvalidOperationException(respuesta.StatusCode.ToString());

Java

java
// JDK 17 o superior, con java.net.http. No hay librería de Facta para Java.
import java.net.URI;
import java.net.http.*;
import java.time.Duration;

public final class Emitir {
  public static void main(String[] args) throws Exception {
    var base = System.getenv("FACTA_API_BASE_URL");
    var constructor = HttpRequest.newBuilder(URI.create(base + "/v1/dte"))
        .header("X-Facta-Key", System.getenv("FACTA_API_KEY"))
        .header("X-Facta-Sign-Key", System.getenv("FACTA_SIGN_KEY"))
        // El mismo pedidoId en cada reintento de esta operación.
        .header("Idempotency-Key", pedidoId)
        .timeout(Duration.ofSeconds(60))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("""
        {
          "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
            }
          ]
        }
        """));
    var peticion = constructor.build();

    var respuesta = HttpClient.newHttpClient().send(peticion, HttpResponse.BodyHandlers.ofString());
    System.out.println("cupo restante: " + respuesta.headers().firstValue("RateLimit-Remaining").orElse("?"));
    // Estados: 200 correcto · 202 correcto (el 202 queda firmado, en contingencia — no se reintenta) · 422 con "mh_rejected" si Hacienda lo negó.
    System.out.println(respuesta.statusCode());
    System.out.println(respuesta.body());
  }
}

Go

go
// Go 1.22 con net/http. No hay librería de Facta para Go.
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

func main() {
	base := os.Getenv("FACTA_API_BASE_URL")
	cuerpo, _ := json.Marshal(map[string]any{"tipoDte": "03", "receptor": map[string]any{"customerId": "374114b6-e957-4c7a-8911-dd6381b1e0ea"}, "items": []any{map[string]any{"descripcion": "Integración de la API de facturación electrónica", "cantidad": 1, "precioUni": 25}}})
	peticion, _ := http.NewRequest("POST", base+"/v1/dte", bytes.NewReader(cuerpo))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("X-Facta-Key", os.Getenv("FACTA_API_KEY"))
	peticion.Header.Set("X-Facta-Sign-Key", os.Getenv("FACTA_SIGN_KEY"))
	// El mismo pedidoId en cada reintento de esta operación.
	peticion.Header.Set("Idempotency-Key", pedidoId)

	cliente := &http.Client{Timeout: 60 * time.Second}
	respuesta, err := cliente.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	fmt.Println("cupo restante:", respuesta.Header.Get("RateLimit-Remaining"))

	var datos map[string]any
	json.NewDecoder(respuesta.Body).Decode(&datos)

	switch respuesta.StatusCode {
	case 200:
		fmt.Println(datos)
	case 202:
		// Firmado, sin respuesta del Ministerio todavía. No reintentar.
		fmt.Println(datos)
	default:
		fmt.Println("error:", datos["error"])
	}
}

Qué contesta

200

Sellado por el MH. Idempotency-Replayed: true si esta respuesta ya estaba guardada de un intento anterior con la misma llave.

202

El documento quedó FIRMADO pero el MH no respondió. No es un fallo que se reintente: los bytes existen y se le deben a Hacienda. La reserva queda retenida en contingencia y se retransmite después; los plazos (24 h / 72 h) son de la ley, no nuestros.

400

Cuerpo mal formado, ausente, de más de 1 MB, o falta Idempotency-Key.

401

unauthorized (no vino llave) o invalid_api_key (no sirve). Las dos tardan lo mismo, a propósito.

En las rutas que firman se suman las dos del segundo factor: sign_key_required (falta X-Facta-Sign-Key, o vino la de apertura por error) y sign_key_invalid (no abre el vault). details.intentosRestantes dice cuántos quedan antes de que la llave se suspenda sola.

403

La llave existe pero no puede: key_revoked, key_expired, key_inactive, forbidden_scope, dte_type_not_allowed, ip_not_allowed, environment_not_allowed o sign_vault_locked (demasiados intentos fallidos de abrir el vault: esperar no lo arregla, hay que reactivar la llave desde la app).

404

El customerId o un productId no existe en la empresa de la llave (o, con catálogo legible, en el catálogo publicado: detail.reason es catalog_unknown_id y detail.catalogStale dice si el catálogo publicado va atrasado).

409

idempotency_in_flight (la primera petición con esa llave todavía trabaja — reintenta) o sign_vault_missing (la llave no está provisionada para firmar — reintentar no la arregla, hay que acuñarla de nuevo desde la app). El code los distingue.

422

El documento no pasó — y hay dos razones muy distintas:

  • validation_failed: no cumple el esquema oficial. No se gastó correlativo; details.issues dice qué campo.
  • retention_mixed_class_unsupported: aplicarReteRenta sobre líneas exentas o no sujetas. No se gastó correlativo.
  • mh_rejected: Hacienda lo leyó y lo negó. Sí se gastó, y details nombra codigoGeneracion y numeroControl para que la corrección los reuse (§167).
  • idempotency_key_reuse: la misma llave con otro cuerpo.
  • no_storage_destination: ya no lo devuelve la emisión (desde el 6-oct-2026). Sin almacenamiento disponible el documento se emite igual y la respuesta trae la advertencia sin_almacenamiento_duradero.
429

rate_limited (por hora, por día, o la ventana propia de /v1/status) o amount_limit (el documento pasa del monto máximo de la llave). El de monto se comprueba ANTES de reservar: no gasta correlativo.

details.window dice cuál de las tres fue.

500

internal_error. El mensaje nunca describe nuestras entrañas.

502

mh_unreachable — no llegamos a Hacienda. Nadie juzgó nada.

RESERVADO: la versión actual nunca lo devuelve. Todo fallo de transporte al MH —caída, timeout, respuesta ilegible, «PROCESADO» sin sello— sale como 202 contingencia, porque para entonces el documento YA está firmado y se le debe a Hacienda; un 502 invitaría a reintentar y el reintento firmaría un segundo documento. El código se queda en la taxonomía para el día en que exista un fallo ANTES de firmar que sí se pueda reintentar.

503

service_unavailable o correlative_unavailable — algo nuestro no está listo. Puede salir en CUALQUIER ruta, incluidas las de consulta: el registro de peticiones se escribe antes de trabajar y falla cerrado, así que si esa tabla no contesta, nada contesta.

Escriba para buscar Por ejemplo: idempotencia, contingencia, 422, emitir.

↑ ↓ moverse Enter abrir Esc cerrar