Getting started
Quickstart
Create a document, send it for signature, and watch it complete — in four calls. Around ten minutes, most of which is getting a key.
What you need
- A key from Settings → API keys. Use a
xo_test_key so nothing is emailed while you experiment. Self-serve keys need the Crew plan or above. - The scopes
documents:write,documents:send, anddocuments:read— the default set.
export XOSIGN_API_KEY="xo_test_..."1Check your key works
Before anything else, confirm the key is live and see what it can do. This call needs no scope and changes nothing.
curl https://xosign.ai/api/v1/account \
-H "Authorization: Bearer $XOSIGN_API_KEY"const KEY = process.env.XOSIGN_API_KEY;
const res = await fetch("https://xosign.ai/api/v1/account", {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());
// { object: "account", mode: "test", scopes: [...], ... }import os, requests
KEY = os.environ["XOSIGN_API_KEY"]
res = requests.get(
"https://xosign.ai/api/v1/account",
headers={"Authorization": f"Bearer {KEY}"},
timeout=30,
)
res.raise_for_status()
print(res.json())
# {'object': 'account', 'mode': 'test', 'scopes': [...], ...}A 200 means you are ready. Check that mode says test and that scopes contains the three above. A 401 here is almost always a truncated key or a non-canonical host — see always call xosign.ai.
2Create a draft
A draft holds the document and its recipients. Nothing is delivered yet, so this step is always safe to repeat.
curl -X POST https://xosign.ai/api/v1/documents \
-H "Authorization: Bearer $XOSIGN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"title": "Framing Agreement",
"type": "contract",
"recipients": [
{
"name": "Jane Contractor",
"email": "jane@example.com",
"channel": "email",
"role": "signer"
}
],
"content": {
"message": "Please review and sign by Friday.",
"expires_in_days": 14
}
}'import { randomUUID } from "node:crypto";
const KEY = process.env.XOSIGN_API_KEY;
const res = await fetch("https://xosign.ai/api/v1/documents", {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
// Generate this BEFORE the first attempt and reuse it on every retry.
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
title: "Framing Agreement",
type: "contract",
recipients: [
{
name: "Jane Contractor",
email: "jane@example.com",
channel: "email",
role: "signer",
},
],
content: {
message: "Please review and sign by Friday.",
expires_in_days: 14,
},
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const doc = await res.json();
console.log(doc.id, doc.status); // "<uuid>" "draft"import os, uuid, requests
KEY = os.environ["XOSIGN_API_KEY"]
res = requests.post(
"https://xosign.ai/api/v1/documents",
headers={
"Authorization": f"Bearer {KEY}",
# Generate this BEFORE the first attempt and reuse it on every retry.
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"title": "Framing Agreement",
"type": "contract",
"recipients": [
{
"name": "Jane Contractor",
"email": "jane@example.com",
"channel": "email",
"role": "signer",
}
],
"content": {
"message": "Please review and sign by Friday.",
"expires_in_days": 14,
},
},
timeout=30,
)
res.raise_for_status()
doc = res.json()
print(doc["id"], doc["status"]) # "<uuid>" "draft"You get back a 201 with the created document:
{
"object": "document",
"id": "9f1c7d2e-4b3a-4c88-9e21-7a5b0c4d8e13",
"type": "contract",
"status": "draft",
"title": "Framing Agreement",
"recipient_count": 1,
"sent_at": null,
"completed_at": null,
"created_at": "2026-08-04T10:22:31.482Z",
"updated_at": "2026-08-04T10:22:31.482Z"
}Keep the id — the next two steps need it.
This creates a real document
Even with a xo_test_ key, the draft above is written to your real account and will appear in the web app alongside everything else. Test mode suppresses email and SMS; it does not give you a separate dataset. Tidy up after yourself, and do not point a test loop at an account you care about.
3Send it for signature
Sending moves the document to sent and delivers a signature request to each recipient. The body is empty — send options were already set in content when you created the draft.
curl -X POST https://xosign.ai/api/v1/documents/$DOCUMENT_ID/send \
-H "Authorization: Bearer $XOSIGN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{}'const res = await fetch(
`https://xosign.ai/api/v1/documents/${doc.id}/send`,
{
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
// Send options came from `content` at creation; the body is empty.
body: "{}",
},
);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const sent = await res.json();
console.log(sent.status, sent.sent_at); // "sent" "2026-08-04T..."res = requests.post(
f"https://xosign.ai/api/v1/documents/{doc['id']}/send",
headers={
"Authorization": f"Bearer {KEY}",
"Idempotency-Key": str(uuid.uuid4()),
},
# Send options came from `content` at creation; the body is empty.
json={},
timeout=30,
)
res.raise_for_status()
sent = res.json()
print(sent["status"], sent["sent_at"]) # "sent" "2026-08-04T..."With a test key the document still becomes sent, but Jane receives nothing. Swap in a xo_live_ key when you are ready for a real signature request to go out.
Use an Idempotency-Key here above all
If the connection drops after we received your send but before you got the response, retrying without a key could deliver a second round of signature requests to real people. With one, the retry replays the original response and sends nothing. See idempotency.
4Watch it complete
Poll the document for its overall status, or its recipients for per-person progress — who opened it, who signed, who declined.
# Status only.
curl -s https://xosign.ai/api/v1/documents/$DOCUMENT_ID \
-H "Authorization: Bearer $XOSIGN_API_KEY" | jq '.status'
# Who has signed, and who has not.
curl -s https://xosign.ai/api/v1/documents/$DOCUMENT_ID/recipients \
-H "Authorization: Bearer $XOSIGN_API_KEY" \
| jq '.data[] | {display_name, status, signed_at}'// Poll about once a minute. That is far inside every plan's rate limit,
// and a signature is a human action — polling faster buys nothing.
const TERMINAL = new Set(["completed", "declined", "voided", "expired"]);
async function waitForCompletion(id) {
for (;;) {
const res = await fetch(`https://xosign.ai/api/v1/documents/${id}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const doc = await res.json();
if (TERMINAL.has(doc.status)) return doc;
await new Promise((r) => setTimeout(r, 60_000));
}
}import time
# Poll about once a minute. That is far inside every plan's rate limit,
# and a signature is a human action — polling faster buys nothing.
TERMINAL = {"completed", "declined", "voided", "expired"}
def wait_for_completion(doc_id):
while True:
res = requests.get(
f"https://xosign.ai/api/v1/documents/{doc_id}",
headers={"Authorization": f"Bearer {KEY}"},
timeout=30,
)
res.raise_for_status()
doc = res.json()
if doc["status"] in TERMINAL:
return doc
time.sleep(60)status moves sent → partially_signed → completed, or lands on declined, voided, or expired. Treat those four as terminal and stop polling.
Do not poll in a tight loop
Signing is a human action that takes hours or days. Once a minute is plenty and sits far inside every plan’s rate limit. Better still, register a webhook and let us tell you the moment it completes.
Where to go next
- Webhooks — stop polling and get told when something happens.
- API reference — every parameter of the calls you just made.
- Errors — what to do when one of them fails.
- Roadmap — what the API cannot do yet, before you design around it.