API: ejemplos de integración
Ejemplos listos para adaptar. Todos hacen lo mismo, que es lo que tiene que hacer una integración bien hecha:
- Generar la
Idempotency-Keydesde tu propia venta (no al azar en cada intento). - Emitir con un timeout generoso.
- Reintentar los fallos de red y los
429/502/503/504con la misma clave y espera creciente. - Tratar un comprobante
inciertoconsultándolo, nunca emitiéndolo de nuevo con otra clave. - Guardar
id,numeroycae, y bajar el PDF.
Antes de empezar leé API: primeros pasos. En todos los ejemplos la clave se lee de la variable de entorno COMPROBADO_KEY y la dirección de COMPROBADO_URL (https://TU-DOMINIO).
curl: emitir y consultar
export COMPROBADO_URL="https://TU-DOMINIO"
export COMPROBADO_KEY="ask_live_XXXXXXXX"
# Emitir (la clave de idempotencia sale del número de pedido de tu sistema)
curl -sS -X POST "$COMPROBADO_URL/v1/invoices" \
--max-time 90 \
-H "Authorization: Bearer $COMPROBADO_KEY" \
-H "Idempotency-Key: pedido-1001" \
-H "Content-Type: application/json" \
-d '{
"puntoVenta": 1,
"receptor": { "docTipo": "dni", "docNro": "30111222", "condicionIva": "consumidorFinal", "nombre": "Ana Pérez" },
"items": [{ "descripcion": "Camisa talle M", "cantidad": 2, "precioUnitario": 1500.5 }]
}'
# Si se cortó la conexión: repetir EXACTAMENTE lo mismo (misma clave y mismo cuerpo).
# No genera otro comprobante: devuelve el resultado del primero o lo consulta en ARCA.
# Consultar y bajar el PDF
curl -sS "$COMPROBADO_URL/v1/invoices/inv_f7524f4f1afc4a568fa70d0c199bb4b4" -H "Authorization: Bearer $COMPROBADO_KEY"
curl -sS -o factura.pdf "$COMPROBADO_URL/v1/invoices/inv_f7524f4f1afc4a568fa70d0c199bb4b4/pdf" -H "Authorization: Bearer $COMPROBADO_KEY"
JavaScript (Node 18 o superior)
Sin dependencias: usa el fetch que trae Node. Corre en tu servidor, nunca en el navegador.
const BASE = process.env.COMPROBADO_URL;
const KEY = process.env.COMPROBADO_KEY;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Estos códigos y los cortes de red se reintentan con la MISMA Idempotency-Key.
const REINTENTABLES = new Set([429, 502, 503, 504]);
async function llamar(metodo, ruta, { cuerpo, idempotencyKey } = {}) {
const headers = { Authorization: `Bearer ${KEY}` };
if (cuerpo) headers['Content-Type'] = 'application/json';
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
return fetch(BASE + ruta, {
method: metodo,
headers,
body: cuerpo ? JSON.stringify(cuerpo) : undefined,
signal: AbortSignal.timeout(90_000), // emitir puede tardar: no cortes antes de 90 s
});
}
/** Emite un comprobante. Devuelve el comprobante ({ estado, numero, cae, … }) o lanza un Error con el detalle. */
export async function emitir(cuerpo, idempotencyKey, maxIntentos = 5) {
for (let intento = 1; ; intento++) {
let res;
try {
res = await llamar('POST', '/v1/invoices', { cuerpo, idempotencyKey });
} catch (e) {
// Timeout o corte de red: no sabemos si llegó. Se repite igual con la misma clave.
if (intento >= maxIntentos) throw new Error(`Sin respuesta de Comprobado: ${e.message}`);
await sleep(2 ** intento * 1000);
continue;
}
if (res.status === 201 || res.status === 202) {
const factura = await res.json();
if (factura.estado === 'pendiente' && intento < maxIntentos) {
await sleep(2 ** intento * 1000); // otro pedido con esta clave está en curso
continue;
}
return factura; // autorizado | rechazado | incierto | pendiente
}
const problema = await res.json().catch(() => ({}));
if (REINTENTABLES.has(res.status) && intento < maxIntentos) {
const espera = Number(res.headers.get('retry-after')) || 2 ** intento;
await sleep(espera * 1000);
continue;
}
// 400, 401, 403, 409, 422…: hay algo que corregir. `errores[].humano` explica qué.
const e = problema.errores?.[0]?.humano;
const error = new Error(
e ? `${e.codigo}: ${e.titulo}. ${e.comoSolucionarlo}` : `HTTP ${res.status}`,
);
error.status = res.status;
error.problema = problema; // para guardar invoiceId, arca, traceId…
throw error;
}
}
/** Un comprobante incierto se resuelve consultándolo: Comprobado le pregunta a ARCA si lo registró. */
export async function resolverIncierto(id, intentos = 10) {
for (let i = 0; i < intentos; i++) {
const res = await llamar('GET', `/v1/invoices/${id}`);
const f = await res.json();
if (f.estado !== 'incierto') return f;
await sleep(5000);
}
throw new Error(
`El comprobante ${id} sigue incierto: consultalo más tarde, no lo emitas de nuevo.`,
);
}
// Uso
const venta = { id: 1001, dni: '30111222', nombre: 'Ana Pérez', total: 3001 };
const factura = await emitir(
{
puntoVenta: 1,
receptor: {
docTipo: 'dni',
docNro: venta.dni,
condicionIva: 'consumidorFinal',
nombre: venta.nombre,
},
items: [{ descripcion: 'Venta web', cantidad: 1, precioUnitario: venta.total }],
},
`venta-${venta.id}`, // la clave sale de TU venta, siempre la misma para esa venta
);
if (factura.estado === 'autorizado') {
console.log(
`Factura ${String(factura.puntoVenta).padStart(4, '0')}-${String(factura.numero).padStart(8, '0')} CAE ${factura.cae}`,
);
} else if (factura.estado === 'incierto') {
const resuelto = await resolverIncierto(factura.id);
console.log('Resultado final:', resuelto.estado);
} else {
console.log(
'Rechazada:',
factura.errores.map((e) => e.humano.titulo),
);
}
Python (3.9 o superior)
Con la librería requests (pip install requests).
import os
import time
import requests
BASE = os.environ["COMPROBADO_URL"]
KEY = os.environ["COMPROBADO_KEY"]
REINTENTABLES = {429, 502, 503, 504}
class ComprobadoError(Exception):
def __init__(self, status, problema):
humano = (problema.get("errores") or [{}])[0].get("humano") or {}
super().__init__(f'{humano.get("codigo", status)}: {humano.get("titulo", "")} {humano.get("comoSolucionarlo", "")}'.strip())
self.status = status
self.problema = problema # invoiceId, arca, traceId…
def llamar(metodo, ruta, cuerpo=None, idempotency_key=None):
headers = {"Authorization": f"Bearer {KEY}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
# timeout generoso: emitir puede tardar unos segundos
return requests.request(metodo, BASE + ruta, json=cuerpo, headers=headers, timeout=90)
def emitir(cuerpo, idempotency_key, max_intentos=5):
"""Emite un comprobante y devuelve el dict de la respuesta (estado, numero, cae…)."""
for intento in range(1, max_intentos + 1):
try:
res = llamar("POST", "/v1/invoices", cuerpo, idempotency_key)
except (requests.ConnectionError, requests.Timeout):
# No sabemos si llegó: se repite con la MISMA clave.
if intento == max_intentos:
raise
time.sleep(2 ** intento)
continue
if res.status_code in (201, 202):
factura = res.json()
if factura["estado"] == "pendiente" and intento < max_intentos:
time.sleep(2 ** intento)
continue
return factura # autorizado | rechazado | incierto | pendiente
if res.status_code in REINTENTABLES and intento < max_intentos:
time.sleep(int(res.headers.get("Retry-After", 2 ** intento)))
continue
raise ComprobadoError(res.status_code, res.json() if res.content else {})
raise RuntimeError("sin respuesta")
def resolver_incierto(id_comprobante, intentos=10):
"""Un incierto se resuelve consultándolo; nunca emitiendo de nuevo."""
for _ in range(intentos):
factura = llamar("GET", f"/v1/invoices/{id_comprobante}").json()
if factura["estado"] != "incierto":
return factura
time.sleep(5)
raise RuntimeError(f"{id_comprobante} sigue incierto: consultalo más tarde")
def descargar_pdf(id_comprobante, destino):
res = llamar("GET", f"/v1/invoices/{id_comprobante}/pdf")
res.raise_for_status()
with open(destino, "wb") as f:
f.write(res.content)
if __name__ == "__main__":
venta = {"id": 1001, "dni": "30111222", "nombre": "Ana Pérez", "total": 3001}
factura = emitir(
{
"puntoVenta": 1,
"receptor": {"docTipo": "dni", "docNro": venta["dni"], "condicionIva": "consumidorFinal", "nombre": venta["nombre"]},
"items": [{"descripcion": "Venta web", "cantidad": 1, "precioUnitario": venta["total"]}],
},
f"venta-{venta['id']}",
)
if factura["estado"] == "incierto":
factura = resolver_incierto(factura["id"])
if factura["estado"] == "autorizado":
print(f'Factura {factura["puntoVenta"]:04d}-{factura["numero"]:08d} CAE {factura["cae"]}')
descargar_pdf(factura["id"], f'factura-{factura["numero"]}.pdf')
else:
print("No se emitió:", [e["humano"]["titulo"] for e in factura["errores"]])
C# (.NET 8 o superior)
Solo HttpClient y System.Text.Json. En una aplicación real registrá el HttpClient una sola vez (por ejemplo con AddHttpClient) en lugar de crearlo en el constructor.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
public sealed record Comprobante(string Id, string Estado, int PuntoVenta, long Numero, string? Cae, JsonElement Errores);
public sealed class ComprobadoClient
{
private static readonly HashSet<HttpStatusCode> Reintentables =
[HttpStatusCode.TooManyRequests, HttpStatusCode.BadGateway, HttpStatusCode.ServiceUnavailable, HttpStatusCode.GatewayTimeout];
private readonly HttpClient _http;
public ComprobadoClient(string baseUrl, string apiKey)
{
_http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(90) };
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
}
/// <summary>Emite un comprobante. La clave de idempotencia sale de TU venta y es la misma en cada reintento.</summary>
public async Task<Comprobante> EmitirAsync(object pedido, string idempotencyKey, int maxIntentos = 5)
{
for (var intento = 1; ; intento++)
{
HttpResponseMessage res;
try
{
using var req = new HttpRequestMessage(HttpMethod.Post, "/v1/invoices") { Content = JsonContent.Create(pedido) };
req.Headers.Add("Idempotency-Key", idempotencyKey);
res = await _http.SendAsync(req);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
{
// Timeout o corte de red: no sabemos si llegó. Se repite con la MISMA clave.
if (intento >= maxIntentos) throw;
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, intento)));
continue;
}
if (res.StatusCode is HttpStatusCode.Created or HttpStatusCode.Accepted)
{
var factura = (await res.Content.ReadFromJsonAsync<Comprobante>(new JsonSerializerOptions(JsonSerializerDefaults.Web)))!;
if (factura.Estado == "pendiente" && intento < maxIntentos)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, intento)));
continue;
}
return factura; // autorizado | rechazado | incierto | pendiente
}
if (Reintentables.Contains(res.StatusCode) && intento < maxIntentos)
{
var espera = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, intento));
await Task.Delay(espera);
continue;
}
// 400, 401, 403, 409, 422…: el cuerpo explica qué corregir (errores[].humano).
throw new HttpRequestException($"Comprobado respondió {(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
}
}
/// <summary>Un comprobante incierto se resuelve consultándolo (Comprobado le pregunta a ARCA); nunca emitiéndolo otra vez.</summary>
public async Task<Comprobante> ResolverInciertoAsync(string id, int intentos = 10)
{
for (var i = 0; i < intentos; i++)
{
var f = (await _http.GetFromJsonAsync<Comprobante>($"/v1/invoices/{id}", new JsonSerializerOptions(JsonSerializerDefaults.Web)))!;
if (f.Estado != "incierto") return f;
await Task.Delay(TimeSpan.FromSeconds(5));
}
throw new TimeoutException($"{id} sigue incierto: consultalo más tarde, no lo emitas de nuevo.");
}
public async Task<byte[]> PdfAsync(string id) => await _http.GetByteArrayAsync($"/v1/invoices/{id}/pdf");
}
Y el uso, en tu Program.cs (las instrucciones de nivel superior van antes de las clases, o en otro archivo):
var client = new ComprobadoClient(Environment.GetEnvironmentVariable("COMPROBADO_URL")!, Environment.GetEnvironmentVariable("COMPROBADO_KEY")!);
var factura = await client.EmitirAsync(new
{
puntoVenta = 1,
receptor = new { docTipo = "dni", docNro = "30111222", condicionIva = "consumidorFinal", nombre = "Ana Pérez" },
items = new[] { new { descripcion = "Venta web", cantidad = 1, precioUnitario = 3001m } },
}, "venta-1001");
if (factura.Estado == "incierto") factura = await client.ResolverInciertoAsync(factura.Id);
Console.WriteLine(factura.Estado == "autorizado" ? $"Factura {factura.PuntoVenta:0000}-{factura.Numero:00000000} CAE {factura.Cae}" : $"No se emitió ({factura.Estado})");
Patrones útiles
Probar sin emitir
Mientras armás la integración, usá POST /v1/invoices/preview con el mismo cuerpo: valida, calcula y devuelve el tipo y los importes, sin consumir números. Es la forma de comprobar que tus datos son correctos antes de emitir.
Qué guardar de cada comprobante
| Dato | Para qué |
|---|---|
id |
Consultar, pedir el PDF y reconciliar un incierto |
numero y puntoVenta |
El número fiscal (0001-00000008) |
cae y caeVencimiento |
Lo que respalda el comprobante ante ARCA |
La Idempotency-Key |
Para reintentar sin duplicar |
arcaCallId |
Diagnóstico: GET /v1/arca/calls/{id} muestra el intercambio con ARCA |
Muchos comprobantes
Si tenés que emitir más de unas decenas de una vez (la facturación del mes, las cuotas), no hagas un pedido por comprobante: mandá un lote (POST /v1/invoice-batches). Valida todo antes de empezar, emite en segundo plano respetando el orden de numeración y se retoma solo si hay un corte. Ver Facturación masiva.
Conciliar contra tu sistema
Para detectar diferencias entre lo que tu sistema cree que emitió y lo que hay en Comprobado, listá el período (GET /v1/invoices?desde=…&hasta=…&take=100&pagina=N) y compará por numero y total. Buscá especialmente los incierto y los pendiente.