01Quickstart
Creating an envelope is one HTTP request. There is no SDK to install, no OAuth dance, and no field-placement editor to configure first.
1. Put anchor tags in your PDF
Siglio finds signing fields by looking for anchor text in your document. Type ^S1 where signer 1 should sign. Set that text in white on a white background and it disappears for the reader while staying findable in the PDF text layer. Full detail in anchor tags.
2. POST the file
Create an envelope
curl -X POST https://api.esigndev.com/v1/envelopes \
-H "Authorization: Bearer sig_..." \
-H "Idempotency-Key: order-41882-contract-1" \
-H "Content-Type: application/json" \
-d '{
"document_name": "agreement.pdf",
"document_base64": "JVBERi0xLjQK...",
"delivery": "both",
"signer": {
"name": "Jane Smith",
"email": "jane@example.com",
"phone": "+18135551212"
},
"sender": { "company_name": "Your Company" }
}'
Create an envelope · Node 18+
import { readFileSync } from "node:fs";
const res = await fetch("https://api.esigndev.com/v1/envelopes", {
method: "POST",
headers: {
"Authorization": "Bearer sig_...",
"Idempotency-Key": "order-41882-contract-1",
"Content-Type": "application/json"
},
body: JSON.stringify({
document_name: "agreement.pdf",
document_base64: readFileSync("agreement.pdf").toString("base64"),
delivery: "both",
signer: { name: "Jane Smith", email: "jane@example.com", phone: "+18135551212" },
sender: { company_name: "Your Company" }
})
});
const envelope = await res.json();
console.log(envelope.id); // env_...
Create an envelope · Python 3
import base64, requests
with open("agreement.pdf", "rb") as f:
pdf = base64.b64encode(f.read()).decode()
res = requests.post(
"https://api.esigndev.com/v1/envelopes",
headers={
"Authorization": "Bearer sig_...",
"Idempotency-Key": "order-41882-contract-1",
},
json={
"document_name": "agreement.pdf",
"document_base64": pdf,
"delivery": "both",
"signer": {"name": "Jane Smith", "email": "jane@example.com", "phone": "+18135551212"},
"sender": {"company_name": "Your Company"},
},
)
envelope = res.json()
print(envelope["id"]) # env_...
Your signers see the sender as “Your Company via Siglio”. If you gave your company name at signup you can leave sender out; it is in the example so the first call works either way. See the sender object.
The request is JSON. Send the PDF inline as document_base64 for files up to 3 MB, which covers most generated contracts in a single call. Larger files, up to 15 MB, go through the two-step upload flow in documents.
The envelope is created and delivered immediately. The response carries an envelope ID beginning with env_, and every response, successful or not, carries a request ID beginning with req_. Log the request ID. It is the difference between a support ticket that gets diagnosed and one that turns into a conversation.
3. Handle the completion webhook
Siglio deletes an envelope’s files 30 days after it completes. When signing completes, your webhook endpoint fires and that is your cue to download the signed document and store it. Build that into your handler on day one, not after someone asks you for a contract from six months ago. See signed documents.
Start here
Every account gets 25 free documents and no card is required to get them. They are real sends to real recipients, so send the first one to your own phone and watch what your signers will actually see.
02Build it with an AI assistant
If you would rather not write the integration yourself, hand this page to an AI assistant that can write and run code, and let it do the work.
Give it this one line:
Read https://esigndev.com/agent.txt and build me a Siglio integration.
Use it from Claude: the MCP server
If you want to send documents from a conversation rather than from code, the Siglio MCP server turns the API into five tools an assistant can call directly: send a document for signature, check an envelope, download the signed PDF, download a file the signer uploaded, and void an envelope. siglio-mcp is on npm and in the MCP Registry. One entry in your config installs it:
{
"mcpServers": {
"siglio": {
"command": "npx",
"args": ["-y", "siglio-mcp"],
"env": { "SIGLIO_API_KEY": "sig_..." }
}
}
}
Put that in claude_desktop_config.json, restart Claude Desktop, and ask it to send something: “Send ~/contracts/lease.pdf to Jane Smith, jane@example.com, for signature.” The server reads the PDF from disk and encodes it itself, so the file never passes through the conversation. Other MCP clients (Cursor, Claude Code, anything that speaks stdio MCP) take the same command, args and env in their own config format.
A send can do what the API does. delivery defaults to auto, and payment, attachments, form_fields and constraints (the last two Enterprise) are passed straight through. check_envelope shows any payment, the files the signer uploaded and their answers by label. Social Security numbers are always shown as the last four digits, because chat transcripts are kept.
What the server will and will not do on its own
It uses your API key as it is. Your first 25 documents are free. After that every send is billed, so if an assistant will send on its own, set a spending cap in your dashboard first.
Void asks first. The server will not void an envelope until the assistant has confirmed with you. Without that confirmation it makes no API call at all.
Retries are safe. Sends are idempotent by content, so an assistant that retries gets the first envelope back rather than putting a second copy in someone’s inbox.
Files over 3 MB are refused with an explanation rather than uploaded. Use the two-step upload from code for those.
That file is written for the assistant rather than for you. It carries the API contract, the two mistakes that break most first integrations, and an instruction to ask what you are building into before it writes anything. If you tell it you are not a developer, it is told not to bury you in code, and to ask what you send and to whom instead.
Three machine-readable references
esigndev.com/agent.txt — instructions for an assistant building an integration.
esigndev.com/llms-full.txt — the complete reference in one fetch: endpoints, tags, states, webhooks, errors and limits.
esigndev.com/openapi.json — OpenAPI 3.1, for code generation and tooling.
All three are checked against the running code on every deploy, so an assistant reading them cannot be told about an endpoint that does not exist or a limit that has changed. That matters more for a machine than for a person: you would notice a wrong answer, an assistant will confidently build on it.
Test before you trust it
Whatever the assistant builds, send one real envelope to yourself and sign it before wiring it into anything. Your free documents deliver for real, so that is a genuine end-to-end proof rather than a mock.
03What an envelope is
An envelope is one signing transaction: one PDF, sent to one or two signers, delivered by email, SMS, or both. Creating it is a single POST.
One envelope is one charge. Two signers, email and SMS both: still one envelope, still 25 cents.
Envelopes carry your business identity
Every envelope goes out under your company name. Signers see it as “Your Company via Siglio”, and it heads the Signature Originator Information block of the signing certificate. You give the name when you sign up; if your account has none yet, the studio and dashboard ask for it, and the API refuses to send (company_name_required) until it exists or the request carries sender.company_name.
During the free trial the company name is all you need. The certificate shows it and leaves the other originator fields blank, never filled in with anyone else’s details. After the trial your full business profile is required: company name, phone, email, and address. You add it in the dashboard in the same step as your card, and from then on every field on the certificate is yours. A send with an incomplete profile is rejected with a message naming the missing fields, and costs nothing.
The sender object
Resolution is per field, not all-or-nothing: for each field, the request wins, then your saved business profile. Override just the company name on one envelope and the rest still rides on your profile.
| Field | Max | Notes |
|---|---|---|
company_name | 100 | Required whenever sender is present. |
phone | 25 | |
email | 254 | Must be a valid email address. |
address | 150 | |
city | 60 | |
state | 30 | |
zip | 15 | |
representative | 100 | Optional. A person's name shown alongside the company. |
Every field is a string. An unknown key, a non-string value, anything over its limit, or a blank company_name rejects the request with invalid_request before the envelope is created, so a bad sender never costs anything.
"sender": {
"company_name": "Coastal Group",
"phone": "+18135551212",
"email": "contracts@coastalgroup.com",
"address": "100 Harbor Way",
"city": "Tampa",
"state": "FL",
"zip": "33602"
}
Your logo on notification emails
Upload your logo once in the portal's Account information section and it appears at the top of the notification emails your signers receive. PNG or JPEG, up to 512 KB. With nothing uploaded, your envelopes carry the Siglio mark instead, so there is no broken-image state to worry about.
Retrieve an envelope
Fetch any envelope by its env_ id to see where it stands. The response is the same shape the create call returned: state, delivery, the signer with signed_at filled in once they have signed (two-signer envelopes carry a signers array; per-signer signed_at is how you tell which of the two has finished). A null signed_at means only that no signature time was recorded for that signer, not that they did not sign: on a completed envelope everyone has signed, so read state for whether and signed_at for when, signing_url, the resolved sender, created_at, and completed_at. An id that does not exist, or that belongs to a different account, is a 404. Polling this works, but the completion webhook is the push version of the same information: treat the webhook as the signal and this endpoint as the check.
Void an envelope
Voiding cancels a sent envelope: the signing link stops working immediately and the signer sees a cancelled notice instead of the document. The response is the envelope with state voided, and your webhook receives envelope.voided. Voiding requires a read_write key, a completed or failed envelope can no longer be voided (a 422 tells you so), and voiding an already-voided envelope is a harmless no-op that returns it unchanged. Voiding does not refund the envelope; the charge was for creating it, as the next section explains.
An envelope that is still not completed 30 days after it was sent voids itself the same way. See expiry.
Collect a payment with the document
An envelope can ask the signer for money. Add a payment object to the create call and the signer is sent a Siglio-hosted payment page, powered by Stripe, that pays you: the money lands in your own Stripe account, and Siglio takes nothing on top of Stripe’s card fees. Connect your Stripe account once, in the document studio: choose Connect Stripe; until that is done the field is refused with payment_not_configured.
"payment": {
"amount": 15000, // whole cents, so $150.00
"description": "Deposit", // optional, shown to the signer
"when": "after" // "after" (default) or "before"
}
After signing is the default: the document goes out exactly as it would without a payment, and the moment it is completed the signer is sent the payment link by the same channel the document went by. Before signing holds the document: the signer receives the payment link instead of the signing link, sees a read-only preview of what they are paying for, and the signing link goes out the moment the payment lands. A held envelope stays in state created, its signing_url is the payment page until it is paid, and it is not billed until the document actually goes out. Void it and it never is.
The envelope carries a payment object from then on, with status (pending, paid, waived when you marked it paid outside Stripe in the studio, or cancelled when a held envelope was voided), paid_at, and pay_url, which you can pass to the signer yourself. When the payment is recorded your webhook receives envelope.paid. USD only, and the signer who pays is signer one.
Ask the signer for files
An envelope can ask the signer to upload documents along with their signature: a photo of their ID, proof of insurance, a W-9, the last two pay stubs. Add an attachments object to the create call. The signer gets a Siglio-hosted upload page that works from a phone camera; they upload photos or PDFs, up to 15 MB each and up to three per item.
"attachments": {
"when": "before", // "before" or "after", no default
"items": [
{ "label": "Photo ID" },
{ "label": "Proof of renter's insurance", "required": false }
]
}
Before signing holds the document exactly as a before-signing payment does: the signer receives the upload link instead of the signing link, sees the document read-only, and the signing link goes out once every required file is in. After signing sends the upload link the moment the document is completed. If the same envelope also has a payment at the same timing, one page and one message cover both, and a held document goes out only when both are done.
The envelope carries an attachments object from then on, with status (pending, received, waived when you skipped the files in the studio, or cancelled when a held envelope was voided), each item with its files, and upload_url. Download a file with GET /v1/envelopes/:id/attachments/:file. When every required file is in, your webhook receives envelope.attachments_received. Files are never emailed: they stay with the envelope until you fetch them.
You are billed on creation, not on completion
The charge is for creating the envelope, not for a completed signature. If the signer declines, ignores it, never opens it, or you void the envelope afterward, the envelope was still created and it still costs 25 cents.
This is not buried in the terms. It is how the product is priced, and it is why your first 25 documents are free. Use them to get the flow right, then send the envelopes you actually intend to send.
Not billable
A request that fails validation costs you nothing. Document checks, tag checks, and signer checks all run before the envelope is created, so a rejected request is never charged. See errors.
04Authentication
Authentication is a bearer token. Put your API key in the Authorization header:
Authorization: Bearer <your key>
No JWT assembly, no key pairs, no OAuth consent screen, no one-time authorization dance. One header.
Scopes
| Scope | What it allows |
|---|---|
read | Non-mutating calls: envelope and status metadata, usage data exposed through the API, webhook delivery information. |
read_write | Everything in read, plus the mutating calls. In V1 that means creating an envelope and voiding one. |
Key lifecycle
There is one kind of key. It is sig_ followed by 32 characters, and it works from your first free document through every paid one; there is no test key to swap out later. You create, rotate, and revoke your own keys, and Key IDs begin with key_. You can hold up to 10 active keys, so different applications can use different keys.
Keys belong to the account, not to individuals. There are no teams, seats, or roles in V1, and keys carry no IP allowlist or per-key volume limit, so treat every key as fully capable within its scope and give each application its own.
Rotation gives you a 24-hour overlap. The replacement key is created and the old one stays valid for 24 hours, so you can deploy without a gap. If you would rather cut over immediately, revoke the old key at any point during that window and it stops working right away.
Keys come from your account, nowhere else
You create, rotate, and revoke keys yourself, in your account. Support can point you to the right screen, but no key is ever issued over email or chat, and none ever will be. If a message offers you one, it isn't from us.
05Free trial
Every account starts with 25 free documents. No card, no separate test mode: the same key and the same sends you will use in production.
- The 25 are shared between the API and the document studio. A document sent from either counts once.
- After 25, add a card and every envelope is 25 cents.
- Adding a card early does not forfeit what is left. You keep sending free until all 25 are used.
- Sending the studio’s sample document to yourself is free and does not count, up to 3 times.
The free documents are not a simulation
They perform real end-to-end delivery to real recipients. Send one to your own phone and it arrives as a real text. Send it to a colleague’s email and it lands in their inbox and they can actually sign it. There is no watermark, no demo stamp, and no fake-signer mode. When your integration works on a free document, you have seen the thing your users will see.
What happens at document 26
The 25 free documents are a one-time allowance per account. They do not refill, reset, or renew. Nothing about them is time-limited either: use 4 and come back six months later and the other 21 are still there.
Once they are used up, a send without a card is refused with payment_required (HTTP 402) and costs nothing. Add a card and your business details in the dashboard, in one step, and the next send goes through at 25 cents. Your key does not change.
Keys issued before October 2026 still work, and behave exactly like a sig_ key.
Resource ID prefixes
Every public ID starts with a prefix that says what it is.
| Prefix | Resource |
|---|---|
env_ | Envelope |
acct_ | Account |
req_ | API request. Send this when reporting a problem. |
evt_ | Webhook event |
key_ | API key |
wh_ | Webhook endpoint |
06Documents
| Rule | Value |
|---|---|
| Accepted format | PDF only |
| Maximum file size | 15 MB |
| Encrypted or password-protected PDFs | Rejected |
You upload the finished PDF with every request. Documents cannot be pulled from a remote URL, and Word documents are not accepted, so generate the document on your side and send the bytes, or send a template you set up in the studio by its id. If your PDF is encrypted or carries an open password, remove the protection before upload. A protected PDF is rejected at creation rather than silently mishandled later.
Two ways to send the file
Inline, up to 3 MB. Base64-encode the PDF and send it as document_base64 in the create request. One call, done. Most generated contracts fit here comfortably.
Upload flow, up to 15 MB. For bigger files, get an upload URL first, put the file there, then create the envelope with the returned ID:
Authenticated with your normal API key, no body required. The response gives you a document ID beginning with doc_ and a signed upload_url. PUT the raw PDF bytes to that URL, then create the envelope with "document_id": "doc_..." instead of document_base64. Send exactly one of the two; both or neither is a 400.
Three rules that keep this flow honest: the upload URL expires in 15 minutes, a document ID is consumed by one envelope and cannot be reused, and uploaded originals are working input rather than storage, so they are swept about 24 hours after use. The size and PDF-only checks are enforced at the storage layer, so an oversized or mistyped file fails at the PUT, not later at creation.
Document rejections
All of these are checked before the envelope is created, so none of them are billable.
| What happened | HTTP | type | code |
|---|---|---|---|
| File over 15 MB | 413 | invalid_document | file_too_large |
| Not a PDF | 415 | unsupported_document | unsupported_file_type |
| Encrypted or password-protected PDF | 422 | invalid_document | encrypted_pdf |
No ^S1 tag found | 422 | missing_required_tag | missing_required_signature_tag |
| Tags found for signer 3 or beyond | 422 | invalid_request | unsupported_signer_tag |
| PDF could not be parsed | 422 | invalid_document | unreadable_pdf |
07Templates
Set a document up once in the document studio, then send it from code by its id. Siglio prints your values on it, places the signature and date marks, and sends it. No PDF in the request.
A template here is the same one you see in the studio, with the same id. Copy the id from the Templates list in the studio, or list your templates with the API. Any key can read them.
List your templates
Newest first. limit is 1 to 100 and defaults to 25. When has_more is true, pass the last id you got as starting_after for the next page.
{
"templates": [
{ "id": "tpl_example4471", "name": "Roofing agreement", "signer_count": 1, "pages": 2,
"created_at": "2026-10-01T14:02:00Z", "updated_at": "2026-10-03T09:15:00Z" }
],
"has_more": false
}
Retrieve a template
One template, with every value it prints and where each one comes from. An id that doesn’t exist, has been archived, or belongs to another account is a 404 template_not_found.
{
"id": "tpl_example4471",
"name": "Roofing agreement",
"signer_count": 1,
"pages": 2,
"merge_fields": [
{ "name": "full_name", "label": "Full name", "signer": 1, "source": "signer", "from": ["name"] },
{ "name": "full_address", "label": "Full address", "signer": 1, "source": "signer",
"from": ["address.line1", "address.line2", "address.city", "address.state", "address.zip"] },
{ "name": "policy_number", "label": "Policy number", "signer": 1, "source": "merge", "slot": 4, "max_length": 100 },
{ "name": "job_site", "label": "Job site", "signer": 1, "source": "merge", "slot": 5, "max_length": 100 },
{ "name": "today", "label": "Today's date", "signer": 1, "source": "auto" }
],
"signing_fields": { "signature": [1], "initial": [], "date": [1] },
"created_at": "2026-10-01T14:02:00Z",
"updated_at": "2026-10-03T09:15:00Z"
}
| Field | What it tells you |
|---|---|
merge_fields | Every value the template prints, in page order. name is what you call it, label is what the sender sees in the studio, signer is 1 or 2. |
source | signer: taken from that signer’s details, and from lists which ones, such as address.city. merge: you send it in merge. auto: Siglio fills it in. |
slot, max_length | For a custom field, its slot (1 to 10, also accepted as custom_N) and the most characters it holds. |
signing_fields | Which signers have a signature, initials and a date mark. |
answer_fields | Present only when the template has boxes the signer fills in (Enterprise). |
Send a template
Create the envelope as usual, with template_id in place of document_base64 or document_id. Send exactly one of the three.
curl -X POST https://api.esigndev.com/v1/envelopes \
-H "Authorization: Bearer sig_..." \
-H "Idempotency-Key: policy-PN-4471" \
-H "Content-Type: application/json" \
-d '{
"template_id": "tpl_example4471",
"signer": {
"name": "Jane Smith",
"email": "jane@example.com",
"phone": "+18135551212",
"address": { "line1": "12 Bay St", "city": "Springfield", "state": "FL", "zip": "33609" }
},
"merge": { "policy_number": "PN-4471", "job_site": "Lot 9" }
}'
- Signer details. A signer takes
name,email,phone,phone_other,company,job_title,address(line1,line2,city,state,zip,country),mergeanddelivery. Fields with sourcesignerare filled from these. First and last name are split fromnameat the first space. - Your values.
mergefills custom fields by name or bycustom_N. Sending the same value both ways is fine; two different values is a 422conflicting_merge_values.referenceis a merge key when the template prints one. Names are case-insensitive. Each value is text or a number, up to 100 characters (reference: 60). - Two signers. Top-level
mergeis shorthand for signer 1. For signer 2, putmergeonsigners[1]. - Missing values are a 422
missing_merge_fieldsthat lists each one. Set"allow_blanks": trueto send with them blank instead. It does not excuse characters the document cannot print. document_namedefaults to the template’s name.payment,attachments,senderandIdempotency-Keywork as they do for any send.form_fieldsandconstraintscome from the template, so sending them withtemplate_idis a 422.
Custom fields
Each signer can have up to ten custom fields, in slots 1 to 10. Name them in the studio with the Custom field tool: lowercase letters, digits and underscores, up to 40 characters, unique for that signer, and not one of Siglio’s own keys or custom_N. A field you don’t name is called custom_N and labelled “Custom Field N”.
Printed values use the Latin alphabet with accents. Cyrillic, Greek and Chinese, Japanese or Korean characters are refused with unsupported_characters. A template has one or two signers for now.
Template errors
All type invalid_request, all checked before anything is created, none billed. Where a code is about particular values, error.fields lists them.
| HTTP | code | When |
|---|---|---|
| 404 | template_not_found | No template with that id on your account |
| 422 | missing_merge_fields | Values the template needs were not sent. fields: name, label, signer. |
| 422 | unknown_merge_field | A merge key the template doesn’t print for that signer. fields: name, signer. |
| 422 | conflicting_merge_values | The same field sent by name and by custom_N with different values. fields: name, signer. |
| 422 | invalid_merge_value | A value that isn’t text or a number, or is too long. fields: name, signer. |
| 422 | unsupported_characters | A value with characters the document can’t print. fields: name, label, signer. |
| 422 | signer_count_mismatch | The request has a different number of signers than the template |
| 422 | template_not_ready | A signer on the template has no signature mark. Fix it in the studio. |
| 422 | form_fields or constraints sent with a template | |
| 400 | More than one of document_base64, document_id and template_id, or merge or allow_blanks without template_id |
09Collecting answers Enterprise
Enterprise
Available on Enterprise accounts. Contact us to turn it on. What the signer and the studio see: data collection.
Before the signer signs, Siglio asks them for every answer on a short form, in document order: typed answers (^M required, ^T optional), dropdowns, checkboxes (^C) and choices (^R). Answers are saved as they type. The review shows each answer where it will print, and one Sign applies the signatures, dates and answers together.
Naming and labelling fields
Pass form_fields on create to give each field a label (what the signer reads), and to set rules:
"form_fields": [
{ "name": "policy_number", "label": "Policy number" },
{ "name": "phone", "label": "Mobile phone", "format": "phone" },
{ "name": "state", "type": "select", "label": "State",
"options": [{ "value": "FL", "label": "Florida" }, { "value": "GA", "label": "Georgia" }] },
{ "name": "co_driver", "type": "checkbox", "label": "I have a co-driver" },
{ "name": "licence", "signer": 2, "label": "Co-driver's licence number",
"show_if": { "field": "co_driver", "checked": true } }
]
Here policy_number, phone and state are ^M or ^T tags, co_driver is a ^C1:co_driver tag, and licence is a ^M2:licence tag.
name(required): matches a tag’s name.signer: optional, but if given it must match the tag.type:text(the default on a^Mor^Ttag),select(a^Mor^Ttag that shows a pick-one list; the chosen option’s label is what prints),checkbox(one^Ctag),checkbox_group(several^Ctags sharing a name, each with a value),radio(an^Rgroup). The type has to match the tag. A mismatch is refused, naming the tag.options: forselect,checkbox_groupandradio:[{ "value": "FL", "label": "Florida" }]. For a group or a radio, every value must match a box on the page, and the page’s words to the right of each box are used when no label is given. A select has 2 to 100 options and takes its options from this list alone.format: on a text field,email,phone,number,date,ziporssn. Checked as the signer types, with a plain message. Stored in one shape each (phone as 10 digits, date asYYYY-MM-DD, ssn as123-45-6789), printed as(813) 555-0142and3/14/2026. The API returns the stored value in full withformatbeside it.label: up to 80 characters. Without it, Siglio uses the words just before the tag on the page, else the name.required: overrides the tag’s own^M(required) or^T(optional).max_length: 1 to 500, on text fields.width: 60 to 540 points, on text and select fields.show_if/required_if:{ "field": "<name>", ... }with exactly one ofequals(a value),not_empty(true),in(a list of up to 50 values),checked(true or false, on a checkbox) orincludes(a value, on a checkbox group). Combine two to ten with{ "all": [...] }or{ "any": [...] }, one level deep. Comparisons ignore case and extra spaces. A radio or select compares by value. A group matchesequalsorinwhen any ticked value does.
Up to 100 entries.
How rules behave
- A rule can use the same signer’s fields, or signer 1’s fields from signer 2.
- A hidden field is skipped, never required, and stored blank.
- Rules can’t loop.
- Every rule is checked again when the signer signs.
Group rules
constraints, next to form_fields on create, holds rules across fields:
"constraints": [
{ "type": "exactly_one", "fields": ["status"] },
{ "type": "at_least", "n": 1, "fields": ["has_license", "newsletter"] },
{ "type": "at_most", "n": 2, "fields": ["plan", "paper_copy", "extras"] },
{ "type": "one_of", "fields": ["ssn", "no_ssn_reason"] },
{ "type": "requires", "field": "address", "when": { "field": "plan", "equals": "annual" } }
]
fieldslists 1 to 20 of one signer’s fields. A lone field is allowed only when it is a checkbox group:exactly_oneover the group means tick exactly one of its boxes.- Counting: a ticked checkbox is 1, a group counts its ticked boxes, a radio or text with an answer is 1. A hidden field counts 0.
one_of: filling one field clears and greys out the others.requiresis arequired_ifby another name.whentakes any condition.- Up to 50 constraints. A bad one is refused with 422
invalid_constraints, naming the entry.
The signer sees each rule in plain words built from your labels (“Choose one of: Single, Married, Divorced.”), as they tap and again if they try to sign with one unmet. They can’t sign until every rule is met.
Answers are printed on the document
Each answer prints from the tag’s left edge, on its line, at 11 pt, shrinking to 8 pt and then wrapping to a second line. An answer must fit its space. The space runs 200 points wide by default (or width), and stops at the next tag on the same line or the right margin.
A ticked checkbox or a chosen option prints as an X in its box. A dropdown prints the chosen option’s label.
Answers are printed in a font that covers Latin, Greek, Cyrillic and Vietnamese. Other scripts (for example Chinese, Arabic, Hebrew or emoji) are refused as the signer types, with a plain message.
Signer 2 sees signer 1’s answers on the document they review. Plan forms with that in mind.
Reading answers
field_values appears on GET /v1/envelopes/{id} and on every webhook, only for envelopes with answer fields:
"field_values": [
{ "name": "status", "signer": 1, "label": "Marital status", "type": "checkbox_group", "value": ["married"], "submitted_at": "..." },
{ "name": "has_license", "signer": 1, "label": "Current licence", "type": "checkbox", "value": true, "submitted_at": "..." },
{ "name": "plan", "signer": 1, "label": "Billing", "type": "radio", "value": "annual", "submitted_at": "..." },
{ "name": "phone", "signer": 1, "label": "Phone", "type": "text", "format": "phone", "value": "8135550142", "submitted_at": "..." }
]
- A text, select or radio is a string or null. A checkbox is
trueorfalse. A group is a list, empty when nothing was ticked. valueis null until that signer has signed.envelope.partially_signedcarries signer 1’s answers, andenvelope.completedcarries everyone’s.- Answers are deleted 30 days after the envelope closes. After that,
valueis null and"deleted": trueappears. GET /v1/envelopes/{id}also returnsform: { fields, constraints }, the definitions and rules the envelope was created with, so an integration can rebuild the form it sent.
Answers are personal data
They arrive in your webhooks and API responses. Store and protect them like any other customer data.
The certificate lists which fields each signer completed and a fingerprint of the answers. It never shows the values. A ticked box or a choice counts as answered.
10Signers
An envelope supports a maximum of two signers. Signer 1 is required, signer 2 is optional. Three or more signers is not supported.
Supplying more than two signers, or tagging for signer 3 and beyond, is rejected at creation and is not billed.
Sending two signers
One signer sends as a signer object. Two signers send as a signers array, in signing order:
"signers": [
{ "name": "Jane Smith", "email": "jane@example.com" },
{ "name": "Sam Carter", "phone": "+18135551212", "delivery": "sms" }
]
Send signer or signers, never both. Each signer can carry its own delivery; leave it off and the envelope’s delivery applies (on auto, each signer is resolved from their own details). The create response returns a signers array with a separate signing_url for each signer, minted up front even though notifications go out one at a time.
The partially signed state
A two-signer envelope passes through one extra state. When signer 1 finishes, the envelope moves to partially_signed and an envelope.partially_signed webhook event fires, so your system knows exactly where a document stands while it waits on the second signature. Sequential signings can span days; this state is how you see that without polling.
Only completed means both signatures are on the document. Never treat partially_signed as done: the signed PDF at that point is half-executed, and the final document only exists once the envelope completes.
Signing is sequential: signer 1, then signer 2.
On a two-signer envelope, signer 2 is not notified until signer 1 has signed, and cannot sign before then either. Signer 2’s signing_url is minted with the envelope and returned in the create response, but until signer 1 has signed it opens on a page saying the document is waiting on the first signer. The order holds even if you hand the link out yourself.
One exception, for now. When a signer’s name is written in a non-Latin script, notification is still sequential but signer 2’s signing_url works from creation. On those envelopes, if one party must sign strictly before the other, keep signer 2’s link to yourself until envelope.partially_signed fires.
You choose which signer goes first, per envelope. An agreement that needs your side to sign before it reaches the customer works the same way as one that needs the customer first.
Design for this. If your integration assumes both signers are notified at creation, it will not behave the way you expect.
Each signer gets the signed PDF
When the envelope completes, every signer is sent the signed PDF, certificate page included: attached to an email when they were reached by email, and by text with a download link when they were reached by text. You do not need to send it yourself. Your copy still comes from GET /v1/envelopes/:id/document when envelope.completed fires.
Tagging for two signers means sending two signers
If your document carries signer-2 tags (^S2, ^M2, and so on) but you create the envelope with only one signer, the request is rejected with an error naming the mismatch.
It is not sent as a one-signer document. That would leave the second signer’s fields on the page with nobody able to fill them, and you would not find out until someone read the finished PDF. A rejection at creation is the cheaper failure, and it is not billed.
11Delivery
You choose how the signing link reaches your signer, at creation time:
- Auto, the default: email and text when the signer has both an email and a phone, otherwise whichever one they have
- SMS
- Both, at the same time
- None: you hand out the link yourself and Siglio contacts nobody (below)
Leave delivery out and you get auto. Send an email and a phone and the signer gets both; send only one and that one is used. email, sms and both stay strict: ask for both without a phone and the call is refused, so you find out rather than silently losing the text. The create response, GET and every webhook carry the resolved value (email, sms, both or none), never auto; with two signers, each entry in signers has its own delivery.
Both means both, at the same time, to the same signer. It is not a fallback after email bounces, and it is not a paid add-on. SMS delivery is included in the 25 cents.
This matters if your signers are contractors, tenants, patients, field staff, or anyone who does not sit in an inbox all day. The link that arrives by text gets opened.
Nothing about this is configured at the account level. You choose fresh on every envelope, so email for one and both channels for the next takes no setup and no support ticket.
On two signers, the choice is per signer
Each signer’s delivery channel is chosen independently. Signer 1 by text and signer 2 by email is a supported combination, as is any other pairing, including both channels for one and a single channel for the other.
This is the answer to a case that comes up constantly: the contractor you only have a mobile number for, and the office manager you only have an email address for, on the same agreement. You do not have to find a channel that works for both of them.
This is about who gets contacted and how. It does not change who is notified when: with two signers, signer 2 is not notified until signer 1 has finished. See signers.
Delivery none: you hand out the link
Send "delivery": "none" when your own app shows the signing link: in your portal, in a chat, on a screen in front of the signer. Siglio sends no request, no reminder and no signed copy to the signer, by email or text. Everything else works as usual: the envelope, its states, the webhooks and the signed PDF you download.
{
"document_name": "Service agreement",
"document_base64": "JVBERi0xLjcK...",
"delivery": "none",
"signer": { "name": "Dana Reyes", "email": "dana@example.com" }
}
- The links come back in the response. A
noneenvelope always has asignersarray in the create response, each entry with its ownsigning_url. Signer one’s link is alsosigning_urlon the envelope, on everyGET. - Each signer still needs an email or a phone. Nobody is contacted at it; it is recorded on the signing certificate.
- It is for the whole envelope. With two signers, set it at the top level. One signer on
noneand the other on a channel is refused. Signing is still in order: signer two’s link shows a waiting page until signer one has signed, andenvelope.partially_signedis your cue to hand it over. - No payment or attachments. Both need a channel to send their link on, so either one with
noneis a 400. - Reminders are skipped; expiry is not. An unsigned
noneenvelope is still voided after 30 days. - The certificate says so. It records the delivery as “Link provided by sender”, the signer’s email or phone as not contacted by Siglio, and claims no channel authentication.
- A document that only the older signing page can handle, for example one with a signer name in a non-Latin script, can’t go link-only: 422
unsupported_for_link_only, not billed. Send it with a channel instead.
The price is the same 25 cents. none works with templates too.
Automatic reminders
A signer who has not signed is reminded automatically, on the same channels they were sent the document on. Reminders go out on days 1, 2, 3, 5, 7, 10, 14 and 21 after that signer was asked, then a last reminder on day 28 that states the expiry date: nine at most. Signer 2’s schedule starts when signer 1 signs.
Reminders are sent only between 11:00 and 19:00 US Eastern, and text reminders end with “Reply STOP to opt out.” They are on by default for every account and have no webhook events of their own. There is no API parameter to turn them off; contact support to turn reminders off for your account.
Expiry after 30 days
An envelope that is not completed 30 days after it was sent is voided automatically. It moves to voided and envelope.voided fires, exactly as for a void you make yourself. There is no separate reason field: voided means either you voided it or it expired unsigned after 30 days. Its signing links then show that the document is no longer available.
Reminders and expiry apply to envelopes created from 28 September 2026 on.
12Idempotency
Envelope creation accepts an idempotency key. Send the same key with the same request and you get the original result back instead of a second envelope.
Use it
Envelope creation is the billed operation. A retry on a network timeout without an idempotency key is how you accidentally create and pay for two envelopes.
You generate the key yourself and send it in the Idempotency-Key header. Any unique string up to 255 characters works. Use something derived from your own record, like order-41882-contract-1.
Idempotency-Key: order-41882-contract-1
Records are kept for 30 days, so a retry days later still resolves correctly rather than creating a second billable envelope.
The pattern
Generate one key per logical envelope in your own system, store it alongside your record, and reuse it on every retry of that same envelope.
Reusing a key with a different body is an error
It is not a silent overwrite. You get a 422 with type: invalid_request and code: idempotency_key_reuse. That is deliberate: it catches the bug where a key gets recycled across two genuinely different envelopes, which would otherwise mean the second one silently never gets sent.
13Webhooks
Webhooks are included on every account. They are not a paid tier, not an add-on, and not gated by volume. Every account, from the first free document.
Configuration
| Endpoints | One per account |
|---|---|
| Event types | All of them. There are no per-event subscriptions. |
| Webhook IDs | wh_... |
| Event IDs | evt_... |
You configure one endpoint for your account and receive every event type on it. Filter by event type in your handler.
Setting one up
Endpoints are managed in your dashboard, under Webhook endpoints. Paste an HTTPS URL, save, and the signing secret is shown to you once. Send test posts a real signed webhook.test event to your handler so you can prove both your route and your signature check work before a real envelope depends on them.
If an endpoint fails for long enough to be marked failed, delivery to it stops entirely. Fix the URL and save again from the dashboard to switch it back on.
Verifying a delivery
Every request we send carries an X-Siglio-Signature header. Verify it and reject anything that does not match, otherwise anyone who learns your endpoint URL can post you a fake completion event.
X-Siglio-Signature: t=1756645701,v1=1f8b...c3d9
t is the Unix timestamp we signed at. v1 is the HMAC-SHA256 of the string t, a literal full stop, and the raw request body, keyed with your signing secret. Sign the bytes you received, not a re-serialized object: re-encoding JSON changes whitespace and key order, and the signature will not match.
// Express. Note express.raw, not express.json.
const crypto = require("crypto");
app.post("/webhooks/siglio", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Siglio-Signature") || "";
const m = header.match(/^t=(\d+),v1=([0-9a-f]+)$/);
if (!m) return res.status(400).end();
const expected = crypto
.createHmac("sha256", process.env.SIGLIO_WEBHOOK_SECRET)
.update(m[1] + "." + req.body)
.digest("hex");
// Constant time, so a wrong signature cannot be guessed a byte at a time.
const ok = m[2].length === expected.length &&
crypto.timingSafeEqual(Buffer.from(m[2]), Buffer.from(expected));
if (!ok) return res.status(401).end();
// Reject anything older than five minutes: a valid signature stays valid
// forever, so the timestamp is what stops an old request being replayed.
if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return res.status(400).end();
const event = JSON.parse(req.body);
// Acknowledge first, work afterwards.
res.status(200).end();
queue.add(event);
});
Rotating the secret in the dashboard takes effect on the next delivery, so update your handler first, then rotate.
Event types
| Event | Fires when |
|---|---|
envelope.delivered | The envelope is created and handed to delivery |
envelope.viewed | A signer opens the document |
envelope.partially_signed | Signer 1 of 2 has finished. Two-signer envelopes only. |
envelope.completed | Every signature is on the document. This is your cue to download it. |
envelope.voided | The envelope was voided: by you, from the studio, or automatically because it was not completed 30 days after it was sent. |
envelope.paid | A requested payment was recorded (paid, or waived by the sender). The envelope in the payload carries payment. |
envelope.attachments_received | The signer uploaded every required file. The envelope in the payload carries attachments with the file ids. |
Events fire on state transitions, not on raw signing activity, so you never receive the same event twice for one envelope and you never receive them out of order relative to the envelope’s state. Switch on the type field.
Retries
Failed deliveries are retried on an exponential backoff with jitter: immediately, then at roughly 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours, then once every 24 hours, stopping after 7 days. Any 2xx response counts as success.
Every attempt is recorded with its HTTP status and response body, and the full delivery history is visible in the portal. An endpoint that fails every attempt for 7 days is marked failed and retries stop.
Not every failure is treated the same
| Your response | What we do | Why |
|---|---|---|
| 408, 429, any 5xx | Full seven-day retry schedule | These usually resolve on their own |
| 401, 403, 404, 410 | Three attempts, then marked failed | Your endpoint is rejecting us, not struggling. A week of retries will not fix a deleted route or a bad auth rule. |
You hear about it early
After three consecutive delivery failures on an endpoint, Siglio emails your technical-alert address. You do not have to wait for the retry window to exhaust itself to find out something is broken, and you do not have to poll the delivery history to notice.
Handler expectations
Return a 2xx quickly and do your work asynchronously. A handler that takes a long time to respond looks like a failing endpoint, and will be treated as one.
There is no manual replay. Once you return a 2xx, that event is delivered and will not be sent again. Acknowledge first and process from your own durable queue, so a crash after the 2xx costs you a retry rather than the event.
14Signed documents
Read this before you build
Siglio deletes an envelope’s files 30 days after it closes. Download the signed PDF, and any files the signer uploaded, when the completion webhook fires, and keep your own copy. That is not a suggestion, it is the integration requirement.
The arrangement, stated plainly because it affects how you build:
- Signers are sent their copy when the envelope completes, with a download link that works for 30 days.
- You, the developer, receive the signed result through the completion webhook.
Downloading the signed PDF
Available once the envelope is completed; before that it answers not-found, and 30 days after completion it answers 410 with code document_deleted. The response streams the PDF bytes directly, no redirect to follow. Call it from your completion-webhook handler and write the file to your own storage.
What is kept, and for how long
While an envelope is in progress, Siglio keeps its files. Thirty days after the envelope reaches completed, voided (including expiry) or failed, its files are deleted: the PDF you uploaded, the signed PDF, and any files the signer uploaded.
The envelope record stays readable through GET /v1/envelopes/{id}: its state, signers, timestamps, and the payment and attachments metadata. Once the files are gone, GET /v1/envelopes/{id}/document and the attachment download both return 410 with code document_deleted.
The signer’s own download link, sent with their copy, works for the same 30 days.
The pattern, stated plainly: on envelope.completed, download the signed PDF (and any attachments) and store them in your system. The copy you keep is the one you will still have in three years, and it is the one your own retention obligations run against.
15Errors
Every API response carries a request ID (req_...), in the response body on errors and in a response header on every request, successful or not. Log it.
Error shape
{
"error": {
"type": "invalid_document",
"code": "missing_required_signature_tag",
"message": "The PDF must include a ^S1 signature tag.",
"request_id": "req_01ABC..."
}
}
How to handle these
Switch on type, log code, never parse message. type is the broad class and is stable. code is the specific reason and new ones may be added over time. message is written for a human reading a log and its wording may change.
Error types
type | HTTP | When |
|---|---|---|
authentication_error | 401 | Missing, malformed, revoked or expired key |
authorization_error | 403 | Valid key, but this call is not allowed: wrong scope, a feature the account doesn’t have, or sending paused while the company name is reviewed |
invalid_request | 400 | Malformed request, bad or missing parameters |
invalid_document | 422 | PDF present but unusable |
unsupported_document | 415 | Not a supported file type |
missing_required_tag | 422 | Required signing tag absent |
invalid_signer | 422 | Signer details missing or unusable for the chosen delivery method |
new_envelopes_paused | 403 | Pause New Envelopes is on |
account_paused | 403 | Account fully paused |
usage_limit_reached | 403 | Approved monthly limit ceiling hit |
self_imposed_cap_reached | 403 | Your own cap hit. Hard stop, no grace. |
payment_required | 402 | Your 25 free documents are used and there is no card on file |
rate_limited | 429 | The limit is 120 requests per minute per API key, across all endpoints. The Retry-After header says how many seconds until the window resets. |
service_unavailable | 503 | Temporary. Safe to retry with your idempotency key. |
internal_error | 500 | Our fault. Send us the request_id. If it happens while an envelope is being created, nothing is billed: the envelope is marked failed and a free document is given back. |
One code arrives with a different status: document_deleted comes back as HTTP 410 (type invalid_request) from the signed PDF and attachment downloads once the envelope’s files have been deleted, 30 days after it closed. See signed documents.
Account errors on a send
These come back from POST /v1/envelopes before anything is created. None of them is billed. The message is the exact text you will see, and it says what to do.
| HTTP | type / code | Message |
|---|---|---|
| 402 | payment_required / payment_required | Your 25 free documents are used. Add a card to keep sending. |
| 422 | invalid_request / company_name_required | Your account has no company name yet. Recipients see it as the sender. Add it in your dashboard at https://esigndev.com/app and send again. |
| 403 | authorization_error / account_under_review | Your company name is being reviewed, so sending is paused for now. Contact support at https://esigndev.com/contact and we will sort it out. |
| 422 | invalid_request / invalid_request | Your free trial is over, so your business profile must be complete. Missing: … (the fields are named) |
| 422 | invalid_request / email_unverified | Confirm your email address before sending billed envelopes: … |
A sender.company_name on the request satisfies company_name_required for that envelope. A request that still sends environment is accepted and the field is ignored.
Features switched off on the service
Now and then we switch a feature off across the service while we fix something. A request that uses it is refused with its own code rather than a vague error, so you can tell which part to leave out. All HTTP 422, type invalid_request, not billed, nothing created.
code | What to do |
|---|---|
link_only_disabled | Send with email, sms or both delivery instead of none |
sender_branding_disabled | Leave sender out; your account’s company name is used |
payments_disabled | Leave payment out to send for signature only |
attachments_disabled | Leave attachments out to send for signature only |
two_signer_disabled | Send one signer per envelope |
Template sends have their own codes, such as template_not_found and missing_merge_fields. See template errors.
feature_not_enabled (HTTP 403, type authorization_error): the document has ^M, ^T, ^C or ^R tags, or the request has form_fields, and the account doesn’t have data collection. Message: “Collecting answers before signing (^M, ^T, ^C or ^R tags, or form_fields) is an Enterprise feature. Contact us to turn it on.” Not billed, nothing created. See collecting answers.
invalid_form_fields and invalid_constraints (HTTP 422, type invalid_request): an entry in form_fields or constraints doesn’t fit the document. For example a name with no matching tag, a type that doesn’t match its tag, a group rule that names a missing field or mixes two signers’ fields, or an n out of range. The message names the entry (constraints[2] ...).
Is it you or is it us?
If calls are failing and the error does not tell you why, check esigndev.com/status. It reports each part of the service separately — the API, sending for signature, the signing pages and webhook delivery — and it is measured every five minutes, so it will not tell you everything is fine while nothing has been checked for an hour.
Which errors cost you money
None of the 4xx classes above. They mean the request did not create an envelope and was not billed.
A 500 or 503 is the ambiguous case, and that is exactly what idempotency keys are for. Retry with the same key and you get either the original result or a fresh attempt, never a double charge.
Common causes of a rejected creation
- The document is not a PDF, is over 15 MB, or is encrypted
- The document has no
^S1anchor - More than two signers were supplied
- The document carries signer-2 tags but only one signer was supplied
- The document has
^M,^T,^Cor^Rtags, or the request hasform_fields, on an account without data collection (Enterprise) form_fieldsorconstraintsdon’t match the document’s tags- The account has no company name yet, or its free documents are used and there is no card
- The account is at a hard stop on its limit, or paused
- The bearer token is missing, malformed, or revoked
16Limits
Every account has an approved monthly envelope limit. There is no arbitrary default. When you activate paid usage you declare your estimated monthly envelope volume, and that estimate becomes your initial approved limit.
Self-service increases go up to 10,000 envelopes per month. You can raise your own limit in the portal at any time up to that ceiling. Above 10,000, the request goes to a person.
Approaching and passing the limit
| Point | Behavior |
|---|---|
| 90% of limit | Warning, with a 24-hour cure window to raise the limit or reduce sending |
| 100% of limit | Sending continues. You are not cut off at exactly 100%. |
| 110% of limit | Hard stop. Envelope creation is refused. |
The 10% band exists so a busy Friday does not break your integration. It is headroom, not an allowance to plan against.
Self-imposed cap
You can set your own cap below your approved limit. A self-imposed cap is a hard stop, not a warning. When you hit it, envelope creation stops, and there is no 110% band above it.
This is the control to use if you want a guaranteed ceiling on spend, for example while a new integration runs unattended for the first time.
17Pause controls
The portal has three controls, all self-service and all immediate: Pause New Envelopes, Pause Account, and Unpause Account. They are independent of your self-imposed monthly cap.
Pause New Envelopes is the lighter one. It blocks new envelope creation while leaving the read API, existing envelopes, webhook delivery and billing all working normally.
What a full pause does
A full pause, whether you triggered it or it followed a payment default, behaves the same way. No new envelopes can be created. Envelopes already in flight stay live and their recipients can still sign them.
Pause suppresses delivery, not capture
Outbound webhook delivery to your endpoint stops, but Siglio keeps capturing every signing event and queues it durably. When the account unpauses, the missed events replay to your endpoint in chronological order, with their original event IDs and original occurrence timestamps. You lose nothing.
Support will not pause or unpause an account on a customer's behalf.
18Pricing
$0.25 per envelope. Flat. Every envelope, at every volume.
There are no volume tiers, no negotiated rates, and no annual commitment. 25 cents at ten envelopes a month, 25 cents at a hundred thousand.
There is no monthly fee at any volume. A month where you send nothing costs nothing. There are no seats and no per-user pricing. Billing is per envelope only. There is no contract and no minimum commitment.
SMS delivery is included. Webhooks are included. The first 25 documents are free and need no card.
19Billing cycle
Billing runs on a monthly anniversary cycle. Your cycle starts the day you activate paid billing and repeats on that same day each calendar month. Activate on March 14 and you are invoiced on the 14th of every month.
If your anniversary falls on a day a month does not have, the cycle bills on the closest last day of that month. A January 31 activation bills February 28, then March 31.
Cards
You keep a primary card and a backup card. If the primary fails, the backup is charged.
If both cards fail
| Step | What happens |
|---|---|
| 1 | A 5-day cure period starts |
| 2 | Daily reminders during those 5 days |
| 3 | If payment has not succeeded by the end of the cure period, the account is fully paused |
A full pause means the account stops. Fix the payment method to restore it. See pause controls for exactly what a pause does and does not affect.
Support cannot change a payment method on your behalf, and cannot issue a credit or a refund. Both are handled by a person.
20Getting help
Real questions get real answers from someone who knows the product, usually the same day.
If your question is about a specific API call, send the req_ ID. It is the fastest path to an actual answer, and it is the difference between a diagnosis and a conversation.
What support will not do
- Create, rotate, or revoke an API key for you
- Change a payment method on your behalf
- Issue a credit or a refund (a person handles those)
- Pause or unpause your account for you
- Advise on whether a given signature is enforceable, whether a document type is eligible for electronic signature, or how e-signature law applies to your situation. That is a question for your counsel.