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:
| Permiso | Qué permite |
|---|---|
read | Consultar: comprobantes, XML, PDF, listados, catálogo, consumo y reportes |
emit | Emitir: la emisión, la vista previa, las devoluciones, las anulaciones y el envío a otra empresa |
receive | Recibir: los comprobantes recibidos de proveedores y su rechazo comercial |
reports | Reporte diario: el envío del resumen diario a la DGI |
settings | Configuració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:
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
-
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 } -
okCFE envía el comprobante (el envío queda recibido,
AS) y consulta su respuesta, en pocos segundos -
Respuesta de prueba, con el formato de la DGI
AEAceptado. En producción, es el documento fiscal válido.
-
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" } -
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 }
-
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 } -
okCFE envía el comprobante (el envío queda recibido,
AS) y consulta su respuesta, en pocos segundos -
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.
-
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" } -
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 }
-
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 } -
okCFE envía el comprobante y espera para consultar su respuesta
El envío queda recibido (
AS) con la hora desde la que se consulta su respuesta (available_atenGET /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" } -
Respuesta de prueba, con el formato de la DGI
AEAceptado, en la consulta de los 30 segundos.
-
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" } -
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 }
-
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 } -
okCFE envía el comprobante y espera para consultar su respuesta
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" } -
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).
-
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" } -
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.
| Valor | Respuesta de prueba |
|---|---|
accepted | Acepta el comprobante (estado AE). Es la respuesta si no se indica ninguna. |
rejected:E04 | Rechaza 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:30 | Acepta, pero la respuesta se puede consultar a partir de los 30 segundos (de 1 a 120) |
timeout | Las 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ódigo | Qué significa y qué hacer |
|---|---|
certificate_platform | La cuenta firma con el certificado de prueba de okCFE. Suba el certificado de la empresa antes de emitir comprobantes reales. |
certificate_missing | Falta el certificado de firma. Súbalo desde el portal. |
certificate_expired, certificate_revoked | El certificado de firma venció o fue revocado. Suba uno vigente. |
certificate_not_yet_valid | El certificado todavía no está vigente. Se podrá firmar desde su fecha de inicio. |
certificate_expiring | El certificado vence pronto. Renuévelo antes del vencimiento. |
no_numbering | No hay numeración (CAE) cargada. Cargue el CAE de cada tipo de comprobante que emite. |
cae_exhausted, cae_expired | Se agotó o venció la numeración de un tipo y serie. Cargue un CAE nuevo. |
cae_running_out, cae_expiring | Quedan pocos números o la numeración vence pronto. Solicite el próximo CAE a tiempo. |
no_type_enabled | Ningún tipo de comprobante está habilitado. Solicite a soporte que lo habilite. |
branch_missing, branch_not_default | Falta la sucursal registrada en la DGI, o ninguna está marcada como principal. Complétela en el perfil del emisor. |
membership_suspended, membership_expired | La cuenta está suspendida o la membresía venció. Comuníquese con soporte. |
membership_limit_reached, membership_near_limit | Se 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 enPENDING(pendiente de la respuesta de la DGI). okCFE lo envía a la DGI sin intervención. - La
Idempotency-Keyse 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, responde409 idempotency_key_reused(esa clave ya se usó con otros datos). external_ides el número de pedido que ve el cliente, único por cuenta. Un segundo comprobante con el mismo número recibe409 external_id_taken(ese pedido ya tiene comprobante), con el id del existente. Se busca conGET /v1/invoices?external_id=%231001.vat_rateen 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/previewcon 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 okCFE | Shopify, order | Magento 2, sales_order | WooCommerce, WC_Order |
|---|---|---|---|
external_id | name (#1001) | increment_id | get_order_number() |
Idempotency-Key | shopify:{tienda}:{id} | magento:{tienda}:{entity_id} | woocommerce:{sitio}:{id} |
gross_amounts: 1 | taxes_included: true | precios con IVA (price_includes_tax) | prices_include_tax |
lines[].description | line_items[].title | items[].name | get_name() |
lines[].quantity | quantity | qty_ordered | get_quantity() |
lines[].unit_price | price | price_incl_tax | get_subtotal() / quantity |
lines[].vat_rate | tax_lines[0].rate × 100 | tax_percent | el rate del impuesto de la línea |
La línea de envío (ENVIO) | shipping_lines[] | shipping_incl_tax | get_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.
- Al confirmar el carrito,
POST /v1/orderscon el mismo cuerpo quePOST /v1/invoices. Responde los totales calculados, el número de la orden (ORD-0000000012) y suversion, o el422con cada error, antes de cobrar. - Si el carrito cambia,
PUT /v1/orders/{id}con el pedido completo (lo que no viene deja de estar) eIf-Matchcon la versión leída. La versión sube. - El cobro usa
totals.payabley el númeroORD-…como referencia del pago. - Al confirmarse el pago,
POST /v1/orders/{id}/executecon laversioncobrada: se emite la factura y se envía a la DGI. Repetirlo devuelve la misma factura; otra versión responde409 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", … } }
| Estado | Significado |
|---|---|
OPEN · Abierta | La factura está validada y espera el pago. Se puede modificar, cancelar o facturar. |
EXECUTED · Facturada | Se emitió su comprobante (invoice_id). No cambia más. |
CANCELLED · Cancelada | Se canceló sin facturar (DELETE /v1/orders/{id}). Su external_id queda libre. |
EXPIRED · Vencida | Pasó 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:
status | Qué significa |
|---|---|
PENDING | Pendiente: firmado y a la espera de la respuesta de la DGI. Todavía no es un documento fiscal válido. |
ACCEPTED | Aceptado: la DGI lo aceptó. Es el documento fiscal válido. |
REJECTED | Rechazado: la DGI lo rechazó. Queda anulado y su número no se vuelve a usar, salvo con los motivos E02 y E09. |
CANCELLED | Anulado: 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ódigo | Se refiere a | Qué significa |
|---|---|---|
AS | Envío | La DGI recibió el envío. El resultado de cada comprobante llega después. |
BS | Envío | La DGI rechazó el envío completo. Ningún comprobante se procesó; okCFE los envía de nuevo. |
BA | Envío | La DGI rechazó el archivo del envío. Ningún comprobante se procesó. |
AE | Comprobante | Aceptado. Es el documento fiscal válido. |
BE | Comprobante | Rechazado. Queda anulado, salvo con los motivos E02 y E09. |
CE | Comprobante | Observado: 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. |
FE | Comprobante | Recibido 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ódigo | Qué significa y qué hacer |
|---|---|
E02 | La DGI ya tiene un comprobante con ese tipo y número. No se anula: se revisa si la DGI lo aceptó antes. |
E03 | El tipo y el número no corresponden a la numeración autorizada (CAE). Verifique la numeración cargada. |
E04 | La firma electrónica no es válida. Verifique el certificado de firma. |
E05 | El comprobante no cumple el formato de la DGI. Corrija los datos y emita uno nuevo. |
E07 | La fecha de firma no corresponde a la vigencia de la numeración. Verifique la fecha y el CAE. |
E09 | El 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. |
E10 | El tipo de cambio no es válido. Corrija el tipo de cambio y emita uno nuevo. |
E12 | El 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:
| HTTP | code | Qué pasó y qué hacer |
|---|---|---|
| 422 · Datos no válidos | invalid_cfe | Hay datos del comprobante que corregir. Corrija lo que indica cada errors[].path; no se usó ningún número. |
| 409 · Conflicto | idempotency_key_reused | La misma Idempotency-Key llegó con otro cuerpo. Una repetición debe enviar el mismo pedido. |
| 409 · Conflicto | external_id_taken | Ese número de pedido ya tiene comprobante; la respuesta trae su id. |
| 429 · Demasiadas solicitudes | rate_limited | Se superó el límite de solicitudes por minuto. Espere lo que indica Retry-After y vuelva a intentarlo. |
| 429 · Demasiadas solicitudes | quota_exceeded | Se 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 permiso | scope_required | La 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/eventsexplica cada evento yPOST /v1/webhooks/{id}/testenvía uno de prueba. Van firmados enx-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 porevent_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:
| Evento | Cuándo llega |
|---|---|
invoice.emitted | Se emitió un comprobante y quedó pendiente de la DGI (el de una devolución incluye refund) |
invoice.processed | La DGI respondió: aceptado, rechazado u observado |
invoice.reissue_required | La DGI rechazó el comprobante con un motivo que permite volver a emitirlo con el mismo número |
contingency.correction_required | La DGI observó un comprobante de contingencia y hay que informarlo corregido |
dgi.document_rejected | La DGI rechazó comprobantes de un envío, con los motivos de cada uno |
dgi.document_observed | La DGI aceptó comprobantes con observaciones |
dgi.envelope_rejected | La DGI rechazó un envío entero sin revisar sus comprobantes; okCFE los envía de nuevo |
dgi.report_rejected | La DGI rechazó el reporte diario de una fecha |
dgi.acknowledgement_held | Llegó una respuesta de la DGI cuya firma no se pudo validar: queda retenida hasta que soporte la revise |
reception.received | Un proveedor envió comprobantes a la empresa: cuántos llegaron y cuántos se rechazaron |
cae.running_low | A un tipo de comprobante le quedan pocos números en su CAE |
cae.expiring | El CAE de un tipo vence pronto y no hay otro cargado |
cae.exhausted | Un tipo ya no se puede emitir: su CAE no tiene números libres o venció |
certificate.expiring | El certificado de firma electrónica vence pronto, o ya venció |
order.executed | Se facturó una orden: trae la orden, la referencia del pago y el comprobante |
order.expired | Una orden venció sin facturarse |
tenant.configuration_changed | Cambió la configuración de la cuenta |
membership.limit_warning | El consumo llegó al aviso de un límite (80 %) |
membership.limit_reached | El consumo llegó al límite (100 %) |
membership.renewed | Se 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 tienda | En 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_idcon 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}/refundsindica lo que queda por devolver de cada línea.
PDF y ticket POS
pdf_urles 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-linkgenera 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ámetroencodingelige 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.columnselige 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ón | Requisitos e instalación | Estado |
|---|---|---|
| API REST | Cualquier lenguaje, con JSON y una clave de API | Disponible |
| SDK de TypeScript | Node 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 fetch | Disponible, a pedido |
| SDK de PHP | PHP 8.1 o posterior, con cualquier cliente PSR-18 (Guzzle, Symfony HttpClient) y sin framework. Se instala con Composer desde un repositorio path o vcs | Disponible, a pedido |
| SDK de Java | Java 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.Sdk | Disponible, a pedido |
| Shopify, Magento y WooCommerce | Conectores para instalar desde la tienda | Pró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ódigo | Tipo | Para qué se usa |
|---|---|---|
| 101 | e-Ticket | Venta a un consumidor final o a quien no tiene RUC |
| 102, 103 | Nota de crédito y nota de débito de e-Ticket | Anulan o reducen un e-Ticket, o le suman un importe |
| 111 | e-Factura | Venta a una empresa con RUC |
| 112, 113 | Nota de crédito y nota de débito de e-Factura | Anulan o reducen una e-Factura, o le suman un importe |
| 121 | e-Factura de exportación | Venta de bienes o servicios al exterior |
| 122, 123 | Nota de crédito y nota de débito de e-Factura de exportación | Anulan o reducen una e-Factura de exportación, o le suman un importe |
| 124 | e-Remito de exportación | Acompaña la salida de mercadería del país |
| 131 | e-Ticket por cuenta ajena | Venta a consumidor final en nombre de un tercero |
| 132, 133 | Nota de crédito y nota de débito de e-Ticket por cuenta ajena | Anulan o reducen un e-Ticket por cuenta ajena, o le suman un importe |
| 141 | e-Factura por cuenta ajena | Venta a una empresa con RUC en nombre de un tercero |
| 142, 143 | Nota de crédito y nota de débito de e-Factura por cuenta ajena | Anulan o reducen una e-Factura por cuenta ajena, o le suman un importe |
| 151 | e-Boleta de entrada | La emite quien compra, cuando el vendedor no emite comprobantes |
| 152, 153 | Nota de crédito y nota de débito de e-Boleta de entrada | Anulan o reducen una e-Boleta de entrada, o le suman un importe |
| 181 | e-Remito | Acompaña el traslado de mercadería; no es una venta |
| 182 | e-Resguardo | Constancia de las retenciones o percepciones de impuestos |
| 201 a 282 | El mismo tipo, de contingencia | Comprobante 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.