¿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.
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.
De cero a un XML firmado (Jobs API)
Tres pasos. El primero se hace una vez; los otros dos son tu integración.
- Genera tu API key. Sin registro: . Te damos la key y un webhook secret.
-
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. - 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.
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.
Sign Agent + lume-ecf
.p12 en tu infra. Cron 24/7 renueva token DGII y firma XML. Emisión de noche y por lotes.
Setup RiserUp + Jobs API
El cliente configura el .p12 para tu producto en browser. Tú POST el documento. Usuario confirma Firmar en popup.
Vault en tu origen
Instalas el paquete en tu bundle. Tú pintas Setup. Firma programática en sesión desbloqueada (8 h).
deviceSign.sign(xml)A = desatendido con lume-ecf. B/C = usuario presente (SaaS, checkout, vault browser). El vault B/C es por producto + origen + browser + equipo.
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 (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).
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.
| Secreto | Para 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.
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"
}'
| Campo | Notas | |
|---|---|---|
unsignedXml | sí | String XML. Máx. 2 MB. |
callbackUrl | sí | HTTPS (o http://localhost en dev). Recibe el firmado. |
returnUrl | no | Redirect 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>" }
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ó.
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.
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étodo | Qué 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.
Códigos estables
HTTP API responde JSON { "error": "CODE" }. El JS API lanza DeviceSignError con err.code.
| Código | Dónde | Cuándo |
|---|---|---|
UNAUTHORIZED | HTTP 401 | Falta el Bearer o la key no es válida. |
RATE_LIMITED | HTTP 429 | Pasaste tu límite por minuto, o 3 keys/hora al generar. |
INVALID_JSON | HTTP | Body que no es JSON. |
MISSING_XML | HTTP | Falta unsignedXml. |
XML_TOO_LARGE | HTTP 413 | Más de 2 MB. |
BAD_CALLBACK | HTTP | No es HTTPS, o el host no está en tu allowlist. |
BAD_RETURN_URL | HTTP | returnUrl inválida. |
BAD_NAME | HTTP | /v1/keys sin nombre de producto. |
BAD_EMAIL | HTTP | /v1/keys con email inválido. |
BAD_CALLBACK_HOST | HTTP | El host llevaba esquema o ruta. |
NOT_FOUND | HTTP 404 | Job inexistente o TTL vencido. |
NOT_SIGNED | HTTP | complete sin Signature. |
BAD_P12 | JS | Archivo o password del cert inválidos. |
BAD_PIN | JS | PIN/frase inválidos en Setup, o incorrectos en unlock. |
NO_VAULT | JS | No hay setup en este origen / este producto. |
WRONG_PRODUCT | JS | El vault no coincide con el producto. |
SESSION_LOCKED | JS | Hay vault; falta unlock. |
CERT_EXPIRED | JS | notAfter pasado. |
SIGN_FAILED | JS | XML malformado o fallo DSig. |
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.