Firma individual y masiva (PAdES-B/BT/LTA)¶
Segunda familia de endpoints de firma, en paralelo a los de Documentos (/api/doc/*). No los reemplaza: conviven. La diferencia principal es el contrato (JSON + Base64 en vez de multipart) y que soporta explícitamente niveles PAdES, además de firma masiva con seguimiento asíncrono.
Todos los endpoints requieren token (Authorization: Bearer <token>) y que el dni_firmante coincida con el dni del token. Si no coincide, responde 401 UNAUTHORIZED.
El certificado usado es siempre el principal del dni_firmante (ver Certificados); su vigencia y revocación (OCSP/CRL) se verifican una sola vez por operación — una vez en la firma individual, y una sola vez para todo el lote en la firma masiva (se reutiliza para los N documentos, no se repite por archivo).
Niveles de firma (nivel_firma)¶
| Valor | Sello de tiempo (TSA) | Datos de revocación embebidos (DSS) | Uso típico |
|---|---|---|---|
PAdES-B | No | No | Firma liviana, validación solo mientras el certificado esté vigente |
PAdES-BT | Sí | No | Firma con evidencia de "cuándo" se firmó |
PAdES-LTA | Sí | Sí (+ doc-timestamp) | Archivo de largo plazo; la firma sigue siendo verificable aunque el certificado expire después |
nivel_firma decide esto de forma exclusiva y no hay forma de combinarlo (por ejemplo, no existe un flag para forzar/quitar el TSA en PAdES-BT). Un valor distinto a los tres anteriores responde 400 BAD_REQUEST.
Cuando el nivel requiere TSA (PAdES-BT/PAdES-LTA), se usa siempre la configuración marcada isDefault = true en /api/tsa — este contrato no tiene un parámetro equivalente a tsaAlias. Si no existe ninguna TSA por defecto, responde 400 BAD_REQUEST ("No default TSA configuration found").
firma_visual¶
Objeto opcional. Si se omite por completo, la firma es invisible (igual que enviar "type": "INVISIBLE").
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
type | string | No (default INVISIBLE) | TEXTO, IMAGEN, IMAGEN_TEXTO o INVISIBLE |
img_base64 | string | Sí si type es IMAGEN/IMAGEN_TEXTO | Imagen del sello, en Base64 |
ubicacion_x, ubicacion_y | string numérico | No (default "0") | Posición del sello, en puntos PDF, origen abajo-izquierda de la página |
largo, alto | string numérico | No (default "180"/"100") | Tamaño del sello, en puntos |
pagina | string numérico | No (default "1") | Página donde va el sello. "0" = todas las páginas, "-1" = última página, cualquier otro número = esa página (1-indexado) |
motivo | string | No | Motivo de la firma (también disponible como placeholder [MOTIVO]) |
ciudad | string | No | Ciudad (placeholder [CIUDAD]; también se usa como location de la firma) |
formato_fecha | string | No (default yyyy-MM-dd HH:mm:ss) | Patrón DateTimeFormatter de Java para el placeholder [FECHA]. Un patrón inválido responde 400 BAD_REQUEST |
texto | string | Sí si type es TEXTO/IMAGEN_TEXTO | Plantilla del bloque de texto (ver Placeholders) |
Un valor no numérico en ubicacion_x/ubicacion_y/largo/alto/pagina responde 400 BAD_REQUEST con el nombre del campo afectado.
Placeholders en texto¶
La plantilla se separa por &; cada segmento resultante es una línea del bloque de firma. Placeholders soportados:
| Placeholder | Valor |
|---|---|
[FIRMANTE] | Nombre del titular del certificado |
[ORG] | Empresa (extraída del certificado) |
[DNI] | dni_firmante |
[CARGO] | Cargo (extraído del certificado) |
[MOTIVO] | firma_visual.motivo |
[FECHA] | Fecha/hora actual, formateada con formato_fecha |
[CIUDAD] | firma_visual.ciudad |
Si una línea contiene únicamente placeholders y todos resuelven vacío (por ejemplo [CARGO] cuando el certificado no tiene esa metadata), la línea se omite del sello — no hace falta ningún flag para ocultarla, a diferencia de printJobTitle/printCompany en Documentos.
Ejemplo de plantilla:
Página y ajuste automático (clamp)¶
Con pagina: "0" el sello visual se dibuja en todas las páginas del documento (una anotación por página), pero la firma criptográfica sigue siendo una sola — se ancla en la última página. Con pagina: "-1" el sello y la firma van únicamente en la última página.
Para cada página objetivo, la posición y el tamaño del sello se ajustan automáticamente contra el tamaño real de esa página (ancho/alto ya considerando rotación /Rotate): si el rectángulo pedido no entra tal cual (por ejemplo el tamaño por defecto en una página muy pequeña, o una página angosta en modo "todas las páginas" con hojas mixtas), se reduce el tamaño y se reposiciona lo mínimo necesario para que quede completamente dentro del borde. Este ajuste se registra como warn en el log del servidor, nunca hace fallar la firma. Un número de página fuera de rango (mayor al total de páginas del PDF) responde 400 BAD_REQUEST.
POST /api/firma/individual¶
{
"dni_firmante": "12345678",
"nombre": "archivo.pdf",
"base64": "<pdf en base64>",
"nivel_firma": "PAdES-BT",
"firma_visual": {
"type": "IMAGEN_TEXTO",
"img_base64": "<imagen en base64>",
"ubicacion_x": "0",
"ubicacion_y": "0",
"largo": "300",
"alto": "40",
"pagina": "1",
"motivo": "Doy fe del documento",
"ciudad": "Lima",
"formato_fecha": "dd/MM/yyyy HH:mm",
"texto": "Firmado digitalmente por: [FIRMANTE]&Empresa: [ORG]&DNI: [DNI]&[MOTIVO]&[FECHA]"
}
}
nombre es solo informativo/de trazabilidad; no puede contener / ni \ (responde 400 BAD_REQUEST si los tiene).
Respuesta 200 OK:
POST /api/firma/masiva¶
{
"nombre_zip": "bloque1.zip",
"dni_firmante": "12345678",
"archivos": [
{ "nombre": "archivo-1.pdf", "base64": "...", "nivel_firma": "PAdES-BT", "firma_visual": { "...": "..." } },
{ "nombre": "archivo-2.pdf", "base64": "...", "nivel_firma": "PAdES-B" }
]
}
firma_visual es opcional por archivo (cada uno puede tener su propio nivel y tipo de sello). Respuesta 202 ACCEPTED:
{
"success": true,
"mensaje": "2 documentos en cola para firma masiva",
"solicitud": "K3F9QZ1B7C",
"verificar_firmado": "GET /firma/estado/K3F9QZ1B7C"
}
Límites¶
| Límite | Propiedad | Default |
|---|---|---|
| Cantidad de archivos por lote | app.firma.masiva.max-archivos | 100 |
| Peso total decodificado del lote | app.firma.masiva.max-peso-mb | 300 MB |
Ambos se validan antes de crear cualquier registro o escribir un solo archivo en disco; si se exceden, responde 400 BAD_REQUEST y no queda nada pendiente. Un nombre con / o \ en cualquier archivo del lote también responde 400 BAD_REQUEST (para todo el lote).
Procesamiento¶
El lote se procesa en segundo plano, en un pool de hilos dedicado (app.firma.masiva.worker-pool-size, default 3), separado de los hilos que atienden requests HTTP. Si un documento falla al firmar, se marca ERROR en su detalle y el resto del lote continúa. Si se acumulan 3 fallos consecutivos (indicio de una falla sistémica, por ejemplo el TSA caído), el resto de los documentos pendientes se marca ERROR sin intentar firmarlos, en vez de reintentar uno por uno contra un servicio no disponible.
Estado final de la solicitud:
| Estado | Cuándo |
|---|---|
COMPLETADO | Todos los documentos se firmaron sin error |
COMPLETADO_CON_ERRORES | Al menos un documento falló, pero el zip se generó igual con los que sí se firmaron |
ERROR | Falló la verificación del certificado del lote, o la generación del zip en sí |
GET /api/firma/estado/{solicitud}¶
{
"success": true,
"solicitud": "K3F9QZ1B7C",
"estado": "PROCESANDO",
"mensaje": "Procesando documentos",
"progreso": { "procesados": 1, "total": 2 }
}
Cuando estado es COMPLETADO o COMPLETADO_CON_ERRORES, la respuesta incluye además:
404 NOT_FOUND si el código no existe o no pertenece al dni del token (mismo mensaje en ambos casos, para no revelar a un tercero si un código ajeno existe).
GET /api/firma/zip/{solicitud}¶
404 NOT_FOUND en los mismos casos que estado. 409 CONFLICT si la solicitud existe pero todavía no terminó de procesarse (estado PENDIENTE, PROCESANDO o ERROR).
Contenido del zip¶
Un .pdf por cada documento firmado exitosamente (con el nombre original, o nombre(1).ext, nombre(2).ext, etc. si dos archivos del lote tenían el mismo nombre) más un manifest.json con un arreglo, un objeto por documento del lote original (incluyendo los que fallaron):
[
{
"nombre": "archivo-1.pdf",
"nivel_firma": "PAdES-BT",
"estado": "OK",
"nombre_en_zip": "archivo-1.pdf",
"checksum_sha256_antes": "3a7bd3e2...",
"checksum_sha256_despues": "9f2c1a0e...",
"peso_antes_kb": 128.4,
"peso_despues_kb": 131.9,
"fecha_hora_firma": "2026-07-18T00:05:12.000+00:00"
},
{
"nombre": "archivo-2.pdf",
"nivel_firma": "PAdES-B",
"estado": "ERROR",
"error": "No existe un certificado principal registrado para el dni 12345678"
}
]
Si algún archivo del lote se llamara literalmente manifest.json, se renombra automáticamente dentro del zip (a manifest(1).json) para no chocar con el manifest real; su nombre original se preserva igual en el JSON.
Retención y limpieza¶
Cada solicitud (zip, PDFs de entrada/salida en disco, y los registros en base de datos) se elimina automáticamente app.firma.masiva.retencion-horas (default 24) horas después de creada, mediante un job que corre cada hora. Solo se elimina si la solicitud ya terminó (COMPLETADO / COMPLETADO_CON_ERRORES / ERROR); una solicitud que sigue PENDIENTE o PROCESANDO más allá de su retención no se borra (se registra un warn en el log, indicando un posible lote atascado).
Códigos de respuesta¶
| Código | Causa |
|---|---|
| 200 OK | Firma individual completada, o consulta de estado/zip exitosa |
| 202 ACCEPTED | Lote masivo encolado |
| 400 BAD_REQUEST | Campo inválido/faltante, nivel_firma no soportado, firma_visual con datos inconsistentes, página fuera de rango, límites del lote excedidos, sin TSA por defecto cuando se requiere |
| 401 UNAUTHORIZED | Token inválido/vacío, o dni_firmante no coincide con el dni del token |
| 404 NOT_FOUND | (estado/zip) el código de solicitud no existe o no es del dni del token |
| 409 CONFLICT | (zip) la solicitud todavía no tiene un zip disponible |
| 422 UNPROCESSABLE_ENTITY | El certificado principal del dni_firmante no existe, no está vigente, o está revocado |
| 500 INTERNAL_SERVER_ERROR | Error inesperado |
Ver ejemplos completos en Casos de uso: firmar un documento individual (PAdES) y en Casos de uso: firma masiva de documentos.