Skip to content
SSalesShiftDocs

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.

Executed end to endContract fully executed10,485-byte PDF rendered

Draft the contract

POST/contracts201 Created

Party A is you; party B is the counterparty. Both need a name and an email before the document can be sent.

contract.json
{
  "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]"
}
Shell
curl -s -X POST "$API/contracts" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  --data-binary @contract.json
Real response (HTTP 201, trimmed)
{
  "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.

PATCH/contracts/{contract_id}
KeyTypeNotes
idstringUnique within the document. Duplicates are rejected.
typestringOne of signature, initials, date, name, email, text, checkbox
partystringExactly a or b
pageintegerZero-based; must be inside pages
x, y, w, hnumber0–100, percent of the page. x+w and y+h must stay on the sheet.
requiredbooleanA real boolean — the string "false" is truthy and would block the signature
Up to 500 fields per document.
fields.json — executed
{
  "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:

Executed — validation
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:400
x 90 + w 30 = 120. Coordinates only mean anything inside 0–100; off-sheet fields would be silently clipped by the renderer.

Send for signature

POST/contracts/{contract_id}/send
Executed
curl -s -X POST "$API/contracts/ad6b5cd2-…/send" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'
Real response (trimmed)
{
  "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"
  }
}
Signing links were delivered by real email to both parties. Tokens are valid for 30 days.

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.

GET/contracts/sign/{token}no auth
Real response (trimmed)
{
  "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.

Executed
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:400

Required fields are enforced by field

Executed
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:400
The error is structured, so a signing UI can scroll to and highlight the offending field rather than showing a generic message.

Signature and date fields for the signing party are auto-filled from the signature itself; only text and checkbox fields need values.

Signing, for real

POST/contracts/sign/{token}row-locked
Executed — party B
curl -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}
Executed — the same token again
{"detail":"You already signed this contract"}
HTTP:400
The handler is row-locked, so two simultaneous posts cannot both sign.
Executed — party A completes it
curl -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 inputFieldNotes
Typedsignature + stylestyle selects the rendered typeface, e.g. cursive
Drawnimage_dataData URI, up to 300 KB. Recorded in the audit trail as drawn: true
InitialsinitialsUp 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.

GET/contracts/{contract_id}/audit
Real audit trail
2026-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_as versus signed_as is 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_downloaded is 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.

GET/contracts/{contract_id}/pdfthe sender's copy
Executed
curl -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.4
A real 10,485-byte PDF. Each party also gets a download-only link at /contracts/sign/{token}/pdf.

Status lifecycle

StatusReached byEditable?
draftCreation or uploadYes
sentPOST /sendOnly until the first signature
partially_signedOne party signedNo — parties can no longer be changed
completedBoth parties signedNo, and cannot be voided
declinedA party declined with a reasonNo
voidedDELETE before completionNo

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