Documentación

No custodies el certificado de tus clientes. Ellos firman en su máquina. Tú recibes el documento.

Para CEOs, founders y developers que están construyendo un SaaS y no quieren el .p12 en su servidor. El único formulario nuestro es el Setup del certificado. El documento entra y sale por API.

HTTP API · POPUP · WEBHOOK · SIN CUSTODIA

Empieza aquí

De cero a un XML firmado

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. Abre nuestro Setup en su browser. La clave privada nunca sale de su equipo, y 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

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.

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.

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 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"
  }'
CampoNotas
unsignedXmlString XML. Máx. 2 MB.
callbackUrlHTTPS (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…"
}

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>" }
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,
  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 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.

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 corto o incorrecto.
NO_VAULTJSNo hay setup en este origen.
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.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).