API reference
Signature API
Send a document for signature and get back an executed copy plus an audit trail. Three rails — REST, MCP, and keyless x402 — one meter, and the same validation on every one of them.
Quickstart
Send an envelope, then poll. The send is synchronous: by the time it returns, the links are live and the first group has been invited.
curl -X POST https://sign.ounie.com/api/envelopes \
-H "Authorization: Bearer sgn_live_…" \
-H "Content-Type: application/json" \
-d '{
"document": { "url": "https://example.com/sow.pdf",
"title": "Statement of work" },
"signers": [
{ "name": "A. Reyes", "email": "a@acme.com" },
{ "name": "J. Okonjo", "email": "j@ours.com", "order": 2 }
],
"subject": "Statement of work — Q3",
"expires_in_days": 14
}'
# then, for free, as often as you like:
curl https://sign.ounie.com/api/envelopes/<id> \
-H "Authorization: Bearer sgn_live_…"15 credits to send. 10 more only if and when every signer signs. Polling, reading the audit trail, downloading an executed copy you have already paid for, and withdrawing an envelope are free forever. Buy credits at ounie.com.
What this is not
Every response on every rail carries legalQualification: "none" and a human-readable notice, so a program can read the boundary rather than inferring it from marketing copy. The MCP tool what_this_is_not and the public /api/pricing return the same two lists as below.
Recorded — every one of these is something the server observed on a real request:
- The sha256 of the exact document bytes each party was served
- When each signer was invited, when they first opened the document, and when they signed
- The client IP address and user-agent string observed on the signing request
- The full name each signer typed, and their explicit consent to sign electronically
- Every event in append-only order, timestamped in UTC
Not recorded, and therefore not claimed:
- Signer identity. Nobody's ID is checked, and no document is compared against one.
- Proof that the person at the keyboard is the person the email was addressed to.
- Notarisation, witnessing, or any certificate issued by a qualified trust service provider.
- Geolocation. An IP address is not a location and is not presented as one.
- Handwriting. A typed name is a typed name, not a biometric signature.
If your matter needs a qualified electronic signature, a notary, a witness or verified identity, this is the wrong product. That is a real answer and it is better than a confident one.
Authentication
Three ways in, all resolving to the same Ounie account and the same wallet:
- App key —
Authorization: Bearer sgn_live_…, minted at /dashboard/api-keys. Five active keys per account. - Fleet master key — your ounie.com developer key (
ounie_live_…) works here too, once you have enabled “Use across Ounie apps” in your ounie.com settings. Off by default. - Session cookie — the shared
.ounie.comsession, which is what the dashboard uses. Same endpoints, no key needed in a browser.
For hosts that cannot set an Authorization header, every REST route and the MCP transport also accept ?api_key=sgn_live_….
POST /api/envelopes
Sends one envelope. Costs 15 credits, reserved before any work happens — so an account without the balance gets a 402 with the exact shortfall, and can never overdraw.
| Field | Type | Notes |
|---|---|---|
| document | object | Exactly one of url, base64 or markdown. See below. |
| signers | array | 1–10. Each needs an email; name and order are optional. |
| subject | string | Up to 200 chars. Defaults to “Please sign: <title>”. |
| message | string | Up to 4,000 chars, shown above the document. |
| expires_in_days | integer | 1–90, default 14. |
| webhook_url | string | https only. POSTed once when the envelope reaches a terminal state. Best-effort — polling is the contract. |
| agent_name | string | Who the signer is told this came from. Shown on the signing page. |
The document
Three shapes, because an agent will have the document in whichever one its previous step produced. Exactly one. Supplying two returns 400 document_ambiguous rather than being resolved by precedence — silently ignoring a field is how the wrong document gets signed.
| Shape | What happens |
|---|---|
| url | Fetched over https only, never through a redirect, with DNS checked so a public hostname pointing at a private address is refused. Magic bytes decide whether it is a PDF, not the Content-Type header. |
| base64 | Decoded and checked the same way. A data: URL prefix is tolerated. |
| markdown | Typeset here as a plain single-column PDF: headings, paragraphs, bullet and numbered lists, rules, blockquotes. Up to 60,000 characters. |
Whatever arrives is hashed with sha256 immediately and never rewritten. That hash appears on the certificate, against every signer, on the signing page, and in the invitation email — so verifying is one command.
Ceiling: 8 MB, checked against the declared length and the bytes actually read.
Signers & order
Each signer gets their own link — 256 bits of entropy in the URL, a sha256 digest in the database. A database read can therefore neither sign anything nor open anyone's page.
order groups them. Equal values sign in parallel; a higher group is only invited once every lower group has signed, and its token is minted at that moment rather than up front. Omit it and everyone is invited at once.
"signers": [
{ "name": "A. Reyes", "email": "a@acme.com", "order": 1 },
{ "name": "B. Chen", "email": "b@acme.com", "order": 1 },
{ "name": "J. Okonjo", "email": "j@ours.com", "order": 2 }
]
// A and B are invited now. J is invited when both have signed.Duplicate addresses are refused. So is any address on Ounie's shared suppression list or this app's own opt-outs — and that check runs before anything is quoted or reserved.
The response
{
"ok": true,
"envelopeId": "9f2c41ab-…",
"reference": "9F2C41AB",
"status": "sent",
"artifactStatus": "none",
"subject": "Statement of work — Q3",
"expiresAt": "2026-09-07T…Z",
"document": {
"filename": "Statement-of-work.pdf",
"sha256": "9f2c41ab7e…",
"bytes": 184213, "pages": 6, "origin": "url"
},
"signers": [
{ "name": "A. Reyes", "email": "a@acme.com", "order": 1,
"status": "invited", "invitedAt": "…",
"viewedAt": null, "signedAt": null, "ip": null, "userAgent": null }
],
"executedPdfUrl": null,
"auditTrailUrl": null,
"statusUrl": "https://sign.ounie.com/api/envelopes/9f2c41ab-…",
"legalQualification": "none",
"notice": "Signature produces an executed record…",
"invited": 1,
"deliveryWarnings": [],
"creditsCharged": 15,
"executionCredits": 10
}“Invited” is not “delivered”. SMTP is asynchronous, so an accepted message can still hard-bounce minutes later. We say invited everywhere rather than promising delivery, and deliveryWarnings carries any address the provider rejected outright.
Polling & downloads
GET /api/envelopes/<id> is free and returns the same shape. Add ?events=1 for the full append-only audit log. Once the artifact is ready, executedPdfUrl and auditTrailUrl are freshly signed short-lived URLs, minted per request rather than stored.
Every status response is no-store. That is deliberate: a cached status is a status that can contradict a withdrawal.
GET /api/envelopes lists your envelopes, newest first. Also free.
Execution & holds
The instant the last signature lands the envelope flips to executed. That flip is unconditional and free. The 10-credit charge is a separate step that builds the executed PDF and the audit artifact.
If your wallet is short at that moment, nothing is lost: the signatures stand, the envelope is executed, and artifactStatus reads awaiting_credits with a hold block naming the shortfall. A signature that has been given cannot be taken back because a balance was short.
POST /api/envelopes/<id>/execute
# Idempotent. Already-ready returns 200 and charges nothing.
# Short wallet returns 402 with required_credits and buy_credits_url.Withdrawing
DELETE /api/envelopes/<id> withdraws an envelope that has not been fully signed. Free — changing your mind should never cost anything.
Every signing link stops working on the next request. The signing page is served no-store precisely so that revocation is immediate rather than whenever an edge cache decides to expire.
The audit trail
An append-only log. Nothing in this app updates or deletes an event, because the executed PDF can be rebuilt from these rows and the rows cannot be rebuilt from the PDF. If the two ever disagree, the log wins.
| Event | When |
|---|---|
| created | The envelope row exists and the document is stored |
| invited | An invitation was accepted by the mail provider |
| delivery_failed | The provider rejected the address outright |
| viewed | A signer opened their page for the FIRST time |
| document_downloaded | A signer opened the document itself |
| signed | A signature was recorded, with the typed name |
| declined | A signer refused |
| executed | Every signer had signed |
| expired | The window closed with signatures outstanding |
| voided | The sender withdrew it |
The JSON artifact at auditTrailUrl carries every event plus a notRecorded array — so a program reading it can see the boundary as clearly as a person reading the certificate page can.
MCP
A Model Context Protocol server for Claude, Cursor, ChatGPT, the AI SDK and the Ounie AI Team.
Endpoint https://sign.ounie.com/api/mcp (legacy SSE: /api/sse)
Auth Authorization: Bearer sgn_live_…
# Hosts that can't set headers (e.g. the Ounie AI Team):
https://sign.ounie.com/api/mcp?api_key=sgn_live_…| Tool | Cost |
|---|---|
| send_for_signature | 15 credits |
| get_envelope | free |
| list_envelopes | free |
| produce_executed_copy | 10 credits, idempotent |
| void_envelope | free |
| what_this_is_not | free · public |
| get_credit_balance | free |
| get_pricing | free · public |
| whoami | free |
x402
Keyless pay-per-call in USDC on Base — no account, no key. https://sign.ounie.com/api/x402/send answers 402 with terms; sign and retry with an X-Payment header.
curl -X POST https://sign.ounie.com/api/x402/send \
-H "Content-Type: application/json" \
-d '{"document":{"markdown":"# Mutual NDA\n\n…","title":"Mutual NDA"},
"signers":[{"name":"A. Reyes","email":"a@acme.com"}]}'
# → 402
{ "x402Version": 1,
"accepts": [{ "scheme": "exact", "network": "base",
"maxAmountRequired": "300000",
"payTo": "0x…", "asset": "0x8335…2913",
"extra": { "name": "USD Coin", "version": "2" } }] }Two gates. Anything knowable from the request alone and always fatal — a malformed address, a document URL that resolves to a private address, a suppressed recipient — is a 4xx with no price in the body. You are never asked to sign a payment authorization for work that cannot succeed. Everything else is caught after verify and before settle, so an envelope that could not be delivered returns 402 with nothing moved on chain.
The response carries a statusUrl with a poll token. Polling it and downloading the executed copy are free — this call already paid for both.
Errors
| Status | Meaning |
|---|---|
| 400 | A Gate-1 refusal. Fatal, knowable from the request, and never quoted a price on x402. |
| 401 | No valid session, key or master key. |
| 402 | Insufficient credits (with required_credits, balance_credits, buy_credits_url), or an unsettled x402 payment. |
| 404 | No such envelope, or not yours. |
| 409 | The envelope is not in a state that allows this — already signed, already withdrawn, not yet executed. |
| 422 | The envelope could not be sent and the reserve was refunded in full. |
| 502 | The send or the artifact build failed. Nothing was charged. |
| 503 | Signing is not configured on this deployment. |
Gate-1 codes you can act on: document_required, document_ambiguous, document_url_must_be_https, document_url_blocked, document_must_be_pdf, document_too_large, signers_required, signer_email_invalid, signer_email_duplicate, too_many_signers, expires_in_days_invalid, recipient_suppressed, too_many_open_envelopes.
Limits
| Limit | Value |
|---|---|
| Signers per envelope | 10 |
| Open envelopes per account | 50 |
| Document size | 8 MB |
| Markdown length | 60,000 characters |
| Expiry window | 1–90 days (default 14) |
| Active API keys | 5 per account |
| Download link lifetime | 5 minutes, re-minted per request |
Questions the docs do not answer: get in touch.