Guide
Contracts and e-sign
A contract here is a document with two named parties, a set of placed fields, two signing links, and an audit trail that records who did what from which address. When both parties have signed it freezes, and a PDF is rendered with the signatures, a certificate of completion and that audit trail bound into it.
This guide runs the whole lifecycle: draft, place anchors, send, sign as both parties, and download the executed PDF. Every step below was executed, including the guard rails — the validation failures shown are real 400s, not invented examples.
Draft the contract
/contracts201 CreatedParty A is you; party B is the counterparty. Both need a name and an email before the document can be sent.
{
"title": "Docs walkthrough - mutual NDA",
"content_html": "<h1>Mutual Non-Disclosure Agreement</h1><p>This Agreement is entered into between <b>{{party_a_name}}</b> and <b>{{party_b_name}}</b>.</p><p>1. Each party may disclose confidential information to the other.</p><p>2. Neither party will disclose it onward for three years.</p>",
"party_a_name": "Sam Rivera",
"party_a_email": "[email protected]",
"party_b_name": "Sam Rivera (counterparty mailbox)",
"party_b_email": "[email protected]"
}curl -s -X POST "$API/contracts" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
--data-binary @contract.json{
"id": "ad6b5cd2-1f14-45ff-a2df-bff0e0dca863",
"title": "Docs walkthrough - mutual NDA",
"status": "draft",
"party_a_name": "Sam Rivera",
"party_a_email": "[email protected]",
"party_b_name": "Sam Rivera (counterparty mailbox)",
"party_b_email": "[email protected]",
"signed_a_at": null,
"signed_b_at": null,
"sent_at": null,
"completed_at": null,
"content_sha256": null,
"fields": null,
"token_expires_at": null,
"sign_url_a": null,
"sign_url_b": null
}sign_url_a and sign_url_bstay null until the document is sent — the signing tokens are minted at send time, not at draft time.To bring an existing document instead, POST /contracts/upload accepts PDF, DOCX, TXT, MD or HTML up to 10 MB and extracts the content into an editable document that rides the same flow.
Place signature and field anchors
Fields are positioned in percent of the page, not pixels, so the same layout renders correctly at any output size. Each field is assigned to party a or b, and only that party sees and fills it.
/contracts/{contract_id}| Key | Type | Notes |
|---|---|---|
id | string | Unique within the document. Duplicates are rejected. |
type | string | One of signature, initials, date, name, email, text, checkbox |
party | string | Exactly a or b |
page | integer | Zero-based; must be inside pages |
x, y, w, h | number | 0–100, percent of the page. x+w and y+h must stay on the sheet. |
required | boolean | A real boolean — the string "false" is truthy and would block the signature |
{
"fields": {
"pages": 1,
"fields": [
{ "id": "sig-a", "type": "signature", "party": "a", "page": 0, "x": 10, "y": 70, "w": 30, "h": 8, "required": true },
{ "id": "date-a", "type": "date", "party": "a", "page": 0, "x": 10, "y": 80, "w": 20, "h": 5, "required": true },
{ "id": "sig-b", "type": "signature", "party": "b", "page": 0, "x": 55, "y": 70, "w": 30, "h": 8, "required": true },
{ "id": "date-b", "type": "date", "party": "b", "page": 0, "x": 55, "y": 80, "w": 20, "h": 5, "required": true },
{ "id": "title-b", "type": "text", "party": "b", "page": 0, "x": 55, "y": 87, "w": 30, "h": 5, "required": true },
{ "id": "ack-b", "type": "checkbox", "party": "b", "page": 0, "x": 55, "y": 94, "w": 4, "h": 4, "required": true }
]
}
}Geometry is validated. A field that runs off the page is refused:
curl -s -X PATCH "$API/contracts/ad6b5cd2-…" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"fields":{"pages":1,"fields":[
{"id":"bad","type":"signature","party":"a","page":0,
"x":90,"y":10,"w":30,"h":8,"required":true}]}}'
{"detail":"fields[0] extends past the edge of page 1"}
HTTP:400Send for signature
/contracts/{contract_id}/sendcurl -s -X POST "$API/contracts/ad6b5cd2-…/send" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'{
"success": true,
"sent_party_b": true,
"sent_party_a": true,
"reminder": false,
"data": {
"status": "sent",
"sent_at": "2026-08-06T18:34:25.646285+00:00",
"token_expires_at": "2026-09-05T18:34:25.646285+00:00",
"sign_url_a": "http://localhost:3020/sign/3dd0985130c9a73f87aac25bb02b09f094ad57a8",
"sign_url_b": "http://localhost:3020/sign/1b0375b8e31fb94def116b6670b317a947a52788"
}
}Re-sending chases only the party who still owes a signature — reminder flips to true and the already-signed party is not emailed again.
The signing experience
The public signing view takes no credential; the token is the credential. Opening it records a viewed audit event for that party, once.
/contracts/sign/{token}no auth{
"success": true,
"party": "b",
"mode": "sign",
"can_sign": true,
"party_name": "Sam Rivera (counterparty mailbox)",
"party_email": "[email protected]",
"already_signed": false,
"other_signed": false,
"envelope_id": "ad6b5cd2-1f14-45ff-a2df-bff0e0dca863",
"consent_text": "I agree that the signature and initials will be my electronic representation of my signature and initials for all purposes on this document — with the same legal force as a pen-and-paper signature, and I consent to use electronic records and signatures.",
"doc": { "…": "title, content_html, both parties' signature state, and the placed fields" },
"audit": [ "…" ]
}The response carries only the fields for this party, so a client can render the form without filtering. Two guards must be satisfied before a signature is accepted; both were exercised.
Consent is mandatory
curl -s -X POST "$API/contracts/sign/1b0375b8…" \
-H 'Content-Type: application/json' \
-d '{"name":"Sam Rivera","signature":"Sam Rivera","consent":false}'
{"detail":"You must agree to use electronic records and signatures in order to sign"}
HTTP:400Required fields are enforced by field
curl -s -X POST "$API/contracts/sign/1b0375b8…" \
-H 'Content-Type: application/json' \
-d '{"name":"Sam Rivera","signature":"Sam Rivera","consent":true,"field_values":{}}'
{"detail":{"message":"Required field 'Text' on page 1 is not completed",
"field_id":"title-b","field_type":"text","page":0}}
HTTP:400Signature and date fields for the signing party are auto-filled from the signature itself; only text and checkbox fields need values.
Signing, for real
/contracts/sign/{token}row-lockedcurl -s -X POST "$API/contracts/sign/1b0375b8…" \
-H 'Content-Type: application/json' \
-d '{"name":"Sam Rivera","signature":"Sam Rivera","style":"cursive",
"initials":"JW","consent":true,
"field_values":{"title-b":"Director","ack-b":"true"}}'
{"success":true,"status":"partially_signed","both_signed":false}{"detail":"You already signed this contract"}
HTTP:400curl -s -X POST "$API/contracts/sign/3dd09851…" \
-H 'Content-Type: application/json' \
-d '{"name":"Sam Rivera","signature":"Sam Rivera","style":"cursive","consent":true}'
{"success":true,"status":"completed","both_signed":true}| Signature input | Field | Notes |
|---|---|---|
| Typed | signature + style | style selects the rendered typeface, e.g. cursive |
| Drawn | image_data | Data URI, up to 300 KB. Recorded in the audit trail as drawn: true |
| Initials | initials | Up to 20 characters, used for initials fields |
A counterparty can also refuse: POST /contracts/sign/{token}/decline takes a required reason and moves the document to declined.
The audit trail
This is the real, complete trail for the contract above — every row generated by the commands on this page. Note the user agent: these events were recorded from curl, because that is what actually made the requests.
/contracts/{contract_id}/audit2026-08-06 18:34:01 UTC | - | created | 127.0.0.1 | {"by": "sam.rivera", "title": "Docs walkthrough - mutual NDA"}
2026-08-06 18:34:25 UTC | - | edited | 127.0.0.1 | {"by": "sam.rivera", "fields": 6}
2026-08-06 18:34:31 UTC | - | sent | 127.0.0.1 | {"by": "sam.rivera", "to_party_a": true, "to_party_b": true,
"expires_at": "2026-09-05T18:34:25.646285+00:00"}
2026-08-06 18:34:52 UTC | b | viewed | 127.0.0.1 | {"mode": "sign", "status": "sent"}
2026-08-06 18:35:27 UTC | b | consented | 127.0.0.1 | {"explicit": true, "text": "I agree that the signature and initials…"}
2026-08-06 18:35:27 UTC | b | signed | 127.0.0.1 | {"invited_as": "Sam Rivera (counterparty mailbox)",
"signed_as": "Sam Rivera", "style": "cursive", "drawn": false, …}
2026-08-06 18:35:36 UTC | a | consented | 127.0.0.1 | {"explicit": true, "text": "I agree that the signature and initials…"}
2026-08-06 18:35:36 UTC | a | signed | 127.0.0.1 | {"invited_as": "Sam Rivera", "signed_as": "Sam Rivera",
"style": "cursive", "drawn": false, "content_sha256": …}
2026-08-06 18:35:36 UTC | - | completed | 127.0.0.1 | {"content_sha256": "a03e0568292f216e314ba080b336ecd7166af858f80cf112c33aa3176be214cf"}
2026-08-06 18:35:53 UTC | - | pdf_downloaded | 127.0.0.1 | {"by": "sam.rivera", "bytes": 10485}invited_asversussigned_asis deliberate: it records that the person invited as one name typed another, rather than quietly overwriting the invitation.content_sha256is captured at each signature and again at completion. It is the hash of the document text that was signed, which is what makes a later “that is not what I agreed to” answerable.- Even
pdf_downloadedis recorded, with the byte count.
The executed PDF
Rendered by the tenant node’s Go e-sign engine: the document, the signature frames, a certificate of completion and the audit trail.
/contracts/{contract_id}/pdfthe sender's copycurl -s -D - -o executed.pdf "$API/contracts/ad6b5cd2-…/pdf" \
-H "Authorization: Bearer $TOKEN"
HTTP/1.1 200 OK
content-disposition: attachment; filename="Docs walkthrough - mutual NDA.pdf"
content-length: 10485
content-type: application/pdf
$ head -c 8 executed.pdf
%PDF-1.4Status lifecycle
| Status | Reached by | Editable? |
|---|---|---|
draft | Creation or upload | Yes |
sent | POST /send | Only until the first signature |
partially_signed | One party signed | No — parties can no longer be changed |
completed | Both parties signed | No, and cannot be voided |
declined | A party declined with a reason | No |
voided | DELETE before completion | No |
Voiding a fully executed contract is refused: “A fully executed contract cannot be voided. Sign a termination or amendment agreement instead.”
Contracts are how a quote gets accepted
Sending a quote freezes it into a contract document and hands it to this same flow. When both parties sign, the quote flips to accepted and an invoice is created automatically. That path is verified end to end in Quotes and invoicing.
Common problems
400 fields[0] extends past the edge of page 1
Coordinates are percentages, not pixels or points. Check that x + w and y + h each stay within 100. A field designed against a 612-point-wide page and pasted in unchanged will always fail this.
400 Contract already has signatures on an edit
Once anyone has signed, the parties and content are frozen — otherwise the document someone signed would not be the document on file. Void it and draft a new one. The full message names what can no longer be changed.
The counterparty says the link does not work
Check token_expires_at; signing tokens last 30 days from send. Check the status — if the contract completed, that link is now a download link and answers 400 This link is for downloading the executed copy only. Re-sending mints a fresh link for whoever still owes a signature.
400 Counterparty email required before sending
Both parties need an email address. There is a matching guard for your own side — without party_a_email, party A would have no way to countersign. Set both with a PATCH before sending.
502 PDF engine unavailable
PDF rendering is an HTTP round-trip to the tenant node’s Go e-sign engine, so this is an infrastructure fault, not a document fault. The signatures are already committed — rendering happens after the commit precisely so a rendering failure cannot invalidate a signature that legitimately happened. Retry the download.
A required checkbox will not accept false
required must be a real JSON boolean. The string "false"is truthy, which turns an optional field into one that blocks the signature — and the validator rejects it up front rather than letting that happen at signing time.
Next
Quotes and invoicing
Signing a quote's contract is what accepts it — and creates the invoice.
Sending email
Signing links are delivered through the same sending path, with the same caps.
Webhooks and events
React to contract and quote events in your own systems.
REST API reference
All 12 contract routes with parameters and error cases.