Guides

Send one document to multiple signers with an API

Send a template to multiple recipients with a Node.js API call. Set sequential or parallel signing, map each signer to their fields, and wait for the whole document to finish. Tested examples included.

Akbar Ali · 10 October 2026

One PDF. Two people need to sign it. Surely this is just an array of email addresses?

Almost. The array is the easy part. The awkward part is deciding who signs first, which signature box belongs to whom, and whether "signed" means one person finished or everyone finished. I keep those decisions separate in PutMySign: the template owns the workflow; the API supplies this document's people.

I'm going to send a mutual NDA to a customer and our legal signer. The Node.js examples below were tested locally against a mock API, including the error paths. They don't send a real document or prove a delivery reached an inbox. Use a test key while building: it never sends email.

Trap 1: treating the PDF as the workflow

A PDF doesn't know that the box on page two belongs to the customer, or that legal should wait for them. Before the API call, create a template in the dashboard:

  1. Add your PDF.
  2. Add a Customer role and a Legal role.
  3. Assign each signature, text, or checkbox field to the role that should fill it.
  4. Set the roles' signing order.

That is a one-time setup for this kind of document. You don't upload the PDF or send field coordinates in each API request. The API sends the template you already built. If the field is assigned to the wrong role, fixing the recipients array won't fix it. Fix the template.

Trap 2: confusing the array order with signing order

I fetch the template roles first:

// api.js - Node.js 22+, server-side only
export async function api(path, { key, body, baseUrl = "https://putmysign.com/v1" }) {
  if (!key) throw new Error("Missing PUTMYSIGN_KEY");
  const response = await fetch(`${baseUrl}${path}`, {
    method: body === undefined ? "GET" : "POST",
    headers: {
      Authorization: `Bearer ${key}`,
      ...(body === undefined ? {} : { "Content-Type": "application/json" }),
    },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
    signal: AbortSignal.timeout(10_000),
  });
  const data = await response.json();
  if (!response.ok) {
    throw new Error(`${response.status}: ${data.error?.code ?? "api_error"}`);
  }
  return data;
}
// list-roles.js
import { api } from "./api.js";

const key = process.env.PUTMYSIGN_KEY;
const templates = await api("/templates", { key });
for (const template of templates.data) {
  console.log(template.id, template.name, template.roles);
}

Copy the intended template ID and its role IDs. A template might have these roles:

[
  { "id": "role_customer", "name": "Customer", "step": 0, "field_count": 3 },
  { "id": "role_legal", "name": "Legal", "step": 1, "field_count": 2 }
]

The IDs above are placeholders, not IDs you can use on your account. The useful bit is step:

WorkflowTemplate stepsWhat happens
ParallelCustomer 0, Legal 0Both get their signing email at the same time.
SequentialCustomer 0, Legal 1Customer gets the email first. Legal starts after step 0 finishes.
MixedCustomer 0, Co-signer 0, Legal 1Both people on step 0 sign before Legal starts.

Set this in the template. There is no signing_order parameter in the send request. Rearranging the recipients array doesn't change it. The API docs spell out the step rule: everyone on the same step is invited together, and the next step waits for the previous one to finish.

Trap 3: sending people without mapping their roles

Each template role needs exactly one recipient. Use a different email address for each role. The role_id ties the person to the fields and step you set above; name is a display name, not the routing key.

// send-nda.js
import { api } from "./api.js";

const key = process.env.PUTMYSIGN_KEY;
const templateId = process.env.PUTMYSIGN_TEMPLATE_ID;
if (!templateId) throw new Error("Missing PUTMYSIGN_TEMPLATE_ID");

const document = await api(`/templates/${encodeURIComponent(templateId)}/send`, {
  key,
  body: {
    title: "Mutual NDA - Acme",
    recipients: [
      { role_id: "role_customer", email: "customer@example.com", name: "Dana" },
      { role_id: "role_legal", email: "legal@example.com", name: "Sam" },
    ],
  },
});

console.log(document.id, document.status);
console.table(document.recipients.map(({ role_id, status, step }) => ({ role_id, status, step })));

Replace the placeholder role IDs and example addresses before running it. Keep the API key on your server, not in browser JavaScript. Store the returned document ID against the deal or account in your own database.

A missing role, repeated role, or repeated email is an invalid request, not a second signer taking a shortcut. Inspect the template before sending. Errors use stable codes such as invalid_request (422) and quota_exceeded (402); don't branch on the human-readable message.

One more trap: a timeout doesn't tell you whether the server created the document. Don't blindly retry a send and create a second NDA. Check your account's documents and reconcile what happened before sending again. This example deliberately has no automatic POST retry.

Trap 4: celebrating the first signature

One Does Not Simply meme. Top caption: "One does not simply". Bottom caption: "Mark it done after one signature".
The other signer would like a word.

recipient.signed means one recipient signed. document.completed means the whole document finished. Even if today's template only has one signer, I wouldn't wire business completion to the per-recipient event: tomorrow's template might have two.

Here is the small piece of business logic I run after verifying the webhook signature. It returns an action for a worker, rather than downloading a file inside the webhook request:

// completion-action.js
export function completionAction(event) {
  if (event?.event !== "document.completed") return null;
  const document = event.data?.document;
  if (!event.id || !document?.id || !document.download_url) {
    throw new Error("Completion event is missing its ID, document, or PDF URL");
  }
  const url = new URL(document.download_url);
  if (url.protocol !== "https:") throw new Error("Expected an HTTPS PDF URL");
  return {
    eventId: event.id,
    documentId: document.id,
    downloadUrl: url.href,
  };
}

My handler flow is:

  1. Verify X-Signature against the raw request body, including its timestamp.
  2. Parse the verified JSON and call completionAction(event).
  3. If it returns an action, persist it in a durable queue with a unique event-ID constraint. If that event already exists, don't enqueue it again.
  4. Return a 2xx once the queue write is safe. A worker retrieves and stores the PDF, with its own retry handling.

The function above is only the event filter. It is not signature verification, a durable queue, or deduplication. Don't expose it as an unverified webhook endpoint. The signed-webhook guide covers raw bytes, HMAC verification, replay checks, and duplicate deliveries. PutMySign retries failed deliveries, so a repeated event is normal.

You can track recipient.viewed and recipient.signed for progress. Handle recipient.declined as a separate business path rather than waiting forever for a completion that hasn't happened. Don't assume events arrive once or in order.

Trap 5: downloading before everyone finishes

The document file endpoint returns the original PDF while signing is still going on. Once everyone has signed, it returns the finished PDF with signatures, the signing certificate, and the audit trail. The URL existing isn't evidence that the document is finished.

If you're not using webhooks yet, fetch GET /v1/documents/:id and inspect the current recipients. A document stays sent while someone still needs to sign. For the webhook path, wait for document.completed, then use its download_url. Keep completion and file retrieval as separate jobs so a temporary download failure doesn't cause your app to send a new document.

What changes for embedded signing?

Add embed: true to the same send body to skip emails and get a signing URL for each recipient. Only give a signer their own URL. Don't expose the whole send response to every client or mount the first recipient's session for all of them.

The embedded SDK's onSigned callback still only means this person signed. It isn't your "all signers done" signal. Wait for document.completed on your server before you unlock whatever comes next. The Next.js embedded-signing guide covers mounting, allowed origins, and session cleanup.

Try it without emailing your customers

Create a two-role template, generate a test key under Developers, and send it from your server. Inspect the returned roles, steps, and statuses before you switch to a live key. Test keys skip email, so use that first run to check the payload, not to test inbox delivery.

Try PutMySign's signing API, or start with the API docs. If the workflow is right in the template, the send call really is just an array of people. The part I don't skip is waiting for all of them.