Firmar y transmitir
Alcance: issue. Y X-Facta-Sign-Key.
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 |
|---|---|---|
prepareTokenrequerido |
texto | Tal cual lo devolvió |
documentorequerido |
objeto | El documento canónico de |
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/sign" \
-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 '{
"prepareToken": "<el prepareToken que devolvió /v1/dte/prepare>",
"documento": "<preparado.documento, sin tocar un centavo>"
}'
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/sign", {
method: "POST",
headers,
body: JSON.stringify({
"prepareToken": "<el prepareToken que devolvió /v1/dte/prepare>",
"documento": "<preparado.documento, sin tocar un centavo>"
}),
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!,
});
// El documento viaja de vuelta SIN TOCAR un centavo: el servidor comprueba un
// MAC sobre su hash canónico y un cambio se rechaza en vez de firmarse.
const dte = await facta.firmar(preparado, { idempotencyKey: pedidoId });
console.log(dte.estado, dte.numeroControl);
if (dte.estado === "sellado") console.log(dte.selloRecibido, dte.totales.totalPagar);
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/sign",
data=json.dumps({
"prepareToken": "<el prepareToken que devolvió /v1/dte/prepare>",
"documento": "<preparado.documento, sin tocar un centavo>"
}).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/sign");
$cuerpo = json_encode([
"prepareToken" => "<el prepareToken que devolvió /v1/dte/prepare>",
"documento" => "<preparado.documento, sin tocar un centavo>",
], 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/sign");
peticion.Content = JsonContent.Create(new { prepareToken = "<el prepareToken que devolvió /v1/dte/prepare>", documento = "<preparado.documento, sin tocar un centavo>" });
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 Firmar {
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/sign"))
.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("""
{
"prepareToken": "<el prepareToken que devolvió /v1/dte/prepare>",
"documento": "<preparado.documento, sin tocar un centavo>"
}
"""));
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{"prepareToken": "<el prepareToken que devolvió /v1/dte/prepare>", "documento": "<preparado.documento, sin tocar un centavo>"})
peticion, _ := http.NewRequest("POST", base+"/v1/dte/sign", 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.
Firmado sin respuesta del MH; queda en contingencia.
Cuerpo mal formado, 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).
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.
prepare_token_invalid (vencido, de otra llave, o el documento cambió) o mh_rejected.
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.
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.