ComprobadoDocumentaciónIngresar

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:

  1. Generar la Idempotency-Key desde tu propia venta (no al azar en cada intento).
  2. Emitir con un timeout generoso.
  3. Reintentar los fallos de red y los 429/502/503/504 con la misma clave y espera creciente.
  4. Tratar un comprobante incierto consultándolo, nunca emitiéndolo de nuevo con otra clave.
  5. Guardar id, numero y cae, 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.