De cero a un XML firmado
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. Abre nuestro Setup en su browser. La clave privada nunca sale de su equipo, y 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.
Dos caminos. Misma regla.
Setup del certificado es una cosa: una vez en RiserUp, sesión de 8 h. Firmar documentos es otra: tu backend manda el XML o PDF por API y recibe el firmado. El cliente no usa nuestro formulario de firma.
Setup en RiserUp + API
El cliente configura el .p12 aquí. Tú POST el documento. Si la sesión está desbloqueada, se firma solo. Si no, PIN y se firma solo.
Vault en tu origen
Instalas el paquete en tu bundle. Tú pintas Setup. El XML sigue yendo por tu API, no por un form de RiserUp.
deviceSign.sign(xml)El vault es por origen + browser + equipo. Opción 1: un setup en RiserUp Sign sirve a todos los anfitriones. Opción 2: el vault vive en tu dominio — otro host = otro setup.
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 si la sesión de 8 h caducó). No hay upload de XML en nuestra UI.
Cliente Setup .p12 en RiserUp (una vez)
Tú POST /v1/jobs { unsignedXml, callbackUrl }
← { id, signUrl }
Usuario abre signUrl solo para desbloquear si hace falta
la firma corre sola en este origen
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…"
}
Abre signUrl (redirect, popup o iframe) para que este
origen firme. Si el vault ya está desbloqueado, no hay formulario:
se firma y vuelve a tu callbackUrl. 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. Te sirve si embebes el flujo y quieres leer estado.
{
"id": "3b2c…",
"status": "pending",
"unsignedXml": "<ECF>...</ECF>",
"returnUrl": "https://app.ejemplo.com/invoices/123"
}
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,
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 origen. |
Vite:
optimizeDeps: {
include: ["@riserup/device-sign", "node-forge", "xmldsigjs"],
}
Handshake típico: status() → overlay PIN 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 corto o incorrecto. |
NO_VAULT | JS | No hay setup en este origen. |
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.4: API key self-serve desde esta página, allowlist de callback, jobs en Postgres con TTL 30 min, webhook firmado con reintentos. Next: PDF (PAdES).