Emitir un DTE
Alcance: issue. Y X-Facta-Sign-Key, que es el segundo factor.
Parámetros
| Nombre | Dónde | Qué es |
|---|---|---|
X-Facta-Keyrequerido |
cabecera | La llave de la API. Nunca en |
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. |
Qué se manda
| Campo | Tipo | Qué es |
|---|---|---|
tipoDterequerido |
01 · 03 · 05 · 06 · 11 · 14 |
|
receptoropcional |
Receptor o ReceptorExportacion o ReceptorSujetoExcluido o null |
Su forma depende del tipo: |
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 |
numPagoElectronicoopcional |
texto | Solo en la nota de débito (06). En otro tipo es |
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. |
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 |
formaPagoopcional |
texto | CAT-017. 01 = efectivo. |
observacionesopcional |
texto |
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
# -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 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)
import { Facta, FactaError } from "@facta/api";
const facta = new Facta({
apiKey: process.env.FACTA_API_KEY!,
signKey: process.env.FACTA_SIGN_KEY!,
});
try {
const dte = await facta.emitir(
{
"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
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 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#
// .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
// 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 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
Sellado por el MH. Idempotency-Replayed: true si esta respuesta ya estaba guardada de un intento anterior con la misma llave.
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.
Cuerpo mal formado, ausente, de más de 1 MB, o falta Idempotency-Key.
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.
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).
El customerId no existe en la empresa de la llave.
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.
El documento no pasó — y hay dos razones muy distintas:
validation_failed: no cumple el esquema oficial. No se gastó correlativo;details.issuesdice qué campo.mh_rejected: Hacienda lo leyó y lo negó. Sí se gastó, ydetailsnombracodigoGeneracionynumeroControlpara que la corrección los reuse (§167).idempotency_key_reuse: la misma llave con otro cuerpo.no_storage_destination: la empresa no tiene ningún destino de almacenamiento conectado y verificado en los últimos 30 días. No se gastó correlativo — es la puerta deapi-almacenamiento-y-contingencia.md§2.1, y corre antes que nada más: firmar sin un sitio donde el documento pueda aterrizar deja al cliente sin ninguna copia salvo la de esta misma respuesta.
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.
internal_error. El mensaje nunca describe nuestras entrañas.
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.
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.