Desarrolladores · API v1

Guía de integración

Los pasos que sigue una tienda o un sistema para que cada pedido termine en un comprobante aceptado por la DGI, en el orden en que se hacen. Todos los ejemplos funcionan en el sandbox (el ambiente de pruebas).

Claves y permisos

Cada sistema usa su propia clave de API, en la cabecera X-API-Key o en Authorization: Bearer. La crea un administrador de la cuenta en el portal, en «Claves de API», y se muestra una sola vez. Al crearla, se le asignan solo los permisos que necesita:

PermisoQué permite
readConsultar: comprobantes, XML, PDF, listados, catálogo, consumo y reportes
emitEmitir: la emisión, la vista previa, las devoluciones, las anulaciones y el envío a otra empresa
receiveRecibir: los comprobantes recibidos de proveedores y su rechazo comercial
reportsReporte diario: el envío del resumen diario a la DGI
settingsConfiguración: el perfil del emisor, la numeración (CAE), los webhooks y los avisos

Una tienda que emite y después descarga el PDF necesita emit y read. Ninguna clave puede crear usuarios ni otras claves, ni subir el certificado de firma. Eso lo hace un administrador desde el portal, para que una clave expuesta no permita tomar el control de la cuenta.

Primero, el sandbox

Una clave de prueba (cfe_test_…) emite en el sandbox de su cuenta, con la misma API y el mismo pedido que una clave real. El sandbox tiene numeración propia (serie SB), firma con un certificado de prueba y no cuenta para el consumo. Nada llega a la DGI: cada comprobante recibe una respuesta de prueba con los estados y los códigos de la DGI, y pasa por los mismos pasos que en producción. El PDF y el ticket dicen «SIN VALOR FISCAL — AMBIENTE DE PRUEBAS: no se envió a la DGI», y cada comprobante, evento y webhook incluye "sandbox": true.

La cabecera Cfe-Sandbox-Outcome (o la configuración de la clave, al crearla) elige la respuesta de prueba. Sin ninguna de las dos, es accepted:

Elija la respuesta de la DGI (de prueba)

curl -X POST https://api.okcfe.tinylab.com.uy/v1/invoices \
  -H "x-api-key: $CLAVE_DE_PRUEBA" -H "content-type: application/json" \
  -H "idempotency-key: invoice:shopify:mi-tienda:5012345" \
  -H "cfe-sandbox-outcome: acceptedrejected:E04delay:30timeout" -d @pedido.json
  1. 201 Created · al instante, con el comprobante pendiente

    { "id": "7c2e9a4b-1d3f-4e8a-9b6c-2f5d8e1a3c40",
      "cfe_type": 101, "series": "SB", "number": 61,
      "status": "PENDING", "status_name": "pendiente de envío", "sandbox": true }
  2. okCFE envía el comprobante (el envío queda recibido, AS) y consulta su respuesta, en pocos segundos

  3. Respuesta de prueba, con el formato de la DGI

    AEAceptado. En producción, es el documento fiscal válido.

  4. Su sistema consulta GET /v1/invoices/{id} · extracto

    { "id": "7c2e9a4b-1d3f-4e8a-9b6c-2f5d8e1a3c40",
      "status": "ACCEPTED", "status_name": "aceptado",
      "rejection_reason": null,
      "rejection_causes": [],
      "submission_id": "5b1e8c3a-9f2d-4c7b-8e6a-1d4f9c2b7e53" }
  5. Y llega el webhook invoice.processed, con los totales del envío

    { "id": 4521, "event": "invoice.processed", "event_id": 8812,
      "payload": { "submission_id": "5b1e8c3a-9f2d-4c7b-8e6a-1d4f9c2b7e53",
        "accepted": 1, "rejected": 0, "observed": 0, "unresolved": 0, "reissue": 0 },
      "sandbox": true }
  1. 201 Created · al instante, con el comprobante pendiente

    { "id": "7c2e9a4b-1d3f-4e8a-9b6c-2f5d8e1a3c40",
      "cfe_type": 101, "series": "SB", "number": 61,
      "status": "PENDING", "status_name": "pendiente de envío", "sandbox": true }
  2. okCFE envía el comprobante (el envío queda recibido, AS) y consulta su respuesta, en pocos segundos

  3. Respuesta de prueba, con el formato de la DGI

    BERechazado, con el motivo E04: la firma electrónica no es válida. El número queda anulado.

  4. Su sistema consulta GET /v1/invoices/{id} · extracto

    { "id": "7c2e9a4b-1d3f-4e8a-9b6c-2f5d8e1a3c40",
      "status": "REJECTED", "status_name": "rechazado",
      "rejection_reason": "E04: Firma electrónica no es válida",
      "rejection_causes": [
        { "code": "E04", "gloss": "Firma electrónica no es válida", "detail": null }
      ],
      "submission_id": "5b1e8c3a-9f2d-4c7b-8e6a-1d4f9c2b7e53" }
  5. Y llega el webhook invoice.processed, con los totales del envío

    { "id": 4521, "event": "invoice.processed", "event_id": 8812,
      "payload": { "submission_id": "5b1e8c3a-9f2d-4c7b-8e6a-1d4f9c2b7e53",
        "accepted": 0, "rejected": 1, "observed": 0, "unresolved": 0, "reissue": 0 },
      "sandbox": true }
  1. 201 Created · al instante, con el comprobante pendiente

    { "id": "7c2e9a4b-1d3f-4e8a-9b6c-2f5d8e1a3c40",
      "cfe_type": 101, "series": "SB", "number": 61,
      "status": "PENDING", "status_name": "pendiente de envío", "sandbox": true }
  2. okCFE envía el comprobante y espera para consultar su respuesta

    30 s de espera · en esta demo, 3 s

    El envío queda recibido (AS) con la hora desde la que se consulta su respuesta (available_at en GET /v1/submissions/{id}), como lo indica la DGI. okCFE no consulta antes de esa hora.

    Mientras tanto, GET /v1/invoices/{id} · extracto

    { "status": "PENDING", "status_name": "pendiente de envío",
      "submission_id": "5b1e8c3a-9f2d-4c7b-8e6a-1d4f9c2b7e53" }
  3. Respuesta de prueba, con el formato de la DGI

    AEAceptado, en la consulta de los 30 segundos.

  4. Su sistema consulta GET /v1/invoices/{id} · extracto

    { "id": "7c2e9a4b-1d3f-4e8a-9b6c-2f5d8e1a3c40",
      "status": "ACCEPTED", "status_name": "aceptado",
      "rejection_reason": null,
      "rejection_causes": [],
      "submission_id": "5b1e8c3a-9f2d-4c7b-8e6a-1d4f9c2b7e53" }
  5. Y llega el webhook invoice.processed, con los totales del envío

    { "id": 4521, "event": "invoice.processed", "event_id": 8812,
      "payload": { "submission_id": "5b1e8c3a-9f2d-4c7b-8e6a-1d4f9c2b7e53",
        "accepted": 1, "rejected": 0, "observed": 0, "unresolved": 0, "reissue": 0 },
      "sandbox": true }
  1. 201 Created · al instante, con el comprobante pendiente

    { "id": "7c2e9a4b-1d3f-4e8a-9b6c-2f5d8e1a3c40",
      "cfe_type": 101, "series": "SB", "number": 61,
      "status": "PENDING", "status_name": "pendiente de envío", "sandbox": true }
  2. okCFE envía el comprobante y espera para consultar su respuesta

    30 s de espera · en esta demo, 3 s

    El envío queda recibido (AS), pero cada consulta de su respuesta vence sin contestar. okCFE vuelve a consultar, cada vez más espaciado (2, 4, 8, 16 segundos…).

    Mientras tanto, GET /v1/invoices/{id} · extracto

    { "status": "PENDING", "status_name": "pendiente de envío",
      "submission_id": "5b1e8c3a-9f2d-4c7b-8e6a-1d4f9c2b7e53" }
  3. Respuesta de prueba, con el formato de la DGI

    AEAceptado, en la primera consulta después de los 30 segundos (puede llegar cerca del minuto).

  4. Su sistema consulta GET /v1/invoices/{id} · extracto

    { "id": "7c2e9a4b-1d3f-4e8a-9b6c-2f5d8e1a3c40",
      "status": "ACCEPTED", "status_name": "aceptado",
      "rejection_reason": null,
      "rejection_causes": [],
      "submission_id": "5b1e8c3a-9f2d-4c7b-8e6a-1d4f9c2b7e53" }
  5. Y llega el webhook invoice.processed, con los totales del envío

    { "id": 4521, "event": "invoice.processed", "event_id": 8812,
      "payload": { "submission_id": "5b1e8c3a-9f2d-4c7b-8e6a-1d4f9c2b7e53",
        "accepted": 1, "rejected": 0, "observed": 0, "unresolved": 0, "reissue": 0 },
      "sandbox": true }

Lo mismo que en producción: el status del comprobante cambia y el evento invoice.processed llega al webhook y al feed; un rechazo también envía dgi.document_rejected a los destinos que lo eligen. Los identificadores son de ejemplo.

ValorRespuesta de prueba
acceptedAcepta el comprobante (estado AE). Es la respuesta si no se indica ninguna.
rejected:E04Rechaza el comprobante (estado BE) con el motivo indicado, un código de E01 a E15, los que emite la DGI; sin código, E05. E04: la firma electrónica no es válida. E05: el comprobante no cumple el formato de la DGI. Como en la DGI, E02 (el número ya existe) no anula: el comprobante válido es el ya registrado y queda ACCEPTED, con la respuesta original en el envío; E09 lo deja REJECTED sin anular el número (reissuable: true; en el sandbox no se reemite). Otro código (por ejemplo E25, un motivo comercial que la DGI no emite) es 422 invalid_sandbox_outcome. No hay respuesta «observado» (CE): la DGI sólo observa comprobantes de contingencia, los emitidos en papel, y el ambiente de pruebas no los lleva.
delay:30Acepta, pero la respuesta se puede consultar a partir de los 30 segundos (de 1 a 120)
timeoutLas consultas de la respuesta vencen durante 30 segundos; después, acepta

Lo que incluye el sandbox

Claves
Hasta 10 claves de prueba activas, además de las de su membresía
Por mes
10.000 comprobantes de prueba
Guardados
Hasta 100.000; al llegar a ese número, la API responde 409 sandbox_full (el sandbox está lleno) hasta que se vacía
Limpieza
Se vacía solo después de 30 días sin emitir. También puede vaciarlo desde el portal en cualquier momento: las claves se mantienen y la numeración vuelve a 1

Para pasar a producción, en su código solo se cambia la clave de prueba por la real. Además, la cuenta debe tener cargados su certificado de firma y su CAE: lo indica el control de la sección siguiente.

¿Puede emitir?

Con la clave real, GET /v1/tenant/readiness indica si la cuenta puede emitir (can_emit) y qué falta. Cada control trae un código estable, para mostrarlo en la pantalla de su conector. Una cuenta nueva sin CAE responde can_emit: false; cuando se carga, pasa a true. Los códigos más frecuentes:

CódigoQué significa y qué hacer
certificate_platformLa cuenta firma con el certificado de prueba de okCFE. Suba el certificado de la empresa antes de emitir comprobantes reales.
certificate_missingFalta el certificado de firma. Súbalo desde el portal.
certificate_expired, certificate_revokedEl certificado de firma venció o fue revocado. Suba uno vigente.
certificate_not_yet_validEl certificado todavía no está vigente. Se podrá firmar desde su fecha de inicio.
certificate_expiringEl certificado vence pronto. Renuévelo antes del vencimiento.
no_numberingNo hay numeración (CAE) cargada. Cargue el CAE de cada tipo de comprobante que emite.
cae_exhausted, cae_expiredSe agotó o venció la numeración de un tipo y serie. Cargue un CAE nuevo.
cae_running_out, cae_expiringQuedan pocos números o la numeración vence pronto. Solicite el próximo CAE a tiempo.
no_type_enabledNingún tipo de comprobante está habilitado. Solicite a soporte que lo habilite.
branch_missing, branch_not_defaultFalta la sucursal registrada en la DGI, o ninguna está marcada como principal. Complétela en el perfil del emisor.
membership_suspended, membership_expiredLa cuenta está suspendida o la membresía venció. Comuníquese con soporte.
membership_limit_reached, membership_near_limitSe alcanzó el límite de emisiones de la membresía, o falta poco.

Emitir

Un e-Ticket (tipo 101) con su envío, a partir de un pedido de una tienda con precios con IVA incluido:

curl -X POST https://api.okcfe.tinylab.com.uy/v1/invoices \
  -H "x-api-key: $CLAVE" \
  -H "idempotency-key: invoice:shopify:mi-tienda:5012345" \
  -H "content-type: application/json" \
  -d '{
    "cfe_type": 101,
    "external_id": "#1001",
    "gross_amounts": 1,
    "lines": [
      { "description": "Remera de algodón", "quantity": "2",
        "unit_price": "1220.00", "vat_rate": "22" },
      { "description": "Envío a domicilio", "quantity": "1",
        "unit_price": "244.00", "vat_rate": "22",
        "codes": [{ "type": "INT1", "code": "ENVIO" }] }
    ]
  }'
import { CfeClient, IdempotencyKey } from '@cfe/sdk'

const cfe = new CfeClient({
  baseUrl: 'https://api.okcfe.tinylab.com.uy',
  apiKey: process.env.CFE_API_KEY!,
})

const cfeDelPedido = await cfe.emit(
  {
    cfe_type: 101,
    external_id: '#1001',
    gross_amounts: 1,
    lines: [
      { description: 'Remera de algodón', quantity: '2',
        unit_price: '1220.00', vat_rate: '22' },
      { description: 'Envío a domicilio', quantity: '1',
        unit_price: '244.00', vat_rate: '22',
        codes: [{ type: 'INT1', code: 'ENVIO' }] },
    ],
  },
  { idempotencyKey: IdempotencyKey.forInvoice('shopify', 'mi-tienda', 5012345) },
)
use Cfe\Sdk\Client;
use Cfe\Sdk\IdempotencyKey;
use Symfony\Component\HttpClient\Psr18Client;

$cfe = new Client('https://api.okcfe.tinylab.com.uy', getenv('CFE_API_KEY'), new Psr18Client());

$cfeDelPedido = $cfe->emit([
    'cfe_type' => 101,
    'external_id' => '#1001',
    'gross_amounts' => 1,
    'lines' => [
        ['description' => 'Remera de algodón', 'quantity' => '2',
         'unit_price' => '1220.00', 'vat_rate' => '22'],
        ['description' => 'Envío a domicilio', 'quantity' => '1',
         'unit_price' => '244.00', 'vat_rate' => '22',
         'codes' => [['type' => 'INT1', 'code' => 'ENVIO']]],
    ],
], IdempotencyKey::forInvoice('shopify', 'mi-tienda', 5012345));
import uy.cfe.sdk.CfeClient;
import uy.cfe.sdk.IdempotencyKey;

CfeClient cfe = CfeClient.create("https://api.okcfe.tinylab.com.uy", System.getenv("CFE_API_KEY"));

JsonNode cfeDelPedido = cfe.emit(Map.of(
    "cfe_type", 101,
    "external_id", "#1001",
    "gross_amounts", 1,
    "lines", List.of(
        Map.of("description", "Remera de algodón", "quantity", "2",
               "unit_price", "1220.00", "vat_rate", "22"),
        Map.of("description", "Envío a domicilio", "quantity", "1",
               "unit_price", "244.00", "vat_rate", "22",
               "codes", List.of(Map.of("type", "INT1", "code", "ENVIO"))))),
    IdempotencyKey.forInvoice("shopify", "mi-tienda", 5012345));
using Cfe.Sdk;

using var cfe = new CfeClient("https://api.okcfe.tinylab.com.uy", Environment.GetEnvironmentVariable("CFE_API_KEY")!);

JsonNode cfeDelPedido = await cfe.EmitAsync(new
{
    CfeType = 101,
    ExternalId = "#1001",
    GrossAmounts = 1,
    Lines = new object[]
    {
        new { Description = "Remera de algodón", Quantity = "2",
              UnitPrice = "1220.00", VatRate = "22" },
        new { Description = "Envío a domicilio", Quantity = "1",
              UnitPrice = "244.00", VatRate = "22",
              Codes = new[] { new { Type = "INT1", Code = "ENVIO" } } },
    },
}, IdempotencyKey.ForInvoice("shopify", "mi-tienda", 5012345));
  • Responde 201 Created (el comprobante se creó) con el comprobante numerado, firmado y en PENDING (pendiente de la respuesta de la DGI). okCFE lo envía a la DGI sin intervención.
  • La Idempotency-Key se arma con el pedido: si la solicitud se repite por un corte o un tiempo de espera agotado, la API devuelve el mismo comprobante. Con la misma clave y otro cuerpo, responde 409 idempotency_key_reused (esa clave ya se usó con otros datos).
  • external_id es el número de pedido que ve el cliente, único por cuenta. Un segundo comprobante con el mismo número recibe 409 external_id_taken (ese pedido ya tiene comprobante), con el id del existente. Se busca con GET /v1/invoices?external_id=%231001.
  • vat_rate en lugar del indicador de facturación: la tasa que su tienda ya calculó (22, 10 o 0). okCFE la traduce al indicador de la DGI (3: tasa básica, 2: tasa mínima, 1: exento).
  • El envío es una línea más, con el código interno INT1 ENVIO: así una devolución lo identifica sola.
  • Antes de emitir, POST /v1/invoices/preview con el mismo cuerpo devuelve los totales y todos los errores juntos, cada uno con su ruta (lines[1].vat_rate), sin usar un número.

Una solicitud que la API rechaza nunca usa un número de comprobante.

De qué campo de su tienda sale cada dato

Los datos mínimos de un pedido de Shopify, Magento 2 o WooCommerce. Los conectores para instalar todavía no están disponibles: esto es lo que hace su desarrollador con la API o un SDK.

En okCFEShopify, orderMagento 2, sales_orderWooCommerce, WC_Order
external_idname (#1001)increment_idget_order_number()
Idempotency-Keyshopify:{tienda}:{id}magento:{tienda}:{entity_id}woocommerce:{sitio}:{id}
gross_amounts: 1taxes_included: trueprecios con IVA (price_includes_tax)prices_include_tax
lines[].descriptionline_items[].titleitems[].nameget_name()
lines[].quantityquantityqty_orderedget_quantity()
lines[].unit_pricepriceprice_incl_taxget_subtotal() / quantity
lines[].vat_ratetax_lines[0].rate × 100tax_percentel rate del impuesto de la línea
La línea de envío (ENVIO)shipping_lines[]shipping_incl_taxget_shipping_methods()

Órdenes: validar antes de cobrar

Cuando la tienda cobra antes de facturar, una orden valida la factura entera al confirmar el carrito y la emite cuando el pago se confirma. Crear una orden no usa un número de la numeración ni cuenta en el consumo del mes.

  1. Al confirmar el carrito, POST /v1/orders con el mismo cuerpo que POST /v1/invoices. Responde los totales calculados, el número de la orden (ORD-0000000012) y su version, o el 422 con cada error, antes de cobrar.
  2. Si el carrito cambia, PUT /v1/orders/{id} con el pedido completo (lo que no viene deja de estar) e If-Match con la versión leída. La versión sube.
  3. El cobro usa totals.payable y el número ORD-… como referencia del pago.
  4. Al confirmarse el pago, POST /v1/orders/{id}/execute con la version cobrada: se emite la factura y se envía a la DGI. Repetirlo devuelve la misma factura; otra versión responde 409 order_version_mismatch (la orden cambió) y no emite.
curl -X POST https://api.okcfe.tinylab.com.uy/v1/orders -H "x-api-key: $CLAVE" -d @pedido.json
# 201: { "id": "0192…", "number": "ORD-0000000057", "state": "OPEN", "version": 1, "totals": { "payable": "1220.00", … } }
curl -X POST https://api.okcfe.tinylab.com.uy/v1/orders/0192…/execute -H "x-api-key: $CLAVE" \
  -d '{"version": 1, "paid_amount": "1220.00", "payment_reference": "MP-88123"}'
# 201: { "order": { "state": "EXECUTED", "invoice_id": "…" }, "invoice": { "series": "A", … } }
EstadoSignificado
OPEN · AbiertaLa factura está validada y espera el pago. Se puede modificar, cancelar o facturar.
EXECUTED · FacturadaSe emitió su comprobante (invoice_id). No cambia más.
CANCELLED · CanceladaSe canceló sin facturar (DELETE /v1/orders/{id}). Su external_id queda libre.
EXPIRED · VencidaPasó su plazo (expires_at, 7 días desde el último cambio) sin facturarse. Su external_id queda libre.

Si la facturación falla por algo que cambió desde que se creó la orden (el cupo del mes, la numeración, el certificado), la orden sigue abierta con el problema en last_error: resuelva la causa y facture de nuevo con la misma versión. La factura y la orden quedan unidas: la factura trae order_id y order_number, y el evento order.executed avisa cada factura de una orden. POST /v1/orders/{id}/duplicate crea una orden nueva con el pedido de otra, para facturar de nuevo una vencida o cancelada.

Estados y respuestas de la DGI

El campo status de un comprobante tiene uno de estos valores:

statusQué significa
PENDINGPendiente: firmado y a la espera de la respuesta de la DGI. Todavía no es un documento fiscal válido.
ACCEPTEDAceptado: la DGI lo aceptó. Es el documento fiscal válido.
REJECTEDRechazado: la DGI lo rechazó. Queda anulado y su número no se vuelve a usar, salvo con los motivos E02 y E09.
CANCELLEDAnulado: no tiene efecto fiscal.

La DGI responde con estos códigos de estado, que aparecen en los envíos (GET /v1/submissions/{id}) y en el portal:

CódigoSe refiere aQué significa
ASEnvíoLa DGI recibió el envío. El resultado de cada comprobante llega después.
BSEnvíoLa DGI rechazó el envío completo. Ningún comprobante se procesó; okCFE los envía de nuevo.
BAEnvíoLa DGI rechazó el archivo del envío. Ningún comprobante se procesó.
AEComprobanteAceptado. Es el documento fiscal válido.
BEComprobanteRechazado. Queda anulado, salvo con los motivos E02 y E09.
CEComprobanteObservado: sólo un comprobante de contingencia (CFC). La DGI no lo rechaza, porque vale el papel; se informa de nuevo corregido o se anula el papel.
FEComprobanteRecibido y reliquidado: la DGI lo registró y corrigió importes.

Un rechazo trae cada motivo en rejection_causes, con su code, el texto oficial de la DGI (gloss) y el detalle que haya agregado la DGI (detail, o null). Los motivos que más se ven:

CódigoQué significa y qué hacer
E02La DGI ya tiene un comprobante con ese tipo y número. No se anula: se revisa si la DGI lo aceptó antes.
E03El tipo y el número no corresponden a la numeración autorizada (CAE). Verifique la numeración cargada.
E04La firma electrónica no es válida. Verifique el certificado de firma.
E05El comprobante no cumple el formato de la DGI. Corrija los datos y emita uno nuevo.
E07La fecha de firma no corresponde a la vigencia de la numeración. Verifique la fecha y el CAE.
E09El RUC del emisor o el tipo de comprobante no corresponden a la numeración (CAE). Cargue el CAE correcto y vuelva a emitir con el mismo número.
E10El tipo de cambio no es válido. Corrija el tipo de cambio y emita uno nuevo.
E12El comprobante de referencia no existe en los registros. Verifique el comprobante que se referencia.

Los motivos de rechazo de un envío empiezan con S (por ejemplo, S03: el certificado de firma no es válido) y las observaciones de un comprobante de contingencia, con C. La lista completa, con el texto de la DGI, está en GET /v1/catalog/codes (rejection_causes y dgi_states).

Errores

Todos los errores llegan como application/problem+json, con un code estable, un detail en el idioma de Accept-Language y el field. Un comprobante con datos que no validan trae todos sus errores juntos en errors, cada uno con la ruta del JSON enviado:

{
  "status": 422, "code": "invalid_cfe", "field": "NomItem", "detail": "…",
  "errors": [
    { "path": "lines[0].description", "code": "length", "field": "NomItem",
      "detail": "…", "params": { "min": 1, "max": 80, "got": 81 } },
    { "path": "recipient.number", "code": "invalid_ruc", "field": "DocRecep",
      "detail": "…", "params": {} }
  ]
}

En el ejemplo, length indica que la descripción supera los 80 caracteres e invalid_ruc, que el RUC del receptor no es válido. Con path se marca el campo del formulario; con code y params se escribe un mensaje propio. Los errores más frecuentes:

HTTPcodeQué pasó y qué hacer
422 · Datos no válidosinvalid_cfeHay datos del comprobante que corregir. Corrija lo que indica cada errors[].path; no se usó ningún número.
409 · Conflictoidempotency_key_reusedLa misma Idempotency-Key llegó con otro cuerpo. Una repetición debe enviar el mismo pedido.
409 · Conflictoexternal_id_takenEse número de pedido ya tiene comprobante; la respuesta trae su id.
429 · Demasiadas solicitudesrate_limitedSe superó el límite de solicitudes por minuto. Espere lo que indica Retry-After y vuelva a intentarlo.
429 · Demasiadas solicitudesquota_exceededSe alcanzó el límite de emisiones del mes de la membresía. La respuesta trae el límite, el uso y la fecha de renovación.
403 · Sin permisoscope_requiredLa clave no tiene el permiso que requiere la ruta; el error indica cuál falta.
502, 503, 504 · Servicio no disponible—Una interrupción momentánea. Repita la solicitud si es seguro: una consulta, una emisión con Idempotency-Key o una devolución.

Cada code que puede devolver cada ruta está en el contrato OpenAPI.

Webhooks y eventos

La respuesta de la DGI llega después del 201. Hay dos formas de recibirla, y se recomienda usar las dos:

  • Webhooks (POST /v1/webhooks): avisos automáticos a su sistema, que llegan en el momento. Puede tener varias direcciones de destino, cada una con su secreto y los eventos que elija (o todos); GET /v1/webhooks/events explica cada evento y POST /v1/webhooks/{id}/test envía uno de prueba. Van firmados en x-cfe-signature: sha256=… (HMAC-SHA256 del cuerpo, con el secreto de ese destino) y se reintentan; si un destino falla, los demás siguen recibiendo. Pueden llegar más de una vez: descarte los repetidos por event_id.
  • El feed (GET /v1/events?since=): los mismos eventos, que su sistema consulta con un cursor. Después de una interrupción, consulte desde el último cursor para recibir, en orden, todo lo que el webhook no pudo entregar.

El cuerpo de cada webhook es {"id", "event", "payload", "event_id", "sandbox"}; event_id es el id del mismo evento en el feed, y sandbox indica si viene del ambiente de pruebas. Hay veinte eventos, y cada destino elige los suyos:

EventoCuándo llega
invoice.emittedSe emitió un comprobante y quedó pendiente de la DGI (el de una devolución incluye refund)
invoice.processedLa DGI respondió: aceptado, rechazado u observado
invoice.reissue_requiredLa DGI rechazó el comprobante con un motivo que permite volver a emitirlo con el mismo número
contingency.correction_requiredLa DGI observó un comprobante de contingencia y hay que informarlo corregido
dgi.document_rejectedLa DGI rechazó comprobantes de un envío, con los motivos de cada uno
dgi.document_observedLa DGI aceptó comprobantes con observaciones
dgi.envelope_rejectedLa DGI rechazó un envío entero sin revisar sus comprobantes; okCFE los envía de nuevo
dgi.report_rejectedLa DGI rechazó el reporte diario de una fecha
dgi.acknowledgement_heldLlegó una respuesta de la DGI cuya firma no se pudo validar: queda retenida hasta que soporte la revise
reception.receivedUn proveedor envió comprobantes a la empresa: cuántos llegaron y cuántos se rechazaron
cae.running_lowA un tipo de comprobante le quedan pocos números en su CAE
cae.expiringEl CAE de un tipo vence pronto y no hay otro cargado
cae.exhaustedUn tipo ya no se puede emitir: su CAE no tiene números libres o venció
certificate.expiringEl certificado de firma electrónica vence pronto, o ya venció
order.executedSe facturó una orden: trae la orden, la referencia del pago y el comprobante
order.expiredUna orden venció sin facturarse
tenant.configuration_changedCambió la configuración de la cuenta
membership.limit_warningEl consumo llegó al aviso de un límite (80 %)
membership.limit_reachedEl consumo llegó al límite (100 %)
membership.renewedSe renovó la membresía

Un destino con "events": ["*"] recibe todos, también los que se agreguen más adelante. GET /v1/webhooks/{id}/deliveries muestra cada entrega con lo que respondió su sistema, y PATCH /v1/webhooks/{id} con "enabled": false lo pone en pausa: lo pendiente espera hasta que se reanude.

Las personas del equipo pueden recibir avisos por correo, WhatsApp, Telegram, Slack o Teams (/v1/notifications): un certificado por vencer, pocos números de CAE, un rechazo de la DGI. Cada destino se confirma antes de recibir nada, con un código o, en Telegram, con un enlace que se abre desde el chat; los avisos se configuran desde el portal o por la API.

Verifique la firma con el cuerpo tal como llegó, antes de interpretarlo:

import { verifyWebhook } from '@cfe/sdk'

// cuerpo: el texto tal como llegó, sin interpretar
const evento = await verifyWebhook(cuerpo, request.headers['x-cfe-signature'], [secretoNuevo, secretoViejo])
use Cfe\Sdk\Webhook;

$cuerpo = file_get_contents('php://input'); // tal como llegó
$evento = Webhook::verify($cuerpo, $_SERVER['HTTP_X_CFE_SIGNATURE'] ?? null, [$secretoNuevo, $secretoViejo]);
http_response_code(204);
import uy.cfe.sdk.Webhook;
import uy.cfe.sdk.WebhookEvent;

// cuerpo: los bytes tal como llegaron, sin interpretar
WebhookEvent evento = Webhook.verify(cuerpo, request.getHeader("x-cfe-signature"), secretoNuevo, secretoViejo);
using Cfe.Sdk;

// cuerpo: los bytes tal como llegaron, sin interpretar
WebhookEvent evento = Webhook.Verify(cuerpo, Request.Headers["x-cfe-signature"], secretoNuevo, secretoViejo);

Mientras cambia el secreto, pase los dos: cualquiera de ellos es válido. La URL del webhook debe ser pública y usar https.

curl "https://api.okcfe.tinylab.com.uy/v1/events?since=$CURSOR&limit=100" -H "x-api-key: $CLAVE"
# { "events": [ { "id": 8812, "event": "invoice.processed", "payload": { … } } ],
#   "next_cursor": "ZTE6…", "has_more": false, "retention_days": 30 }

Al recibir invoice.processed, GET /v1/invoices/{id} indica el resultado: ACCEPTED (el pedido queda facturado) o REJECTED, con cada motivo en rejection_causes. Si la DGI rechazó con un motivo que conserva el número (E02 o E09), POST /v1/invoices/{id}/reissue vuelve a emitirlo.

Devoluciones

Cada devolución de la tienda es un POST /v1/invoices/{id}/refund con su identificador en refund_id: el creditmemo de Magento, el refund de Shopify o el reembolso de WooCommerce. okCFE emite la nota de crédito; su sistema solo indica qué se devolvió.

Lo que devolvió la tiendaEn la solicitud
Una remera de tres"lines": [{ "index": 0, "quantity": "1" }]
El envío"shipping": {}
Un importe, repartido entre las tasas del CFE"amounts": [{ "amount": "300" }]
Un importe a una tasa"amounts": [{ "amount": "300", "vat_rate": "22" }]
  • Repetir es seguro: el mismo refund_id con el mismo cuerpo devuelve la misma nota de crédito.
  • No se devuelve de más, ni por línea, ni por tasa, ni en total. Lo que excede responde 422 refund_exceeds_remaining (el importe supera lo que queda por devolver), con el saldo.
  • GET /v1/invoices/{id}/refunds indica lo que queda por devolver de cada línea.

PDF y ticket POS

  • pdf_url es un enlace fijo al PDF, sin credenciales, para el correo de confirmación al cliente. Si el enlace se expuso, POST /v1/invoices/{id}/pdf-link genera uno nuevo y el anterior deja de funcionar.
  • El ticket POS, GET /v1/invoices/{id}/pos, para impresoras térmicas de 80 y 57 mm. El parámetro encoding elige el formato: json, los bloques para dibujar el ticket en pantalla (cfe-pos/1, con su JSON Schema público); escpos, los datos listos para una impresora compatible con ESC/POS; text, texto plano. columns elige el ancho: 48 caracteres por renglón (80 mm) o 32 (57 mm).
curl -o ticket.bin "https://api.okcfe.tinylab.com.uy/v1/invoices/$ID/pos?encoding=escpos&columns=48" \
  -H "x-api-key: $CLAVE"

Límites

Las respuestas incluyen RateLimit-Limit, RateLimit-Remaining y RateLimit-Reset. Un 429 rate_limited (demasiadas solicitudes) incluye Retry-After: espere ese tiempo y vuelva a intentarlo; los SDK lo hacen solos. GET /v1/usage indica el consumo del mes y los límites de la membresía, y llegan avisos por webhook al 80 % y al 100 %.

SDK

Cuatro clientes generados a partir del contrato de la API, con los mismos nombres y el mismo comportamiento. Incluyen un método por operación, reintentos que respetan Retry-After, la Idempotency-Key derivada del pedido, errores con su ruta (error.at('lines[0].quantity')), la verificación de la firma del webhook y un iterador del feed que guarda el cursor.

IntegraciónRequisitos e instalaciónEstado
API RESTCualquier lenguaje, con JSON y una clave de APIDisponible
SDK de TypeScriptNode 22.18 o posterior, sin dependencias en tiempo de ejecución (usa fetch y WebCrypto). npm run build deja el JavaScript para Deno, Bun u otro entorno con fetchDisponible, a pedido
SDK de PHPPHP 8.1 o posterior, con cualquier cliente PSR-18 (Guzzle, Symfony HttpClient) y sin framework. Se instala con Composer desde un repositorio path o vcsDisponible, a pedido
SDK de JavaJava 17 o posterior (Spring Boot 3, Jakarta EE), con Jackson como única dependencia. mvn install lo deja en el repositorio local de Maven (uy.cfe:cfe-sdk)Disponible, a pedido
SDK de C#.NET 8 o posterior, y .NET Framework 4.7.2 o posterior, con HttpClient y System.Text.Json. dotnet pack arma el paquete NuGet Cfe.SdkDisponible, a pedido
Shopify, Magento y WooCommerceConectores para instalar desde la tiendaPróximamente

Cada SDK incluye los mismos ejemplos completos: un pedido (de Shopify en TypeScript, de WooCommerce en PHP y de un sistema de gestión en Java y C#) hasta el comprobante, la devolución, el PDF y el ticket; el mismo pedido con una orden, desde el carrito hasta la factura al confirmarse el pago; la instalación, que deja su webhook con los eventos que usa y lo prueba; y un recorrido de prueba con los mismos pasos en los cuatro, que se corre contra el sandbox con una clave de prueba.

Disponibles a pedido: se entregan junto con la clave de prueba (todavía no están publicados en npm, Packagist, Maven Central ni NuGet).

Tipos de comprobante

El campo cfe_type lleva el código de la DGI de cada tipo. okCFE emite los 42:

CódigoTipoPara qué se usa
101e-TicketVenta a un consumidor final o a quien no tiene RUC
102, 103Nota de crédito y nota de débito de e-TicketAnulan o reducen un e-Ticket, o le suman un importe
111e-FacturaVenta a una empresa con RUC
112, 113Nota de crédito y nota de débito de e-FacturaAnulan o reducen una e-Factura, o le suman un importe
121e-Factura de exportaciónVenta de bienes o servicios al exterior
122, 123Nota de crédito y nota de débito de e-Factura de exportaciónAnulan o reducen una e-Factura de exportación, o le suman un importe
124e-Remito de exportaciónAcompaña la salida de mercadería del país
131e-Ticket por cuenta ajenaVenta a consumidor final en nombre de un tercero
132, 133Nota de crédito y nota de débito de e-Ticket por cuenta ajenaAnulan o reducen un e-Ticket por cuenta ajena, o le suman un importe
141e-Factura por cuenta ajenaVenta a una empresa con RUC en nombre de un tercero
142, 143Nota de crédito y nota de débito de e-Factura por cuenta ajenaAnulan o reducen una e-Factura por cuenta ajena, o le suman un importe
151e-Boleta de entradaLa emite quien compra, cuando el vendedor no emite comprobantes
152, 153Nota de crédito y nota de débito de e-Boleta de entradaAnulan o reducen una e-Boleta de entrada, o le suman un importe
181e-RemitoAcompaña el traslado de mercadería; no es una venta
182e-ResguardoConstancia de las retenciones o percepciones de impuestos
201 a 282El mismo tipo, de contingenciaComprobante en papel (CFC) del tipo que resulta de restar 100: 211 es una e-Factura de contingencia

El contrato completo

Cada ruta, con sus campos y sus errores, está en el contrato OpenAPI, público y sin credenciales: api.okcfe.uy/v1/openapi.json. El catálogo (GET /v1/catalog/cfe-types) indica, para cada uno de los 42 tipos de CFE, qué campos lleva, cuáles son opcionales y cuáles completa okCFE con los datos de su cuenta.

Solicitar una clave de prueba