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/v1Authentication
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
| Field | Type | Description |
|---|---|---|
| recipients | arrayrequired | One entry per role on the template. Fill in every role, once each, using a different email address for each. |
| recipients[].role_id | stringrequired | A role id from GET /v1/templates. |
| recipients[].email | stringrequired | Where we send the signing link. |
| recipients[].name | string | The signer's name. Shown to them and printed on the certificate. If you leave it out, we use the role name. |
| title | string | The document title. If you leave it out, we use the template name. |
| expiry_days | integer | How many days the signing links stay valid. Defaults to the template's own setting. Your plan sets the maximum. |
| embed | boolean | Set 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.
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>| Field | Type | Description |
|---|---|---|
| onReady | callback | The signing page has loaded. |
| onSigned | callback | This 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. |
| onDeclined | callback | This person declined. You get { documentId }. |
| destroy() | method | Removes the iframe and stops listening. Call it when you take the component off the page, or the next session will fire your handlers twice. |
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" }
}
}| Field | Type | Description |
|---|---|---|
| unauthorized | 401 | The key is missing, wrong, or has been revoked. |
| quota_exceeded | 402 | You have used up this month's documents. |
| forbidden | 403 | The account this key belongs to no longer exists. |
| not_found | 404 | There is no template or document with that id on your account. |
| method_not_allowed | 405 | Wrong HTTP method for this endpoint. |
| invalid_request | 422 | Something in the request body is missing, or does not match the template's roles. |
| rate_limited | 429 | More than 600 requests in an hour. Wait as long as Retry-After says. |
| server_error | 500 | Our problem. Try again in a moment. |