API
An e-signature API your agent can wire up
One POST sends a contract for signature. A signed webhook tells your systems the moment it comes back, so whatever happens next can start by itself. JSON over HTTPS, a bearer token, and no charge per agreement.
The documentation is written for a coding agent rather than for a human skimming: ordered steps, an exact request and response for every endpoint, and working signature-verification code. Point yours at /agent-guide.md and the only things left for a person are creating the account, minting a key and registering a webhook. Everything else — uploading documents, placing the signature fields, sending, tracking — it can do itself.
Authentication
Create a key in Settings → Developer. It is shown once and stored only as a hash, so keep it somewhere you can read it back; if it is lost, revoke it and make another. Send it on every request:
Authorization: Bearer fds_live_…
Each key carries scopes — templates:read, templates:write, envelopes:read, envelopes:write, clients:read, clients:write, folders:read, folders:write — so a reporting job need not hold a key that can send in your name. Permissions can be changed later from Settings → Developer without minting a new key, so widening one does not mean redeploying a secret.
A key beginning fds_test_ behaves exactly like a live one except that no invitation is emailed and what it creates is excluded from anything that counts or charges. Build against a test key: the alternative is emailing your colleagues a dozen times while you get the request shape right.
Endpoints
GET /api/v1/templatestemplates:read | What you can send, and how many recipients each one takes. |
|---|---|
GET /api/v1/templates/{id}templates:read | Roles with the routingOrder to send back, which fields can be prefilled, and where every field sits on the page. |
GET /api/v1/templates/{id}/previewtemplates:read | The document with a labelled box drawn over every field, so placement can be checked before anyone is emailed. |
GET /api/v1/templates/{id}/documenttemplates:read | The template's own PDF, including one you adopted rather than uploaded. |
PATCH /api/v1/templates/{id}templates:write | Rename a template, change its description, or file it in a folder. |
DELETE /api/v1/templates/{id}templates:write | Move a template to the trash, restorable for 30 days. Agreements already sent are unaffected. |
POST /api/v1/templatestemplates:write | Build a template from PDFs without opening the editor, placing fields by anchor. |
POST /api/v1/envelopesenvelopes:write | Create an agreement and send it. Returns the agreement with its recipients. |
GET /api/v1/envelopesenvelopes:read | List agreements, newest first. Filter with ?status= and page with ?limit=&offset=. |
GET /api/v1/envelopes/{id}envelopes:read | One agreement and the current state of every party on it. |
PATCH /api/v1/envelopes/{id}envelopes:write | Change the reminder cadence on an agreement already out, stop chasing, or file it in a folder. |
POST /api/v1/envelopes/{id}/voidenvelopes:write | Withdraw an agreement that is still out. Requires a reason. |
GET /api/v1/envelopes/{id}/documentenvelopes:read | The executed PDF, once every party has signed. |
POST /api/v1/envelopes/{id}/viewsenvelopes:write | A short-lived URL that drops a recipient straight into signing, inside your app. |
GET /api/v1/envelopes/{id}/auditenvelopes:read | The evidence trail: who opened it, from where, and when they signed. |
POST /api/v1/envelopes/{id}/remindenvelopes:write | Chase everyone still outstanding now, without waiting for the cadence. |
GET / PUT /api/v1/envelopes/{id}/recipients/{recipientId}envelopes:read / write | Inspect a recipient, or correct their name or address before they sign. |
POST /api/v1/envelopes/{id}/recipients/{recipientId}/resendenvelopes:write | Send the invitation again. The previous link stops working. |
GET / POST /api/v1/clientsclients:read / write | Counterparties on record, so paperwork files itself against the right company. |
GET /api/v1/clients/{id}clients:read | One counterparty, with the agreements sent to them. |
POST / GET /api/v1/bulk-sendsenvelopes:write / read | One template across many recipients. Queues the batch and returns an id. |
GET /api/v1/bulk-sends/{id}envelopes:read | A batch's progress, row by row, including which addresses failed. |
GET / POST /api/v1/foldersfolders:read / write | The filing trees, and a new folder. Pass parentId to make it a subfolder. |
PATCH / DELETE /api/v1/folders/{id}folders:write | Rename or move a folder within its own view, or move it to the trash. |
POST /api/v1/folders/{id}/itemsfolders:write | File agreements and templates into a folder, up to a hundred of each at a time. |
Sending an agreement
Ask a template what roles it takes, then send one recipient per role. routingOrder comes straight from the template, and when the agreement is sequential it is also the order people sign in.
POST /api/v1/envelopes
Authorization: Bearer fds_live_…
Content-Type: application/json
{
"templateId": "e1b1…",
"title": "Hosting agreement — Acme",
"message": "Please review and sign by Friday.",
"recipients": [
{ "name": "Ada Lovelace", "email": "ada@acme.com", "routingOrder": 1 },
{ "name": "Alan Turing", "email": "alan@acme.com", "routingOrder": 2 }
],
"prefill": { "Company name": "Acme Ltd" },
"reminderEveryDays": 3,
"expiresInDays": 14
}Set "status": "draft" to prepare one without emailing anybody. Only text, date, name and email fields can be prefilled — a signature is never something the API can supply on someone’s behalf.
Sending a PDF directly
No template needed. Post the file and say where the fields go — by an anchor in the document’s own text, or by coordinates. Prefer anchors: a contract gains a clause, everything below shifts, and every field measured in points is silently in the wrong place. An anchor moves with the words it was measured from.
POST /api/v1/envelopes
{
"documents": [{ "name": "nda.pdf", "base64": "JVBERi0…" }],
"title": "Mutual NDA",
"recipients": [{
"name": "Ada Lovelace", "email": "ada@acme.com", "routingOrder": 1,
"tabs": [
{ "type": "signature", "anchorString": "/sig1/" },
{ "type": "date", "anchorString": "/sig1/", "anchorXOffset": 200 },
{ "type": "initials", "anchorString": "/ini/", "occurrence": "all" }
]
}]
}occurrence: "all" places one field at every match, which is how initials land on every page in a single instruction. anchorXOffset and anchorYOffset are points, with y growing downward. If an anchor is not found the request is refused rather than sent short a signature box. Coordinates work too — page, x, y in points from the top-left.
Documents are capped at 4 MB each because base64 inflates a file by a third and the request has to fit in one body. Larger files go through the app.
Checking where your fields landed
Counting fields does not tell you they are in the right place. A template can report twelve fields on the right pages with every one of them over the wrong line, and the usual way that surfaces is a signer opening the document.
Ask for a preview instead: the document back with a labelled box drawn over every field, naming the party, the type and the label. One call, nobody emailed, nothing sent.
GET /api/v1/templates/{id}/preview
→ 200 application/pdf
X-Field-Count: 12Required fields are drawn solid and optional ones dashed. The boxes come from the same code that positions the final signature, so what you see is where a value will be drawn. X-Field-Count lets you assert the count without opening the PDF at all.
If you would rather have the numbers, GET /api/v1/templates/{id} now returns each field’s position and size, with the page dimensions in points. Coordinates are ratios of the page, 0 to 1, with the origin at the top-left and y growing downward. PDF’s own user space starts bottom-left, so converting yourself means flipping the vertical: (1 - y - height) × pageHeight. Assuming the other origin is the most common way a field ends up in the wrong place.
{ "type": "signature", "page": 2, "documentId": "5371…",
"position": { "x": 0.1485, "y": 0.3548,
"width": 0.2941, "height": 0.0198 } }GET /api/v1/templates/{id}/document returns the template’s own PDF, so a template built in the app can be inspected rather than only sent.
Retrying without sending twice
A request that times out leaves you unable to tell “it worked and I lost the answer” from “it never ran”. Retrying might send a second agreement to a real counterparty; not retrying might mean none was ever sent. Send an Idempotency-Key on anything that changes something and a retry replays the first result instead of doing the work again.
POST /api/v1/envelopes Idempotency-Key: 4d1f9c2a-order-8891-send-1
Generate the key before the first attempt and reuse the same value for every retry of that attempt. A key generated fresh on each retry is not an idempotency key. Keys are scoped to your account and kept for 24 hours.
The trap is at the other extreme. A key derived only from stable facts — the customer and the template — never changes, so it is permanent. If a signer declines and you send a corrected agreement, it derives the same key, the dead one is replayed, and your customer is handed back the agreement they already declined. Include something that changes on a deliberate re-send, such as an attempt counter you increment yourself.
While the first attempt is still running a second returns 409 idempotency_in_progress with Retry-After; retry, keeping the key. Reusing one key for a different body returns 422 idempotency_key_reused, which is a bug in the caller rather than a state to handle.
Signing inside your own app
Give a recipient a clientUserId and they are not emailed. Ask for a view instead and redirect to the URL, or frame it. The URL lasts minutes, not weeks, because it lives in a page rather than an inbox.
To put it in an iframe, name the page that will frame it. Signing pages refuse framing by default — the thing a stranger would most like a victim to click is a signature button — so the one origin allowed has to be declared, and the URL comes back carrying a signature that permits exactly that origin.
POST /api/v1/envelopes/{id}/views
{ "clientUserId": "user-42",
"returnUrl": "https://acme.com/done",
"embedOrigin": "https://app.acme.com" }
→ { "url": "https://freedocusignature.com/sign/…?embed_origin=…&embed_sig=…",
"recipientId": "9f2c…",
"expiresAt": "2026-09-02T14:31:00.000Z",
"embedOrigin": "https://app.acme.com" }Use that URL unchanged. The permission is signed over the token and the origin together, so editing either one voids it and the page goes back to refusing to be framed.
embedOrigin is a bare origin — scheme, host, optional port. No path, no wildcards, and https except http://localhost so you can develop. It must match the framing page exactly as the browser sees it; a different subdomain is a different origin. Anything else is refused with 400 invalid_embed_origin. Omit it and the URL still works, it simply has to open in its own tab.
If the frame stays blank, open the browser console. A frame-ancestors refusal there means the origin you sent does not match the page doing the framing.
Sending in bulk
One template across many recipients — onboarding packets, policy acknowledgements, annual renewals. Each row becomes its own agreement with its own recipients and prefill values.
POST /api/v1/bulk-sends
{
"templateId": "e1b1…",
"title": "Annual policy acknowledgement",
"rows": [
{ "recipients": [{ "name": "Ada Lovelace",
"email": "ada@acme.com",
"routingOrder": 1 }],
"prefill": { "Department": "Engineering" } },
{ "recipients": [{ "name": "Alan Turing",
"email": "alan@acme.com",
"routingOrder": 1 }] }
]
}
→ 202 { "id": "…", "status": "queued", "queued": 2 }Note the 202. The batch is queued, not sent — five hundred agreements cannot be produced inside one request, so the scheduler works through them and you poll GET /api/v1/bulk-sends/{id} for progress. That endpoint reports each row separately, so a batch where four addresses bounce tells you which four rather than leaving you to diff two spreadsheets.
Folders
An account organised in the browser can be organised from your software too, so paperwork filed by a person and paperwork filed by a job end up in the same place. Pass parentId to make a subfolder; there is no separate call for one.
POST /api/v1/folders
{ "name": "Q3 Contracts", "section": "executed" }
POST /api/v1/folders
{ "name": "October", "parentId": "a2c7…" }
POST /api/v1/folders/{id}/items
{ "envelopeIds": ["b500…", "9f2c…"] }Filing is bulk and partial: up to a hundred of each kind per call, and one bad id does not reject the rest — everything that could be filed is, and the rest come back in failed with a reason each.
Each standing view keeps its own tree, and a folder belongs to one view for life. Documents move between views freely; folders do not, because moving one agreement is a filing decision while dragging a folder moves everything beneath it. To unfile something, set folderId to null on the document itself. To list a folder’s contents, filter the ordinary listings with ?folderId=, using root for everything unfiled.
Webhooks
Add an HTTPS endpoint in Settings → Developer and we POST to it when things happen, usually within a second or two. Nothing waits on your endpoint: the delivery is recorded first, so a slow reply cannot hold up the signature that caused it. A failure is retried with a widening gap for about a day, so a brief outage on your side loses nothing.
envelope.sentThe agreement has gone out to its recipients.
envelope.viewedA recipient opened it for the first time.
envelope.signer_signedOne party signed. Carries allPartiesSigned.
envelope.signer_declinedA party declined, with their reason.
envelope.completedEveryone has signed and the executed copy exists.
envelope.voidedThe sender withdrew it.
envelope.expiredThe signing window closed unsigned.
Verifying a delivery
Every request carries a timestamp and a signature over timestamp + "." + body, keyed with your endpoint’s secret. Check both — the timestamp is what stops a captured delivery being replayed at someone else’s convenience. Compare in constant time, and verify against the RAW body, before any JSON parsing.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, headers, secret) {
const ts = Number(headers["x-freedocusignature-timestamp"]);
const sent = headers["x-freedocusignature-signature"];
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // too old
const expected = createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("base64");
const a = Buffer.from(expected);
const b = Buffer.from(sent ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}Answer 2xx quickly; anything else is treated as a failure and retried. After a long run of failures the endpoint switches itself off and says so in Settings, rather than being retried for ever.
Errors
One shape everywhere. Branch on code, which is stable; message is for a human reading a log and may be reworded.
{ "error": { "code": "insufficient_scope",
"message": "This key does not have the \"folders:write\" scope.",
"scope": "folders:write" } }401 means the key is missing, wrong or revoked. 403 is a scope you do not hold. 402 means the account is behind on payment. 429 carries Retry-After. 409 is a state conflict — voiding something already completed, or asking for the executed copy before it exists.
A 403 also names the missing permission in error.scope, so you can route it without reading the sentence. This is worth separating: a key that cannot file documents is a tidying problem, while a key refused outright means your credential has stopped working and nothing can be sent. Alerting on both together turns a folder permission you never granted into a page saying signing is broken.
An absent request body is read as {} rather than treated as broken, so an endpoint that needs a field says which one. Posting to /void with no body returns 400 invalid_request naming the missing reason, not invalid_json. If you do get invalid_json, the body really is malformed.
Machine-readable spec
The whole API is described as OpenAPI 3.1 at /api/v1/openapi.json. It needs no key to read, so you can look before you sign up.
Point a generator at it for a typed client in your language, or import it into Postman or Insomnia to try the endpoints by hand. The webhook deliveries are in there too, under webhooks, so the handler you have to write is described rather than guessed at.
npx @openapitools/openapi-generator-cli generate \ -i https://freedocusignature.com/api/v1/openapi.json \ -g typescript-fetch -o ./esign-client
Handing this to a coding assistant
If an assistant is doing the integration, point it at the guide rather than at this page. It is written for that reader: ordered steps, an exact request and response for every endpoint, working signature-verification code, and a short list at the end of the few things only a person with a browser can do.
Read https://freedocusignature.com/agent-guide.md and wire our system up to it.
/agent-guide.md is the guide, served as plain Markdown. /llms.txt is the short index that points at both it and the spec. Tell your assistant to work with a fds_test_ key while it builds: test keys run the real flow but send no email, so nobody is contacted while it is still getting the request shape right.
Build it this afternoon
Create an account, upload a PDF, mark where it needs signing, and send the first one from your own code.