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.
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:
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.
| Ambiente | Prefijo | Uso |
|---|---|---|
| TesteCF | testecf | Desarrollo y pruebas — sin tarjeta, 14 días. |
| CerteCF | certecf | Proceso formal de certificación ante la DGII. |
| eCF | ecf | Operació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:
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 comprador | Resultado simulado | Qué te permite probar |
|---|---|---|
| 000000001 | Rechazado, con mensaje de error de la DGII | manejo de rechazos |
| 000000002 | AceptadoCondicional, con observaciones | manejo de aceptación condicional |
| 000000003 | EnProceso dos consultas, luego Aceptado | el polling de estado |
| 000000004 | EnProceso indefinidamente | tu propio timeout de espera |
| 000000005 | Error 500 al enviar | tu lógica de reintentos |
| 000000006 | Sin respuesta (timeout) | tu backoff ante una DGII caída |
| 000000007 | El envío se acepta, pero la consulta posterior responde "no encontrado" | el caso ambiguo de RFCE no encontrado |
| cualquier otro | Aceptado | el 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 |
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
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.
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.
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.
Las siete opciones de config/efac.php, todas configurables por variable de entorno:
| Variable | Por defecto | Significado |
|---|---|---|
| EFAC_BASE_URL | https://api.g-efac.com | URL base de la API. |
| EFAC_CLIENT_ID | — | Client id OAuth2 del emisor. Obligatoria. |
| EFAC_CLIENT_SECRET | — | Client secret OAuth2. Obligatoria. |
| EFAC_SCOPE | efac.v1 | Scope OAuth2 solicitado. |
| EFAC_CACHE_STORE | store por defecto | Store de caché donde se guarda el access token. |
| EFAC_CONNECT_TIMEOUT | 5 | Timeout de conexión, en segundos. |
| EFAC_TIMEOUT | 30 | Timeout 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.
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.
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.
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.
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.
| Query | Tipo | Descripción |
|---|---|---|
| status | string | Filtra por estado del comprobante. |
| limit | int | Máximo de ítems por página. |
| cursor | string | Cursor de la página siguiente (nextCursor). |
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.
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.
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.
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.
Anulación de rangos
Anula un rango de e-NCF no usados o firmados pero no enviados. Devuelve cuántos comprobantes se anularon.
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
| type | Se dispara cuando |
|---|---|
| invoice.accepted | La DGII aceptó el e-CF — aceptación plena o condicional. `data.status` distingue las dos. |
| invoice.rejected | La DGII rechazó el e-CF. El e-NCF queda quemado y no se reutiliza. |
| invoice.received | Entró un e-CF a tu nombre por el plano de recepción y fue aceptado. |
| commercial_approval.received | Entró una Aprobación Comercial (ACECF) de un comprador. Un cambio de veredicto sobre el mismo e-CF es un evento nuevo. |
| contingency.activated | Un comprobante se emitió en contingencia porque la DGII no respondía. |
| sequence.low | A un tipo de comprobante le queda menos del 5 % del rango autorizado. |
| certificate.expiring | El 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.
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.
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.
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.
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.
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.
| Query | Tipo | Descripción |
|---|---|---|
| cursor | string | Opaco, emitido por nosotros. Omítelo para empezar por el evento más antiguo retenido. Un cursor que no emitimos es 400. |
| limit | int | Máximo 100, que es también el valor por omisión. Un número mayor se recorta; cero o negativo es 400. |
| type | string | Filtro 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 Gone — nunca 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ódigo | Significado |
|---|---|
| 400 | Petición inválida — cuerpo o parámetros mal formados. |
| 401 / 403 | Token ausente/expirado, scope insuficiente, cuenta suspendida o ambiente no habilitado. |
| 409 | Conflicto — clave de idempotencia reutilizada con otro cuerpo, emisión en progreso, o endpoint de webhook duplicado / tope alcanzado. |
| 410 | Cursor del feed de eventos fuera de la ventana de retención; resincroniza y reinicia sin cursor. |
| 422 | e-CF no procesable — falla de validación XSD o de negocio. |
| 502 / 503 | La 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.