Referencia de la API · v1

API para desarrolladores

Integra emisión, recepción y consulta de e-CF directamente desde tu ERP, POS o software contable. La API de G-eFac se encarga de la firma XAdES, la transmisión a la DGII, la contingencia y el archivo — tú solo envías JSON.

URL base
https://api.g-efac.com/v1
Formato
JSON · errores RFC 7807

Autenticación

La API usa OAuth 2.0 client credentials. Pide un token con tu client_id y client_secret, luego envíalo como Authorization: Bearer en cada llamada. La identidad del tenant sale del claim tenant del token — nunca de la URL.

Para apps que actúan en nombre de un usuario, /v1 también admite Authorization Code + PKCE; para integraciones server-to-server usa client credentials, como en el ejemplo.

Un solo scope da acceso completo a tu superficie /v1:

efac.v1
Acceso completo al tenant
Solicitar token · cURL
curl -X POST https://api.g-efac.com/connect/token \ -d grant_type=client_credentials \ -d client_id=$EFAC_ID \ -d client_secret=$EFAC_SECRET \ -d scope=efac.v1 # respuesta { "access_token": "eyJhbGci…", "token_type": "Bearer", "expires_in": 3600 }

Ambientes

Certifícate y prueba en TesteCF antes de operar en producción. Cada tenant tiene su propio certificado, secuencias y configuración de ambiente.

AmbientePrefijoUso
TesteCFtestecfDesarrollo y pruebas — sin tarjeta, 14 días.
CerteCFcertecfProceso formal de certificación ante la DGII.
eCFecfOperación real en producción.

Sandbox: sin certificado, sin RNC, sin rango

Sandbox no es un ambiente de la DGII ni un peldaño más en la escalera de certificación — es nuestro propio simulador. Las nueve integraciones que hablan con la DGII se responden en proceso, dentro de G-eFac, sin ninguna llamada a dgii.gov.do. Todo lo que ocurre antes de esa frontera —mapeo, cuadratura, validación contra el XSD, firma, archivado, numeración de e-NCF, la máquina de estados, el diario de eventos— corre igual que en producción.

Por eso es el único ambiente que un desarrollador puede usar sin ensamblar nada primero: no hace falta un certificado digital, no hace falta un RNC registrado, y no hace falta gestionar rangos de e-NCF autorizados — se siembran solos en cuanto pides la credencial.

Cómo obtener una credencial

Desde el portal del contribuyente, pantalla Credenciales de API (Configuración), pide una credencial para el ambiente Sandbox. El ambiente va en la ruta, no en el cuerpo, y la operación exige el mismo paso de verificación en dos pasos (MFA) que crear o rotar cualquier otra credencial:

Crear credencial Sandbox · cURL
curl -X POST https://api.g-efac.com/portal/v1/api-clients/Sandbox \ -H "Authorization: Bearer {token_del_portal}" \ -H "Content-Type: application/json" \ -d '{"code": "123456"}' # respuesta { "clientId": "tenant-131880690-sandbox", "clientSecret": "…", "environment": "Sandbox", "issuedAt": "2026-08-29T12:00:00Z" }

El clientSecret aparece solo en esta respuesta. Con esas credenciales, pide un token exactamente como en Autenticación — el token resultante ya lleva Sandbox como ambiente, y POST /v1/invoices funciona igual que contra cualquier otro ambiente, con las mismas respuestas y los mismos eventos por webhook o por feed.

Dos diferencias deliberadas: qrUrl siempre es null —la DGII no opera este ambiente, así que no existe timbre que verificar— y los documentos se retienen por Webhooks:EventRetentionDays (30 días por defecto), no por el piso fiscal de diez años que aplica a un e-CF real.

RNC comprador reservados

El simulador acepta por defecto. Para ejercitar un resultado distinto al camino feliz, emite el documento con uno de estos RNC como comprador — nunca como emisor, que es siempre el de tu propio tenant:

RNC compradorResultado simuladoQué te permite probar
000000001Rechazado, con mensaje de error de la DGIImanejo de rechazos
000000002AceptadoCondicional, con observacionesmanejo de aceptación condicional
000000003EnProceso dos consultas, luego Aceptadoel polling de estado
000000004EnProceso indefinidamentetu propio timeout de espera
000000005Error 500 al enviartu lógica de reintentos
000000006Sin respuesta (timeout)tu backoff ante una DGII caída
000000007El envío se acepta, pero la consulta posterior responde "no encontrado"el caso ambiguo de RFCE no encontrado
cualquier otroAceptadoel camino feliz

Un tipo 32 por debajo del umbral de RFCE no lleva comprador en absoluto — es válido que no lo lleve — así que no tiene forma de disparar un RNC reservado y siempre se acepta. Si necesitas forzar un resultado distinto sobre un consumo bajo el umbral, agrégale un comprador: está permitido, solo que no es obligatorio.

Lo que Sandbox no prueba

Una aceptación simulada no es una predicción del veredicto de la DGII. El simulador no implementa las reglas de obligatoriedad condicional que la DGII sí aplica — esas reglas no se pueden expresar en los XSD, y G-eFac todavía no las codifica en ningún punto del flujo — así que acepta por defecto documentos que la DGII real rechazaría. Pasar Sandbox es evidencia de que tu ERP conversa correctamente con G-eFac; no acorta ni sustituye el proceso de certificación (TesteCF/CerteCF) ante la DGII.

SDKs oficiales

Puedes llamar la API REST directamente, pero el SDK te ahorra la parte repetitiva: pide el token a /connect/token, lo cachea y lo renueva antes de que expire, arma el cuerpo de la petición y te devuelve resultados y excepciones tipadas en lugar de problem+json crudo.

Lenguaje Paquete Versión Docs
PHP 8.2+
Cliente base, sin framework. Transporte PSR-18.
g-efac/sdk 0.3.x Packagist
Laravel 12
Service provider, fachada y configuración publicable.
g-efac/laravel 0.3.x Packagist
Estabilidad

Mientras /v1 siga siendo provisional, los paquetes se publican en 0.x y las versiones menores pueden romper compatibilidad. Cuando la API se congele, el SDK pasa a 1.0.0 y adopta semver en serio: desde ahí, ninguna versión menor rompe compatibilidad.

¿Tu cliente factura desde Odoo? No hace falta integrar nada: hay un módulo para Odoo que emite a través de esta misma API.

¿Integras desde otro lenguaje? La API es REST plano: mira todos los endpoints y descarga el OpenAPI.

PHP · inicio rápido

Instalación
composer require g-efac/sdk

El paquete no impone ningún cliente HTTP: depende solo de las interfaces PSR-18 y PSR-17. Añade php-http/discovery y build() detecta lo que ya tengas instalado, o inyecta las tres piezas a mano — cliente, fábrica de request y fábrica de stream. Si falta cualquiera de las tres, build() lanza InvalidArgumentException.

Primer comprobante
use Efac\Sdk\EfacClient; use Efac\Sdk\Enum\EcfTipo; use Efac\Sdk\Enum\TaxTreatment; use Efac\Sdk\Invoice\Invoice; use Efac\Sdk\Invoice\Item; use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; // HttpFactory implementa tanto RequestFactoryInterface como StreamFactoryInterface, // y viene incluida al instalar guzzlehttp/guzzle (composer require guzzlehttp/guzzle). $factory = new HttpFactory(); $efac = EfacClient::builder() ->baseUrl('https://api.g-efac.com') ->credentials($clientId, $clientSecret) ->httpClient(new Client()) ->requestFactory($factory) ->streamFactory($factory) ->build(); $factura = Invoice::of(EcfTipo::CreditoFiscal31) ->seller('101000001', 'ACME SRL', 'Av. Principal 1') ->buyer('130000002', 'Cliente SA', email: '[email protected]') ->addItem(Item::goods('Widget', qty: 2, unitPrice: 500.00, tax: TaxTreatment::Itbis18)) ->build(); $resultado = $efac->invoices()->emit($factura, 'orden-1042'); echo $resultado->encf; // E310000000001 echo $resultado->qrUrl; // timbre para la Representación Impresa

No escribes nada de OAuth: el cliente pide el token a /connect/token, lo cachea y lo renueva antes de que expire.

La clave de idempotencia es obligatoria

emit() exige una clave y no la genera por ti. La razón es fiscal, no estética: sin Idempotency-Key, reintentar una petición que expiró por timeout puede emitir un e-CF duplicado y consumir un segundo e-NCF, que es un recurso fiscal con seguimiento legal.

Usa un identificador estable y propio de tu sistema — el número de orden, el id de tu factura interna. Un UUID nuevo en cada reintento cambiaría en cada intento y anularía el mecanismo justo cuando hace falta. Reenviar la misma clave con el mismo cuerpo repite la respuesta original en lugar de volver a emitir; reutilizarla con un cuerpo distinto, o mientras una emisión bajo esa clave sigue en curso, devuelve 409.

Cuando la emisión queda en cola

La DGII no siempre responde de inmediato. En ese caso el servidor devuelve 202 y el e-CF queda en cola. Dentro de una petición web, usa $efac->invoices()->status($encf) para consultar el estado sin bloquear. El ayudante awaitTerminal() espera el veredicto con backoff exponencial, pero es opt-in y no va dentro de una petición web.

202 · esperar veredicto
use Efac\Sdk\Exception\TimeoutException; if ($resultado->isQueued()) { // Nunca dentro de una petición web: esto espera. Úsalo en un worker // de cola o en un job de consola. try { $estado = $efac->invoices()->awaitTerminal($resultado->encf, timeoutSeconds: 60); } catch (TimeoutException $e) { // OJO: esto NO es un fallo de emisión. El e-CF se emitió; la DGII // no había dado veredicto dentro de la ventana. $e->lastStatus // trae lo último visto. } }

Reimpresiones, historial, anulaciones y el archivo fiscal están en la documentación del paquete.

Laravel · inicio rápido

Requiere Laravel 12. El auto-discovery de Composer registra el service provider y el alias Efac al instalar, así que no hay que tocar config/app.php ni ningún archivo de arranque.

Instalación y configuración
composer require g-efac/laravel # publica config/efac.php php artisan vendor:publish --tag=efac-config

Las siete opciones de config/efac.php, todas configurables por variable de entorno:

Variable Por defecto Significado
EFAC_BASE_URLhttps://api.g-efac.comURL base de la API.
EFAC_CLIENT_IDClient id OAuth2 del emisor. Obligatoria.
EFAC_CLIENT_SECRETClient secret OAuth2. Obligatoria.
EFAC_SCOPEefac.v1Scope OAuth2 solicitado.
EFAC_CACHE_STOREstore por defectoStore de caché donde se guarda el access token.
EFAC_CONNECT_TIMEOUT5Timeout de conexión, en segundos.
EFAC_TIMEOUT30Timeout de la petición completa, en segundos.

Si falta el id o el secret, el paquete lanza una excepción clara — pero la primera vez que se resuelve EfacClient, no durante el boot() del framework. Es deliberado: fallar en el arranque rompería artisan en CI, en un clone recién hecho o en un contenedor corriendo migraciones, donde de todas formas nadie llama a la API.

Emitir con la fachada
use Efac\Laravel\Efac; use Efac\Sdk\Enum\EcfTipo; use Efac\Sdk\Enum\TaxTreatment; use Efac\Sdk\Invoice\Invoice; use Efac\Sdk\Invoice\Item; $factura = Invoice::of(EcfTipo::CreditoFiscal31) ->seller('101000001', 'ACME SRL', 'Av. Principal 1') ->buyer('130000002', 'Cliente SA', email: '[email protected]') ->addItem(Item::goods('Widget', qty: 2, unitPrice: 500.00, tax: TaxTreatment::Itbis18)) ->build(); $resultado = Efac::invoices()->emit($factura, 'orden-1042'); echo $resultado->encf; // E310000000001

EfacClient también está registrado como singleton, así que puedes pedirlo por tipo en un controlador, un job o un comando: la fachada y la inyección llegan al mismo objeto. La clave de idempotencia sigue siendo obligatoria por ambas vías — la regla es la misma. El resto está en la documentación del paquete.

POST/v1/invoices

Emitir un e-CF

Envía tu factura como JSON. G-eFac construye el XML en el orden del XSD, lo firma con XAdES, lo transmite a la DGII y te devuelve el trackId, el código de seguridad y el QR. Responde 200 si es terminal o 202 con cabecera Location si queda en cola.

Pasa una cabecera Idempotency-Key para reintentar sin riesgo: reenviar la misma clave con el mismo cuerpo repite la respuesta original con Idempotent-Replayed: true, sin volver a emitir.

Petición
POST /v1/invoices Authorization: Bearer {token} Idempotency-Key: 8f2a-0417 { "type": "31", "buyer": { "rnc": "131880681", "name": "Cliente SRL" }, "items": [{ "name": "Servicio de consultoría", "quantity": 1, "unitPrice": 10000, "tax": "Itbis18" }], "payment": { "method": "credito" } }
202 · Accepted
# Location: /v1/invoices/E310000000007 { "encf": "E310000000007", "status": "EnProceso", "trackId": "8f2a1c33-…", "securityCode": "a1b2c3", "qrUrl": "https://ecf.dgii.gov.do/…", "qrImage": "data:image/png;base64,…" }
GET/v1/invoices/{encf}

Consultar estado

Devuelve el estado local y el de la DGII de un e-CF emitido. Haz polling con backoff exponencial hasta un estado final (Aceptado, Rechazado). El campo discrepancia avisa si el snapshot local difiere del de la DGII.

200 · OK
{ "encf": "E310000000007", "discrepancia": false, "local": { "estado": "AceptadoDGII", "trackId": "8f2a…", "intentos": 1, "enContingencia": false }, "dgii": { "disponible": true, "estado": "Aceptado", "codigo": 1, "montoTotal": 11800, "totalITBIS": 1800, "fechaFirma": "2026-07-21T14:03:00" } }
GET/v1/invoices

Comprobantes en cola

Lista los e-CF encolados o en contingencia del tenant, paginados por cursor. Filtra con status, controla el tamaño con limit y avanza con cursor. La respuesta trae items, nextCursor y hasMore; cada ítem indica horasRestantes antes de vencer la ventana de contingencia.

QueryTipoDescripción
statusstringFiltra por estado del comprobante.
limitintMáximo de ítems por página.
cursorstringCursor de la página siguiente (nextCursor).
POST/v1/held-invoices

Facturas retenidas

Para una venta que se confirma después —una cuenta de restaurante, un pedido que se paga luego— puedes retener una factura de consumo (tipo 32): se firma con el e-NCF cero E320000000000 y recibes un securityCode y un QR provisionales. No se consume ningún e-NCF y nada entra en el registro fiscal hasta que la liberas con POST /v1/held-invoices/{id}/emit, que emite exactamente como POST /v1/invoices. Si la venta no se cierra, DELETE la abandona sin dejar rastro.

El QR provisional no es verificable en la DGII y cambia al liberar: el código de seguridad definitivo sale de la firma del documento con su e-NCF real. Imprímelo como provisional. Solo se admite el tipo 32; otros tipos responden 422.

201 · Created
# Location: /v1/held-invoices/7c9e6679-… { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "status": "Held", "encf": "E320000000000", "securityCode": "a1b2c3", "qrUrl": "https://fc.dgii.gov.do/…/ConsultaTimbreFC?…", "montoTotal": 1180.00, "emittedEncf": null }
GET/v1/received-documents

Documentos recibidos

Verifica la integridad de un e-CF que recibiste, cruzando tu copia local contra la DGII. Requiere sellerRnc y encf. El campo codigoDifiere señala si el código de seguridad no coincide.

GET · con query
GET /v1/received-documents?sellerRnc=130123456&encf=E310000000123 Authorization: Bearer {token}
POST/v1/commercial-approvals

Aprobación comercial

Envía la Aprobación Comercial (ACECF) — o el rechazo — de un e-CF que recibiste. Indica approve: true para aprobar, o false con rejectionReason. G-eFac firma y transmite la respuesta al emisor.

Petición
POST /v1/commercial-approvals { "sellerRnc": "130123456", "encf": "E310000000123", "approve": true }
POSTGET/v1/sequences

Secuencias e-NCF

Autoriza un rango de secuencias e-NCF para un tipo (POST) o inspecciona los rangos vigentes (GET ?type=31). G-eFac detecta el agotamiento antes de emitir y admite rangos no contiguos.

El campo opcional environment autoriza el rango en el próximo ambiente del contribuyente antes de promoverlo — la DGII emite esa autorización por adelantado y el paso a producción la exige. Si lo omites, el rango queda en el ambiente que direcciona tu credencial. Esos dos son los únicos valores aceptados, tanto al escribir como al leer; GET /v1/sequences/all devuelve ambos en environment y nextEnvironment.

POST · autorizar rango
POST /v1/sequences { "type": "31", "from": 1, "to": 1000, "expiry": "2027-12-31" } # respuesta { "type": "31", "ranges": [{ "from": 1, "next": 1, "to": 1000, "expiry": "2027-12-31" }] } # rango del próximo ambiente, antes de promover POST /v1/sequences { "type": "31", "from": 1, "to": 1000, "environment": "ECF" } GET /v1/sequences/all # respuesta { "sequences": [...], "externalNumbering": false, "environment": "CerteCF", "nextEnvironment": "ECF" }
POST/v1/void-requests

Anulación de rangos

Anula un rango de e-NCF no usados o firmados pero no enviados. Devuelve cuántos comprobantes se anularon.

Petición / respuesta
POST /v1/void-requests { "type": "31", "from": 950, "to": 1000 } # respuesta { "voided": 51 }

Eventos

El veredicto de la DGII no llega en la respuesta de POST /v1/invoices: esa petición devuelve 202 y el comprobante queda en cola. Para no tener que consultar cada documento abierto en un temporizador, G-eFac publica lo que ocurre como eventos, y los entrega por dos transportes sobre el mismo diario: webhooks firmados (nosotros llamamos a tu servidor) y el feed por cursor (tu sistema pregunta). Puedes usar uno, el otro, o los dos: ven exactamente los mismos eventos.

Los siete tipos

typeSe dispara cuando
invoice.acceptedLa DGII aceptó el e-CF — aceptación plena o condicional. `data.status` distingue las dos.
invoice.rejectedLa DGII rechazó el e-CF. El e-NCF queda quemado y no se reutiliza.
invoice.receivedEntró un e-CF a tu nombre por el plano de recepción y fue aceptado.
commercial_approval.receivedEntró una Aprobación Comercial (ACECF) de un comprador. Un cambio de veredicto sobre el mismo e-CF es un evento nuevo.
contingency.activatedUn comprobante se emitió en contingencia porque la DGII no respondía.
sequence.lowA un tipo de comprobante le queda menos del 5 % del rango autorizado.
certificate.expiringEl certificado digital vence dentro de 30 días.

La suscripción es por endpoint y por tipo. Un tipo que no reconocemos —un nombre mal escrito— es 400 al registrar, nunca una suscripción que se descarta en silencio.

El sobre

Los cinco campos de arriba —id, type, occurredAt, rnc, environment— no cambian nunca; data depende del tipo. El sobre es delgado a propósito: identidad más un enlace. Los datos fiscales completos se leen con la consulta que data.url señala, así el detalle no termina en los registros del servidor que recibe el callback y el sobre no se rompe cada vez que crece una respuesta de /v1.

Cuál consulta señala data.url lo decide el type, no los campos del data: para invoice.accepted, invoice.rejected, contingency.activated y commercial_approval.received —comprobantes que emitió usted— es GET /v1/invoices/{encf}. Para invoice.received es GET /v1/received-documents?sellerRnc={sellerRnc}&encf={encf}, porque ahí el encf es el del emisor que se lo envió: los e-NCF son secuenciales por emisor, así que ese mismo número suele existir también en su propia numeración y /v1/invoices —que resuelve el emisor por el tenant del token— le devolvería otro comprobante.

Sobre · invoice.rejected
{ "id": "evt_01jq8zk4t7m2x9p0yb3d5w6r7a", "type": "invoice.rejected", "occurredAt": "2026-08-26T14:03:11.0000000+00:00", "rnc": "130123456", "environment": "ECF", "data": { "encf": "E310000000123", "status": "RechazadoDgii", "trackId": "3f9a1c7e…", "url": "https://api.g-efac.com/v1/invoices/E310000000123" } }

environment se escribe TesteCF, CerteCF o ECF — la misma grafía que acepta /v1 al escribir, no la de los documentos de la DGII.

Las dos alertas permanentes no llevan encf ni url: sequence.low trae episode y detail (la frase que dice qué tipo está bajo y cuántos quedan), y certificate.expiring trae episode y expiresAt. episode es un contador: verlo en 2 significa que la condición se resolvió y volvió a romperse, no que llegó un duplicado.

Entrega al menos una vez, y sin orden garantizado

Esto no es un detalle de implementación, es parte del contrato. Un evento puede llegarte más de una vez —un reintento tras un timeout de tu lado entrega dos veces— y dos eventos de documentos distintos pueden llegar en cualquier orden. El campo id es tu clave de deduplicación: guárdalo y descarta el que ya procesaste antes de tocar la contabilidad. Una integración escrita como si la entrega fuera exactamente una vez terminará asentando el mismo comprobante dos veces.

POST/v1/webhooks

Webhooks firmados

Registra una URL y G-eFac le hace POST del sobre cada vez que ocurre uno de los tipos a los que suscribiste ese endpoint. La URL debe ser https, no puede llevar credenciales ni resolver a una dirección privada o de enlace local, y admite hasta 2048 caracteres; description, hasta 200.

Registro · el secreto viaja una vez
POST /v1/webhooks { "url": "https://erp.tuempresa.do/efac/callbacks", "events": ["invoice.accepted", "invoice.rejected"], "description": "ERP central" } # 201 — el secreto viaja UNA sola vez, aquí { "webhook": { "id": "7c1f…", "enabled": true, … }, "secret": "kR2v…" }

El secret aparece solo en esta respuesta y en la de rotación: no se puede volver a leer desde ninguna consulta. Guárdalo antes de cerrar la respuesta; si lo pierdes, rota. Los endpoints pertenecen al ambiente de la credencial que los registró, así que un ensayo en CerteCF nunca dispara callbacks hacia el ERP de producción.

Un 409 es el tope de endpoints por ambiente o una URL que este contribuyente ya tiene registrada — el fragmento de type del problema (endpoint-limit o duplicate-url) distingue los dos casos, porque el remedio es distinto: en el primero borra un endpoint, en el segundo amplía la lista events del que ya existe. Un segundo endpoint sobre la misma URL recibiría cada evento dos veces, con dos firmas distintas. Un 503 significa que este despliegue no tiene configurado el almacén de claves: el feed sirve los mismos eventos y no necesita secreto.

Verificar la firma

Cada entrega trae cinco encabezados. El material firmado es timestamp + "." + cuerpo crudo — el timestamp va dentro de la firma, así una petición capturada no se puede reenviar con uno nuevo.

Encabezados de la entrega
POST https://erp.tuempresa.do/efac/callbacks X-Efac-Event-Id: evt_01jq8zk4t7m2x9p0yb3d5w6r7a X-Efac-Event-Type: invoice.rejected X-Efac-Timestamp: 1787836991 X-Efac-Delivery-Attempt: 1 X-Efac-Signature: v1=9f86d081884c7d… # durante la ventana de rotación, dos valores separados por un espacio X-Efac-Signature: v1=<nuevo> v1=<anterior>
PHP · verificación
// El cuerpo CRUDO, sin reserializar: se firma byte a byte. $raw = file_get_contents('php://input'); $ts = $_SERVER['HTTP_X_EFAC_TIMESTAMP'] ?? ''; $firma = $_SERVER['HTTP_X_EFAC_SIGNATURE'] ?? ''; // 1 · rechaza un timestamp viejo: sin esto una petición capturada se reenvía siempre if (abs(time() - (int) $ts) > 300) { http_response_code(400); exit; } // 2 · HMAC-SHA256 sobre timestamp + "." + cuerpo crudo $esperado = 'v1=' . hash_hmac('sha256', $ts . '.' . $raw, $secreto); // 3 · compara en tiempo constante contra CADA valor del encabezado $ok = false; foreach (explode(' ', $firma) as $v) { if (hash_equals($esperado, $v)) { $ok = true; } } if (!$ok) { http_response_code(401); exit; } // 4 · deduplica por $sobre['id'] antes de tocar tu contabilidad, y responde 2xx http_response_code(200);

Tres reglas que no son opcionales: firma sobre el cuerpo crudo (si lo decodificas y lo vuelves a serializar, los bytes cambian y la firma no cuadra), compara en tiempo constante (hash_equals, nunca ==), y rechaza un timestamp fuera de tu tolerancia. La versión del esquema va en el valor (v1=), no en el nombre del encabezado, así que un futuro v2= puede convivir con él.

Rotar el secreto

POST /v1/webhooks/{id}/rotate-secret emite un secreto nuevo y mantiene el anterior válido durante 24 horas (previousValidUntil en la respuesta). Durante esa ventana X-Efac-Signature lleva dos valores separados por un espacio y un verificador correcto acepta si cualquiera de los dos coincide — por eso el ejemplo de arriba recorre el encabezado en vez de comparar contra uno solo. Sin esa ventana, cada rotación sería una caída garantizada salvo que despliegues en el mismo instante en que llamas a la API.

Reintentos, dead-letter y desactivación

Un 2xx es entrega correcta; cualquier otra cosa, incluido un timeout, reintenta. Son 8 intentos con espera creciente —4, 16, 64 y 256 minutos, luego un tope de 6 horas— que cubren unas 24 horas en total: un ERP que se cae un viernes por la tarde no pierde nada por eso. Agotados los intentos, la entrega queda en dead-letter y la puedes ver en GET /v1/webhooks/{id}/deliveries (id del evento, tipo, intentos, último código HTTP, último error). Un 410 Gone de tu lado es definitivo desde el primer intento: es la forma estándar de retirar un endpoint que ya no usas.

Tras 20 dead-letters consecutivos el endpoint se desactiva solo y GET /v1/webhooks explica por qué en disabledReason y disabledAt. Se reactiva con PATCH … { "enabled": true }. Lo que estaba en cola detrás de la desactivación se descarta en vez de guardarse, así que reactivar nunca produce una avalancha de callbacks viejos: para recuperar ese período, lee el feed. Una entrega correcta reinicia el contador, de modo que un endpoint simplemente inestable nunca se desactiva.

GET/v1/events

Feed de eventos

El mismo diario, en sentido contrario: tu sistema pregunta en lugar de recibir. Es la respuesta soportada para un ERP de escritorio — sin URL entrante, sin IP fija, detrás de NAT y apagado de noche, ese cliente no puede recibir un callback y una escalera de 8 intentos contra una estación apagada el fin de semana termina siempre en dead-letter. El feed degrada bien: el ERP que estuvo apagado desde el viernes pregunta una vez el lunes y recibe todo el rezago, en orden.

GET · con cursor
GET /v1/events?cursor=eyJ0IjoiMjAy…&limit=100&type=invoice.rejected Authorization: Bearer {token} # respuesta — más antiguos primero { "data": [ { "id": "evt_…", "type": "invoice.rejected", … } ], "nextCursor": "eyJ0IjoiMjAy…" } # 410 — el cursor quedó fuera de la ventana de retención { "status": 410, "title": "Cursor expired." }
QueryTipoDescripción
cursorstringOpaco, emitido por nosotros. Omítelo para empezar por el evento más antiguo retenido. Un cursor que no emitimos es 400.
limitintMáximo 100, que es también el valor por omisión. Un número mayor se recorta; cero o negativo es 400.
typestringFiltro opcional y repetible por tipo de evento. Un tipo desconocido es 400.

La respuesta trae data —los sobres, del más antiguo al más reciente, byte por byte iguales a los que firma un webhook— y nextCursor. Guarda ese cursor y devuélvelo en la próxima llamada; trátalo como opaco, no lo interpretes. Un cliente al día recibe una página vacía con su mismo cursor de vuelta.

La ventana de retención, y el 410

Un evento se conserva 30 días desde occurredAt, independientemente de si hubo webhooks, de si estaban activos y de si la entrega funcionó. Esa ventana es la garantía del feed. Un cursor más antiguo devuelve 410 Gonenunca la página más vieja que quede. Devolverte una página plausible con un hueco dentro sería peor que un error: no tendrías con qué detectarlo. Ante un 410, resincroniza con GET /v1/invoices y GET /v1/received-invoices, y vuelve a empezar el feed sin cursor.

Consulta desde el servidor, no desde cada estación

/v1 es OAuth2 client-credentials, y un client_secret incrustado en un binario de escritorio instalado en cuarenta máquinas ya no es un secreto: cualquiera con acceso a un puesto lo extrae y emite comprobantes fiscales a nombre del contribuyente. Quien consulta el feed debe ser la instancia central o el servidor del ERP, que guarda la credencial y el cursor en un solo sitio y reparte a las estaciones por tu propia red. Un único cursor compartido es además lo correcto funcionalmente: cuarenta clientes con cuarenta cursores leen cuarenta veces lo mismo.

Errores

Los errores usan RFC 7807 (application/problem+json) con type, title, status, detail e instance.

CódigoSignificado
400Petición inválida — cuerpo o parámetros mal formados.
401 / 403Token ausente/expirado, scope insuficiente, cuenta suspendida o ambiente no habilitado.
409Conflicto — clave de idempotencia reutilizada con otro cuerpo, emisión en progreso, o endpoint de webhook duplicado / tope alcanzado.
410Cursor del feed de eventos fuera de la ventana de retención; resincroniza y reinicia sin cursor.
422e-CF no procesable — falla de validación XSD o de negocio.
502 / 503La DGII o la contraparte no respondió; reintenta con backoff.

Todos los endpoints

La superficie de integración de /v1. La especificación completa, incluidos los endpoints administrativos, está en el OpenAPI y la colección de Postman.

POST /v1/invoices Emitir un e-CF
GET /v1/invoices Comprobantes en cola del tenant
GET /v1/invoices/{encf} Estado local y en la DGII
POST /v1/held-invoices Retener una factura de consumo (tipo 32)
GET /v1/held-invoices Facturas retenidas del tenant
GET /v1/held-invoices/{id} Una factura retenida
GET /v1/held-invoices/{id}/qr QR provisional de una retenida
POST /v1/held-invoices/{id}/emit Liberar: asignar e-NCF y emitir
DELETE /v1/held-invoices/{id} Abandonar una retenida
GET /v1/received-documents Integridad de un e-CF recibido
POST /v1/commercial-approvals Enviar aprobación / rechazo comercial
POST /v1/sequences Autorizar un rango de secuencias e-NCF
GET /v1/sequences Inspeccionar rangos autorizados
POST /v1/void-requests Anular un rango de e-NCF
GET /v1/events Feed de eventos por cursor
POST /v1/webhooks Registrar un endpoint de callback
GET /v1/webhooks Listar los endpoints registrados
GET /v1/webhooks/{id} Leer un endpoint
PATCH /v1/webhooks/{id} Cambiar url, eventos o enabled
DELETE /v1/webhooks/{id} Eliminar un endpoint
POST /v1/webhooks/{id}/rotate-secret Rotar el secreto de firma
GET /v1/webhooks/{id}/deliveries Intentos de entrega recientes