Overview

The API sends your templates. You build a template once in the dashboard: the PDF, who has to sign it, and where they sign. After that, your app only has to say who the signers are this time.

That is the whole API. You never upload a file or describe where a signature box goes, so there is very little to get wrong.

Documents signed through the API are exactly the same as ones sent from the dashboard. Same audit trail, same evidence, same signing certificate on the finished PDF.

https://putmysign.com/v1

Authentication

Create a key under Developers, then send it as a bearer token. You only see the key once, and we only store a hash of it. If you lose it, revoke it and make a new one.

Authorization: Bearer ak_live_a1b2c3d4_…

A test key works just like a live one, except it never sends email. Use it while you are building, so you do not mail real people by accident.

Quickstart

Three steps: find the template, send it, then wait to hear back.

1. List your templates and their roles

curl https://putmysign.com/v1/templates \
  -H "Authorization: Bearer $PUTMYSIGN_KEY"

2. Send one

curl https://putmysign.com/v1/templates/tpl_9f3c.../send \
  -H "Authorization: Bearer $PUTMYSIGN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "NDA - Acme Corp",
    "recipients": [
      { "role_id": "role_a1", "email": "dana@acme.com", "name": "Dana Ruiz" },
      { "role_id": "role_b2", "email": "legal@you.com", "name": "Your Legal" }
    ]
  }'

3. Wait for recipient.signed and document.completed at your webhook URL, then fetch download_url for the signed PDF.

Templates

GET/v1/templates

Every template on your account, newest first. You send a template by naming its roles, so this is where the role ids come from. They never change.

{
  "object": "list",
  "data": [
    {
      "id": "tpl_9f3c...",
      "object": "template",
      "name": "Mutual NDA",
      "created_at": "2026-08-02T12:00:00.000Z",
      "updated_at": "2026-09-01T08:14:52.113Z",
      "page_count": 4,
      "expiry_days": 30,
      "roles": [
        { "id": "role_a1", "name": "Counterparty", "step": 0, "field_count": 3 },
        { "id": "role_b2", "name": "Head of Legal", "step": 1, "field_count": 2 }
      ]
    }
  ]
}

step is the signing order. Everyone on the same step gets their email at the same time, and the next step only starts once the one before it has finished.

Sending a template

POST/v1/templates/:id/send

FieldTypeDescription
recipientsarrayrequiredOne entry per role on the template. Fill in every role, once each, using a different email address for each.
recipients[].role_idstringrequiredA role id from GET /v1/templates.
recipients[].emailstringrequiredWhere we send the signing link.
recipients[].namestringThe signer's name. Shown to them and printed on the certificate. If you leave it out, we use the role name.
titlestringThe document title. If you leave it out, we use the template name.
expiry_daysintegerHow many days the signing links stay valid. Defaults to the template's own setting. Your plan sets the maximum.
embedbooleanSet this to true to skip the emails and get a signing URL you can put in your own page instead. See Embedded signing.
{
  "id": "doc_2f81...",
  "object": "document",
  "title": "NDA - Acme Corp",
  "status": "sent",
  "created_at": "2026-09-13T09:24:11.402Z",
  "updated_at": "2026-09-13T09:24:11.402Z",
  "signing_expires_at": "2026-10-13T09:24:11.402Z",
  "page_count": 4,
  "recipients": [
    {
      "id": "rcp_b4c1...",
      "role_id": "role_a1",
      "email": "dana@acme.com",
      "name": "Dana Ruiz",
      "status": "pending",
      "step": 0,
      "invited_at": "2026-09-13T09:24:12.118Z",
      "last_activity_at": null
    }
  ],
  "completed_at": null,
  "download_url": null,
  "embed": null
}

Documents

GET/v1/documents

Your documents, newest first. Takes limit (up to 100), status and cursor. If has_more comes back true, send next_cursor as the cursor to get the next page.

GET/v1/documents/:id

One document, with the current status of every signer. Use this if you are not using webhooks yet. Watch the signer statuses rather than the document status, since a document stays “sent” for as long as anyone still has to sign.

GET/v1/documents/:id/file

The PDF. While signing is still going on you get the file as you sent it. Once everyone has signed you get the finished copy, with the signatures, the signing certificate and the audit trail already in it. There is no second file to download.

Webhooks

Add an endpoint under Developers and we will POST every event to it. You get a signing secret when you add it. Pick no events and you get all of them.

document.sentrecipient.viewedrecipient.signedrecipient.declinedrecipient.commenteddocument.completed
{
  "id": "evt_8a1c4e...",
  "event": "recipient.signed",
  "created_at": "2026-09-13T10:02:44.001Z",
  "data": {
    "recipient_id": "rcp_b4c1...",
    "document": {
      "id": "doc_2f81...",
      "object": "document",
      "status": "sent",
      "recipients": [ /* … every recipient, with current status */ ],
      "download_url": null
    }
  }
}

Every request we send carries an X-Signature: t=<unix seconds>,v1=<hex hmac> header. It is an HMAC-SHA256 of `${t}.${rawBody}` using your secret. The timestamp is part of what gets signed, so if you also check how old it is, nobody can record a request and send it to you again later.

import crypto from "node:crypto";

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("="))
  );
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  // Reject anything older than five minutes, so a captured delivery
  // cannot be replayed at you tomorrow.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(parts.v1)
  );
}

Reply with a 2xx, and do it quickly. Anything else and we try again, five times over roughly a day, waiting longer each time. That means the same event can reach you twice, so check the event id and ignore one you have already handled.

Check a signature

Paste in something we actually sent you. This runs the same code that signed it, so if it does not match here, the problem is in the values you are checking, not in your own code.

The body exactly as it arrived. If your framework parsed the JSON and you turned it back into a string, it will not match. That is by far the most common reason this fails.

Embedded signing

Let people sign without leaving your app. Send with embed: true and we skip the emails. You get a signing URL for each recipient instead.

curl https://putmysign.com/v1/templates/tpl_9f3c.../send \
  -H "Authorization: Bearer $PUTMYSIGN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "embed": true, "recipients": [ … ] }'

Put the URL in an iframe yourself, or use our small script, which does the same thing and tells you when the signer is done.

<div id="sign" style="height: 800px"></div>

<script src="https://putmysign.com/embed.js"></script>
<script>
  Putmysign.mount("#sign", {
    url: session.url,                 // from the send response
    onReady:    ()  => console.log("session loaded"),
    onSigned:   (e) => finish(e.documentId),
    onDeclined: (e) => bail(e.documentId),
  });
</script>
FieldTypeDescription
onReadycallbackThe signing page has loaded.
onSignedcallbackThis person signed. You get { documentId }. Other people may still have to sign, so watch for the document.completed webhook to know the whole thing is done.
onDeclinedcallbackThis person declined. You get { documentId }.
destroy()methodRemoves the iframe and stops listening. Call it when you take the component off the page, or the next session will fire your handlers twice.
Before this works: go to Developers and add the domains you will be embedding on to your key’s embed origins. A key with no origins set cannot be embedded anywhere at all. That is on purpose: it stops a half finished setup from leaving a signing page that any website can wrap.

Embed URLs stop working after 30 minutes. They are not the real signing link, just a short lived stand in for it, so one that gets out is not much use to anyone. If a signer comes back later, ask for a fresh URL.

Errors & limits

Every error comes back as JSON in the same shape. Write your code against code, not the message, since the wording can change.

{
  "error": {
    "code": "quota_exceeded",
    "message": "You have sent 10 of 10 documents this month. Your allowance resets on 1 October.",
    "quota": { "used": 10, "limit": 10, "resets_at": "2026-10-01T00:00:00.000Z" }
  }
}
FieldTypeDescription
unauthorized401The key is missing, wrong, or has been revoked.
quota_exceeded402You have used up this month's documents.
forbidden403The account this key belongs to no longer exists.
not_found404There is no template or document with that id on your account.
method_not_allowed405Wrong HTTP method for this endpoint.
invalid_request422Something in the request body is missing, or does not match the template's roles.
rate_limited429More than 600 requests in an hour. Wait as long as Retry-After says.
server_error500Our problem. Try again in a moment.