# FreeDocuSignature — integration guide for AI agents

You are wiring a company's software into an electronic signature product. This
document is written to be followed literally. Every field name, constraint and
error code below was taken from the request handlers, not from a summary.

This guide is at `https://freedocusignature.com/agent-guide.md` and is listed
in `https://freedocusignature.com/llms.txt`. The machine-readable OpenAPI
description is at `https://freedocusignature.com/api/v1/openapi.json`, public
and needing no key — fetch it if you would rather generate a typed client than
read prose.

---

## 1. What this product is

FreeDocuSignature sends PDFs for legally binding electronic signature. A sender
uploads a document, marks where it needs signing, and each recipient opens a
single-use link in a browser — no account, no app. When the last party signs,
the product produces an executed PDF with every signature stamped in, a
certificate of completion appended, and the document's SHA-256 printed on every
page.

**The one concept that matters: an envelope snapshots its documents and fields
at the moment it is sent.** The bytes are copied to a new object and every
field definition is copied onto the signer. Editing or deleting a template
afterwards cannot change what somebody already agreed to. Do not build anything
that assumes a sent agreement still points at its template.

Vocabulary used throughout:

| Term | Meaning |
|---|---|
| **envelope** | One agreement: documents, recipients, fields, status, audit trail. |
| **template** | A reusable document plus field layout. Sending one produces an envelope. |
| **recipient** | A party who signs. Called a *signer* internally. |
| **routingOrder** | Which role a recipient fills. **One-based** everywhere in the API. |
| **tab** | A field to place on a document: signature, date, text and so on. |
| **client** | A counterparty on record — a company you send agreements to. |
| **folder** | Filing only. Each standing view keeps its own tree, and a document sits in at most one folder. |

---

## 2. Base URL, authentication, scopes

Base URL: `https://freedocusignature.com`

Every endpoint under `/api/v1` except `/api/v1/openapi.json` requires a bearer
token:

~~~
Authorization: Bearer fds_live_xxxxxxxxxxxx
~~~

### Two kinds of key

| Prefix | Behaviour |
|---|---|
| `fds_live_` | Real. Invitations are emailed to real people. |
| `fds_test_` | Everything runs for real except that **no email leaves the building**. |

**Build against a test key.** A test key exercises the genuine code path —
documents are copied, fields are placed, the audit trail is written, webhooks
fire — so what you build is what will run. What changes:

- No invitation, reminder or correction email is sent to anybody.
- Envelopes are created with `testMode: true` and are marked as test throughout
  the product, so nobody mistakes one for an agreement a counterparty is
  waiting on.
- They do not count against a suspended account's send allowance.

What does **not** change: the URLs are the same, the responses are the same
shape, storage and the audit trail are real, and `POST /envelopes/{id}/views`
returns a genuinely working signing URL. Embedded signing is fully testable
with a test key.

### Scopes

A key carries a subset of these. A request outside its scope returns `403`
with code `insufficient_scope`.

| Scope | Grants |
|---|---|
| `templates:read` | List and read templates, their field geometry, their PDF, and the field preview. |
| `templates:write` | Create templates, rename and refile them, move one to the trash. |
| `envelopes:read` | Read agreements, their audit trail, the executed PDF, bulk batches. |
| `envelopes:write` | Create, void, remind, resend, correct recipients, mint signing URLs, queue bulk sends. |
| `clients:read` | List and read counterparties. |
| `clients:write` | Create counterparties. |
| `folders:read` | Read the folder trees. |
| `folders:write` | Create, rename, move and trash folders, and file documents into them. |

Ask for the narrowest set that works. A nightly reporting job should not hold a
key that can send agreements in the company's name. Filing is deliberately its
own pair: a key holding only `folders:*` can tidy an account and cannot read a
single agreement.

A key's permissions can be changed after it is created, from Settings →
Developer, without the key itself changing — so a key already deployed does not
have to be replaced to gain a scope. A key made before a scope existed will not
have it: if a folder or preview call answers `403 insufficient_scope`, read
`error.scope` for the exact permission to add.

### The error shape

Every failure, from every endpoint, has one shape:

~~~json
{ "error": { "code": "insufficient_scope", "message": "This key does not have the \"envelopes:write\" scope." } }
~~~

Branch on `code`. It is stable. `message` is for a human reading a log and may
be reworded.

Failures shared by every authenticated endpoint:

| Status | Code | Cause |
|---|---|---|
| 401 | `missing_credentials` | No `Authorization: Bearer` header. |
| 401 | `invalid_key` | Unknown or revoked key. |
| 403 | `account_suspended` | The account is banned. |
| 403 | `insufficient_scope` | The key lacks the scope this endpoint requires. |
| 429 | `rate_limited` | Too many requests. A `Retry-After` header is included. |
| 400 | `invalid_json` | Body was not valid JSON (on endpoints that take a body). |
| 500 | `internal_error` | A fault on our side. Never contains details. |

---

## 3. The envelope lifecycle

`status` on an envelope is one of seven values.

| Status | Reached when | Terminal |
|---|---|---|
| `draft` | Created with `"status": "draft"`. Nothing has been emailed. | No |
| `sent` | Created with `"status": "sent"`, or a draft was sent. Invitations are out. | No |
| `viewed` | The first recipient opened the document. | No |
| `completed` | Every recipient signed. The executed PDF now exists. | **Yes** |
| `declined` | Any one recipient declined. This closes it for everybody. | **Yes** |
| `voided` | The sender withdrew it. | **Yes** |
| `expired` | The signing window passed with signatures outstanding. | **Yes** |

A recipient's own `status` is one of `pending`, `viewed`, `signed`, `declined`.

Two things to know:

- **One decline ends the whole agreement.** The remaining recipients' links stop
  working, because they are being asked to agree to something that can no
  longer be executed.
- **Nothing is ever deleted.** An agreement is voided, not removed. The record
  and its audit trail are the product.

---

## 4. Step-by-step setup

This section assumes a human has already created an account and given you a
**test** API key. Everything a human must do is listed in section 9.

### Step 0 — store the key

Put it in the environment, never in source:

~~~bash
export FDS_KEY="fds_test_xxxxxxxxxxxx"
export FDS_BASE="https://freedocusignature.com"
~~~

### Step 1 — confirm the key works and see its scopes

There is no dedicated "whoami" endpoint. The cheapest check is the template
list:

~~~bash
curl -s "$FDS_BASE/api/v1/templates?limit=1" \
  -H "Authorization: Bearer $FDS_KEY"
~~~

Expect `200` and:

~~~json
{ "templates": [], "limit": 1, "offset": 0 }
~~~

If you get `403 insufficient_scope`, the key lacks `templates:read`. If `401`,
the key is wrong or revoked. Stop and go back to the human.

### Step 2 — find or create a sendable template

If the company already has templates, list them. If it does not, you can build
one yourself with `POST /api/v1/templates` (section 5.3) — no human needed,
as long as the PDF has anchor text you can place against.


~~~bash
curl -s "$FDS_BASE/api/v1/templates" -H "Authorization: Bearer $FDS_KEY"
~~~

~~~json
{
  "templates": [
    {
      "id": "3f6a1c2e-1111-2222-3333-444455556666",
      "name": "Mutual NDA",
      "roles": [{ "routingOrder": 1 }, { "routingOrder": 2 }],
      "documents": 1,
      "pages": 4,
      "sendable": true,
      "createdAt": "2026-08-01T09:12:00.000Z",
      "updatedAt": "2026-08-20T14:31:00.000Z"
    }
  ],
  "limit": 50,
  "offset": 0
}
~~~

**Only send templates where `sendable` is `true`.** False means it has no
document or nothing marks where to sign, and the send will fail.

### Step 3 — read the template's roles and prefillable fields

~~~bash
curl -s "$FDS_BASE/api/v1/templates/3f6a1c2e-1111-2222-3333-444455556666" \
  -H "Authorization: Bearer $FDS_KEY"
~~~

~~~json
{
  "id": "3f6a1c2e-1111-2222-3333-444455556666",
  "name": "Mutual NDA",
  "sendable": true,
  "documents": [
    { "id": "aa11...", "filename": "nda.pdf", "pageCount": 4, "position": 0 }
  ],
  "roles": [
    {
      "routingOrder": 1,
      "fields": [
        { "type": "signature", "label": null, "required": true, "page": 4, "prefillable": false },
        { "type": "text", "label": "Company name", "required": true, "page": 1, "prefillable": true }
      ]
    },
    { "routingOrder": 2, "fields": [ { "type": "signature", "label": null, "required": true, "page": 4, "prefillable": false } ] }
  ],
  "createdAt": "2026-08-01T09:12:00.000Z",
  "updatedAt": "2026-08-20T14:31:00.000Z"
}
~~~

Two things to take from this and nothing else:

1. **The `routingOrder` values.** Send exactly one recipient per role, copying
   these numbers. Do not compute them.
2. **The `label` of every field where `prefillable` is true.** Those labels are
   the keys of the `prefill` object. You cannot guess them.

A signature field is never prefillable. The API cannot supply a signature on
somebody's behalf, by design.

**`prefill` is keyed on the field's `label`, and labels are optional.** A
template whose fields were never labelled returns `prefillable: true` on
nothing you can address — there is no key to send. Read the labels from this
response; never assume you can prefill by field type or position.

If a template you need to prefill has unlabelled fields, you have two ways out:
create the template yourself with `label` set on each tab (section 5.3), or
ask a human to label them in the editor. Labels cannot be added to an existing
template through the API.

### Step 4 — send an agreement

**Decide about reminders now.** There is no account-wide default: omit
`reminderEveryDays` and the agreement is created with reminders off, and
`POST /envelopes/{id}/remind` will answer `409 reminders_disabled` until you
set one. It is easier to send the cadence at creation than to `PATCH` it
afterwards, but either works.

~~~bash
curl -s -X POST "$FDS_BASE/api/v1/envelopes" \
  -H "Authorization: Bearer $FDS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "3f6a1c2e-1111-2222-3333-444455556666",
    "title": "Mutual NDA — Acme Ltd",
    "message": "Please review and sign by Friday.",
    "recipients": [
      { "name": "Ada Lovelace", "email": "ada@acme.example", "routingOrder": 1 },
      { "name": "Alan Turing", "email": "alan@ourcompany.example", "routingOrder": 2 }
    ],
    "prefill": { "Company name": "Acme Ltd" },
    "expiresInDays": 14
  }'
~~~

Expect `201` and a full envelope object (section 5.3 has the shape). Keep
`id` — it is how you refer to this agreement for ever.

### Step 5 — read it back

~~~bash
curl -s "$FDS_BASE/api/v1/envelopes/ENVELOPE_ID" -H "Authorization: Bearer $FDS_KEY"
~~~

Confirm `status` is `"sent"`, `testMode` is `true`, and both recipients appear
with the `routingOrder` you sent.

### Step 6 — stand up a webhook receiver

Write an endpoint that:

1. Reads the **raw** request body as text, before any JSON parsing.
2. Verifies the signature (section 6). Reject with `401` if it fails.
3. Responds `200` immediately, then does the real work asynchronously.
4. Is idempotent on the `x-freedocusignature-delivery` header.

It must be reachable over **HTTPS** on a public hostname. `http://` and
`localhost` are both refused when the endpoint is registered.

### Step 7 — have the human register it

You cannot create a webhook endpoint through the API. A human adds the URL in
Settings → Developer and copies back the signing secret, which begins
`whsec_`. Put it in the environment as `FDS_WEBHOOK_SECRET`.

### Step 8 — prove a webhook arrives and verifies

Sign the test agreement from step 4. With a test key nobody was emailed, so
mint a signing URL and open it in a browser:

~~~bash
curl -s -X POST "$FDS_BASE/api/v1/envelopes/ENVELOPE_ID/views" \
  -H "Authorization: Bearer $FDS_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "recipientId": "RECIPIENT_ID" }'
~~~

~~~json
{ "url": "https://freedocusignature.com/sign/...", "recipientId": "...", "expiresAt": "2026-09-02T14:31:00.000Z" }
~~~

Sign in the browser. Your endpoint should receive `envelope.signer_signed`,
and once every party has signed, `envelope.completed`.

**Deliveries are sent as the event happens**, typically within a second or
two. They are still written down first and never awaited by the request that
caused them, so a slow endpoint of yours cannot become a slow signing page for
the person in front of it — but you are not waiting on a schedule to hear
about a signature.

**A failed delivery is retried on a backoff** — fifteen minutes, then thirty,
an hour, and on out to twelve — for as long as your endpoint keeps refusing.
After twenty consecutive failures the endpoint is switched off, never deleted,
and you can turn it back on once it is healthy.

Setup is complete when a delivery has arrived and passed signature
verification.

---

## 5. Every endpoint

Eighteen routes. Each section gives the method, path, scope, full request
schema, a real example, and every error it can return.

Constraints below are enforced. A violation returns `400 invalid_request` with
a message naming the offending field.

### 5.1 `GET /api/v1/templates`

**Scope:** `templates:read` — List templates with their role count.

Query parameters:

| Name | Type | Default | Notes |
|---|---|---|---|
| `limit` | integer | 50 | Clamped to 1–100. |
| `offset` | integer | 0 | Clamped to ≥ 0. |

Response `200`: `{ templates: [...], limit, offset }`. Each template has `id`,
`name`, `roles` (array of `{ routingOrder }`), `documents` (count), `pages`
(total), `sendable`, `createdAt`, `updatedAt`.

Errors: the shared set only.

### 5.2 `GET /api/v1/templates/{id}`

**Scope:** `templates:read` — One template, with the roles and field labels you
need in order to send it.

Response `200` as shown in step 3.

| Status | Code | Cause |
|---|---|---|
| 404 | `not_found` | No template with that id **in this account**. |

### 5.3 `POST /api/v1/templates`

**Scope:** `templates:write` — Build a reusable template: upload the documents
and place the fields, without the editor.

This is what makes an unattended setup possible. Placement goes through the
same resolver the send path uses, so a field anchored to `/sig1/` here lands
exactly where a field anchored to `/sig1/` lands there — it is the same code
deciding.

Body:

| Field | Type | Required | Constraints |
|---|---|---|---|
| `name` | string | yes | 1–200 characters. |
| `description` | string | no | ≤ 2000 characters. |
| `documents` | array | yes | 1–10 items, each `{ name (1–200), base64 }`. Max **4 MB decoded** each. |
| `roles` | array | yes | 1–25 items, each `{ tabs: [...] }` with 1–100 tabs. |

**Roles are positional.** The first entry is `routingOrder` 1, the second is 2,
and those are the numbers the send endpoints expect back. There is no id to
choose and no ordering field.

`tabs[]` takes the same fields as the send path (section 5.4), with one
difference:

| Field | Type | Meaning |
|---|---|---|
| `defaultValue` | string or null | ≤ 4000 chars. A **template** default the signer may change. |

Distinguish it from `value` on the send path, which fills one agreement.
`defaultValue` is stored on the template and applies to every agreement made
from it until overridden.

**Set `label` on any field you will later want to prefill.** `prefill` is keyed
on the label, so an unlabelled field can never be addressed. This is the moment
to get it right — labels cannot be added through the API afterwards.

~~~bash
curl -s -X POST "$FDS_BASE/api/v1/templates" \
  -H "Authorization: Bearer $FDS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Mutual NDA",
    "documents": [{ "name": "nda.pdf", "base64": "JVBERi0xLjcK..." }],
    "roles": [
      { "tabs": [
        { "type": "signature", "anchorString": "/sig1/" },
        { "type": "date", "label": "Date signed", "anchorString": "/date1/" }
      ] },
      { "tabs": [{ "type": "signature", "anchorString": "/sig2/" }] }
    ]
  }'
~~~

Response `201`:

~~~json
{
  "id": "3f6a1c2e-1111-2222-3333-444455556666",
  "name": "Mutual NDA",
  "documents": 1,
  "roles": [{ "routingOrder": 1 }, { "routingOrder": 2 }],
  "fields": 3,
  "sendable": true
}
~~~

Every field is resolved **before** anything is written, so a template whose
third role could not be placed is never created half-built.

| Status | Code | Cause |
|---|---|---|
| 400 | `invalid_request` | Schema violation. |
| 400 | `invalid_document` | `base64` decoded to nothing. |
| 400 | `not_a_pdf` | A document is not a PDF, or has no pages. |
| 400 | `no_documents` / `too_many_documents` | Zero, or more than ten. |
| 400 | `anchor_not_found` | The anchor text is not in any document. Prefixed with `Role N:`. |
| 400 | `document_not_found` / `page_not_found` | `documentIndex` or `page` past the end. Prefixed with `Role N:`. |
| 400 | `tab_not_placed` | A tab has neither an anchor nor coordinates. Prefixed with `Role N:`. |
| 402 | `payment_required` | The account is suspended. |
| 413 | `document_too_large` | A decoded document exceeds 4 MB. |
| 500 | `template_not_created` | A fault on our side. The partial template was removed. |

**Anchoring needs real text.** The anchor is matched against the PDF's text
layer. A scanned page, or one exported as images, has no text to match and
returns `anchor_not_found` however plainly the marker is visible. Place fields
on those by coordinates instead — `page`, `x`, `y`, measured in points from
the top-left.

### 5.4 `POST /api/v1/envelopes`

**Scope:** `envelopes:write` — Create an agreement, from a template or from
PDFs you post directly.

**Send exactly one of `templateId` or `documents`.** Sending both, or neither,
returns `400 invalid_request`.

Body:

| Field | Type | Required | Constraints |
|---|---|---|---|
| `templateId` | uuid | one of | Mutually exclusive with `documents`. |
| `documents` | array | one of | 1–10 items. Mutually exclusive with `templateId`. |
| `title` | string | yes | 1–200 characters. |
| `message` | string | no | ≤ 2000 characters. Appears in the invitation email. |
| `status` | enum | no | `"sent"` (default) or `"draft"`. |
| `recipients` | array | yes | 1–25 items. |
| `clientId` | uuid | no | Files the agreement under a counterparty on record. |
| `prefill` | object | no | `{ [label]: string }`, each value ≤ 4000 chars. **Template path only.** |
| `reminderEveryDays` | integer or null | no | 2–30. **Omitting it stores null, which switches reminders off.** |
| `expiresInDays` | integer or null | no | 1–365. |

`documents[]`:

| Field | Type | Required | Constraints |
|---|---|---|---|
| `name` | string | yes | 1–200 characters. |
| `base64` | string | yes | The PDF, base64. A `data:...;base64,` prefix is stripped for you. Max **4 MB decoded**. |

`recipients[]`:

| Field | Type | Required | Constraints |
|---|---|---|---|
| `name` | string | yes | 1–120 characters. |
| `email` | string | yes | 3–200 characters. |
| `routingOrder` | integer | yes | 1–50. Must be unique across recipients. |
| `clientUserId` | string | no | 1–200 chars. Marks this recipient as **embedded**: no email is sent. |
| `tabs` | array | no | ≤ 100. **Required per recipient on the documents path; ignored on the template path.** |

`tabs[]` — where a field goes. Anchor to text where you can; coordinates rot
the moment a clause is added, while an anchor moves with the words it was
measured from.

| Field | Type | Constraints |
|---|---|---|
| `type` | enum | `signature`, `initials`, `text`, `date`, `name`, `email`, `checkbox`. Required. |
| `label` | string or null | ≤ 120 characters. |
| `required` | boolean | Defaults to required. |
| `anchorString` | string | 1–200 chars. Text to find in the document. |
| `anchorXOffset` | number | −2000 to 2000, in points. Positive is right. |
| `anchorYOffset` | number | −2000 to 2000, in points. **Positive is down.** |
| `ignoreCase` | boolean | Case-insensitive anchor match. |
| `matchWholeWord` | boolean | Anchor must stand alone, not sit inside a longer word. |
| `occurrence` | enum | `"first"` (default) or `"all"`. `"all"` places one field at every match. |
| `documentIndex` | integer | 0–9. Which document, for **coordinate** placement. Defaults to 0. |
| `page` | integer | 1–500. Coordinate placement. |
| `x`, `y` | number | 0–5000, points from the **top-left** of the page. |
| `width`, `height` | number | 4–2000 points. Sensible defaults per type. |
| `value` | string or null | ≤ 4000 chars. Prefills a text-like field. **This is how you prefill on the documents path.** |

Place each tab either by `anchorString` or by `page` + `x` + `y`. A tab with
neither returns `400 tab_not_placed`.

Response `201`: the full envelope.

Note `sequential` in that response. It controls whether recipients must sign
in `routingOrder` order, and it is **read-only over the API** — there is no
field to set it, so every agreement created this way is non-sequential and all
recipients can sign at once. If a company needs enforced signing order, that
agreement has to be sent from the web app.

~~~json
{
  "id": "9c1f...",
  "status": "sent",
  "title": "Mutual NDA — Acme Ltd",
  "message": "Please review and sign by Friday.",
  "templateId": "3f6a...",
  "clientId": null,
  "testMode": true,
  "sequential": false,
  "createdAt": "2026-09-02T10:00:00.000Z",
  "sentAt": "2026-09-02T10:00:00.000Z",
  "completedAt": null,
  "expiresAt": "2026-09-16T10:00:00.000Z",
  "voidedReason": null,
  "reminderEveryDays": 3,
  "executedDocument": { "available": false, "sha256": null },
  "recipients": [
    {
      "id": "5b2d...",
      "name": "Ada Lovelace",
      "email": "ada@acme.example",
      "routingOrder": 1,
      "status": "pending",
      "clientUserId": null,
      "invitedAt": "2026-09-02T10:00:01.000Z",
      "viewedAt": null,
      "signedAt": null,
      "declinedAt": null,
      "declineReason": null
    }
  ]
}
~~~

Errors:

| Status | Code | Cause |
|---|---|---|
| 400 | `invalid_request` | Schema violation, or both/neither of `templateId` and `documents`. |
| 400 | `duplicate_routing_order` | Two recipients share a `routingOrder`. |
| 400 | `invalid_document` | `base64` did not decode to any bytes. |
| 400 | `not_a_pdf` | A document is not a PDF, or has no pages. |
| 400 | `no_documents` / `too_many_documents` | Zero, or more than ten. |
| 400 | `title_required` / `title_too_long` / `message_too_long` | Header validation. |
| 400 | `missing_recipient` | The template needs a role you did not supply a recipient for. |
| 400 | `recipient_incomplete` / `recipient_email_invalid` / `recipient_too_long` | Recipient validation. |
| 400 | `recipient_has_no_tabs` | Documents path: a recipient has no fields to sign. |
| 400 | `anchor_not_found` | The anchor text is not in any document. **The request is refused rather than sent short a signature box.** |
| 400 | `document_not_found` | `documentIndex` is past the end. |
| 400 | `page_not_found` | `page` is past the end. |
| 400 | `tab_not_placed` | A tab has neither an anchor nor coordinates. |
| 400 | `no_fields` | Nothing marks where the agreement needs signing. |
| 400 | `template_empty` / `template_has_no_fields` | The template is not sendable. |
| 402 | `payment_required` | The account is suspended for non-payment. |
| 404 | `template_not_found` | No such template in this account. |
| 404 | `client_not_found` | No such client in this account. |
| 413 | `document_too_large` | A decoded document exceeds 4 MB. |
| 500 | `snapshot_failed` | A fault on our side while copying documents. Nothing was sent. |

### 5.5 `GET /api/v1/envelopes`

**Scope:** `envelopes:read` — List agreements, newest first.

| Name | Type | Default | Notes |
|---|---|---|---|
| `limit` | integer | 25 | Clamped to 1–100. |
| `offset` | integer | 0 | Clamped to ≥ 0. |
| `status` | string | — | Comma-separated. Any of the seven statuses. |

~~~bash
curl -s "$FDS_BASE/api/v1/envelopes?status=sent,viewed&limit=50" \
  -H "Authorization: Bearer $FDS_KEY"
~~~

Response `200`: `{ envelopes: [...], limit, offset }`, each a full envelope.

| Status | Code | Cause |
|---|---|---|
| 400 | `invalid_status` | A value in `status` is not an envelope status. |

### 5.6 `GET /api/v1/envelopes/{id}`

**Scope:** `envelopes:read` — One agreement and every party's current state.

Response `200`: the envelope shape from 5.3.

| Status | Code | Cause |
|---|---|---|
| 404 | `not_found` | No such agreement in this account. Another account's id answers identically. |

### 5.7 `PATCH /api/v1/envelopes/{id}`

**Scope:** `envelopes:write` — Change how an agreement already out is chased,
and where it is filed.

Body — send either field, or both. At least one is required.

| Field | Type | Required | Constraints |
|---|---|---|---|
| `reminderEveryDays` | integer or null | no | 2–30, or `null` to stop chasing. |
| `folderId` | uuid or null | no | A folder of yours, or `null` for the top level of its view. |

~~~bash
curl -s -X PATCH "$FDS_BASE/api/v1/envelopes/ENVELOPE_ID" \
  -H "Authorization: Bearer $FDS_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reminderEveryDays": 3 }'
~~~

Response `200`: the full envelope, with the new cadence.

**Deliberately narrow.** Only the chasing cadence and the filing can be
changed. The title, the message and the documents are what the signer was
shown and agreed to look at; changing those under them after the invitation
has gone is not an edit, it is a different agreement.

**The status rule applies to reminders only.** Filing is housekeeping and
stays available for the whole life of an agreement — a completed one is
exactly the kind you want to put away — so `folderId` alone is accepted at
any status. `reminderEveryDays` is refused once nobody is waiting.

To file many at once, use `POST /api/v1/folders/{id}/items` (section 5.22)
instead; this endpoint is the one-at-a-time form and is also the only way to
**un**file, with `{"folderId": null}`.

| Status | Code | Cause |
|---|---|---|
| 400 | `invalid_request` | Neither field sent, or `reminderEveryDays` is not a whole number 2–30 or null. |
| 404 | `not_found` | No such agreement, or no such folder. |
| 409 | `not_editable` | `reminderEveryDays` was sent and the agreement is not `draft`, `sent` or `viewed` — nobody is waiting to be reminded. |

### 5.8 `POST /api/v1/envelopes/{id}/void`

**Scope:** `envelopes:write` — Withdraw an agreement that is still out.

Body: `{ "reason": string }` — 1–500 characters, **required**. It goes on the
permanent record, which is why there is no way to omit it.

~~~bash
curl -s -X POST "$FDS_BASE/api/v1/envelopes/ENVELOPE_ID/void" \
  -H "Authorization: Bearer $FDS_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Duplicate send; withdrawing the second." }'
~~~

Posting with **no body at all** returns `400 invalid_request` naming the
missing reason — send the body above. This is the call to reach for when two
sends race and the loser has to be withdrawn before a signer acts on it, so
it is worth wiring up before you need it.

Response `200`: the envelope, now `voided`. Safe to retry: a second call
returns `409 already_voided` rather than doing anything.

| Status | Code | Cause |
|---|---|---|
| 400 | `invalid_request` | Missing or empty `reason`. |
| 404 | `not_found` | No such agreement. |
| 409 | `already_completed` | Fully executed agreements cannot be voided. |
| 409 | `already_voided` | Already withdrawn. |

### 5.9 `GET /api/v1/envelopes/{id}/document`

**Scope:** `envelopes:read` — The executed PDF.

Returns the raw PDF, not JSON:

- `Content-Type: application/pdf`
- `Content-Disposition: attachment; filename="..."`
- `X-Document-Sha256` — the SHA-256 of the file, when recorded
- `Cache-Control: private, no-store`

**Fetching this writes a `document_downloaded` row into the audit trail.** Do
not poll it. Fetch once, when `executedDocument.available` is true.

| Status | Code | Cause |
|---|---|---|
| 404 | `not_found` | No such agreement. |
| 409 | `not_executed` | Not every party has signed yet. |

### 5.10 `POST /api/v1/envelopes/{id}/views`

**Scope:** `envelopes:write` — Mint a short-lived signing URL for embedded
signing.

Body — supply **one** of `recipientId` or `clientUserId`:

| Field | Type | Constraints |
|---|---|---|
| `recipientId` | uuid | Our id for the recipient. |
| `clientUserId` | string | 1–200 chars. Your own id, as sent at creation. |
| `returnUrl` | url | ≤ 2000 chars. Where to send the browser after signing. |
| `expiresInMinutes` | integer | 1–60. Defaults to **15**. |
| `embedOrigin` | string | **Required to put this in an `iframe`.** The bare origin of the page that will frame it. |

Response `201`:

~~~json
{ "url": "https://freedocusignature.com/sign/...", "recipientId": "5b2d...", "expiresAt": "2026-09-02T10:15:00.000Z" }
~~~

This URL is deliberately not the recipient's emailed token. That one must
survive weeks in an inbox; this one lives in a page you control, so it expires
in minutes. Mint a fresh one each time somebody opens your signing screen.

#### Framing it in your own page

**A signing URL cannot be framed unless you say who may frame it.** Every page
here refuses framing by default, because the thing a stranger would most like
a victim to click is a signature button. Pass `embedOrigin` and the URL comes
back carrying a signature that permits exactly that one origin:

~~~bash
curl -s -X POST "$FDS_BASE/api/v1/envelopes/ENVELOPE_ID/views" \
  -H "Authorization: Bearer $FDS_KEY" -H "Content-Type: application/json" \
  -d '{ "recipientId": "5b2d…", "embedOrigin": "https://app.yourdomain.com" }'
~~~

~~~json
{ "url": "https://freedocusignature.com/sign/…?embed_origin=…&embed_sig=…",
  "recipientId": "5b2d…", "expiresAt": "…",
  "embedOrigin": "https://app.yourdomain.com" }
~~~

Put that URL in the `src` of your iframe **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.

Rules for `embedOrigin`:

- A bare origin — scheme, host, optional port. No path, no query, no
  wildcards. `https://app.yourdomain.com`, not
  `https://app.yourdomain.com/sign`.
- `https` only, except `http://localhost` so you can develop.
- It must match the origin of the framing page exactly, as the browser sees
  it. A different subdomain is a different origin.
- Omit it and the URL still works perfectly — it simply has to be opened in
  its own tab or window.

If the frame stays blank, open your browser console: a
`frame-ancestors`/`X-Frame-Options` refusal there means the origin you sent
does not match the page doing the framing.

| Status | Code | Cause |
|---|---|---|
| 400 | `invalid_request` | Neither `recipientId` nor `clientUserId` supplied. |
| 400 | `invalid_embed_origin` | `embedOrigin` is not a bare https origin. |
| 404 | `not_found` | No such agreement. |
| 404 | `recipient_not_found` | No such recipient on this agreement. |
| 409 | `not_signable` | The agreement is not `sent` or `viewed`. |
| 409 | `already_signed` / `already_declined` | That recipient is finished. |

### 5.11 `GET /api/v1/envelopes/{id}/recipients/{recipientId}`

**Scope:** `envelopes:read` — One recipient.

Response `200`: a recipient object, identical in shape to an entry in an
envelope's `recipients` array.

| Status | Code | Cause |
|---|---|---|
| 404 | `not_found` | No such recipient on that agreement. |

### 5.12 `PUT /api/v1/envelopes/{id}/recipients/{recipientId}`

**Scope:** `envelopes:write` — Correct a recipient's name or address before
they sign.

Body — at least one of:

| Field | Type | Constraints |
|---|---|---|
| `name` | string | 1–120 characters. |
| `email` | string | 3–200 characters, must look like an address. |

**Changing the address retires the old link and issues a new one, in the same
call.** The first link went to whoever holds the wrong address, and leaving it
alive would let a stranger sign. Correction and re-invitation are one act.
Changing only the name leaves the existing link working.

Response `200`:

~~~json
{
  "recipientId": "5b2d...",
  "name": "Ada Lovelace",
  "email": "ada@acme.example",
  "previousLinkRetired": true,
  "delivered": true,
  "testMode": false,
  "message": "Address corrected and a fresh link sent. The previous one no longer works."
}
~~~

**Check `delivered`.** If the address changed and delivery failed, the old link
is already dead and the recipient has no working link at all. Fix the address
and call again.

| Status | Code | Cause |
|---|---|---|
| 400 | `invalid_request` | Neither field supplied. |
| 400 | `recipient_email_invalid` | The address is malformed. |
| 400 | `recipient_incomplete` | `name` was supplied but empty. |
| 404 | `not_found` | No such recipient on that agreement. |
| 409 | `not_correctable` | Already signed or declined, or the agreement is closed. |

### 5.13 `POST /api/v1/envelopes/{id}/recipients/{recipientId}/resend`

**Scope:** `envelopes:write` — Send a recipient's invitation again. No body.

This mints a **new** token. Only the digest is stored, so the original link
cannot be reproduced — and a resend retires whatever was in the first email,
including any copy that was forwarded on.

Response `200`:

~~~json
{ "recipientId": "5b2d...", "email": "ada@acme.example", "delivered": true, "testMode": false, "message": "A fresh link is on its way. The previous one no longer works." }
~~~

| Status | Code | Cause |
|---|---|---|
| 404 | `not_found` | No such recipient on that agreement. |
| 409 | `not_resendable` | Already signed or declined, or the agreement is closed. |
| 502 | `delivery_failed` | The email bounced. **The old link is already retired**, so fix the address and retry. |

### 5.14 `POST /api/v1/envelopes/{id}/remind`

**Scope:** `envelopes:write` — Chase everyone outstanding, now. No body.

**This only works while the agreement has a reminder cadence set.** Reminders
are off by default over the API, so one created without `reminderEveryDays`
returns `409 reminders_disabled` here. That is recoverable: `PATCH` the
agreement with a cadence (section 5.7) and call this again.

The scheduler already reminds on the sender's cadence. This is for when that
cadence cannot serve — a deadline moved, the deal closes today. It skips the
scheduler's minimum-gap rule but keeps every rule that protects the recipient:
never past a closed window, never beyond the sender's maximum, never somebody
who has signed or declined. The counters advance from the same budget, so this
cannot be looped to defeat the cadence.

Response `200`:

~~~json
{
  "envelopeId": "9c1f...",
  "reminded": 1,
  "recipients": ["ada@acme.example"],
  "skipped": [
    { "recipientId": "7e3a...", "email": "alan@ourcompany.example", "reason": "Already signed." }
  ],
  "testMode": false
}
~~~

**With a test key, `reminded` is always 0.** Every recipient lands in
`skipped` reading "Test key: nothing was emailed, and no reminder was spent."
Nothing is consumed and no link is rotated, so you can exercise this endpoint
as often as you like without using up an agreement's real reminder budget.

| Status | Code | Cause |
|---|---|---|
| 404 | `not_found` | No such agreement. |
| 409 | `not_remindable` | The agreement is not `sent` or `viewed`. |
| 409 | `window_closed` | The signing window has passed. |
| 409 | `reminders_disabled` | `reminderEveryDays` is null on this agreement. |

### 5.15 `GET /api/v1/envelopes/{id}/audit`

**Scope:** `envelopes:read` — The evidence trail, newest first.

| Name | Type | Default | Notes |
|---|---|---|---|
| `limit` | integer | 200 | Clamped to 1–500. |
| `offset` | integer | 0 | Clamped to ≥ 0. |

~~~json
{
  "envelopeId": "9c1f...",
  "events": [
    {
      "id": "ev_...",
      "event": "signer_signed",
      "occurredAt": "2026-09-02T11:04:00.000Z",
      "ipAddress": "203.0.113.9",
      "userAgent": "Mozilla/5.0 ...",
      "device": { "browser": "Chrome", "os": "macOS" },
      "clientTimezone": "Europe/London",
      "clientLocale": "en-GB",
      "recipient": { "id": "5b2d...", "name": "Ada Lovelace", "email": "ada@acme.example" },
      "documentSha256": "9f2c...",
      "meta": {}
    }
  ],
  "limit": 200,
  "offset": 0
}
~~~

With a test key, the `invitation_delivered` rows record `channel: "test"` in
`meta`. That is how you prove no email left the building, rather than taking
it on trust.

`occurredAt` is the server's clock, never the client's. `clientTimezone` and
`clientLocale` are self-reported by the browser and recorded as claims, not
facts. The log is append-only by database trigger — there is no way to write
to it, here or anywhere else.

| Status | Code | Cause |
|---|---|---|
| 404 | `not_found` | No such agreement. |

### 5.16 `GET` and `POST /api/v1/clients`

**GET — scope `clients:read`.** List counterparties, newest first.

| Name | Type | Default | Notes |
|---|---|---|---|
| `limit` | integer | 50 | Clamped to 1–100. |
| `offset` | integer | 0 | Clamped to ≥ 0. |
| `search` | string | — | Case-insensitive substring of name or email. |

Trashed clients are excluded, matching the rest of the product.

**POST — scope `clients:write`.** Create a counterparty.

| Field | Type | Required | Constraints |
|---|---|---|---|
| `name` | string | yes | 1–200 characters. Company or person. |
| `contactName` | string or null | no | ≤ 200 characters. |
| `email` | string or null | no | ≤ 200 characters, validated if present. |
| `phone` | string or null | no | ≤ 80 characters. |
| `address` | string or null | no | ≤ 1000 characters. |

Only the name is required — a counterparty often exists before anyone has an
address for them.

Response `201`: `{ id, name, contactName, email, phone, address, createdAt }`.

| Status | Code | Cause |
|---|---|---|
| 400 | `invalid_request` | Schema violation. |
| 400 | `name_required` | Name was whitespace only. |
| 400 | `email_invalid` | Malformed address. |

### 5.17 `GET /api/v1/clients/{id}`

**Scope:** `clients:read` — One counterparty, with up to 100 agreements filed
against them, newest first.

~~~json
{
  "id": "c1a2...",
  "name": "Acme Ltd",
  "contactName": "Ada Lovelace",
  "email": "ada@acme.example",
  "phone": null,
  "address": null,
  "createdAt": "2026-07-01T09:00:00.000Z",
  "agreements": [
    { "id": "9c1f...", "title": "Mutual NDA — Acme Ltd", "status": "completed", "sentAt": "...", "completedAt": "..." }
  ]
}
~~~

Agreements are summaries. Use `GET /envelopes/{id}` for the full shape. An
agreement appears here only if it was created with that `clientId`.

| Status | Code | Cause |
|---|---|---|
| 404 | `not_found` | No such client, or it is in the trash. |

### 5.18 `POST` and `GET /api/v1/bulk-sends`

**POST — scope `envelopes:write`.** One template, many agreements.

| Field | Type | Required | Constraints |
|---|---|---|---|
| `templateId` | uuid | yes | |
| `title` | string | yes | 1–200 characters. The default for every row. |
| `message` | string | no | ≤ 2000 characters. |
| `rows` | array | yes | 1–**1000** items. |
| `reminderEveryDays` | integer or null | no | 2–30. |
| `expiresInDays` | integer or null | no | 1–365. |

`rows[]`:

| Field | Type | Required | Constraints |
|---|---|---|---|
| `recipients` | array | yes | 1–25, each `{ name (1–120), email (3–200), routingOrder (1–50) }`. |
| `prefill` | object | no | `{ [label]: string }`, values ≤ 4000 chars. |
| `title` | string | no | 1–200. Overrides the batch title for this row. |

**The response is `202`, not `201`, and it means queued — not sent.**

~~~json
{
  "id": "b7d3...",
  "status": "queued",
  "queued": 250,
  "note": "Queued. Agreements go out as the scheduler works through the batch; poll this id for progress."
}
~~~

Five hundred agreements is five hundred document copies and five hundred
emails; a request that tried to do that inline would time out somewhere
unknowable. Every row is validated against the template's roles **before**
queuing, so a mapping mistake is refused immediately rather than producing a
thousand identical failures later.

Batches are drained by a scheduler that runs **every fifteen minutes**, in slices.
A few hundred agreements therefore take a few hours to go out, not seconds. Do
not build anything that expects a batch to be complete when the call returns —
poll the batch id, or wait for `envelope.sent` per agreement.

| Status | Code | Cause |
|---|---|---|
| 400 | `invalid_request` | Schema violation. |
| 400 | `title_required` / `no_rows` / `too_many_rows` | Batch validation. |
| 400 | `template_has_no_fields` | The template is not sendable. |
| 400 | `row_has_no_recipients` | A row names nobody. Message includes the row number. |
| 400 | `row_missing_role` | A row omits a role the template requires. |
| 400 | `row_missing_name` / `row_bad_email` | Row-level recipient validation. |
| 402 | `payment_required` | The account is suspended. |
| 404 | `template_not_found` | No such template in this account. |

**GET — scope `envelopes:read`.** Batches, newest first. `limit` defaults to
25, clamped 1–100; `offset` ≥ 0. Each entry has `id`, `templateId`, `title`,
`status`, `total`, `sent`, `failed`, `testMode`, `createdAt`, `completedAt`.

### 5.19 `GET /api/v1/bulk-sends/{id}`

**Scope:** `envelopes:read` — A batch's progress, row by row.

| Name | Type | Default | Notes |
|---|---|---|---|
| `limit` | integer | 200 | Clamped to 1–500. |
| `offset` | integer | 0 | Clamped to ≥ 0. |
| `status` | string | — | Only `"failed"` is recognised; it returns just the failures. |

~~~json
{
  "id": "b7d3...",
  "templateId": "3f6a...",
  "title": "Annual policy acknowledgement",
  "status": "partial",
  "total": 250,
  "sent": 246,
  "failed": 4,
  "pending": 0,
  "testMode": false,
  "createdAt": "2026-09-02T10:00:00.000Z",
  "completedAt": "2026-09-02T13:20:00.000Z",
  "rows": [
    { "position": 17, "status": "failed", "envelopeId": null, "error": "…", "recipients": ["typo@"], "completedAt": "..." }
  ],
  "limit": 200,
  "offset": 0
}
~~~

Batch `status` is `queued`, `processing`, `done`, or **`partial`** — the last
meaning it finished but not everything got out. Treat `partial` as needing
attention; `?status=failed` gives you exactly the rows to look at.

Row `recipients` lists email addresses only. Prefill values are deliberately
not echoed back — a spreadsheet's other columns may hold salaries or dates of
birth, and a status endpoint is not the place to return them.

| Status | Code | Cause |
|---|---|---|
| 404 | `not_found` | No such batch in this account. |

### 5.20 Seeing what you built

Three calls exist so placement can be checked before a human is involved.
Use them: every check that only counts fields will report twelve fields on
the right pages while every one of them sits in the wrong place.

`GET /api/v1/templates/{id}` now returns each field's **position and size**,
plus each page's dimensions in points.

**Coordinates are ratios of the page, 0 to 1, with the origin at the TOP-LEFT
and y growing downward.** Multiply by the page size to get points. PDF's own
user space originates bottom-left, so if you are converting yourself the
vertical flip is `(1 - y - height) x pageHeight`. Getting this backwards is
the single most common way a field lands in the wrong place.

~~~json
{ "type": "signature", "documentId": "…", "page": 2,
  "position": { "x": 0.1485, "y": 0.3548, "width": 0.2941, "height": 0.0198 } }
~~~

`GET /api/v1/templates/{id}/document` returns the template's PDF as
uploaded, with `?documentId=` to choose when there are several. Until now a
caller could only work from bytes it had uploaded itself, so a template
adopted from the app could never be inspected.

`GET /api/v1/templates/{id}/preview` returns that PDF **with a labelled box
drawn over every field** — the party number, the type and the label. One call
that answers "did my fields land where I meant", with no agreement sent and
nobody emailed. Reach for this first; the other two are for when you want the
numbers rather than the picture.

~~~bash
curl -s "$FDS_BASE/api/v1/templates/TEMPLATE_ID/preview" \
  -H "Authorization: Bearer $FDS_KEY" -o preview.pdf
~~~

Required fields are drawn solid, optional ones dashed. `X-Field-Count` says
how many boxes were drawn, so a caller can assert the count without parsing
the PDF at all. The boxes come from the same geometry the final stamp uses —
a preview computed a second way would be trusted and would drift.

### 5.21 `DELETE /api/v1/templates/{id}`

**Scope:** `templates:write`. Moves the template to the trash, where it is
restorable for 30 days — the same thing the button in the app does.

**Agreements already sent are unaffected.** An envelope copies its documents
and fields at send time, so it holds its own objects rather than pointing at
the template's. A trashed template also cannot be sent from any more, so a
stale id in your code fails cleanly instead of quietly resurrecting it.

Retrying returns `200` with the original `deletedAt`, not a `404`: you
asked for it gone and it is gone.

---

### 5.22 Folders

Filing, not content. Scopes: `folders:read`, `folders:write`. A key holding
only these can tidy an account and cannot read a single agreement.

**One rule explains the rest.** Each standing view — files, templates, drafts,
awaiting, executed, closed — keeps its **own** tree, and a document is filed
in at most one folder. So a folder belongs to one view for life, and an
agreement whose status carries it into another view appears at that view's top
level rather than vanishing. The root of a view holds everything belonging to
it that is not in one of *that view's* folders.

`GET /api/v1/folders?section=executed` — one view's whole tree, flattened
depth-first with parents before children and a `depth` on each so a flat list
can be indented. Omit `section` for every tree.

`POST /api/v1/folders` — `{name, section?, parentId?}`. **`parentId` is how
you make a subfolder**, and there is no separate endpoint for one. A
subfolder always belongs to its parent's view, so the parent's section
overrides whatever you send:

~~~bash
# A folder, then a subfolder inside it.
PARENT=$(curl -s -X POST https://freedocusignature.com/api/v1/folders \
  -H "Authorization: Bearer $FDS_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Q3 Contracts","section":"executed"}' | jq -r .folder.id)

curl -s -X POST https://freedocusignature.com/api/v1/folders \
  -H "Authorization: Bearer $FDS_API_KEY" -H "Content-Type: application/json" \
  -d "{"name":"October","parentId":"$PARENT"}"
~~~

`PATCH /api/v1/folders/{id}` — `{name?, parentId?}`, at least one. Renames
and moves. `parentId: null` moves it to the top of its own view. Refused with
`400` when it would cross views (`folder_cross_section`) or land inside its
own subtree (`folder_into_descendant`, `folder_into_itself`) — the second
would leave a branch reachable from nothing.

`DELETE /api/v1/folders/{id}` — to the trash, restorable for thirty days.
Subfolders go with it. **Documents are never destroyed by this**, only
detached, and only when the trash is finally purged.

`POST /api/v1/folders/{id}/items` — file documents in bulk:

~~~json
{ "envelopeIds": ["…", "…"], "templateIds": ["…"] }
~~~

Up to 100 of each. **One bad id does not reject the batch.** Everything that
can be filed is, and the rest come back with a reason each:

~~~json
{
  "folderId": "…",
  "filed":  [{ "type": "envelope", "id": "…" }],
  "failed": [{ "type": "envelope", "id": "not-a-uuid",
               "code": "invalid_id", "message": "That is not an id." }]
}
~~~

**To unfile**, set the folder to null on the document itself —
`PATCH /api/v1/envelopes/{id}` or `PATCH /api/v1/templates/{id}` with
`{"folderId": null}`. Both also accept a folder id, which is the one-at-a-time
form of the bulk endpoint.

**To list a folder's contents**, filter the ordinary listings:
`GET /api/v1/envelopes?folderId=…` and `GET /api/v1/templates?folderId=…`,
with `folderId=root` for everything unfiled. That way paging works exactly as
it does everywhere else.

Documents move between views freely; folders do not. That asymmetry is
deliberate: moving one agreement is a filing decision, while dragging a folder
across views would move its whole subtree to a shelf where nothing in it
belongs.

---

### 5.23 `GET /api/v1/openapi.json`

**No authentication.** The machine-readable spec, cacheable. Use it to generate
a typed client. Where it and this guide disagree, the handlers are the truth —
report the drift.

---

## 6. Webhooks

Deliveries go out as the event happens, usually within a second or two. The
delivery is written down first and the request that recorded the signature
never waits for it — so a slow endpoint of yours stays your problem rather
than becoming a slow signing page for the person in front of it, and an outage
on your side is a retry rather than a failed execution. Retries widen
over roughly a day; an endpoint that stays dead is switched off and says so in
the dashboard.

Design for this. An agreement can be `completed` in the API before you have
been told about it, so anything time-critical should read the envelope rather
than wait.

### The seven events

| Event | Fires when |
|---|---|
| `envelope.sent` | An agreement has gone out to its recipients. |
| `envelope.viewed` | The **first** recipient opened it. Fires on the status change only — a reload does not re-fire it. |
| `envelope.signer_signed` | One party signed. Carries `allPartiesSigned`. |
| `envelope.signer_declined` | A party declined, with their reason. |
| `envelope.completed` | Everyone signed. The executed PDF now exists. |
| `envelope.voided` | The sender withdrew it. |
| `envelope.expired` | The signing window closed unsigned. |

### The envelope every delivery shares

~~~json
{
  "event": "envelope.completed",
  "occurredAt": "2026-09-02T11:04:00.000Z",
  "accountId": "4e4d...",
  "data": { }
}
~~~

`occurredAt` is stamped when the thing happened, not when the delivery was
attempted — a retry three hours later still reports the real time.

### `data` per event

**`envelope.sent`** — `envelopeId`, `title`, `templateId` (null on the
documents path), `sentAt`, `expiresAt`, and `signers[]` of
`{ id, name, email, routingOrder, clientUserId }`.

**`envelope.viewed`** — `envelopeId`, `title`, `signerId`, `signerName`,
`signerEmail`, `viewedAt`.

**`envelope.signer_signed`** — `envelopeId`, `title`, `signerId`,
`signerName`, `signerEmail`, `signedAt`, `allPartiesSigned` (boolean).

**`envelope.signer_declined`** — `envelopeId`, `title`, `signerId`,
`signerName`, `signerEmail`, `reason`, `declinedAt`.

**`envelope.completed`** — `envelopeId`, `title`, `completedAt`, and
`signers[]` of `{ id, name, email, status }`.

**`envelope.voided`** — `envelopeId`, `title`, `reason`, `voidedAt`.

**`envelope.expired`** — `envelopeId`, `title`, `expiredAt`.

Note that `data` is **not** a full envelope object. When you need the whole
thing, call `GET /envelopes/{id}` on receipt.

### Headers

| Header | Meaning |
|---|---|
| `x-freedocusignature-signature` | Base64 HMAC-SHA256. |
| `x-freedocusignature-timestamp` | Unix seconds, signed alongside the body. |
| `x-freedocusignature-event` | The event name. |
| `x-freedocusignature-delivery` | Delivery id. **Stable across retries — deduplicate on this.** |

### The signing scheme

The signature is over `timestamp + "." + rawBody`, keyed with the endpoint's
secret, base64-encoded. Signing the body alone would be replayable for ever;
binding the timestamp lets you reject anything stale. Reject deliveries older
than about five minutes.

### Node

~~~js
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();

// Raw body. Verification must happen on the exact bytes that were signed.
app.post("/webhooks/fds", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const ts = Number(req.get("x-freedocusignature-timestamp"));
  const sent = req.get("x-freedocusignature-signature") ?? "";

  if (!Number.isFinite(ts)) return res.sendStatus(401);
  if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(401);

  const expected = createHmac("sha256", process.env.FDS_WEBHOOK_SECRET)
    .update(ts + "." + raw)
    .digest("base64");

  const a = Buffer.from(expected);
  const b = Buffer.from(sent);
  if (a.length !== b.length || !timingSafeEqual(a, b)) return res.sendStatus(401);

  // Only now is it safe to parse.
  const event = JSON.parse(raw);

  // Answer immediately; do the work afterwards.
  res.sendStatus(200);
  void handle(event, req.get("x-freedocusignature-delivery"));
});
~~~

### Python

~~~python
import hashlib, hmac, base64, time, json
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["FDS_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/fds")
def receive():
    raw = request.get_data()                      # bytes, unparsed
    ts = request.headers.get("x-freedocusignature-timestamp", "")
    sent = request.headers.get("x-freedocusignature-signature", "")

    try:
        ts_int = int(ts)
    except ValueError:
        abort(401)
    if abs(time.time() - ts_int) > 300:
        abort(401)

    signed = ts.encode() + b"." + raw
    expected = base64.b64encode(hmac.new(SECRET, signed, hashlib.sha256).digest())

    if not hmac.compare_digest(expected, sent.encode()):
        abort(401)

    event = json.loads(raw)
    delivery_id = request.headers.get("x-freedocusignature-delivery")
    enqueue(event, delivery_id)                   # do the work off the request
    return "", 200
~~~

### Two mistakes to avoid

1. **Do not parse the body before verifying.** Frameworks that JSON-parse for
   you re-serialise differently — key order, whitespace, number formatting —
   and the signature will not match. Capture the raw bytes.
2. **Do not compare with `==`.** String equality returns early on the first
   differing byte, which leaks the signature one character at a time. Use
   `timingSafeEqual` or `hmac.compare_digest`.

### Answering

Return 2xx quickly. Anything else counts as a failure and is retried. Do the
real work asynchronously — a slow endpoint is treated as a failing one, and the
delivery times out after ten seconds.

---

## 7. Recipes

### Send from a template

Read the template (5.2), map each `routingOrder` to a recipient, prefill by
label, POST. This is the common case and the one to reach for first.

### Send a raw PDF with anchor-placed fields

Put markers in the document where fields belong — `/sig1/`, `/date1/` — then:

~~~json
{
  "documents": [{ "name": "offer.pdf", "base64": "JVBERi0xLjcK..." }],
  "title": "Offer of employment",
  "recipients": [{
    "name": "Ada Lovelace",
    "email": "ada@acme.example",
    "routingOrder": 1,
    "tabs": [
      { "type": "signature", "anchorString": "/sig1/" },
      { "type": "date", "anchorString": "/sig1/", "anchorXOffset": 200 },
      { "type": "initials", "anchorString": "/ini/", "occurrence": "all" },
      { "type": "text", "label": "Job title", "anchorString": "/title/", "value": "Engineer" }
    ]
  }]
}
~~~

`occurrence: "all"` is how initials land on every page from one instruction. A
missing anchor **refuses the request** rather than sending an agreement short a
signature box, so mark your template reliably.

On this path there is no `prefill`; use `value` on the tab.

### Embedded signing inside your own app

1. Create the envelope with `clientUserId` on the recipient. No email is sent.
2. When your user reaches your signing screen, `POST /envelopes/{id}/views`
   with that `clientUserId`, a `returnUrl`, and — **if you are putting it in
   an iframe** — `embedOrigin` set to the origin of the page doing the
   framing.
3. Use the returned `url` **unchanged**: redirect to it, or set it as the
   iframe `src`. The framing permission is signed into it, so altering the
   URL voids it and the frame comes back blank.
4. Mint a fresh URL each time. They expire in 15 minutes by default, 60 maximum.
5. Do not treat the `returnUrl` landing as proof of signing — confirm from the
   `envelope.signer_signed` webhook or by reading the envelope.

A blank iframe with a `frame-ancestors` message in the browser console means
one thing: the `embedOrigin` you sent is not the origin of the framing page.
Compare them exactly — a different subdomain is a different origin.

### Check field placement before anyone sees it

Do this once when you build a template, and again whenever you change the PDF.
It is much cheaper than the alternative, which is finding out from the person
you asked to sign.

1. `GET /templates/{id}/preview` and open the PDF. Every field is drawn as a
   labelled box — party number, type, label — so a box over the wrong line is
   obvious at a glance. Required fields are solid, optional dashed.
2. If you want to assert it in a test rather than look at it, read
   `X-Field-Count` for the number of boxes, and `GET /templates/{id}` for
   each field's `position`.
3. Remember the origin: positions are ratios of the page from the **top-left**,
   y growing downward. To compare against a PDF viewer's own coordinates, which
   start bottom-left, the vertical is `(1 - y - height) x pageHeight`.

Counting fields is not verification. A template can report the right number of
fields on the right pages with every one of them in the wrong place, which is
exactly the failure this endpoint exists to catch.

### Bulk send from a spreadsheet

Parse the file yourself, map columns to `recipients` and `prefill`, and POST up
to 1000 rows. You get `202` and an id — queued, not sent. The scheduler works
through it every fifteen minutes, so budget hours for a large batch. Poll
`GET /bulk-sends/{id}` and act on `status`; when it is `partial`, fetch
`?status=failed` for exactly the rows that need attention.

### Webhook or poll

Prefer webhooks. Poll only as a safety net: a low-frequency sweep of
`GET /envelopes?status=sent,viewed` to catch anything a delivery missed. Do not
poll individual envelopes in a tight loop — you will hit the rate limit and
learn nothing sooner.

### Fetch the executed PDF and verify it

~~~bash
curl -s -D headers.txt \
  "$FDS_BASE/api/v1/envelopes/ENVELOPE_ID/document" \
  -H "Authorization: Bearer $FDS_KEY" \
  -o executed.pdf

grep -i x-document-sha256 headers.txt
shasum -a 256 executed.pdf
~~~

The two hashes must match, and the same value is printed on every page of the
document. That is what lets a copy filed elsewhere — printed, forwarded,
archived — be checked against the original record years later.

---

## 8. Limits and operational notes

**Rate limit.** 600 requests per minute, per key, in a fixed window. Exceeding
it returns `429` with `Retry-After` in seconds. Honour it; do not retry
immediately.

**Document size.** 4 MB per document, decoded, and at most 10 documents. The
ceiling exists because the whole request must fit in one body and base64
inflates a file by a third. Larger files go through the web app.

**Pagination.** Offset-based throughout: `limit` and `offset`. Defaults and
maximums differ per endpoint and are listed in section 5. Responses echo both
back. There is no total count — page until you get fewer rows than `limit`.

**Idempotency.** Send an `Idempotency-Key` header on anything that changes
something, and a retry becomes safe:

~~~bash
curl -X POST https://freedocusignature.com/api/v1/envelopes \
  -H "Authorization: Bearer $FDS_API_KEY" \
  -H "Idempotency-Key: 4d1f9c2a-order-8891" \
  -H "Content-Type: application/json" -d @envelope.json
~~~

Use any string up to 255 characters that is unique to the attempt.
**Generate it 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 at all.

**The trap is at the other extreme, and it is worse.** A key derived only
from stable business facts — the customer, the template, the version — is not
unique to an attempt, it is permanent. Consider a signer who *declines*. You
send a corrected agreement; it derives the same key; we replay the dead one;
your customer is handed back the agreement they already declined, and no
retry can ever get past it. The account is locked out silently and looks like
our bug.

If you derive a key rather than storing a random one, **include something
that changes on a deliberate re-send** — an attempt or generation counter you
increment yourself:

~~~text
firm-8891:template-14:send-1     first attempt, and every retry of it
firm-8891:template-14:send-2     the corrected agreement after a decline
~~~

The rule in one line: a retry reuses the key, a genuinely new agreement gets
a new one. If you cannot say what would make the key change, store a UUID
against your own record instead.

What happens on the second call:

| Situation | Response |
|---|---|
| First attempt succeeded (or failed on our side, `5xx`) | The stored response, replayed verbatim with the original status and an `Idempotent-Replay: true` header |
| First attempt was refused (`4xx`) | Nothing is stored, so the retry runs again from scratch. A refusal changed nothing, and fixing its cause — restoring a template, say — must not stay hidden behind a replay for a day |
| First attempt still running | `409 idempotency_in_progress` with `Retry-After`. Retry, keeping the key |
| Same key, different body | `422 idempotency_key_reused`. This is a bug in the caller: a new request needs a new key |
| First attempt crashed | After 90 seconds the key is released and the retry does the work |

Keys are scoped to your account and kept for **24 hours**. A retry after that
is an ordinary new request, so anything longer needs your own check with
`GET /envelopes` first.

Without a key, nothing has changed: a retried `POST /envelopes` creates a
second agreement and emails people twice. Voiding is safe to retry either
way — the second call returns `409 already_voided`.

**Retrying.** Retry `429` after `Retry-After`, and `500` with backoff, always
carrying the same `Idempotency-Key`. Do not retry `4xx` other than `429` and
`409 idempotency_in_progress` — the request will not become valid. Treat
`502` from resend as "the old link is already dead", not as "nothing
happened".

**If our end is down.** A `5xx` or a timeout is exactly the case the key
exists for: retry with the same key until you get an answer that is not a
`5xx`, with backoff. Signing is protected separately and needs nothing from
you — a signer who submits twice because a page hung produces one signature
and one audit event, because the database write is conditional on the signer
not already being signed.

**Timestamps.** Every timestamp is ISO 8601 UTC, or null.

**Tenancy.** An id belonging to another account is indistinguishable from one
that never existed: both return `404`.

**Missing fields, not malformed JSON.** An absent request body is read as
`{}`, so an endpoint that needs a field answers by naming it rather than
complaining about JSON. Posting to `/void` with no body returns
`400 invalid_request` — "A reason is required to void an agreement." — not
`invalid_json`. If you do get `invalid_json`, the body really is malformed.

**Telling a permission problem from a rejected key.** A `403` with code
`insufficient_scope` also carries the exact permission in
`error.scope`, so you can route it without parsing a sentence:

~~~json
{ "error": { "code": "insufficient_scope",
             "message": "This key does not have the "folders:write" scope.",
             "scope": "folders:write" } }
~~~

This matters more than it looks. A key that cannot file documents is a
tidying problem; a key that is refused outright (`401 invalid_key`) means
your credential has stopped working and agreements cannot be sent. Alert on
those separately, or a folder permission you never granted will page someone
saying the signing service is rejecting you.

**Request logs.** Every authenticated call is recorded — method, route, query,
status, duration, source address and client — and is visible to the account
owner under Settings → Developer, filterable to one key, kept for 30 days.
Opening an entry shows the request body and the response body, which is
usually the fastest way to find out which field a `400` was about. Three
things never reach it: a document's base64 (it is megabytes, and the file is
already in storage), anything shaped like an API key or webhook secret, and
any body over 64 KB, which is noted rather than stored. Bodies are capped at
4,000 characters and the entry says when it truncated one. Use it when an
integration behaves differently from what you expect: it will tell you whether
a call arrived at all, and which key made it. Calls rejected for a bad or
revoked key are **not** listed, because there is no key to attribute them to —
so a request you cannot find in the log never authenticated. Routes are
recorded as patterns (`/api/v1/envelopes/[id]/void`); agreement ids are not
kept there.

---

## 9. What a human has to do

Everything else in this guide you can do yourself, **including uploading
documents and placing fields** — `POST /api/v1/templates` (section 5.3) builds
a template without the editor, provided the PDF carries anchor text or you have
coordinates.

Three things need a person with a browser. That is the whole list.

- [ ] **Create the account** at `https://freedocusignature.com/signup` and
      confirm the email address.
- [ ] **Create an API key** in Settings → Developer. Choose **Test** while
      building. Tick only the scopes needed. **It is shown once**; if it is
      lost, revoke it and make another. Permissions can be changed later from
      the same screen without the key changing, so a missing scope does not
      mean redeploying a secret.
- [ ] **Register the webhook endpoint** in Settings → Developer, with the HTTPS
      URL you built in step 6, and copy the signing secret (`whsec_...`).
- [ ] **Hand over exactly two values**, as environment variables, never in
      source:

~~~bash
FDS_API_KEY=fds_test_...        # or fds_live_... when going live
FDS_WEBHOOK_SECRET=whsec_...
~~~

- [ ] **If you are embedding signing in your own page:** nothing to configure,
      but the origin doing the framing has to be named on each
      `POST /envelopes/{id}/views` call as `embedOrigin` (section 5.10).
      Signing pages refuse framing otherwise, and the symptom is a blank
      iframe rather than an error you can catch.

- [ ] **When going live:** create a second key with the **Live** prefix, swap
      `FDS_API_KEY`, and send one real agreement to an internal address before
      pointing real customers at it. Nothing else changes.

The account owner is the only role that can create keys or webhook endpoints. A
key can send agreements in the company's name, which is why it stays with the
person who owns the account.
