@devgoliiive/unidigital-sdk
El sdk tipado del servicio de facturación fiscal Unidigital. Envuelve las
operaciones de /v1, verifica las firmas de los webhooks y normaliza los
errores en una jerarquía discriminada por un código estable.
El sdk hace tres cosas y ninguna más. No reintenta respuestas HTTP, no espera a que un documento se finalice, no valida el payload en local y no trae dependencias en runtime.
Instalación
El sdk se publica como paquete público en npmjs.com, bajo la organización
devgoliiive. No hace falta .npmrc, bunfig.toml ni token: ni en local, ni
en CI, ni en el build de Railway.
pnpm add @devgoliiive/unidigital-sdk
# o
bun add @devgoliiive/unidigital-sdk
Skill para agentes
El paquete trae una skill de Claude Code con las invariantes del SDK, versionada con él. Enlázala desde la raíz de tu repo:
mkdir -p .claude/skills
ln -s ../../node_modules/@devgoliiive/unidigital-sdk/skills/unidigital-sdk .claude/skills/unidigital-sdk
Uso
Construir el sdk
import { createClient } from "@devgoliiive/unidigital-sdk";
const unidigital = createClient({
baseUrl: process.env.UNIDIGITAL_API_URL!,
apiKey: process.env.UNIDIGITAL_API_KEY!,
// Opcionales:
timeoutMs: 10_000, // por defecto; ajustable por llamada
retry: { attempts: 2 }, // solo fallos de transporte, nunca respuestas HTTP
// fetch: miFetchInstrumentado,
});
No hay URL por defecto: hornear una envejece mal, y apuntar al entorno equivocado emite documentos fiscales reales.
Chequeo de conectividad
Verifica la conectividad y las credenciales antes de emitir nada real.
await unidigital.checkConnectivity();
Resolver una serie
const { series } = await unidigital.series.lookup({
unidigitalAccountId: accountId,
});
const seriesId = series.find((serie) => serie.name === "A")?.seriesId;
Cada serie trae el seriesId que pide documents.issue. El lookup registra
las series que Unidigital reporta, así que una serie nueva aparece en la
siguiente llamada. Si tu catálogo no encuentra una serie, vuelve a consultarlo.
Una serie con seriesId: null tiene su nombre ocupado por otra serie de la
misma cuenta. No puedes emitir con ella hasta que un operador lo corrija en el admin.
Emitir una factura
const { trackingId, status } = await unidigital.documents.issue({
unidigitalAccountId: accountId,
seriesId,
idempotencyKey: order.id, // obligatoria, y sale de tu dominio
payload: {
Name: "Carlos Perez",
FiscalRegistryCode: "J",
FiscalRegistry: "297182748",
Address: "Av. Principal, Caracas",
PaymentType: "CONTADO",
EmailTo: "[email protected]",
EmissionDateAndTime: "2026-01-02T10:00:00Z",
Currency: "USD",
TaxBase: 100,
TaxAmount: 16,
Total: 116,
GrandTotal: 116,
IGTFPercentage: 3,
TaxPercentReduced: 8,
TaxPercentSumptuary: 31,
Details: [
{
Description: "Boleto general",
Quantity: 1,
UnitPrice: 100,
Amount: 100,
TaxAmount: 16,
TaxPercent: 16,
TotalAmount: 116,
OperationCode: "C001",
ProductType: 1,
},
],
},
});
La clave de idempotencia es obligatoria y el SDK nunca la genera. Un reintento tiene que reutilizar la misma clave. Si generas una nueva, el servicio emite dos documentos fiscales con dos correlativos quemados.
Los campos que el servicio se asigna a sí mismo —Number, SerieStrongId,
DocumentType y los Affected* que describen el documento afectado— no son
enviables: el tipo los rechaza antes del round trip.
Emitir una nota de crédito o débito
El documento afectado va por su id, y nada más sobre él.
await unidigital.notes.issue({
unidigitalAccountId: accountId,
seriesId,
documentType: "credit_note", // o "debit_note"
affectedDocumentId: invoiceId,
idempotencyKey: refund.id,
payload: {/* como arriba */},
});
Consultar el estado
Es el respaldo cuando el webhook no llegó. Las lecturas (getStatus,
getContent, void) aceptan el id del documento o el trackingId que
devolvió issue.
const doc = await unidigital.documents.getStatus(trackingId);
doc.invoiceNumber; // el correlativo dentro de la serie
doc.controlNumber; // el número de control del SENIAT, null hasta `issued`
El correlativo y el número de control son números distintos. Un documento no está legalmente completo hasta tener el segundo.
No hay helper de espera a estado final, y es deliberado: la finalización tarda minutos, así que una espera bloqueante dentro de un handler HTTP es una caída esperando su turno. Reconcilia desde un job con esta lectura.
Obtener el enlace del documento
const { url } = await unidigital.documents.getContent(documentId);
Anular un documento
const result = await unidigital.documents.void(documentId);
if (result.outcome === "voided_local_record_pending") {
// La anulación SÍ ocurrió en el proveedor; solo nuestro registro no ha
// cuadrado, y el servicio lo cierra con un barrido propio. No es un fallo.
}
Es síncrona, no lleva clave de idempotencia (anular dos veces es seguro) y el SDK no la reintenta.
Verificar un webhook
Es una función suelta: verificar no necesita API key ni URL base, y el proceso que recibe webhooks rara vez es el que emite documentos.
import { verifyWebhook, WebhookVerificationError } from "@devgoliiive/unidigital-sdk";
// El cuerpo tiene que ser el CRUDO, tal cual llegó. Re-serializarlo reordena
// claves y la firma deja de coincidir.
try {
const event = verifyWebhook({
rawBody,
headers: req.headers,
secret: process.env.UNIDIGITAL_WEBHOOK_SECRET!,
toleranceSeconds: 300, // por defecto
});
// Deduplica por el delivery id, NUNCA por el document id: un mismo documento
// genera más de un evento, y cada uno es un hecho distinto.
if (await alreadyProcessed(event.deliveryId)) return;
switch (event.type) {
case "fiscal_document.issued":
// event.data.controlNumber ya está disponible
break;
case "fiscal_document.approved":
case "fiscal_document.rejected":
case "fiscal_document.voided":
break;
}
} catch (error) {
if (error instanceof WebhookVerificationError) {
// error.reason: "missing_header" | "malformed_header" | "invalid_secret"
// | "timestamp_out_of_window" | "signature_mismatch"
// | "malformed_body"
}
}
Errores
Toda respuesta no exitosa llega como excepción tipada. Discrimina por code,
nunca por el mensaje: el mensaje es libre, lleva el texto de Unidigital en un
fallo aguas arriba, y cambia cuando alguien corrige una errata.
Hay tres raíces de error, y un catch completo las cubre las tres:
UnidigitalError (el servicio respondió), UnexpectedResponseError (respondió
algo que el envelope no explica) y TransportError (nunca hubo respuesta). El
guard isUnidigitalSdkError cubre las tres de una vez.
import { ConflictError, UnidigitalError, ValidationError } from "@devgoliiive/unidigital-sdk";
try {
await unidigital.documents.void(documentId);
} catch (error) {
if (error instanceof ConflictError) {
if (error.code === "void_in_flight") {
// Ya hay una anulación en vuelo: espera y consulta el estado.
}
if (error.code === "document_not_voidable") {
// Definitivo: el documento no existe fiscalmente. Ríndete.
}
}
if (error instanceof ValidationError) {
error.issues; // qué campo corregir
}
if (error instanceof UnidigitalError) {
error.code;
error.status;
error.requestId; // cítalo al pedir soporte
}
}
| Clase | Status | Para qué sirve |
|---|---|---|
ValidationError | 400 | El payload es inválido; issues nombra el campo. |
AuthenticationError | 401 | API key ausente, inválida o sin tenant. |
PermissionError | 403 | El tenant no tiene permiso sobre esa cuenta. |
NotFoundError | 404 | No existe — o es de otro tenant, que responde igual. |
ConflictError | 409 | El estado del documento impide la operación. |
UpstreamError | 502 | Fallo de Unidigital. |
ServiceUnavailableError | 503 | Permanente: puerto sin cablear o credenciales. |
InternalError | 500 | Error nuestro. Cita el requestId. |
UnexpectedResponseError | — | La respuesta no encaja en el envelope; conserva status y cuerpo. |
TransportError | — | Fallo de transporte agotado. No es una respuesta del servicio. |
Si compilas sin strict
El compilador no te mostrará ningún | null de esta superficie. Los campos
que pueden faltar son: en DocumentStatusView, strongId, invoiceNumber,
controlNumber, batchStrongId, rejectionReason e issuedAt; en el data de
un webhook, invoiceNumber, controlNumber, issuedAt y reason; y issues
en ValidationError. Compruébalos a mano.
Reintentos
El SDK reintenta solo fallos de transporte (DNS, conexión reiniciada, socket colgado, timeout), con tope bajo y backoff con jitter, reusando la misma clave de idempotencia.
Ninguna respuesta HTTP se reintenta, aunque la intuición diga lo contrario:
aquí un 503 es una condición permanente, y un 502 en la anulación puede
significar que la anulación sí se aplicó, caso que el servicio reconcilia
con un barrido propio.
Dobles de test
La interfaz del sdk se exporta como tipo, para que escribas tus propios dobles y se rompan en CI cuando la superficie cambie.
import type { UnidigitalClient } from "@devgoliiive/unidigital-sdk";
const fake: UnidigitalClient = {/* el compilador te dice qué falta */};
No se publican mocks ni un doble stub: la resolución legacy no ve subpaths, y meterlos en la raíz arrastraría la herramienta de mocking a las declaraciones o metería código de test en el bundle de producción.
Versionado y publicación
Bump manual, semver independiente, sin gestor de changesets. El major del SDK sigue al major de la API; minor y patch son suyos.
Publicar es subir la versión en packages/sdk/package.json y llevarlo a
main: el workflow .github/workflows/publish-sdk.yml verifica el empaquetado
y publica. Una versión que el registry ya tiene se salta sin fallar.
Para que un cambio no se quede sin publicar, .github/workflows/sdk-version.yml
falla un PR hacia main que toca src/ o tsdown.config.ts sin subir la versión.
Sube también SDK_VERSION en src/version.ts; un test exige que coincidan.
El workflow se autentica con trusted publishing (OIDC): npmjs.com confía en ese workflow de este repo, y no hay token guardado. La configuración vive en npmjs.com, en Settings → Trusted Publisher del paquete.
Para publicar a mano, inicia sesión con npm login como miembro de la
organización devgoliiive:
pnpm --filter @devgoliiive/unidigital-sdk release