The APISign mark: a signature stroke over a ruled line

The paperwork is a POST request.

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.

claude_desktop_config.json
{
  "mcpServers": {
    "apisign": {
      "url": "https://apisign.io/mcp",
      "headers": { "x-api-key": "sk_live_…" }
    }
  }
}
what it does with that
// → 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.

No seats.

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.

No envelope.

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.

No polling.

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

Five statuses, nine events, and they do not line up.

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 signedcontract_completed is the name of the event, not of a state you will ever read back.

draftPOST /contract/create

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.

POSTyour webhook URL
{
  "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": {}
  }
}
cancelledcontract_cancelled

POST /contract/cancel, at any point before the last signature. The links stop working.

expiredcontract_expired

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.

draft
Created, not sent. Free to update.
sent
Out for signature. Some signers may already be done.
signed
Every signer finished. The PDF is sealed.
cancelled
Withdrawn before completion.
expired
Ran past its expiry with signatures outstanding.

Templates

A contract is text. So write it as text.

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.md — the template
# 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}}
POST/contract/create — variables
{
  "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"
}
apisign.io/sign/clx456def/sig_7h2k

Service Agreement

This agreement is between Acme Corporation and Joe Designs LLC, effective September 14, 2026.

Scope

Design and build of the Acme customer portal, through launch.

Fees

The Client shall pay $18,000, due net 15.

Clientsignature · signer
Datedate · signer

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.

Word documents convert on arrival

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.

Six field types, two fillers

text, email, date, initials, signature and signer_name. Each declares completedBy as creator or signer, and that is the whole permissions model.

Thirty-seven to start from

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

The signer gets a link, not an account.

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.

apisign.io/sign/clx456def/sig_7h2k

Service Agreement — Acme Corp

From Joe Designs LLC · for Jordan Lee · expires in 14 days

1 field remaining

…and the Client shall pay $18,000, due net 15. This agreement is governed by the laws of the State of New Mexico.

A finger on a phone, or a trackpad.
Client signature{{signature}}
Dateauto
On opencontract_viewed
On submitcontract_signed
When the last one landscontract_completed

What gets written down while they do it

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.

Order, when order matters

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.

signature
A full signature, drawn, typed or uploaded
initials
The short version, for initialling pages
date
A picker, or filled in automatically at signing
text
Free text — an address, a title, a number
email
Text, validated as an address
signer_name
Filled from the signer record, not typed

Afterwards

The PDF can prove it is the PDF.

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

Issuer
APISign · apisign.io
Sealed
2026-09-15T16:04:57Z
Algorithm
PKCS#7 · RSA-SHA256
Document SHA-256
9f86d081884c7d65…2b7e
Verify at
apisign.io/verify/clx456def

The same five lines the seal puts on the last page of the PDF.

Webhooks

Signed, retried, and honest about both.

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.

contract_createdA new contract was created
contract_sentSent to its signers
contract_resentSent again
contract_viewedA signer opened it
contract_signedA signer completed their signature
contract_completedEvery party has signed
contract_declinedA signer refused
contract_expiredPassed its expiration date
contract_cancelledWithdrawn before completion
verify-webhook.js
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);
}

Four steps, in this order

  1. 01Split the header on , and read t= and v1=.
  2. 02Reject the request if t is more than five minutes from now.
  3. 03Compute HMAC-SHA256(secret, "<t>.<raw body>") and hex-encode it.
  4. 04Compare that against v1 with a constant-time comparison.
every delivery carries
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.

When it does not land

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.

After attempt 11 minute
After attempt 25 minutes
After attempt 330 minutes
After attempt 42 hours
After attempt 5Nothing further — the delivery is marked failed

MCP

Twelve tools an agent can actually finish a job with.

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.

Templates

template_listEvery template in the organization
template_getOne template, with its content and fields
template_createA new template from Markdown
template_updateChange the content or the field definitions
template_uploadA base64 .docx or .doc, converted on arrival
template_archiveRetire one without deleting it

Contracts

contract_listFilterable by status
contract_getOne contract, with every signer's state
contract_createFrom a template, or from raw Markdown
contract_updateDrafts only — a sent contract is frozen
contract_sendThe billable one. Emails the signers
contract_cancelWithdraw it and kill the signing links
claude_desktop_config.json
{
  "mcpServers": {
    "apisign": {
      "url": "https://apisign.io/mcp",
      "headers": { "x-api-key": "sk_live_…" }
    }
  }
}

Registered as io.apisign/apisign

Listed 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

The same verbs, at a prompt.

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.

a session
$ 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
7

Templates

template list--json
template get <id>--output <file> --json
template create--file --name --interactive
template update <id>--file --name
template upload <file>--name
template delete <id>--yes

Contracts

contract list--status <draft|sent|signed|cancelled> --json
contract get <id>--output <file> --json
contract create--template --name --interactive
contract send <id>--email <emails...> --message
contract cancel <id>--reason
contract logs <id>--json

Every read command takes --json, which is the part that makes it scriptable rather than merely typeable. Full reference in the CLI docs.

Money

$0.25, once, when it goes out.

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

DocuSign$775
PandaDoc$1,035
Dropbox Sign$645
SignNow$520
eSignatures.com$245
APISign$125

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.

Sending a contract

$0.25, drawn from your balance once, at the moment the contract is sent.

Resending one

A resend runs the same send path, so it costs another $0.25. There is no free reminder.

Extra signers

The charge is per contract, not per signature. Six signers cost what one does.

Test-mode contracts

The full send path runs and nothing is charged. The request goes to you rather than the signers and the document is stamped TEST.

Team members

There is no seat count anywhere in the billing code. Invite whoever needs access.

Templates, webhooks, keys, storage

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

Things better learned now than in production.

Documented behaviour, all of it, and none of it obvious from the endpoint names.

contract_expired does not fire yet

You 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.

Retries ride on your next event

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.

Ten failures disables the endpoint

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 mode still wants a balance

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.

The signing link is only in the email

/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.

A sent contract is frozen

/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.

20 contracts, no card.

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.