Produktaktualisierungen

New in Firma.dev: Sign the Whole Deal Packet in One Request, Each File Sealed on Its Own

Geschrieben von

Inhalt

A B2B deal usually closes on a stack of files: a master services agreement, a statement of work, an NDA and an order form, all going to the same customer for the same signature. As of API v1.39.0 that whole packet can go out as one Firma.dev signing request, and every file in it comes back as its own sealed document.

Multi-document signing is live for every Firma.dev account

This is new, and it's already switched on for everyone. The release adds:

  • A template or signing request can hold up to 20 documents, with a limit of 100 MB per document set.

  • POST /templates, POST /signing-requests and POST /signing-requests/create-and-send accept document_ids, a list of uploads in document order.

  • New /documents routes add, replace, rename, reorder, remove and download documents one at a time.

  • Anchor tags can target a single document with document_index.

  • Each document of a completed signing request downloads as its own individually sealed file, with its own SHA-256 hash.

The changelog dates the release October 12, 2026, and multi-document features have been available to every company since October 13, 2026. There's nothing to enable and no flag to request.

Pricing doesn't move either. One signing request is one credit, €0.049 (~5¢ USD), whether it carries one document or twenty. The docs FAQ says so directly: one credit equals one signing request, regardless of how many signers or documents it contains. It's still pay-as-you-go with no monthly minimums and no per-seat fees.

What a four-document packet used to take

Until this release, a signing request held one document. Sending a packet meant writing plumbing around that limit, and it usually looked like this:

Step

What your code had to do

1. Merge

Combine the MSA, SOW, NDA and order form into one PDF

2. Offset

Add the page count of every earlier file to each field's page number

3. Send

Create the signing request from the merged PDF

4. Split

Cut the single sealed PDF apart by page range

5. Map

Attach each slice to the right record in your app

Steps 2 and 4 are where it breaks. A customer swaps in a two-page NDA where a three-page one used to be, and every field after it lands on the wrong page. The split ranges drift the same way... and for a SaaS platform whose own cutsomers upload these sets, each tenant's packet is slightly different, so the bug turns up one tenant at a time.

With v1.39.0 the merge, offset and split steps go away. Each file goes in as its own upload, moves through one signing flow and comes back sealed on its own.

Sending the packet with document_ids

Upload each file with POST /documents

Every document in the set starts as an upload. POST /documents takes a file name, size and content type and returns a temporary document_id plus a presigned upload_url. You PUT the raw file bytes to that URL. It's one upload per file... so a four-document packet means four uploads before the signing request even exists.

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;
}

This path is also the one to use for large files. Since v1.40.0, any request body over 6 MiB returns 413 REQUEST_TOO_LARGE, so big PDFs belong on POST /documents regardless. A single upload can be up to 50 MB (the whole set tops out at 100 MB).

Create and send in document order

Pass 1 to 20 upload ids in document_ids, in the order the documents should appear. POST /signing-requests/create-and-send takes them. So do POST /signing-requests, for a draft you send later, and POST /templates.

Each create takes exactly one document source: document, document_id, document_ids or template_id. Send more than one and the API returns 400 CONFLICTING_DOCUMENT_SOURCE. A repeated id returns 400 DUPLICATE_DOCUMENT_ID, more than 20 returns 400 MAX_DOCUMENTS_EXCEEDED, and a set over 100 MB returns 413 DOCUMENT_SET_TOO_LARGE.

Point anchor tags at the right document

Anchor tags now accept document_index, the 1-based position of the target document in document_ids. With one document it's optional and defaults to 1. With more than one it's required, and leaving it out returns 400 ANCHOR_DOCUMENT_REQUIRED. With it, the {{SIGN_HERE}} in the NDA stays separate from the {{SIGN_HERE}} in the SOW, even when every file in the packet uses the same marker.

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

If anchor tags are new to you, the anchor tags walkthrough covers offsets, occurrences and sizing.

Every document comes back sealed on its own

GET /signing-requests/{id} returns a documents array. Each entry has document_id, position, label, original_filename and page_count, and on signing requests it also carries document_only_hash, the SHA-256 of the individually sealed document once the certificate is bound (null before that).

To fetch a file, call GET /signing-requests/{id}/documents/{document_id}/download. It returns a signed URL valid for 1 hour:

  • type=original (the default) returns the document as uploaded.

  • type=document returns the individually sealed document once the certificate is bound. Before then it returns 404 DOCUMENT_SIGNED_NOT_AVAILABLE, so treat that code as "not ready yet" and come back a little later.

For a SaaS app this removes the last bit of glue code. The loop below writes the signed MSA to the contract record, the signed NDA to the NDA record and so on, with each file's hash stored next to it. And because position follows the order you sent in document_ids, it maps straight back to the packet you built.

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,
    });
  }
}

If you also want the request-level downloads (the document-only PDF and the certificate on its own), those are covered in the split PDF downloads post. Every signing request also keeps its audit trail.

Templates that hold a document set

A template can carry the whole packet too, which fits platforms that send the same set to every new customer. Create it with document_ids on POST /templates, then maintain each file under /templates/{id}/documents:

  • POST /templates/{id}/documents adds a document at position, or last when you leave position out.

  • PATCH /templates/{id}/documents/{document_id} replaces the document with a new upload (document_id) or renames it (label). Send exactly one of the two per call.

  • PUT /templates/{id}/documents/order reorders the set. The list must name every document exactly once, or it returns 400 INVALID_DOCUMENT_ORDER.

  • DELETE /templates/{id}/documents/{document_id} removes one. The last remaining document can't be removed.

A replacmeent keeps the fields in place as long as the new file has the same page count and every page is the same size within 1 pt. Otherwise you get 400 PAGE_COUNT_MISMATCH or 400 DIMENSION_MISMATCH. So when legal updates the NDA, you swap that one file... and the fields on the MSA, SOW and order form stay where they were. The replace template document feature page covers replacement in more depth.

There's also a concurrency guard, and it matters most in multi-tenant setups (two services, or two admins, editing the same template). documents_layout_version increases whenever documents are added, removed or reordered. Field writes through PUT and PATCH on /templates/{id} and /signing-requests/{id} send the value from your last read. It's required once there's more than one document or the layout version is above 1 (409 DOCUMENTS_LAYOUT_VERSION_REQUIRED if missing), and a stale value returns 409 DOCUMENT_SET_VERSION_CONFLICT. If a tenant admin reorders the packet in your UI while a background job is moving a field, the second write fails with a 409 and nobody's change gets silently overwirtten.

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");
}

Signing requests use the same /signing-requests/{id}/documents routes while they're still drafts. Once a request is sent, finished, cancelled or declined, every document write returns 409 DOCUMENT_SET_LOCKED.

What changes in an existing integration

The release is additive. Requests that never use document_ids, document_index or the /documents routes behave exactly as before. Three things are worth checking:

  1. Generated clients. If you build a client from the OpenAPI spec, document is now optional on POST /templates, and the seal participant schema in signing-request responses is now named SealParticipantApi (it was SealParticipant).

  2. Strict error parsing. If your code rejects unknown error codes, add the new ones. The MultiDocumentErrorCode schema in the API reference lists them all.

  3. Template copies. POST /templates/{id}/copy turns a multi-document template into a template with one merged document. Field content and absolute page numbers are kept. Per-document labels and positions are dropped, which is worth planning for if you copy templates into customer workspaces.

On the legal side nothing changes. Firma.dev is compliant with the ESIGN Act, UETA, eIDAS (SES and AES) and UK eIDAS, and signatures are legally binding in 55+ countries, for every document in the set.

Summary

Multi-document signing is live in the Firma.dev API for every account. Upload each file with POST /documents, pass up to 20 ids in document_ids, aim anchor tags with document_index, and download each document individually sealed with its own document_only_hash. Templates can hold the same sets and update one file at a time, with documents_layout_version guarding concurrent edits. The whole packet costs one credit, €0.049 (~5¢ USD). Full details are in the API changelog.

Get started with Firma.dev for free, no credit card required.

  1. Überschrift

Hintergrundbild

Bereit, elektronischen Unterschriften zu Ihrer Anwendung hinzuzufügen?

Kostenlos starten. Keine Kreditkarte erforderlich. Zahlen Sie nur 0,049 € pro Umschlag, wenn Sie bereit sind, live zu gehen.

Hintergrundbild

Bereit, elektronischen Unterschriften zu Ihrer Anwendung hinzuzufügen?

Kostenlos starten. Keine Kreditkarte erforderlich. Zahlen Sie nur 0,049 € pro Umschlag, wenn Sie bereit sind, live zu gehen.

Hintergrundbild

Bereit, elektronischen Unterschriften zu Ihrer Anwendung hinzuzufügen?

Kostenlos starten. Keine Kreditkarte erforderlich. Zahlen Sie nur 0,049 € pro Umschlag, wenn Sie bereit sind, live zu gehen.