Actualizaciones de producto

Novedades en Firma.dev: firma todo el paquete de la operación en una sola solicitud, con cada archivo sellado por separado

Escrito por

Contenido

Un acuerdo B2B suele cerrarse sobre una pila de archivos: un contrato marco de servicios (MSA), una orden de trabajo (SOW), un acuerdo de confidencialidad (NDA) y un formulario de pedido, todos dirigidos al mismo cliente para la misma firma. A partir de la versión v1.39.0 de la API, todo ese paquete puede enviarse como una única solicitud de firma de Firma.dev, y cada archivo incluido se devuelve como un documento sellado independiente.

La firma multidocumento ya está disponible para todas las cuentas de Firma.dev

Esta funcionalidad es nueva y ya está activada para todo el mundo. La versión añade:

  • Una plantilla o solicitud de firma puede contener hasta 20 documentos, con un límite de 100 MB por conjunto de documentos.

  • POST /templates, POST /signing-requests y POST /signing-requests/create-and-send aceptan document_ids, una lista de subidas en el orden de los documentos.

  • Las nuevas rutas de /documents permiten añadir, sustituir, renombrar, reordenar, eliminar y descargar documentos de uno en uno.

  • Las etiquetas de anclaje pueden dirigirse a un solo documento mediante document_index.

  • Cada documento de una solicitud de firma completada se descarga como un archivo sellado individualmente, con su propio hash SHA-256.

El registro de cambios fecha la publicación el 12 de octubre de 2026, y las funciones multidocumento han estado disponibles para todas las empresas desde el 13 de octubre de 2026. No hay nada que activar ni ningún flag que solicitar.

El precio tampoco cambia. Una solicitud de firma equivale a un crédito, 0,049 € (~5 ¢ USD), tanto si contiene un documento como veinte. Las preguntas frecuentes de la documentación lo indican directamente: un crédito equivale a una solicitud de firma, independientemente de cuántos firmantes o documentos contenga. Sigue siendo un modelo de pago por uso, sin consumos mínimos mensuales ni tarifas por usuario.

Lo que requería antes un paquete de cuatro documentos

Hasta esta versión, una solicitud de firma admitía un solo documento. Enviar un paquete implicaba programar soluciones provisionales para sortear ese límite, y solía ser algo así:

Paso

Lo que debía hacer tu código

1. Combinar

Unir el MSA, SOW, NDA y formulario de pedido en un solo PDF

2. Desplazar

Sumar el número de páginas de cada archivo previo al número de página de cada campo

3. Enviar

Crear la solicitud de firma a partir del PDF combinado

4. Dividir

Separar el PDF único sellado según el rango de páginas

5. Asignar

Adjuntar cada fragmento al registro correspondiente de tu aplicación

Los pasos 2 y 4 es donde todo falla. Un cliente sustituye un NDA por uno de dos páginas donde antes había uno de tres, y todos los campos posteriores terminan en la página equivocada. Los rangos de división se desajustan de la misma forma... y para una plataforma SaaS cuyos propios clientes suben estos conjuntos, el paquete de cada tenant es ligeramente diferente, por lo que el error aparece tenant por tenant.

Con la v1.39.0 desaparecen los pasos de combinar, desplazar y dividir. Cada archivo se envía como una subida independiente, pasa por un único flujo de firma y regresa sellado por separado.

Envío del paquete con document_ids

Subir cada archivo con POST /documents

Cada documento del conjunto comienza como una subida. POST /documents recibe el nombre del archivo, el tamaño y el tipo de contenido, y devuelve un document_id temporal junto con una upload_url prefirmada. Haces un PUT de los bytes brutos del archivo a esa URL. Se realiza una subida por archivo... por lo que un paquete de cuatro documentos supone cuatro subidas antes de que exista la propia solicitud de firma.

const API = "https://api.firma.dev/functions/v1/signing-request-api";
const headers = {
  Authorization: process.env.FIRMA_API_KEY,
  "Content-Type": "application/json",
};

async function upload(fileName, bytes) {
  const res = await fetch(`${API}/documents`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      file_name: fileName,
      file_size: bytes.length,
      content_type: "application/pdf",
    }),
  });
  const { document_id, upload_url } = await res.json();

  await fetch(upload_url, {
    method: "PUT",
    headers: { "Content-Type": "application/pdf" },
    body: bytes,
  });
  return document_id;
}
const API = "https://api.firma.dev/functions/v1/signing-request-api";
const headers = {
  Authorization: process.env.FIRMA_API_KEY,
  "Content-Type": "application/json",
};

async function upload(fileName, bytes) {
  const res = await fetch(`${API}/documents`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      file_name: fileName,
      file_size: bytes.length,
      content_type: "application/pdf",
    }),
  });
  const { document_id, upload_url } = await res.json();

  await fetch(upload_url, {
    method: "PUT",
    headers: { "Content-Type": "application/pdf" },
    body: bytes,
  });
  return document_id;
}
const API = "https://api.firma.dev/functions/v1/signing-request-api";
const headers = {
  Authorization: process.env.FIRMA_API_KEY,
  "Content-Type": "application/json",
};

async function upload(fileName, bytes) {
  const res = await fetch(`${API}/documents`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      file_name: fileName,
      file_size: bytes.length,
      content_type: "application/pdf",
    }),
  });
  const { document_id, upload_url } = await res.json();

  await fetch(upload_url, {
    method: "PUT",
    headers: { "Content-Type": "application/pdf" },
    body: bytes,
  });
  return document_id;
}

Esta ruta también es la que debe usarse para archivos grandes. Desde la v1.40.0, cualquier cuerpo de solicitud superior a 6 MiB devuelve 413 REQUEST_TOO_LARGE, por lo que los PDF grandes deben procesarse siempre mediante POST /documents. Una sola subida puede tener hasta 50 MB (el conjunto completo tiene un límite de 100 MB).

Crear y enviar en el orden de los documentos

Pasa de 1 a 20 identificadores de subida en document_ids, en el orden en que deben aparecer los documentos. POST /signing-requests/create-and-send los acepta, al igual que POST /signing-requests (para un borrador que enviarás más tarde) y POST /templates.

Cada creación admite exactamente un origen de documento: document, document_id, document_ids o template_id. Si envías más de uno, la API devuelve 400 CONFLICTING_DOCUMENT_SOURCE. Un identificador duplicado devuelve 400 DUPLICATE_DOCUMENT_ID, más de 20 devuelve 400 MAX_DOCUMENTS_EXCEEDED y un conjunto superior a 100 MB devuelve 413 DOCUMENT_SET_TOO_LARGE.

Vincular las etiquetas de anclaje al documento correcto

Las etiquetas de anclaje ahora aceptan document_index, la posición basada en 1 del documento de destino dentro de document_ids. Con un solo documento es opcional y su valor predeterminado es 1. Con más de un documento es obligatorio, y omitirlo devuelve 400 ANCHOR_DOCUMENT_REQUIRED. Con este parámetro, el {{SIGN_HERE}} del NDA se mantiene independiente del {{SIGN_HERE}} del SOW, incluso cuando todos los archivos del paquete utilicen el mismo marcador.

const packet = [
  { name: "msa.pdf", bytes: msa },
  { name: "sow.pdf", bytes: sow },
  { name: "nda.pdf", bytes: nda },
  { name: "order-form.pdf", bytes: orderForm },
];
const documentIds = await Promise.all(packet.map((d) => upload(d.name, d.bytes)));

const res = await fetch(`${API}/signing-requests/create-and-send`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    name: "Acme Corp - Q4 deal packet",
    document_ids: documentIds, // MSA, SOW, NDA, order form
    recipients: [
      {
        id: "temp_1",
        first_name: "Dana",
        last_name: "Reyes",
        email: "dana@acme.example",
        designation: "Signer",
        order: 1,
      },
    ],
    anchor_tags: [
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 1 },
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 2 },
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 3 },
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 4 },
      { anchor_string: "{{DATE}}", type: "date", recipient_id: "temp_1", document_index: 4 },
    ],
  }),
});
const signingRequest = await res.json(); // signingRequest.id
const packet = [
  { name: "msa.pdf", bytes: msa },
  { name: "sow.pdf", bytes: sow },
  { name: "nda.pdf", bytes: nda },
  { name: "order-form.pdf", bytes: orderForm },
];
const documentIds = await Promise.all(packet.map((d) => upload(d.name, d.bytes)));

const res = await fetch(`${API}/signing-requests/create-and-send`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    name: "Acme Corp - Q4 deal packet",
    document_ids: documentIds, // MSA, SOW, NDA, order form
    recipients: [
      {
        id: "temp_1",
        first_name: "Dana",
        last_name: "Reyes",
        email: "dana@acme.example",
        designation: "Signer",
        order: 1,
      },
    ],
    anchor_tags: [
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 1 },
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 2 },
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 3 },
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 4 },
      { anchor_string: "{{DATE}}", type: "date", recipient_id: "temp_1", document_index: 4 },
    ],
  }),
});
const signingRequest = await res.json(); // signingRequest.id
const packet = [
  { name: "msa.pdf", bytes: msa },
  { name: "sow.pdf", bytes: sow },
  { name: "nda.pdf", bytes: nda },
  { name: "order-form.pdf", bytes: orderForm },
];
const documentIds = await Promise.all(packet.map((d) => upload(d.name, d.bytes)));

const res = await fetch(`${API}/signing-requests/create-and-send`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    name: "Acme Corp - Q4 deal packet",
    document_ids: documentIds, // MSA, SOW, NDA, order form
    recipients: [
      {
        id: "temp_1",
        first_name: "Dana",
        last_name: "Reyes",
        email: "dana@acme.example",
        designation: "Signer",
        order: 1,
      },
    ],
    anchor_tags: [
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 1 },
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 2 },
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 3 },
      { anchor_string: "{{SIGN_HERE}}", type: "signature", recipient_id: "temp_1", document_index: 4 },
      { anchor_string: "{{DATE}}", type: "date", recipient_id: "temp_1", document_index: 4 },
    ],
  }),
});
const signingRequest = await res.json(); // signingRequest.id

Si no conoces las etiquetas de anclaje, la guía paso a paso sobre etiquetas de anclaje explica los desplazamientos, las apariciones y las dimensiones.

Cada documento se devuelve sellado de forma independiente

GET /signing-requests/{id} devuelve un array documents. Cada elemento incluye document_id, position, label, original_filename y page_count, y en las solicitudes de firma también contiene document_only_hash, el SHA-256 del documento sellado individualmente una vez vinculado el certificado (null antes de eso).

Para obtener un archivo, llama a GET /signing-requests/{id}/documents/{document_id}/download. Devuelve una URL firmada válida durante 1 hora:

  • type=original (el valor predeterminado) devuelve el documento tal y como se subió.

  • type=document devuelve el documento sellado individualmente una vez vinculado el certificado. Antes de ese momento devuelve 404 DOCUMENT_SIGNED_NOT_AVAILABLE, por lo que debes interpretar ese código como «aún no disponible» e intentarlo de nuevo un poco más tarde.

Para una aplicación SaaS, esto elimina el último fragmento de código de unión. El siguiente bucle escribe el MSA firmado en el registro del contrato, el NDA firmado en el registro del NDA, etc., guardando el hash de cada archivo junto a él. Y dado que position respeta el orden enviado en document_ids, se asigna directamente al paquete que construiste.

async function storeSignedPacket(signingRequestId, packetRecords) {
  const sr = await fetch(`${API}/signing-requests/${signingRequestId}`, { headers })
    .then((r) => r.json());

  for (const doc of sr.documents) {
    const res = await fetch(
      `${API}/signing-requests/${signingRequestId}/documents/${doc.document_id}/download?type=document`,
      { headers }
    );
    const body = await res.json();

    if (body.code === "DOCUMENT_SIGNED_NOT_AVAILABLE") {
      return scheduleRetry(signingRequestId); // certificate not bound yet
    }
    if (!res.ok) throw new Error(`${body.code}: ${body.error}`);

    const pdf = Buffer.from(await (await fetch(body.download_url)).arrayBuffer());

    await records.saveSignedFile({
      record: packetRecords[doc.position - 1], // contract, SOW, NDA, order form
      file: pdf,
      sha256: doc.document_only_hash,
    });
  }
}
async function storeSignedPacket(signingRequestId, packetRecords) {
  const sr = await fetch(`${API}/signing-requests/${signingRequestId}`, { headers })
    .then((r) => r.json());

  for (const doc of sr.documents) {
    const res = await fetch(
      `${API}/signing-requests/${signingRequestId}/documents/${doc.document_id}/download?type=document`,
      { headers }
    );
    const body = await res.json();

    if (body.code === "DOCUMENT_SIGNED_NOT_AVAILABLE") {
      return scheduleRetry(signingRequestId); // certificate not bound yet
    }
    if (!res.ok) throw new Error(`${body.code}: ${body.error}`);

    const pdf = Buffer.from(await (await fetch(body.download_url)).arrayBuffer());

    await records.saveSignedFile({
      record: packetRecords[doc.position - 1], // contract, SOW, NDA, order form
      file: pdf,
      sha256: doc.document_only_hash,
    });
  }
}
async function storeSignedPacket(signingRequestId, packetRecords) {
  const sr = await fetch(`${API}/signing-requests/${signingRequestId}`, { headers })
    .then((r) => r.json());

  for (const doc of sr.documents) {
    const res = await fetch(
      `${API}/signing-requests/${signingRequestId}/documents/${doc.document_id}/download?type=document`,
      { headers }
    );
    const body = await res.json();

    if (body.code === "DOCUMENT_SIGNED_NOT_AVAILABLE") {
      return scheduleRetry(signingRequestId); // certificate not bound yet
    }
    if (!res.ok) throw new Error(`${body.code}: ${body.error}`);

    const pdf = Buffer.from(await (await fetch(body.download_url)).arrayBuffer());

    await records.saveSignedFile({
      record: packetRecords[doc.position - 1], // contract, SOW, NDA, order form
      file: pdf,
      sha256: doc.document_only_hash,
    });
  }
}

Si también deseas las descargas a nivel de solicitud (el PDF solo con el documento y el certificado por separado), se explican en el artículo sobre descargas de PDF divididos. Cada solicitud de firma conserva también su registro de auditoría.

Plantillas que contienen un conjunto de documentos

Una plantilla también puede contener el paquete completo, lo cual resulta ideal para plataformas que envían el mismo conjunto a cada nuevo cliente. Créala con document_ids en POST /templates y luego gestiona cada archivo bajo /templates/{id}/documents:

  • POST /templates/{id}/documents añade un documento en position, o al final si omites la posición.

  • PATCH /templates/{id}/documents/{document_id} sustituye el documento por una nueva subida (document_id) o lo renombra (label). Envía exactamente una de las dos opciones por llamada.

  • PUT /templates/{id}/documents/order reordena el conjunto. La lista debe nombrar cada documento exactamente una vez; de lo contrario, devuelve 400 INVALID_DOCUMENT_ORDER.

  • DELETE /templates/{id}/documents/{document_id} elimina uno. El último documento restante no puede eliminarse.

Una sustitución mantiene los campos en su lugar siempre que el nuevo archivo tenga el mismo número de páginas y cada página mantenga el mismo tamaño con una variación máxima de 1 pt. En caso contrario, recibirás 400 PAGE_COUNT_MISMATCH o 400 DIMENSION_MISMATCH. Así, cuando el equipo legal actualiza el NDA, sustituyes solo ese archivo... y los campos del MSA, SOW y formulario de pedido permanecen intactos. La página de la funcionalidad de sustitución de documentos en plantillas analiza este proceso con más detalle.

También hay una protección contra concurrencia, y cobra especial importancia en entornos multi-tenant (dos servicios, o dos administradores, editando la misma plantilla). documents_layout_version aumenta cada vez que se añaden, eliminan o reordenan documentos. Las operaciones de escritura de campos mediante PUT y PATCH en /templates/{id} y /signing-requests/{id} envían el valor de tu última lectura. Es obligatorio en cuanto hay más de un documento o la versión del diseño es superior a 1 (devuelve 409 DOCUMENTS_LAYOUT_VERSION_REQUIRED si falta), y un valor obsoleto devuelve 409 DOCUMENT_SET_VERSION_CONFLICT. Si el administrador de un tenant reordena el paquete en tu interfaz de usuario mientras un proceso en segundo plano mueve un campo, la segunda escritura falla con un 409 y ningún cambio se sobrescribe de manera silenciosa.

async function moveField(templateId, fieldId, x, y, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    const tpl = await fetch(`${API}/templates/${templateId}`, { headers })
      .then((r) => r.json());

    const res = await fetch(`${API}/templates/${templateId}`, {
      method: "PATCH",
      headers,
      body: JSON.stringify({
        field: { id: fieldId, x, y },
        documents_layout_version: tpl.documents_layout_version,
      }),
    });
    if (res.ok) return res.json();

    const err = await res.json();
    if (err.code !== "DOCUMENT_SET_VERSION_CONFLICT") {
      throw new Error(`${err.code}: ${err.error}`);
    }
    // The document set changed between our read and our write. Reload and retry.
  }
  throw new Error("Document set kept changing; giving up");
}
async function moveField(templateId, fieldId, x, y, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    const tpl = await fetch(`${API}/templates/${templateId}`, { headers })
      .then((r) => r.json());

    const res = await fetch(`${API}/templates/${templateId}`, {
      method: "PATCH",
      headers,
      body: JSON.stringify({
        field: { id: fieldId, x, y },
        documents_layout_version: tpl.documents_layout_version,
      }),
    });
    if (res.ok) return res.json();

    const err = await res.json();
    if (err.code !== "DOCUMENT_SET_VERSION_CONFLICT") {
      throw new Error(`${err.code}: ${err.error}`);
    }
    // The document set changed between our read and our write. Reload and retry.
  }
  throw new Error("Document set kept changing; giving up");
}
async function moveField(templateId, fieldId, x, y, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    const tpl = await fetch(`${API}/templates/${templateId}`, { headers })
      .then((r) => r.json());

    const res = await fetch(`${API}/templates/${templateId}`, {
      method: "PATCH",
      headers,
      body: JSON.stringify({
        field: { id: fieldId, x, y },
        documents_layout_version: tpl.documents_layout_version,
      }),
    });
    if (res.ok) return res.json();

    const err = await res.json();
    if (err.code !== "DOCUMENT_SET_VERSION_CONFLICT") {
      throw new Error(`${err.code}: ${err.error}`);
    }
    // The document set changed between our read and our write. Reload and retry.
  }
  throw new Error("Document set kept changing; giving up");
}

Las solicitudes de firma utilizan las mismas rutas /signing-requests/{id}/documents mientras siguen en estado de borrador. Una vez que la solicitud se envía, finaliza, cancela o rechaza, cualquier escritura en los documentos devuelve 409 DOCUMENT_SET_LOCKED.

Qué cambia en una integración existente

Esta versión añade funcionalidades sin romper nada anterior. Las solicitudes que nunca usan document_ids, document_index o las rutas de /documents funcionan exactamente igual que antes. Conviene revisar tres aspectos:

  1. Clientes generados. Si generas un cliente a partir de la especificación OpenAPI, document ahora es opcional en POST /templates, y el esquema del participante del sello en las respuestas de solicitudes de firma pasa a llamarse SealParticipantApi (antes era SealParticipant).

  2. Validación estricta de errores. Si tu código descarta los códigos de error desconocidos, añade los nuevos. El esquema MultiDocumentErrorCode en la referencia de la API los enumera todos.

  3. Copias de plantillas. POST /templates/{id}/copy convierte una plantilla multidocumento en una plantilla con un único documento combinado. Se conservan el contenido de los campos y los números de página absolutos. Se descartan las etiquetas y posiciones específicas de cada documento, algo a tener en cuenta si copias plantillas en espacios de trabajo de clientes.

En el aspecto legal nada cambia. Firma.dev cumple con la ley ESIGN, UETA, eIDAS (SES y AES) y el eIDAS de Reino Unido, y las firmas son legalmente vinculantes en más de 55 países, para cada documento del conjunto.

Resumen

La firma multidocumento ya está disponible en la API de Firma.dev para todas las cuentas. Sube cada archivo con POST /documents, pasa hasta 20 identificadores en document_ids, orienta las etiquetas de anclaje con document_index y descarga cada documento sellado de forma individual con su propio document_only_hash. Las plantillas pueden contener los mismos conjuntos y actualizarse archivo por archivo, con documents_layout_version protegiendo las ediciones simultáneas. Todo el paquete cuesta un crédito, 0,049 € (~5 ¢ USD). Todos los detalles se encuentran en el registro de cambios de la API.

Empieza a usar Firma.dev gratis, sin tarjeta de crédito.

  1. Encabezado

Imagen de fondo

¿Listo para añadir firmas electrónicas a tu aplicación?

Comienza gratis. No se requiere tarjeta de crédito. Paga solo 0,049 € por sobre cuando estés listo para empezar.

Imagen de fondo

¿Listo para añadir firmas electrónicas a tu aplicación?

Comienza gratis. No se requiere tarjeta de crédito. Paga solo 0,049 € por sobre cuando estés listo para empezar.

Imagen de fondo

¿Listo para añadir firmas electrónicas a tu aplicación?

Comienza gratis. No se requiere tarjeta de crédito. Paga solo 0,049 € por sobre cuando estés listo para empezar.