Python
HTTP directo · no hay librería de Facta para Python
La API es HTTP y JSON, así que Python la llama sin nada más que su biblioteca estándar. Lo que sigue es un programa completo —no un fragmento— que emite una factura y trata cada final por su nombre.
Requisitos
- Python 3.11 o superior. El ejemplo usa
urllib.request, que viene con el intérprete; conrequestsohttpxel código es más corto y las reglas son las mismas. - Una llave de API con el alcance
issue, y su llave de firma. - Un destino de almacenamiento conectado en la app — sin él la emisión se detiene con
422 no_storage_destinationantes de reservar correlativo.
URL base
https://hcnvknpsbadplnfcflxx.supabase.co/functions/v1/api-v1Variables de entorno
Dos cadenas viajan y una no. Guárdelas en el gestor de secretos del despliegue, y las dos primeras en sitios distintos: juntas en el mismo archivo se desperdicia media defensa.
# Las tres cadenas se muestran UNA sola vez, al acuñar la llave.
FACTA_API_KEY=facta_test_k7f3a9c21.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
FACTA_SIGN_KEY=factask_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Esta tercera NO se le pasa a Facta ni al cliente: abre sus credenciales
# de almacenamiento en SU servidor.
FACTA_UNLOCK_KEY=factauk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxEl ejemplo completo
#!/usr/bin/env python3
"""Emitir un DTE contra la API de Facta, con solo la biblioteca estándar.
Objetivo: Python 3.11 o superior. No hay librería de Facta para Python —
esto es HTTP directo, no un cliente oficial.
export FACTA_API_BASE_URL=https://hcnvknpsbadplnfcflxx.supabase.co/functions/v1/api-v1
export FACTA_API_KEY=facta_test_...
export FACTA_SIGN_KEY=factask_...
export PEDIDO_ID=pedido-00417 # opcional; el suyo, persistido por USTED
python3 emitir.py
Lo que este ejemplo cubre: consultar el estado antes de emitir, emitir con
una Idempotency-Key que su sistema posee (no una al azar por intento),
distinguir 200 / 202 / 422, un timeout de 60 s, leer RateLimit-Remaining, y
descargar el JSON firmado byte a byte, sin volver a serializarlo. Lo que NO
cubre: reintentos automáticos (la guía «Idempotencia y reintentos» de
sdk.factadte.com dice en cuáles de los códigos de abajo reintentar es
seguro) y jamás usa FACTA_UNLOCK_KEY — esa cadena abre SU almacenamiento y
no tiene nada que ver con esta API.
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
BASE_URL = os.environ["FACTA_API_BASE_URL"].rstrip("/")
API_KEY = os.environ["FACTA_API_KEY"]
SIGN_KEY = os.environ["FACTA_SIGN_KEY"]
# El identificador de ESTA venta, no uno nuevo por intento. En un sistema real
# esto viene de su propia base de datos — el número de pedido, el ticket de
# caja — y se persiste antes de la primera llamada, precisamente para que un
# reintento (por un timeout, por un reinicio del proceso) use el mismo valor.
PEDIDO_ID = os.environ.get("PEDIDO_ID", "pedido-00417")
TIMEOUT_SECONDS = 60
def peticion(metodo: str, ruta: str, cuerpo: dict | None = None, firma: bool = False) -> tuple[int, dict, bytes]:
"""Hace una petición y devuelve (estado_http, cabeceras, bytes_crudos)."""
cabeceras = {"X-Facta-Key": API_KEY}
if firma:
cabeceras["X-Facta-Sign-Key"] = SIGN_KEY
datos = None
if cuerpo is not None:
cabeceras["Content-Type"] = "application/json"
cabeceras["Idempotency-Key"] = PEDIDO_ID
datos = json.dumps(cuerpo).encode("utf-8")
solicitud = urllib.request.Request(BASE_URL + ruta, data=datos, method=metodo, headers=cabeceras)
try:
with urllib.request.urlopen(solicitud, timeout=TIMEOUT_SECONDS) as respuesta:
return respuesta.status, dict(respuesta.headers), respuesta.read()
except urllib.error.HTTPError as fallo:
return fallo.code, dict(fallo.headers), fallo.read()
def main() -> int:
# 1) El estado primero: confirma que la llave sirve y cuánto cupo queda.
# No pide X-Facta-Sign-Key y no gasta el cupo de emitir.
estado, _cabeceras, cuerpo = peticion("GET", "/v1/status")
if estado != 200:
print("no se pudo leer el estado de la llave:", cuerpo.decode("utf-8"), file=sys.stderr)
return 1
info = json.loads(cuerpo)
print(f"ambiente: {info['ambiente']} · cupo por hora restante: {info['limites']['hora']['remaining']}")
# 2) Emitir. Una factura (01) a consumidor final: el IVA va INCLUIDO en
# precioUni, como exige el esquema oficial de este tipo de documento.
venta = {"tipoDte": "01", "items": [{"descripcion": "Café", "cantidad": 2, "precioUni": 1.5}]}
estado, cabeceras, cuerpo = peticion("POST", "/v1/dte", cuerpo=venta, firma=True)
print("cupo restante:", cabeceras.get("RateLimit-Remaining", "?"))
datos = json.loads(cuerpo)
if estado == 200:
print("sellado:", datos["numeroControl"], datos["selloRecibido"])
elif estado == 202:
# Firmado, sin respuesta del Ministerio todavía. El documento EXISTE
# y se le debe a Hacienda: NO se reintenta esta llamada.
print("en contingencia:", datos["numeroControl"], "—", datos["detalle"])
elif estado == 422:
error = datos.get("error", {})
if error.get("code") == "mh_rejected":
detalle = error.get("details", {})
print("rechazado:", error.get("message"), file=sys.stderr)
print("número ya gastado:", detalle.get("numeroControl"), file=sys.stderr)
else:
print("no se pudo emitir:", error.get("code"), error.get("message"), file=sys.stderr)
return 1
else:
print("error inesperado:", estado, cuerpo.decode("utf-8"), file=sys.stderr)
return 1
# 3) Descargar los bytes EXACTOS del JSON firmado — nunca se vuelven a
# serializar, porque eso no reproduciría los bytes que Hacienda validó.
codigo = datos["codigoGeneracion"]
estado, _cabeceras, archivo = peticion("GET", f"/v1/dte/{codigo}/file?kind=json")
if estado == 200:
nombre = f"{codigo}.json"
with open(nombre, "wb") as salida:
salida.write(archivo)
print("archivado:", nombre)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Lo que este ejemplo no hace
Los demás lenguajes
El mismo documento, en los otros seis. Cambia la sintaxis; no cambian las cabeceras, ni los códigos, ni las reglas.
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));
}
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({{OTROS}}
quot;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({{OTROS}}
quot;en contingencia: {raiz.GetProperty("numeroControl")}");
else if (raiz.TryGetProperty("error", out var error) && error.GetProperty("code").GetString() == "mh_rejected")
Console.WriteLine({{OTROS}}
quot;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"])
}
}