Send a document for signature
Upload a PDF, ask people to sign it, follow each signer, receive signature events and download the signed document and its certificate.
What you need
- An API key with the
signatures:sendscope to create, send, remind and void requests, andsignatures:readto read them and download the signed document. To upload a PDF you also need thetools:runscope, because the upload is the same call the tools use. - A Team, Business or Enterprise account. The API is included in those plans.
- Signature requests left in the plan allowance. A request spends one when it is sent, exactly as in the web app. A draft spends nothing. When the allowance is used, the call is refused with
signatures.exhaustedand the details give the limit, the amount used and when the period resets.
A key acts as the person who made it. A request it creates is private to that person unless you send "visibility": "ACCOUNT", and the key cannot see a private request of another member. A request that is not in your account, or that you may not see, is answered with 404, the same as one that does not exist.
1. Upload the PDF
Upload the document with POST /v1/files, as in the quick start. Instead of an upload you can name a document of the library with "source": { "documentId": "..." } or a signature template with "source": { "templateId": "..." }; list the templates with GET /v1/signature-templates. A request made from a template brings the template fields, and its signers name the template roles by id or label. The upload is used up by the request: if the call is refused, upload the file again.
curl -X POST https://api.pdfloft.com/v1/files \
-H "Authorization: Bearer $PDFLOFT_API_KEY" \
-H "Content-Type: application/pdf" \
--data-binary @contract.pdfimport { readFile } from 'node:fs/promises';
const apiKey = '<your API key>'; // read it from your secret store, never from source
const headers = { Authorization: `Bearer ${apiKey}` };
const body = await readFile('contract.pdf');
const response = await fetch('https://api.pdfloft.com/v1/files', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/pdf' },
body,
});
const file = await response.json(); // { id: 'file_...', sizeBytes, contentType, expiresAt }import os
import requests
headers = {"Authorization": f"Bearer {os.environ['PDFLOFT_API_KEY']}"}
with open("contract.pdf", "rb") as handle:
response = requests.post(
"https://api.pdfloft.com/v1/files",
headers={**headers, "Content-Type": "application/pdf"},
data=handle,
)
file = response.json() # {"id": "file_...", "sizeBytes": ..., ...}2. Create and send the request
Name the signers and where each one signs. Positions are in points (72 to the inch) on the page, counted from 0. With "send": true the request is created and sent in one step, and a send the plan allowance refuses creates nothing. Without it you get a draft, and POST /v1/signature-requests/{requestId}/send sends it later. In a sequential request the first signer is asked first and each next signer after the one before has signed; in a parallel request everyone is asked at once.
Send the Idempotency-Key header. A call repeated with the same key and body returns the first answer and creates one request.
curl -X POST https://api.pdfloft.com/v1/signature-requests \
-H "Authorization: Bearer $PDFLOFT_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"title": "Services agreement",
"source": { "fileId": "file_Qm3nT9xvB2kLw8RzYc1pAe5H" },
"signers": [
{ "role": "Client", "sequence": 1, "name": "Meera Iyer", "email": "meera@example.com" },
{ "role": "Vendor", "sequence": 2, "name": "Ravi Kumar", "email": "ravi@example.com" }
],
"fields": [
{ "signerRole": "Client", "type": "signature", "label": "Client signature", "page": 0, "x": 60, "y": 100, "w": 160, "h": 40 },
{ "signerRole": "Vendor", "type": "signature", "label": "Vendor signature", "page": 0, "x": 60, "y": 180, "w": 160, "h": 40 }
],
"send": true
}'const request = await fetch('https://api.pdfloft.com/v1/signature-requests', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({
title: 'Services agreement',
source: { fileId: file.id },
signers: [
{ role: 'Client', sequence: 1, name: 'Meera Iyer', email: 'meera@example.com' },
{ role: 'Vendor', sequence: 2, name: 'Ravi Kumar', email: 'ravi@example.com' },
],
fields: [
{ signerRole: 'Client', type: 'signature', label: 'Client signature', page: 0, x: 60, y: 100, w: 160, h: 40 },
{ signerRole: 'Vendor', type: 'signature', label: 'Vendor signature', page: 0, x: 60, y: 180, w: 160, h: 40 },
],
send: true,
}),
}).then((r) => r.json()); // { id: 'cm3x8k2f10010', status: 'sent', signers: [...], ... }request = requests.post(
"https://api.pdfloft.com/v1/signature-requests",
headers={**headers, "Idempotency-Key": str(uuid.uuid4())},
json={
"title": "Services agreement",
"source": {"fileId": file["id"]},
"signers": [
{"role": "Client", "sequence": 1, "name": "Meera Iyer", "email": "meera@example.com"},
{"role": "Vendor", "sequence": 2, "name": "Ravi Kumar", "email": "ravi@example.com"},
],
"fields": [
{"signerRole": "Client", "type": "signature", "label": "Client signature", "page": 0, "x": 60, "y": 100, "w": 160, "h": 40},
{"signerRole": "Vendor", "type": "signature", "label": "Vendor signature", "page": 0, "x": 60, "y": 180, "w": 160, "h": 40},
],
"send": True,
},
).json() # {"id": "cm3x8k2f10010", "status": "sent", "signers": [...], ...}To send one template to many people, use POST /v1/signature-requests/bulk with a row for each request. The batch is refused whole when it would pass the allowance.
3. Follow the request
A request is draft, sent, in_progress (at least one signer has signed), then completed, declined, expired or voided. Each signer is pending (not asked yet), sent, viewed, signed or declined. Instead of polling, subscribe to the events below.
curl https://api.pdfloft.com/v1/signature-requests/cm3x8k2f10010 \
-H "Authorization: Bearer $PDFLOFT_API_KEY"
# What happened, in order:
curl https://api.pdfloft.com/v1/signature-requests/cm3x8k2f10010/timeline \
-H "Authorization: Bearer $PDFLOFT_API_KEY"const current = await fetch(`https://api.pdfloft.com/v1/signature-requests/${request.id}`, { headers }).then((r) => r.json());
console.log(current.status, current.signers.map((signer) => `${signer.name}: ${signer.status}`));
const timeline = await fetch(`https://api.pdfloft.com/v1/signature-requests/${request.id}/timeline`, { headers }).then((r) => r.json());
console.log(timeline.chain.valid, timeline.events.map((event) => event.kind));current = requests.get(f"https://api.pdfloft.com/v1/signature-requests/{request['id']}", headers=headers).json()
print(current["status"], [f"{s['name']}: {s['status']}" for s in current["signers"]])
timeline = requests.get(f"https://api.pdfloft.com/v1/signature-requests/{request['id']}/timeline", headers=headers).json()
print(timeline["chain"]["valid"], [event["kind"] for event in timeline["events"]])4. Download the signed document and the certificate
When every signer has signed, the request is completed and signedDocumentAvailable is true. The signed PDF carries the signatures and, when sealing is on, a seal that any PDF reader can verify. The completion certificate lists who signed, when, from where, and the root of the trail. Asking before the request is complete is answered with signature.not_completed.
curl https://api.pdfloft.com/v1/signature-requests/cm3x8k2f10010/signed-document \
-H "Authorization: Bearer $PDFLOFT_API_KEY"
curl https://api.pdfloft.com/v1/signature-requests/cm3x8k2f10010/certificate \
-H "Authorization: Bearer $PDFLOFT_API_KEY"
# Each answer holds a signed link, valid for five minutes. Fetch it without the key:
curl -o signed.pdf "<url from the answer>"const signed = await fetch(`https://api.pdfloft.com/v1/signature-requests/${request.id}/signed-document`, { headers }).then((r) => r.json());
const certificate = await fetch(`https://api.pdfloft.com/v1/signature-requests/${request.id}/certificate`, { headers }).then((r) => r.json());
const signedPdf = await fetch(signed.url).then((r) => r.arrayBuffer()); // the signed link needs no keysigned = requests.get(f"https://api.pdfloft.com/v1/signature-requests/{request['id']}/signed-document", headers=headers).json()
certificate = requests.get(f"https://api.pdfloft.com/v1/signature-requests/{request['id']}/certificate", headers=headers).json()
open("signed.pdf", "wb").write(requests.get(signed["url"]).content) # the signed link needs no keyRemind and void
A reminder gives each signer whose turn it is a new link and ends the old one. Voiding cancels a request that is not complete; the signers who were asked are told and their links stop working. A completed request cannot be voided. Only the sender, or an owner or admin for an account-wide request, can send, remind or void; otherwise the answer is forbidden.
# Remind the signers whose turn it is
curl -X POST https://api.pdfloft.com/v1/signature-requests/cm3x8k2f10010/remind \
-H "Authorization: Bearer $PDFLOFT_API_KEY" -H "Content-Type: application/json" -d '{}'
# Cancel the request
curl -X POST https://api.pdfloft.com/v1/signature-requests/cm3x8k2f10010/void \
-H "Authorization: Bearer $PDFLOFT_API_KEY" -H "Content-Type: application/json" \
-d '{"reason":"The terms changed."}'await fetch(`https://api.pdfloft.com/v1/signature-requests/${request.id}/remind`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: '{}',
});
await fetch(`https://api.pdfloft.com/v1/signature-requests/${request.id}/void`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: 'The terms changed.' }),
});requests.post(f"https://api.pdfloft.com/v1/signature-requests/{request['id']}/remind", headers=headers, json={})
requests.post(
f"https://api.pdfloft.com/v1/signature-requests/{request['id']}/void",
headers=headers,
json={"reason": "The terms changed."},
)Signature events
Subscribe an endpoint to any of these events in Settings, then Developers, or with POST /v1/webhooks. Each is sent once when the change happens, and never for an action that was refused. The data is the request as GET /v1/signature-requests/{requestId} returns it. Events about one signer add signer; a decline or a void adds reason. Delivery, signing and retries are the same as for every webhook: see Webhooks.
| Event | Sent when |
|---|---|
signature.request_sent | A request was sent and the first signers were asked. |
signature.viewed | A signer opened the request, the first time. |
signature.signed | A signer signed. |
signature.declined | A signer declined. The request is closed and the reason is in the data. |
signature.completed | Every signer signed and the signed document and the certificate are ready. |
signature.voided | The request was voided. The reason is in the data. |
signature.expired | The request passed its expiry before every signer signed. |
{
"id": "evt_Xk2mQ9rT4vLw8aBc1dEf",
"type": "signature.signed",
"createdAt": "2026-11-04T10:05:00.000Z",
"data": {
"id": "cm3x8k2f10010",
"title": "Services agreement",
"status": "in_progress",
"signerId": "cm3x8k2f10011",
"signer": { "name": "Meera Iyer", "email": "meera@example.com", "role": "Client", "status": "signed" },
"signers": [ ... ]
}
}