APISign is an electronic signature service whose product is the API. Twelve tools over MCP, a REST endpoint behind every one of them, and a CLI — so the thing that decides a contract should go out can be the thing that sends it.
$ npx add-mcp https://apisign.io/mcp$0.25 a contract · 20 free to start · no card
Point Claude, Cursor or anything else that speaks MCP at the endpoint, hand it a key, and the two calls below are ones it makes on its own.
{
"mcpServers": {
"apisign": {
"url": "https://apisign.io/mcp",
"headers": { "x-api-key": "sk_live_…" }
}
}
}// → contract_create
{
"template_id": "clx123abc",
"name": "Service Agreement — Acme Corp",
"variables": { "client_name": "Acme Corporation", "effective_date": "2026-09-14" },
"expires_in_days": 14,
"signers": [{ "email": "jordan@acme.com", "name": "Jordan Lee", "signing_order": 1 }]
}
// ← { "contract": { "id": "clx456def", "status": "draft" },
// "signers": [{ "id": "sig_7h2k", "status": "pending" }] }
// → contract_send
{ "contract_id": "clx456def" }
// ← { "success": true, "message": "Contract sent to 1 signer" }The paths, the header, the field names and the shape of every response above are the service's own. The ids are invented, and Jordan Lee has never signed anything.
There is no seat count anywhere in the billing code. Invite the whole company and the bill is unchanged, because the thing you are paying for is a contract leaving the building — not a person who might one day send one.
A contract is Markdown with {{variables}} in it. Nothing to drag onto a canvas, no proprietary document format, and a .docx you already have converts on upload — headings, lists and tables intact.
Nine events, delivered signed, retried on a published ladder. You find out somebody opened the link at the moment they open it, and every attempt — including the ones that failed — is in the delivery history.
The life of a contract
This is where integrations go wrong, so it is worth ten seconds up front. Viewed and signed-by-one are things that happen while the status is still sent. And when the last signature lands the status becomes signed — contract_completed is the name of the event, not of a state you will ever read back.
The document is rendered from your template, the variables you passed are substituted, and each signer gets an id you can hold on to. Nothing has been emailed and nothing has been charged.
{
"id": "log_created_9f2a",
"event": "contract_created",
"created_at": "2026-09-14T09:12:04.000Z",
"data": {
"organization_id": "clx8k1m4z0000qz3f7g2c1a9d",
"contract": {
"id": "clx456def",
"name": "Service Agreement — Acme Corp",
"status": "draft",
"test_mode": false
},
"signer": null,
"metadata": {}
}
}POST /contract/cancel, at any point before the last signature. The links stop working.
expires_in_days runs out — 30 by default, 1 to 365 if you set it. The event is subscribable but does not fire yet; see the fine print.
Templates
Every e-signature platform eventually admits that a contract is a few paragraphs with some names in it. APISign starts there: a template is Markdown, a variable is {{snake_case}}, and a contract is that template with a JSON object poured into it.
# Service Agreement
This agreement is between **{{client_name}}** and
**Joe Designs LLC**, effective {{effective_date}}.
## Scope
{{scope}}
## Fees
The Client shall pay {{total_amount}}, due {{due_date}}.
---
**Client:** {{signature}}
**Date:** {{date}}{
"client_name": "Acme Corporation",
"effective_date": "September 14, 2026",
"scope": "Design and build of the Acme customer portal, through launch.",
"total_amount": "$18,000",
"due_date": "net 15"
}This agreement is between Acme Corporation and Joe Designs LLC, effective September 14, 2026.
Design and build of the Acme customer portal, through launch.
The Client shall pay $18,000, due net 15.
The two fields at the bottom never change, whichever contract this is: they are declared completedBy: "signer", so no value you pass will fill them. They stay holes in the document until the person named on it is standing in front of them.
template_upload takes a base64 .docx or .doc and turns it into a Markdown template, preserving headings, lists and tables. Upload the agreement you already use, then put slots in it.
text, email, date, initials, signature and signer_name. Each declares completedBy as creator or signer, and that is the whole permissions model.
NDAs, offer letters, leases, contractor agreements, bills of sale — a public library of drafted templates with the variables already marked up, if you would rather edit than write.
The other end
No sign-up, no download, no plugin — a URL that opens the document in whatever browser they already have, phone included. Try the three ways of making a mark; all of them produce the same audit entry underneath.
Service Agreement — Acme Corp
From Joe Designs LLC · for Jordan Lee · expires in 14 days
…and the Client shall pay $18,000, due net 15. This agreement is governed by the laws of the State of New Mexico.
Every view, every field entry, the IP address it came from and the timestamp it happened at, recorded against the contract rather than reconstructed afterwards. That log is what makes the signature worth having under ESIGN and UETA, and it is readable from /contract/logs and from the dashboard.
signing_order on each signer. Give everybody a 1 and they all get it at once; number them and each is notified as the one before them finishes.
Afterwards
When the last signature lands, the document is hashed with SHA-256 and signed with a PKCS#7 / CMS signature over that hash using RSA-SHA256. The signature travels inside the file, and a visible seal is stamped on the last page carrying the issuer, the timestamp, the hash and the URL to check it at.
Which means the copy in your customer's inbox is checkable by anyone holding it, with no key and no account: GET /api/contract/verify?id=<contractId>, or the page at /verify/<contractId> that renders the document, its signers and the seal metadata. If the certificate is not configured, signing carries on and the seal is simply absent — it degrades rather than failing.
Digitally sealed
The same five lines the seal puts on the last page of the PDF.
Webhooks
Nine events, each delivered as a POST with an HMAC over the raw body and the timestamp it was sent at. Underscores, not dots — an event name spelled any other way is rejected at subscription time with a 400 rather than silently never firing.
import { createHmac, timingSafeEqual } from "node:crypto";
// The raw body, not a re-serialised copy of the parsed JSON — that will not
// match byte for byte, and the comparison below will fail every time.
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && timingSafeEqual(a, b);
}, and read t= and v1=.t is more than five minutes from now.HMAC-SHA256(secret, "<t>.<raw body>") and hex-encode it.v1 with a constant-time comparison.Content-Type: application/json
X-Webhook-Signature: t=1789389124,v1=9f86d081884c7d65…
X-Webhook-Timestamp: 1789389124
X-Webhook-ID: dlv_2b91f0a4
# The event type is not a header. Read it from "event" in the body.A delivery fails if the response is not 2xx, the connection or the TLS handshake does not come up, or nothing completes inside 30 seconds. It gets five attempts on a fixed ladder, and the payload's id is stable across all of them — dedupe on it.
A failure counter runs across deliveries and resets on any success. At ten it flips the endpoint to failed and it stops receiving events entirely, which two fully exhausted deliveries are enough to do.
MCP
Streamable HTTP at https://apisign.io/mcp, authenticated with the same x-api-key header as everything else. The server publishes its own card at /.well-known/mcp.json, so a client that discovers servers automatically finds it without a key.
{
"mcpServers": {
"apisign": {
"url": "https://apisign.io/mcp",
"headers": { "x-api-key": "sk_live_…" }
}
}
}io.apisign/apisignListed where MCP clients go looking, which is the checkable version of saying it works with them:
Rate limits are per key and default to 1,000 requests a minute, refilling on their own. Full reference in the MCP docs.
CLI
apisign-io is a single bundled file with no dependencies of its own — about 270 KB, which is what makes npx a reasonable way to run it. Node 18 or newer, credentials in ~/.apisign/config.json, and the permission on the key decides what it can do: a read-only key lists and reads and nothing else.
$ apisign auth login --api-key sk_live_…
Saved to ~/.apisign/config.json
$ apisign template list --json | jq -r '.[] | "\(.id) \(.name)"'
clx123abc Service Agreement
clx404xyz Mutual NDA
$ apisign contract create --template clx123abc --name "Service Agreement — Acme Corp"
clx456def draft
$ apisign contract send clx456def --email jordan@acme.com
Sent to 1 signer. $0.25 drawn from balance.
$ apisign contract list --status sent --json | jq length
7Every read command takes --json, which is the part that makes it scriptable rather than merely typeable. Full reference in the CLI docs.
Money
No plans, no tiers, no minimum and no seats — there is no subscription anywhere in the product. You hold a balance and each contract you send draws it down. When it runs out, sending returns a 402 naming the amount you are short and charges nothing; auto-recharge tops it up from a saved card if you would rather it did. Unused funds do not expire.
APISign, all in
$125
500 × $0.25, and nothing else
Each competitor is charged at the bottom of its published per-envelope range plus the cheapest plan that has an API on it — 4 of them bill a subscription before the first document goes out. Multi-signer documents widen the gap further on any provider that prices per signature; APISign does not.
$0.25, drawn from your balance once, at the moment the contract is sent.
A resend runs the same send path, so it costs another $0.25. There is no free reminder.
The charge is per contract, not per signature. Six signers cost what one does.
The full send path runs and nothing is charged. The request goes to you rather than the signers and the document is stamped TEST.
There is no seat count anywhere in the billing code. Invite whoever needs access.
Nothing else in the product touches your balance. Sending is the only billable event.
An amber dot is billable; a tick is not. Creating an organization grants $5.00 automatically — 20 contracts — and no card is needed to spend it. The pricing page carries the same rules with the arithmetic written out.
Fine print
Documented behaviour, all of it, and none of it obvious from the endpoint names.
contract_expired does not fire yetYou can subscribe to it and the subscription is stored, but nothing in production marks a contract expired, so the event never arrives. Compare expires_at from /contract/get against the clock in your own code until it does.
A failed delivery is retried when your organization produces its next webhook event, not by a background scheduler. A quiet account can leave a retry sitting past its scheduled time, so reconcile against /contract/get when correctness matters.
A counter runs across deliveries and resets on any success. At ten it flips the webhook to failed and it stops receiving events — which two fully exhausted deliveries are enough to do. Set status back to active to re-enable it.
Test sends are never charged, but the balance check runs before the charge does, so the account needs at least $0.25 on it. Test mode can only be set when the contract is created, never after.
/contract/create gives you signer ids, not URLs. The link is built and delivered by /contract/send, so correlate webhooks and status back to the person using the signer id.
/contract/update takes drafts only. Once it has gone out, the way to change a term is to cancel it and send a new one — which costs another $0.25.
An account comes with $5.00 on it, which is enough to write a template, wire up a webhook, and watch a real signature come back.