Documentación

Firma XML para integradores. Lume no custodia tu .p12. Elige cómo desplegar la firma.

Para CEOs, founders y developers. Tres caminos — misma regla: el certificado vive en tu infra o en el dispositivo de quien firma, nunca en nuestros servidores. ¿API de noche con lume-ecf? Sign Agent en tu servidor. ¿SaaS con usuario presente? Jobs API o vault JS en el browser.

SIGN AGENT · JOBS API · VAULT JS · TRES CAMINOS

Elige primero

¿Qué estás construyendo?

RiserUp Sign cubre tres casos. La garantía de no custodia es la misma; cambia dónde vive la llave y si hace falta un humano presente.

Integrador Lume e-CF (API, lotes, noche): POST /v1/jobs no es desatendido — el usuario debe abrir signUrl y confirmar cada firma. Para transmitir a DGII sin browser necesitas Sign Agent + lume-ecf.
Empieza aquí

De cero a un XML firmado (Jobs API)

Tres pasos. El primero se hace una vez; los otros dos son tu integración.

  1. Genera tu API key. Sin registro: . Te damos la key y un webhook secret.
  2. Tu cliente carga su certificado una vez, para tu app. Abre nuestro Setup en el signUrl. Si ya firmó en la página pública, tiene que configurar otra vez: ese vault no se comparte. PIN de 8+ o una frase. La sesión dura 8 h.
  3. Manda el documento por API. Nosotros te devolvemos el firmado a tu webhook.
curl -sS -X POST https://sign-production-967b.up.railway.app/v1/jobs \
  -H 'authorization: Bearer rsk_live_…' \
  -H 'content-type: application/json' \
  -d '{
    "unsignedXml": "<ECF>...</ECF>",
    "callbackUrl": "https://app.ejemplo.com/hooks/signed-xml"
  }'

Si prefieres que el vault viva en tu propio dominio y no en el nuestro, salta a JS API.

Elige

Tres caminos. Misma regla de no custodia.

Lume no recibe tu .p12. En A y B/C la llave nunca pasa por nuestro servidor de jobs; en A vive en tu servidor con un agente que tú despliegas.

A = desatendido con lume-ecf. B/C = usuario presente (SaaS, checkout, vault browser). El vault B/C es por producto + origen + browser + equipo.

Camino A

Sign Agent + lume-ecf Desatendido

Para integradores que emiten por API ante la DGII. Lume (lume-ecf) arma XML, numera y transmite; tú corres un agente con el .p12 Avansi que renueva el token DGII ~cada hora y firma los comprobantes.

Sin agente 24/7, POST /submit a lume-ecf responde 409 sin token — el comprobante queda en outbox pero DGII no lo recibe hasta que renueves el token.

Elegir superficie

Docker Node lib Python Plugin ERP Sidecar HTTP

Docker (más fácil)

Token-only v1 — ideal si tu ERP ya firma XML. Corre en tu Railway/VPS.

docker run -d --restart=always \
  -e LUME_BASE_URL=https://ecf.lume.com.do \
  -e LUME_API_KEY=lum_live_… \
  -e RNC=131123456 \
  -e DGII_ENV=ecf \
  -e P12_BASE64=… \
  -e P12_PASSWORD=… \
  ghcr.io/riserup/sign-agent

Biblioteca Node (mismo proceso que tu ERP)

import { SignAgent } from "@riserup/sign-agent";

const agent = SignAgent.start({
  lumeBaseUrl: process.env.LUME_BASE_URL,
  lumeApiKey: process.env.LUME_API_KEY,
  rnc: process.env.RNC,
  dgiiEnv: "ecf",
  p12: { base64: process.env.P12_BASE64, password: process.env.P12_PASSWORD },
});

// tras POST lume-ecf /v1/documentos/armar:
const signed = agent.signXml(unsignedXml);
// POST lume-ecf /submit

Plugin en tu producto de facturación

Embebé la lib en tu ERP: start() al boot, signXml() al emitir, token loop en background. El .p12 sale del mismo secret store que ya usa tu app.

Flujo completo

Tu agente (cron ~45 min)
  GET  DGII …/semilla → firmar → validarsemilla
  POST lume-ecf /emisores/{rnc}/token

Tu ERP (noche / lote)
  POST lume-ecf /v1/documentos/armar  →  xml sin firmar
  agent.signXml(xml)
  POST lume-ecf /submit  →  202 (transmite a DGII)

Spec completa: lume-ecf/docs/SIGN-AGENT.md · OpenAPI: ecf.lume.com.do/docs · Paquete: packages/sign-agent (implementación v1 pendiente).

API key

Autenticación

Toda llamada a /v1/jobs lleva tu API key como Bearer. Te damos dos cosas: la API key (para llamarnos) y un webhook secret (para verificar que el callback salió de nosotros). Las dos se muestran una sola vez.

Authorization: Bearer rsk_live_…

Con la key registramos los dominios de callback permitidos y un límite por minuto. Si mandas un callbackUrl fuera de tu allowlist, respondemos BAD_CALLBACK.

SecretoPara quéDónde vive
rsk_live_… Autoriza tus POST /v1/jobs. Tu backend. Nunca en el browser.
whsec_… Verifica el HMAC de cada callback nuestro. Tu backend, junto al handler del webhook.

Tus usuarios finales no necesitan cuenta con nosotros. La cuenta es tuya; ellos solo hacen Setup del certificado en su browser.

HTTP API

Setup en RiserUp + jobs

Base: https://sign-production-967b.up.railway.app

El único formulario de RiserUp que usa tu cliente es Setup (y PIN o frase si la sesión de 8 h caducó), más confirmar Firmar. No hay upload de XML en nuestra UI.

Cliente   Setup .p12 para *tu* producto (una vez)

Tú        POST /v1/jobs { unsignedXml, callbackUrl }
          ← { id, signUrl }   ?product=lume

Usuario   abre signUrl; PIN/frase si hace falta; confirma Firmar
          el vault de la página pública no se usa

Nosotros  POST callbackUrl { id, signedXml }
Tú        verificas Signature; despachas a DGII u otra entidad

POST /v1/jobs

Crea el trabajo y te devuelve la URL de firma. Llámalo desde tu backend, nunca desde el browser: la API key no va en el cliente.

curl -sS -X POST https://sign-production-967b.up.railway.app/v1/jobs \
  -H 'authorization: Bearer rsk_live_…' \
  -H 'content-type: application/json' \
  -d '{
    "unsignedXml": "<ECF>...</ECF>",
    "callbackUrl": "https://app.ejemplo.com/hooks/signed-xml",
    "returnUrl": "https://app.ejemplo.com/invoices/123"
  }'
CampoNotas
unsignedXmlsíString XML. Máx. 2 MB.
callbackUrlsíHTTPS (o http://localhost en dev). Recibe el firmado.
returnUrlnoRedirect del browser tras entregar el callback.

Respuesta 201:

{
  "id": "3b2c…",
  "signUrl": "https://sign-production-967b.up.railway.app/?job=3b2c…&product=lume"
}

Abre signUrl (redirect, popup o iframe) para que este origen firme con el vault de tu producto. El usuario confirma Firmar. El id es un capability URL: quien lo tenga puede leer el documento unsigned. Trátalo como un secreto. TTL 30 min.

GET /v1/jobs/:id

Lo usa nuestra UI. Devuelve también productSlug y productName para abrir el vault de tu producto.

{
  "id": "3b2c…",
  "status": "pending",
  "unsignedXml": "<ECF>...</ECF>",
  "returnUrl": "https://app.ejemplo.com/invoices/123",
  "productSlug": "lume",
  "productName": "Lume"
}

status: pending · signed · delivered · callback_failed

POST /v1/jobs/:id/complete

Lo llama el browser después de firmar en el dispositivo. Nosotros reenviamos a tu callback. Tú no lo llamas desde tu servidor.

POST /v1/jobs/:id/complete
{ "signedXml": "<ECF>…<Signature>…</Signature></ECF>" }
Popup

Abrirlo desde tu UI

Tu backend crea el job y te devuelve signUrl. Tu frontend lo abre en un popup. Cuando terminamos, avisamos a tu página con postMessage y cerramos la ventana. Por ahí viaja solo el id y el estado — nunca el documento.

// 1. tu backend crea el job y te pasa signUrl
const { signUrl } = await fetch("/api/firmar", { method: "POST" }).then(r => r.json());

// 2. abres el popup
const popup = window.open(signUrl, "riserup-sign", "width=520,height=720");

// 3. escuchas el resultado
window.addEventListener("message", (event) => {
  if (event.origin !== "https://sign-production-967b.up.railway.app") return;
  if (event.data?.source !== "riserup-sign") return;

  if (event.data.status === "delivered") {
    // el XML firmado ya llegó a tu callbackUrl. Refresca desde tu backend.
  } else {
    // no se pudo entregar. Reintentamos; muestra estado pendiente.
  }
});

El documento firmado siempre llega por el webhook, no por el popup. Trata el postMessage como una señal de UI: te dice cuándo refrescar, no qué firmó el usuario.

¿Prefieres sin JavaScript? Manda returnUrl al crear el job y usa redirect en vez de popup: devolvemos al usuario a esa URL cuando el callback ya se entregó.

Webhook

Recibir el documento firmado

POST {callbackUrl} · Content-Type: application/json

{
  "id": "3b2c…",
  "signedXml": "<ECF>…<Signature>…</Signature></ECF>"
}

Cada entrega lleva X-RiserUp-Signature con el timestamp y un HMAC-SHA256 del cuerpo, usando tu webhook secret:

X-RiserUp-Signature: t=1786000000,v1=9f86d0818…
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("="))
  );
  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Después verifica la firma XML-DSig del documento. El HMAC dice que venimos de nosotros; el XML-DSig dice que lo firmó el certificado de tu cliente. Devuelve 2xx: si no, reintentamos a los 5 s y a los 30 s. Trata la entrega como al menos una vez — usa el id para no duplicar.

RiserUp no despacha a DGII ni a Aduanas. Eso lo haces tú con el documento ya firmado.

JS API

Vault en tu origen

Si el vault vive en tu dominio, tú pintas Setup. El documento sigue viajando por tu API — no por un formulario de firma de RiserUp. No importes este paquete en Node, Edge ni Railway.

npm install github:RiserUp/riserup-device-sign#v0.1.0
import {
  deviceSign,
  isDeviceSignError,
  MIN_PIN_LENGTH,
  MIN_PASSPHRASE_LENGTH,
  SESSION_TTL_MS,
} from "@riserup/device-sign";

await deviceSign.setup({ p12, certPassword, pin });
await deviceSign.status();
await deviceSign.unlock(pin);
const signedXml = await deviceSign.sign(unsignedXml);
await deviceSign.revoke();
MétodoQué hace
setup({ p12, certPassword, pin })Lee el .p12 en este tab. Lo guarda cifrado en IndexedDB. Cero red.
status()¿Hay vault? ¿Sesión desbloqueada? CN / vencimiento / RNC si viene en el cert.
unlock(pin)Desbloquea la clave a memoria. TTL 8 h o al cerrar el browser.
sign(unsignedXml)XML-DSig enveloped, SHA-256, URI vacía. Compacta whitespace. No hace red.
revoke()Borra el vault de este producto en este origen.

Vite:

optimizeDeps: {
  include: ["@riserup/device-sign", "node-forge", "xmldsigjs"],
}

Handshake típico: status() → overlay PIN/frase si hace falta → tu cloud arma XML unsigned → sign(xml) → POST solo el XML firmado a tu backend → tú verificas y despachas.

Errores

Códigos estables

HTTP API responde JSON { "error": "CODE" }. El JS API lanza DeviceSignError con err.code.

CódigoDóndeCuándo
UNAUTHORIZEDHTTP 401Falta el Bearer o la key no es válida.
RATE_LIMITEDHTTP 429Pasaste tu límite por minuto, o 3 keys/hora al generar.
INVALID_JSONHTTPBody que no es JSON.
MISSING_XMLHTTPFalta unsignedXml.
XML_TOO_LARGEHTTP 413Más de 2 MB.
BAD_CALLBACKHTTPNo es HTTPS, o el host no está en tu allowlist.
BAD_RETURN_URLHTTPreturnUrl inválida.
BAD_NAMEHTTP/v1/keys sin nombre de producto.
BAD_EMAILHTTP/v1/keys con email inválido.
BAD_CALLBACK_HOSTHTTPEl host llevaba esquema o ruta.
NOT_FOUNDHTTP 404Job inexistente o TTL vencido.
NOT_SIGNEDHTTPcomplete sin Signature.
BAD_P12JSArchivo o password del cert inválidos.
BAD_PINJSPIN/frase inválidos en Setup, o incorrectos en unlock.
NO_VAULTJSNo hay setup en este origen / este producto.
WRONG_PRODUCTJSEl vault no coincide con el producto.
SESSION_LOCKEDJSHay vault; falta unlock.
CERT_EXPIREDJSnotAfter pasado.
SIGN_FAILEDJSXML malformado o fallo DSig.
Contrato

Qué nunca viaja

Si tu integración manda esto a un servidor — el nuestro o el tuyo — no es RiserUp Sign.

  • .p12 / .pfx
  • Password del certificado
  • PIN
  • PEM de clave privada

Sí puede viajar: XML unsigned, XML firmado, ids de job. Algoritmos: enveloped, exclusive C14N, SHA-256, RSA-SHA256, URI vacía. No inyectamos Id en el root (los XSD DGII lo rechazan).

v0.5: vault por producto, PIN 8+ o passphrase, confirmación antes de firmar un job. Next: PDF (PAdES), WebAuthn.