# Docustay developer documentation Docustay sends documents for signature. Everything you can do in the app you can also do from code, within the permissions of an API key you create. | You want to | Start here | |---|---| | Send your first document in ten minutes | [Quickstart](/docs/quickstart.md) | | Understand keys, scopes and test keys | [Authentication](/docs/authentication.md) | | Make a document from words and people, answer a template's questions, collect a payment | [Documents from words and people](/docs/documents-from-words.md) | | Turn HTML or a tagged PDF into a template | [Templates and text tags](/docs/templates-and-text-tags.md) | | Show the signing form inside your own app | [Embedded signing](/docs/embedding.md) | | Get told when a document is signed | [Webhooks](/docs/webhooks.md) | | Practise without sending anything real | [Test mode](/docs/test-mode.md) | | Handle failures and limits | [Errors, retries and limits](/docs/errors-and-limits.md) | | Use a ready-made client | [SDKs](/docs/sdks.md) | | Let an AI assistant do it | [MCP server and agent skills](/docs/mcp-and-skills.md) | | Look up an operation | [API reference](/docs/api-reference.md) | Every page here is also served as plain Markdown at the same address with `.md` on the end, and `/llms.txt` lists them all for tools that read documentation. ## What you can build A typical integration is small. Your system decides a document is needed, calls the API with a template and the people's names and emails, and waits for a webhook. When the webhook says the document is completed, your system downloads the signed copy and files it. Everything between is Docustay's job: emailing, reminders, the signing page, the certificate and the seal. ```bash curl -X POST https://docustay.app/api/v1/documents/send \ -H "Authorization: Bearer $DOCUSTAY_KEY" -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \ -d '{ "templateId": "TEMPLATE_ID", "parties": [{ "seat": "signer_1", "name": "Rosa Alvarez", "email": "rosa@example.com" }] }' ``` ## Where to start New to it: do the quickstart with a test key, which emails only you. Already sending: read Webhooks and Errors, retries and limits before you go live, because retries and repeat deliveries are where integrations go wrong. Building a product: read Embedded signing. ## What is included The API, webhooks and embedding are in every plan, within your monthly allowance. Test mode is free and uncounted. The SDKs and the MCP server run on your own machine with your own key. ```js import { Docustay } from "@docustay/sdk"; const docustay = new Docustay(process.env.DOCUSTAY_KEY); ``` --- # Quickstart ## Time to first signature The goal is a first signed document within 30 minutes. In order: make a key (below), make a template (step 1), send it to yourself (step 2), open the signing email and sign, then read the executed document (step 3). Use a test key first, then a live one. In a scripted run on our test environment (sign-up, key, template, send, email, sign) the product's own waiting time was about 20 seconds, so almost all of your time is reading and writing the template. You need a Docustay workspace and a key. In the app open **Developers → API keys** and make a key with the scopes `documents:read` and `documents:write`. Make a **test** key first (it starts `dsk_test_`): everything it does is a practice run. > A test key only works on a workspace that has test mode enabled. If your first request answers `401`, check that you copied the whole key and that it is a key of this workspace. > The examples use `https://app.docustay.app`. A self-hosted install or a practice server has its own address: put that address in the commands instead, and pass it to the SDKs as `baseUrl` (JavaScript) or `base_url` (Python). ## 1. Make a template A template is a document with places for people to sign. The quickest way is HTML with text tags (see [Templates and text tags](/docs/templates-and-text-tags.md)): ```bash curl -X POST https://app.docustay.app/api/v1/templates/html \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"name":"Simple NDA","html":"

Mutual NDA

Both parties keep each other'"'"'s information private.

Signed: {{Sign here;type=signature;role=Partner}}

"}' ``` The answer holds the `templateId` and how many `fields` were placed. ## 2. Send it ```bash curl -X POST https://app.docustay.app/api/v1/documents/send \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"templateId":"","title":"NDA with Acme","parties":[{"seat":"signer_1","name":"Alex Rivera","email":"alex@example.com"}]}' ``` `seat` is the template's seat key. Seats appear in the order roles first appear in the document: the first role is `signer_1`, the next `signer_2`. `GET /api/v1/templates/{id}` lists them. ## 3. Follow it ```bash curl https://app.docustay.app/api/v1/documents/ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` `state` moves `sent` → `partially_signed` → `executed` (or `declined`, `expired`, `void`). When it is `executed`, `GET /api/v1/documents/{id}/signed.pdf` returns the sealed copy. ## The same in code ```js import { Docustay } from "@docustay/sdk"; const docustay = new Docustay(process.env.DOCUSTAY_KEY); const { templateId } = await docustay.createTemplateFromHtml({ name: "Simple NDA", html: "

Signed: {{Sign here;type=signature;role=Partner}}

" }); const { id } = await docustay.sendDocument({ templateId, parties: [{ seat: "signer_1", name: "Alex Rivera", email: "alex@example.com" }] }); console.log((await docustay.getDocument(id)).state); ``` ```python from docustay import Docustay d = Docustay(os.environ["DOCUSTAY_KEY"]) t = d.create_template_from_html("Simple NDA", "

Signed: {{Sign here;type=signature;role=Partner}}

") doc = d.send_document(t["templateId"], [{"seat": "signer_1", "name": "Alex Rivera", "email": "alex@example.com"}]) print(d.get_document(doc["id"])["state"]) ``` Next: [get told when it is signed](/docs/webhooks.md) instead of asking. --- # Authentication Send `Authorization: Bearer ` on every request. Keys are made in **Developers → API keys** and shown once; if you lose one, make another and revoke the old. ## Scopes | Scope | Lets the key | |---|---| | `documents:read` | list and read documents and templates, read the audit log, download signed copies, read reports and teams | | `documents:write` | send documents, make templates, remind, void, start an embedded signing session | A key can only carry scopes its creator holds. A request without the scope gets the same `401` answer as a wrong key, on purpose: nothing tells a guesser which half was right. ## Test keys and live keys `dsk_test_…` keys act on test data only: documents sent with one are practice runs. `dsk_live_…` keys act on real documents. See [Test mode](/docs/test-mode.md). ## What a key can never do No response contains a signing link or an access code, and a key cannot sign for anyone. The one exception is [embedded signing](/docs/embedding.md): for seats **you** mark as embedded when you send, a key can ask for a one-hour session to show your own signer the form on a website you listed. ## Keeping a key safe - Keep it on your server. Never put a key in a web page or a mobile app. - Give it only the scopes it needs, and make one key per integration so one can be revoked alone. - Every use of a key is in the audit log with the key's name, and revoking takes effect at once. ## A request, start to finish List your executed documents with a key that has `documents:read`: ```bash curl https://docustay.app/api/v1/documents?state=executed \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` The same call from JavaScript, using the SDK, which adds the header for you: ```js import { Docustay } from "@docustay/sdk"; const docustay = new Docustay(process.env.DOCUSTAY_KEY); const page = await docustay.listDocuments({ state: "executed", limit: 20 }); ``` ## When a key stops working A `401` after it used to work means the key was revoked, expired or lost a scope. Make a new key, update the one integration that used it, and the audit log will show which key made which call, so you can see whether the old one is still being used somewhere. --- # Templates and text tags A **text tag** is written inside the document's own text where a field should go: ``` {{Buyer signature;type=signature;role=Buyer}} ``` The first part is the name. After it come `key=value` pairs separated by semicolons. A tag needs at least one `key=value`; `{{signer.name}}` with none is a merge field and is left alone. | Key | Meaning | |---|---| | `type` | `signature`, `initials`, `date_signed`, `stamp`, `text` (default), `number`, `date`, `email`, `phone`, `zip`, `checkbox`, `dropdown` (or `select`), `multiselect`, `image`, `file`, `payment` | | `role` | Who fills it in, any words. The first role written is `signer_1`, the next `signer_2`, and so on | | `required` | `true` (default) or `false` | | `default` | A starting value. Not allowed on signature, initials, date_signed or stamp | | `options` | A comma list for dropdown and multiselect: `options=Monthly,Yearly` | | `label` | The caption the signer sees (default: the name) | | `width`, `height` | Size in points (1/72 inch); each type has a default | | `max_length` | Most characters a text field takes | | `amount`, `currency` | For `payment`: a fixed amount, `amount=150.00;currency=usd`. A payment takes exactly one of `amount`, `formula`, `price` or `link` (see Payments below) | | `formula` | For `payment`: an amount worked out from other fields, `formula=quantity*unit_price*(1+tax/100)` | | `price` | For `payment`: a Stripe price on the connected Stripe account, `price=price_1AbC…` | | `link` | For `payment`: a Stripe payment link from the connected Stripe account, `link=https://buy.stripe.com/…` | A value that holds a semicolon or an equals sign goes in double quotes: `default="Smith; Jones"`. ## From HTML `POST /api/v1/templates/html` with `{ "name", "html", "paper" }` prints your HTML to a PDF and places a field wherever a tag sits. Scripts, styles that load anything, frames, forms and remote addresses are removed before printing; pictures must be `data:` addresses; up to 2 MB. The answer is `{ templateId, fields, warnings }`. A tag that could not be read comes back in `warnings`, naming that tag. ## From a document source `POST /api/v1/templates/source` with `{ "name", "source", "profile", "paper" }` keeps a document written in the shared editor's JSON format (`{ "type": "doc", "content": [...] }`) as a template. `profile` is `typeset` (default) or `contract`; `paper` is `letter` or `a4`. Needs the `documents:write` scope and an `Idempotency-Key`. Answers 201 with `{ templateId, profile, version, hash, warnings }`. ```bash curl -X POST https://docustay.app/api/v1/templates/source \ -H "Authorization: Bearer $DOCUSTAY_KEY" -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \ -d '{ "name": "Notice", "source": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Hello" }] }] } }' ``` ## Ask for an AI draft AI drafting is off by default; it is refused plainly while it is off for your workspace or when a limit is reached. A draft is a suggestion for a person to read: nothing is saved or sent. `POST /api/v1/documents/ai/draft` with `{ "prompt", "mode" }` (`mode` is `template` or `document`) answers 202 with `{ jobId, state }`. ```bash curl -X POST https://docustay.app/api/v1/documents/ai/draft \ -H "Authorization: Bearer $DOCUSTAY_KEY" -H "Content-Type: application/json" \ -d '{ "prompt": "A one-page services agreement with a client signature", "mode": "template" }' ``` `GET /api/v1/documents/ai/jobs/{id}` returns `{ id, state, message, source, patches }`. `state` is `queued`, `running`, `done`, `failed` or `cancelled`; poll until it is not queued or running. When done, `source` is the draft and `patches.notes` lists what to check. ```bash curl https://docustay.app/api/v1/documents/ai/jobs/$JOB_ID -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## From a file `POST /api/v1/templates/file` with `{ "name", "fileName", "file" }` where `file` is the base64 of a PDF, Word, Excel, PowerPoint or image file with tags written in it. Tags are found in the file's text, covered with a white box and replaced by the field. ## Check the seats `GET /api/v1/templates/{id}` returns the template's `seats`. Send with one `parties` entry per seat: `{ "seat": "signer_1", "name": "…", "email": "…" }`. ## Payments A `payment` field makes the signer pay on Stripe's own page before their signature completes. The money goes to **the Stripe account you connect** (Settings → Payments → Connect Stripe; Stripe's own sign-up page creates it in your name, separate from any Stripe account you already have). Docustay's fee is 2% of each payment on the Free plan and 0% on Pro, on top of Stripe's own fees, which Stripe charges to that Stripe account as usual. Card details are typed on Stripe's page; Docustay never receives or stores them. A payment field takes exactly one way of setting the amount: | Amount | Tag | Notes | |---|---|---| | Fixed | `amount=150.00` | 0.50 to 999,999.99 | | Formula | `formula=quantity*unit_price*(1+tax/100)` | Numbers, the names of other text fields filled by the same person, `+ - * /` and brackets. Nothing else is read, and nothing is run. The server works the amount out from the values the signer typed; the browser never sends an amount. If the person changes a number after paying, signing is refused. | | Stripe price | `price=price_1AbC…` | A one-time price on your connected Stripe account. The amount comes from Stripe. | | Stripe payment link | `link=https://buy.stripe.com/…` | A link from the same Stripe account, with a fixed price. Its items are charged as listed. | A price or link that cannot be found stops the send, with Stripe's reason, so the signer never meets it. The `payments` list on `GET /api/v1/documents/{id}` shows each payment: status, amount, Stripe's receipt link, refunds, and the Docustay fee (`fee.basisPoints`, `fee.amountMinor`) that was decided when the payment started. A refund returns the fee with it. ## A tag in an HTML file, sent through the API ```bash curl -X POST https://docustay.app/api/v1/templates/html \ -H "Authorization: Bearer $DOCUSTAY_KEY" -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \ -d '{ "name": "Services agreement", "html": "

Client: {{Client signature;type=signature;role=Client}}

" }' ``` --- # Embedded signing Show the signing form inside your own page, so the signer never leaves your app. ## How it fits together 1. **List your website.** In **Developers → Embedding** add each website that will show the form, one per line, for example `https://app.example.com`. While developing, `http://localhost:3000` is allowed. Nothing else (no path, no plain `http`) is. 2. **Send with embedded seats.** On your server add `embedded: ["signer_1"]` to the send request. Nobody is emailed for those seats. 3. **Ask for a session.** On your server call `POST /api/v1/documents/{id}/embed-session` with `{ "seat": "signer_1", "origin": "https://app.example.com" }`. The answer is `{ url, expiresAt }`. The link works for one hour; ask again each time the page loads. 4. **Show it.** Give the `url` to the element. The API key stays on your server. The `url` is the only thing the browser sees. ## The element ```html ``` Attributes: `url`, `height` (pixels or any CSS length), `auto-height` (follow the form's own height), `title`. Events, each with `detail.documentId`: | Event | When | |---|---| | `ready` | the form has loaded | | `waiting` | it is not this person's turn yet | | `completed` | the signer finished | | `declined` | the signer declined | | `error` | the link was refused (expired, wrong, or the website is not on the list) | | `resize` | the form's height changed (`detail.height`) | The `docustay` event carries all of them as `{ type, … }`. Events do not bubble. ## React, Vue, Angular ```jsx import { DocustaySign } from "@docustay/react"; confirmOnServer(documentId)} /> ``` ```vue ``` ```html ``` ## What keeps it safe - The form can be framed only by the websites you listed. Docustay sends a `frame-ancestors` header naming exactly those, and only for a link made for an embedded seat. An ordinary emailed link is never frameable, even with `embed=1` added. - The form sends its events to one parent origin, the one in the link, and only if it is on your list. The element accepts a message only from its own frame and the form's own origin. - Treat `completed` as a prompt, not proof: confirm with `GET /api/v1/documents/{id}` on your server (`state: executed`). ## Let your people build templates inside your own page The builder is the same editor Docustay uses, in a frame on your website. Your server asks for a session; your page shows it. 1. **On your server** (never in a browser), with a key that has the `embedded:write` scope: ``` POST /api/v1/embedded/builder-session { "origin": "https://app.example.com", "name": "Services agreement" } ``` The answer is a `url` that works for one hour. The website must already be on your allowed list (Settings → Embedding); anything else gets a 409. 2. **In your page**, show it: ```html ``` 3. **Listen** for `ready`, `saved` (`{ templateId, name, fields }`) and `error`. Use the `templateId` to send documents from your server as usual. What keeps it safe: the key inside the link is created for this session only, lives in the `#fragment` (so no server log sees it), expires in an hour, and can read and write templates and nothing else — it cannot send a document. The link names the one website that may frame it and is signed; change the website and it will not frame anywhere. --- # Documents from words and people Everything the screens do, a key can do. A document is **words** (a source) plus **people** (a name, an email, a role word). Roles are words from the text, such as `Client` and `Provider`, never seat numbers. ## Make a document ```bash curl -X POST https://app.docustay.app/api/v1/documents \ -H "Authorization: Bearer $DOCUSTAY_KEY" -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \ -d '{ "title": "Retainer agreement", "source": { "type": "doc", "content": [ { "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Retainer agreement" }] }, { "type": "paragraph", "content": [{ "type": "text", "text": "The studio works for " }, { "type": "variableChip", "attrs": { "name": "custom.p_client_name", "label": "Client name" } }, { "type": "text", "text": "." }] }, { "type": "paragraph", "content": [{ "type": "fieldTag", "attrs": { "name": "Client signature", "type": "signature", "role": "Client" } }] } ] }, "people": [{ "name": "Dana Whitfield", "email": "dana@example.com", "role": "Client" }], "send": { "subject": "Please sign", "reminderDays": [3, 7], "expiryDays": 30 } }' ``` The answer is `{ "id", "state": "draft", "roles": [...] }`. The document stays a **draft** until you send it with `POST /api/v1/documents/{id}/send` (or set `sendNow: true`). The same checks as the screen's Send apply. * **People.** `{ name, email, role?, action?, order? }`. `action` is `signer`, `approver`, `viewer`, `assistant` or `cc`. Each person's own name and email are available to the words as merge text: a `custom.p__name` chip (for example `custom.p_client_name`) prints that role's person. * **Smart fields.** A `fieldTag` can fill itself from the person (`autofill: "name"` or `"email"`), allow or forbid changes (`editable`), carry a `placeholder` and `tooltip`, or be filled by you and locked (`locked` with a `default`). * **Sender only sections.** A heading with `audience: "sender"` hides itself and everything below it up to the next heading of the same or higher level. It is in nothing a signer sees, signs or receives. A signer field inside such a section is refused. * **Check first.** `POST /api/v1/documents/check` tells you what a person would be told: errors, warnings and the roles in the text. Nothing is saved. * **Read and change a draft.** `GET /api/v1/documents/{id}/source` returns the words, people and choices. `POST /api/v1/documents/{id}/revise` prints new words into a **new** draft that takes the people and choices; the old draft is deleted, so **the id changes**. ## Templates with questions A `custom.` merge chip in a template is a **question** the sender answers when using it (`kind` is `text`, `number`, `money` or `date`; `default` and `help` are optional). `GET /api/v1/templates/{id}` lists them. Use the template: ```bash curl -X POST https://app.docustay.app/api/v1/templates/$TEMPLATE/documents \ -H "Authorization: Bearer $DOCUSTAY_KEY" -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \ -d '{ "people": [{ "name": "Dana Whitfield", "email": "dana@example.com" }], "variableValues": { "fee": "750", "client_company": "Harbor Traders" } }' ``` Every question must have an answer (or a starting answer). `POST /api/v1/templates/{id}/defaults` sets what a document made from the template starts with: its description, the signer-facing text, the message and the send choices. ## The assistant * **Draft.** `POST /api/v1/documents/ai/draft` takes a plain `prompt` or the wizard's `brief`. `GET /api/v1/ai/wizard` returns the same questions the screen asks, the payment questions and the currencies offered. * **Change.** `POST /api/v1/documents/ai/edit` takes `request` and `source` and returns **proposed changes**, never an edited document: `patches.applied` lists `replace_text`, `insert_block`, `replace_block` and `remove_block` operations for a person to accept. With `variables` (the template's questions) it also proposes answers in `patches.values`. * **Progress.** Read `GET /api/v1/documents/ai/jobs/{id}`: `stage` moves `queued`, `writing`, `checking`, `ready` (or `failed`). `POST /api/v1/documents/ai/jobs/{id}/cancel` stops it. A job whose worker died is put back and finished by itself. * The assistant never sends, never pays and never signs. ## Collect a payment at signing Put the payment in the brief: `"payment": { "collect": true, "amount": "1500", "currency": "usd", "payer_role": "Client", "description": "the first month" }`. The **amount comes only from the person**: the assistant drafts around it, the worker (plain code) checks every amount it proposes against yours and places one Payment field for the payer, and the payment terms are written in the text from your own values. Later payments are written as text (`schedule_note`). The signer pays on Stripe's hosted page and cannot sign until Stripe says it is paid. `GET /api/v1/payments/status` says `connected` (details submitted, charges on, nothing due), `needs_attention` or `not_connected`. **A document with a payment field can only be sent when it says `connected`**; otherwise Send answers with a plain reason. Currencies offered: usd, eur, gbp, cad, aud, nzd, chf, sek, nok, dkk. ## From an SDK or MCP | API | JavaScript | Python | MCP tool | |---|---|---|---| | `POST /documents` | `createDocument` | `create_document` | `create_document` | | `POST /documents/check` | `checkDocument` | `check_document` | `check_document` | | `GET /documents/{id}/source` | `getDraftSource` | `get_draft_source` | `get_draft_source` | | `POST /documents/{id}/revise` | `reviseDocument` | `revise_document` | `revise_document` | | `POST /documents/{id}/send` | `sendDraft` | `send_draft` | `send_draft` (asks first) | | `POST /templates/{id}/documents` | `useTemplate` | `use_template` | `use_template` | | `POST /templates/{id}/defaults` | `setTemplateDefaults` | `set_template_defaults` | `set_template_defaults` | | `POST /documents/ai/draft` | `draftWithAi` | `draft_with_ai` | `draft_document_with_ai` | | `POST /documents/ai/edit` | `editWithAi` | `edit_with_ai` | `edit_document_with_ai` | | `GET /ai/wizard` | `getWizard` | `get_wizard` | `get_wizard` | | `GET /payments/status` | `paymentsStatus` | `payments_status` | `get_payments_status` | Over MCP, nothing emails anyone until `send_draft` is called again with `confirm: true`, and there is no tool that signs, pays or deletes. --- # Webhooks Register an endpoint in **Developers → Webhooks**, choose the events, and Docustay posts a JSON event to it each time one happens. Each endpoint has a secret that starts `whsec_`, shown once. ## Verify every delivery Deliveries carry the [Standard Webhooks](https://www.standardwebhooks.com) headers `webhook-id`, `webhook-timestamp` and `webhook-signature` (`v1,`). Verify against the **raw** body, not re-serialised JSON. ```js import { verifyWebhook } from "@docustay/sdk"; const event = await verifyWebhook(process.env.DOCUSTAY_WEBHOOK_SECRET, req.headers, rawBody); ``` ```python from docustay import verify_webhook event = verify_webhook(secret, headers, raw_body) ``` Both refuse a changed body, a wrong secret and a timestamp more than five minutes old. A receiver using any Standard Webhooks library passes `whsec_` plus the secret with `-` swapped for `+` and `_` for `/`. ## Trying it from your laptop Docustay can only send to a public `https://` address: `localhost`, `127.0.0.1` and office or home network addresses are refused when you add the endpoint, and the message says so. While you build the receiver, give your laptop a public address with a tunnel tool, add that address as the endpoint, and use **Send again** in the delivery log to replay an event as often as you like. When the receiver is deployed, replace the address. ## Delivery Answer with any `2xx` quickly. A delivery that fails is retried, and every attempt is in the delivery log under the endpoint, where a delivery can be replayed. Deliveries can arrive more than once and out of order: use the `webhook-id` to ignore a repeat, and fetch the document (`GET /api/v1/documents/{id}`) rather than trusting the event as the final word. The events carry facts about the document, never a signing link or a code. ## Which events You choose per endpoint. The ones most receivers want are `documents.document.sent`, `documents.document.opened`, `documents.document.signed` (one person signed), `documents.document.executed` (everyone signed and the sealed copy exists), `documents.document.declined`, `documents.document.expired` and `documents.document.voided`. A receiver that only cares about finished documents subscribes to `documents.document.executed` and nothing else. ## A receiver, step by step 1. Read the **raw** request body as bytes before any JSON parser touches it. 2. Verify the three headers against that body with the SDK, as above. If verification throws, answer `400` and stop. 3. Look at `webhook-id`. If you have processed it, answer `200` and stop; this is a repeat. 4. Queue the work (fetch the document, save the signed copy) and answer `200` at once. Do slow work after you have answered. ```bash curl https://docustay.app/api/v1/documents/DOCUMENT_ID/signed.pdf \ -H "Authorization: Bearer $DOCUSTAY_KEY" -o signed.pdf ``` ## The delivery log Each endpoint's page shows a delivery log with the event, the result your server gave and which try it was. **Send again** replays a delivery, so you can fix your receiver and try again without making another document. Repeats carry the same `webhook-id`, which is why your receiver should ignore ids it has already handled. --- # Test mode A document made in test mode is a practice run: - It is emailed **only to the person who sent it**, with `[TEST]` in the subject. - Its pages carry a TEST watermark. - It is left out of usage, billing and reports. - Signing it records nothing as a real signature event. ## Three ways in 1. Use a `dsk_test_` API key. 2. Send with `send_options.test: true` from the app's own send step. 3. Turn on **Settings → Documents → Test mode** so everything the workspace sends is a practice run. Documents carry `test: true` in the API, and the app marks them with a Test chip and a banner on the signing page. Switch back to a live key (or turn the setting off) to send real documents. ## What it is good for Use test mode to check your whole flow before anything real is sent: that your template places fields where you expect, that your webhook receiver verifies a delivery, that your page handles the embedded form's events, and that your own screens show a finished document. Because a test document is emailed only to you, you can send one to a made-up person and read the email yourself. ## A short run-through Send with a test key and open the email: ```bash curl -X POST https://docustay.app/api/v1/documents/send \ -H "Authorization: Bearer $DOCUSTAY_TEST_KEY" \ -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \ -d '{ "templateId": "TEMPLATE_ID", "parties": [{ "seat": "signer_1", "name": "Practice Person", "email": "someone@example.com" }] }' ``` The answer has `"test": true`. The email arrives in your own inbox, not someone@example.com's. Sign it, then fetch the document: ```bash curl https://docustay.app/api/v1/documents/DOCUMENT_ID -H "Authorization: Bearer $DOCUSTAY_TEST_KEY" ``` ## What test mode does not do It does not test payments against real cards. A document made in test mode is marked as a practice run on its pages and in the API (`test: true`), and it is left out of your usage, so you can practise as much as you like. --- # Errors, retries and limits ## Errors Every error is `{ "error": { "kind", "message", "requestId", "retryable" } }`. `kind` is one of `invalid`, `unauthenticated`, `forbidden`, `not_found`, `conflict`, `rate_limited`, `dependency`, `timeout`. Quote `requestId` when you ask for help. | Status | Usually means | |---|---| | 400 | A field is not valid; `message` says which and why | | 401 | The key is missing, wrong, revoked or lacks the scope (one answer for all four) | | 403 | The workspace's plan or a setting does not allow this | | 404 | No such document, template or seat | | 409 | The request is fine but the state is wrong (for example, voiding a finished document) | | 429 | Over the rate limit; wait the `Retry-After` seconds | | 5xx | Our side; safe to retry with the same `Idempotency-Key` | ## Idempotency Requests that create a document or a template (send a document, make a template from HTML, make a template from a file) need an `Idempotency-Key` header of 8 to 200 characters. Repeating a request with the same key and the same body returns the first answer instead of doing it again. Reusing a key with a different body is an error. Other requests, such as a reminder, a void or an embedded session, do not need one. The SDKs make a key for you and reuse it on every retry. ## Rate limits Each key has its own per-minute budget; over it you get `429` with a `Retry-After` header. See [API limits](#api-limits). Sign-in, public signing, download and verification pages also have a per-address limit shared by every server of the install. ## What counts as a document A document is a **sent** document. A package of several documents sent together counts once. Drafts, signers, reminders, resends and test-mode documents never count. The count is per calendar month in UTC and resets on the 1st. - **Free:** 15 documents a month. At the limit, sending is blocked with a `403` whose message says the plan allows 15 documents a month and to upgrade to Pro. Nothing is ever billed for going over: there are no overage bills. - **Pro:** no monthly document limit. ## Email limits Each workspace can send 25 emails a day on Free and 500 a day on Pro. Signing requests, reminders and completed copies sent to signers count. Alerts to the workspace owner, sign-in emails and the usage warning emails do not count and are never blocked. A self-hosted install has no limit. The day is a UTC day and the count resets at midnight UTC. When the limit is reached the send is refused with a plain message. If you send from your own connected mailbox or your own verified domain, the From address uses your provider's sending. That option is in **Settings → Email**; see [Email setup](/docs/email-setup.md). ## API limits Each API key may make 600 requests per minute. Over that, the API answers `429` with a `Retry-After` header (in seconds). Nothing is lost if you wait and retry. Retried `POST` requests with the same `Idempotency-Key` never send twice. There are no per-call or per-API-document fees on any plan. ## Fair use on Pro Pro is unlimited for one organisation's own documents. If your use becomes far above normal (many thousands of documents a month) we will contact you first and agree a plan. We do not bill extra charges automatically. ## Usage and alerts The Billing and Reports pages show documents sent this month and emails sent today. We email the workspace owner once when 80% and once when 100% of the Free monthly document limit is used (the 12th and the 15th document), and once a day when 80% and 100% of the daily email limit is used (Free: the 20th and the 25th email; Pro: the 400th and the 500th). `GET /api/v1/usage` returns the same numbers. It needs an API key with `documents:read`. ```bash curl https://app.docustay.app/api/v1/usage -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ```json { "plan": "free", "period": { "start": "2026-10-01T00:00:00Z", "end": "2026-11-01T00:00:00Z" }, "documents": { "used": 4, "limit": 15 }, "emails": { "usedToday": 6, "limitPerDay": 25, "resetsAt": "2026-10-07T00:00:00Z" } } ``` ## Pagination Lists take `limit` (1 to 200) and `cursor`. The answer carries `page.nextCursor` (null on the last page). ## Upload limits Files uploaded for signing are limited to 25 MB on Free and 100 MB on Pro. ## A retry loop that is safe Always send the same `Idempotency-Key` on every retry of one logical request, and wait for `Retry-After` on a `429`: ```js async function sendWithRetry(body) { const key = crypto.randomUUID(); for (let attempt = 0; attempt < 5; attempt += 1) { const r = await fetch("https://docustay.app/api/v1/documents/send", { method: "POST", body: JSON.stringify(body), headers: { Authorization: `Bearer ${process.env.DOCUSTAY_KEY}`, "Idempotency-Key": key, "Content-Type": "application/json" }, }); if (r.status !== 429 && r.status < 500) return r; await new Promise((ok) => setTimeout(ok, (Number(r.headers.get("retry-after")) || 2 ** attempt) * 1000)); } throw new Error("still failing after five tries"); } ``` ## Reading an error ```json { "error": { "kind": "invalid", "message": "Parties: signer_2 is not a seat on this template.", "requestId": "req_123", "retryable": false } } ``` `retryable: false` means sending the same thing again will fail the same way: fix the request. `retryable: true` means try again with the same key. --- # Email setup Signing requests only help if they arrive. Mail sent from your own domain is far more likely to reach the inbox when the domain publishes three DNS records: SPF, DKIM and DMARC. Docustay shows the exact values to use; this page explains what they are. Every record below uses `example.com`: replace it with your domain. ## The three records **SPF** says which servers may send for your domain. It is a `TXT` record on the sending domain. For a provider, it looks like this: ``` example.com. TXT "v=spf1 include: ~all" ``` A domain may have only **one** SPF record. If you already have one, add the provider's `include:` to it instead of adding a second record. **DKIM** signs each message. The provider gives you a selector and a public key. Add them as a `TXT` or `CNAME` record exactly as shown: ``` selector1._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=" ``` **DMARC** tells receivers what to do when SPF and DKIM fail, and where to send reports. Start by only watching: ``` _dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com" ``` When the reports look clean, move to `p=quarantine`. ## Verify in Docustay 1. Open **Settings → Email**. 2. Add the domain. 3. Add the records it shows at your DNS host. 4. Press **Verify**. The state shows **pending** until DNS answers. DNS can take minutes to hours. ## Test it Send a test email to a mailbox you control. In the message's headers (often "Show original") look for `spf=pass`, `dkim=pass` and `dmarc=pass`. ## Self-hosting The self-host environment file (`docustay/.env.example`) takes one of two options: - `RESEND_API_KEY`: your own Resend API key. - Your own SMTP server: leave `RESEND_API_KEY` empty and enter the server in **Settings → Email** after you sign in. - Amazon SES: SES gives you SMTP credentials (in the SES console, “SMTP settings”). Enter that server, port 587 and those credentials in **Settings → Email** as your own SMTP server. Your sending then goes through your SES account and never through ours. `KEYSTONE_MAIL_SENDER` is the default From address used until you verify your own domain, and `KEYSTONE_PUBLIC_URL` is the address used for links in emails. If neither option is set, Docustay says email isn't set up and will not invite signers. ## Common mistakes - **Two SPF records.** Merge them into one with all the `include:` entries. - **A mistyped DKIM selector.** The record name must match the selector exactly. - **A trailing dot.** Some DNS hosts add your domain for you; a name entered with a trailing dot, or with the domain repeated, ends up in the wrong place. - **Cached answers.** A long TTL on an old record keeps the old answer until it expires; wait, then press Verify again. --- # SDKs Both clients cover every operation in the [API reference](/docs/api-reference.md), add an `Idempotency-Key` to every POST and reuse it on retries, retry `429` and `5xx` honouring `Retry-After`, throw a typed error with `status`, `kind` and the request id, page through lists for you and verify webhooks. | Language | Package | Needs | |---|---|---| | JavaScript / TypeScript | `@docustay/sdk` | Node 18+, a browser, Deno or Bun; no dependencies | | Python | `docustay` | Python 3.9+; standard library only | ```js import { Docustay } from "@docustay/sdk"; const docustay = new Docustay(process.env.DOCUSTAY_KEY); for await (const d of docustay.eachDocument({ state: "executed" })) console.log(d.id, d.title); ``` ```python from docustay import Docustay d = Docustay(os.environ["DOCUSTAY_KEY"]) for doc in d.each_document(state="executed"): print(doc["id"], doc["title"]) ``` Other languages: the API is described by an OpenAPI 3.1 file at `/api/openapi.json` and a Postman collection at `/api/docustay.postman_collection.json`. Frameworks: [`@docustay/react`, `@docustay/vue`, `@docustay/angular`](/docs/embedding.md). ## Pointing at your own server Both clients talk to `https://app.docustay.app` unless you say otherwise. A self-hosted install, or any other Docustay address, goes in the constructor: ```js const docustay = new Docustay(process.env.DOCUSTAY_KEY, { baseUrl: "https://sign.example.com" }); ``` ```python d = Docustay(os.environ["DOCUSTAY_KEY"], base_url="https://sign.example.com") ``` If the address is wrong and answers with a web page instead of the API, the client throws a `DocustayError` of kind `bad_response` that names the address and this option; it does not show a JSON parse error. ## Installing The packages are built and tested but not yet published to the public registries. Until they are, install them from the `sdk/` folder of the repository. The changelog will say when that changes. ## Sending a document ```js import { Docustay } from "@docustay/sdk"; const docustay = new Docustay(process.env.DOCUSTAY_KEY); const doc = await docustay.sendDocument({ templateId: "TEMPLATE_ID", parties: [{ seat: "signer_1", name: "Rosa Alvarez", email: "rosa@example.com" }] }); console.log(doc.id, doc.state); ``` ```python doc = d.send_document(template_id="TEMPLATE_ID", parties=[{"seat": "signer_1", "name": "Rosa Alvarez", "email": "rosa@example.com"}]) print(doc["id"], doc["state"]) ``` ## Handling errors Both clients throw a typed error with `status`, `kind`, `message` and `requestId`. Catch it, look at `kind`, and decide: `invalid` means fix the request, `rate_limited` and `dependency` are retried for you up to a limit, and `conflict` means the document is not in a state that allows what you asked. ## Other languages Any language can call the REST API directly. The OpenAPI 3.1 file describes every operation and generators for Go, Java, Ruby and PHP can build a client from it. The Postman collection lets you try each call by hand. --- # MCP server and agent skills `@docustay/mcp` is a Model Context Protocol server. It runs on your own computer with your own API key and gives an assistant these tools: `list_templates`, `get_template`, `list_documents`, `get_document`, `get_audit_log`, `get_report`, `save_signed_pdf`, `send_document`, `remind`, `void_document` and `create_template_from_html`. ```json { "mcpServers": { "docustay": { "command": "npx", "args": ["-y", "@docustay/mcp"], "env": { "DOCUSTAY_API_KEY": "dsk_test_…" } } } } ``` A server other than `https://app.docustay.app` (a self-hosted install or a practice server) is named with `DOCUSTAY_BASE_URL`, in the same `env` block. ## Safety - **Preview first.** `send_document`, `send_draft`, `remind` and `void_document` do nothing the first time: they return a plain-words preview of who would be emailed. Only a second call with `confirm: true` acts. - **Read-only mode.** Set `DOCUSTAY_READ_ONLY=1` and every tool that changes anything is removed. - **Your key, your scopes.** The key decides what is possible; use a `dsk_test_` key to practise. - **No links, no keys.** No tool returns a signing link, a code or the key. ## Words and people, and the assistant Besides sending a template, the server can make a **draft** from written words and people (`create_document`, `use_template`), check it (`check_document`), read and change it (`get_draft_source`, `revise_document`), set a template's defaults (`set_template_defaults`), ask the Docustay assistant to draft or to propose changes (`draft_document_with_ai`, `edit_document_with_ai`, `cancel_ai_job`), read the wizard's questions (`get_wizard`) and ask whether Stripe is ready for a payment (`get_payments_status`). Making or changing a draft emails nobody: `send_draft` is the only step that does, and it previews first. The assistant's edits come back as proposals for a person to accept. See [Documents from words and people](/docs/documents-from-words.md). ## Claude Code plugin and skills The `docustay` plugin bundles the server and five skills: sending for signature, text tags, embedded signing, webhooks and agent safety. The skills are plain Markdown files, so any agent that reads skill files can use them. ## A conversation, step by step You ask the assistant: "Send the mutual NDA to Sam at sam@example.com." It calls `list_templates`, finds the NDA, then calls `send_document` without `confirm`. The preview says who would be emailed and from which template. You say yes; it calls `send_document` again with `confirm: true`. Nothing is sent until that second call. ```json { "name": "send_document", "arguments": { "templateId": "TEMPLATE_ID", "parties": [{ "seat": "signer_1", "name": "Sam", "email": "sam@example.com" }], "confirm": true } } ``` ## Reading and saving `get_document` returns the state and each person's status. `save_signed_pdf` downloads a finished copy to a folder you name on your own computer. `get_report` returns the workspace's summary report. ## Running it read-only ```bash DOCUSTAY_READ_ONLY=1 DOCUSTAY_API_KEY=dsk_test_… npx -y @docustay/mcp ``` With that variable set, `send_document`, `remind`, `void_document` and `create_template_from_html` are not offered at all, so an assistant cannot call them even by mistake. ## Which key scopes each tool needs The table is derived from the server's own route table and checked by a test, so it cannot drift: `sdk/mcp/SCOPES.md` in the repository. A key without the scope gets the same plain 401 as any bad key. For assistants that cannot run a program on your computer, use the hosted [connector](/docs/connector). --- # Connect ChatGPT, Claude or any assistant The hosted connector lets an AI assistant work on **drafts** in your workspace without anyone copying an API key around. You add one address to the assistant, sign in once, and say Allow. **Address to add:** `https://app.docustay.app/mcp` (on a self-hosted install, your own address plus `/mcp`). ## What happens 1. The assistant finds the sign-in details at `/.well-known/oauth-authorization-server`, registers itself, and opens Docustay's consent page. 2. You sign in if you are not already, read what it can and cannot do, and press Allow. 3. Docustay makes a key for that assistant. It is **draft-only**, lasts 90 days, allows 100 drafts a day, and appears in Developers → API keys as “Connector: ”. Revoke it there and the connector stops at once. ## What it can do List templates and plays, read documents, start a draft from a play (it asks you a few questions with 2 to 4 options), make a draft from a template, read a draft, and **ask** for a draft to be sent. ## What it cannot do Send a document. “Ask to send” files a request; a person approves it in Developers → Approvals. It cannot remind, void or sign, change settings or keys, or see billing. ## Tools `list_templates`, `get_template`, `list_documents`, `get_document`, `list_plays`, `get_play`, `start_draft`, `answer_questions`, `use_template`, `get_draft_source`, `request_send`. Every tool is one call to the same `/api/v1` routes the SDKs use, carrying the assistant's own key, so scopes, limits and the draft-only rule apply exactly as they do anywhere else. ## How the sign-in is protected OAuth with PKCE (S256 only) and dynamic client registration; no client secret is stored. The one-time code lasts two minutes and is sealed with the install's own pepper. Return addresses must be https, or http on localhost, and must be the ones the assistant registered. The key is minted only when the code is exchanged, with the rank of the person who pressed Allow, and a person can only hand over scopes they could mint themselves. ## Your own key instead If you would rather not use sign-in, send `Authorization: Bearer ` to `/mcp` with a key from Developers → API keys. The same tools and rules apply. The local server `@docustay/mcp` (see [MCP server and agent skills](/docs/mcp-and-skills)) has more tools, including sending with a preview step. ## Look at it from a terminal The connector speaks the standard MCP and OAuth discovery documents, so you can see what an assistant sees before you connect it. The first call names the resource and its sign-in server; the second lists what a client may ask for. ```bash curl -s https://app.docustay.app/.well-known/oauth-protected-resource curl -s https://app.docustay.app/.well-known/oauth-authorization-server ``` An assistant registers itself, sends you to a Docustay page to approve, and exchanges the one-time code for a draft-only key. You can see that key afterwards under Developers with its limits: ```bash curl -s https://app.docustay.app/api/v1/documents \ -H "Authorization: Bearer $DOCUSTAY_KEY" # the draft-only key the approval made ``` --- # Licensing Docustay is a product of GLRS LLC. The licences are split on purpose: the server and app are under the **GNU Affero General Public License, version 3 or later**, and the pieces you put inside your own software are under the **MIT licence**. This page is general information, not legal advice; the licence texts in the repository are what apply. ## Which licence covers what | Part | Licence | Where to find it | |---|---|---| | Server, worker, migrations, web app, public site | AGPL-3.0-or-later | `LICENSE` at the top of the repository | | JavaScript SDK `@docustay/sdk`, Python SDK `docustay` | MIT | `sdk/js/LICENSE`, `sdk/python/LICENSE` | | `` element and the React, Vue and Angular wrappers | MIT | `sdk/embed`, `sdk/react`, `sdk/vue`, `sdk/angular` | | MCP server, Claude Code plugin, agent skills | MIT | `sdk/mcp`, `sdk/claude-plugin` | | OpenAPI file | MIT | `docustay/web/static/api/openapi.json` | Every package carries its own `LICENSE` file, so a package you install is licensed on its own, wherever you install it from. ## The line between the two Calling the Docustay API over HTTP, or using the SDKs, does **not** make your application AGPL. Your code is not a derivative work of the server. Copying or modifying **server code** and letting other people use that code over a network does bring the AGPL-3.0 into play for that code: you offer those people the source of your version. Showing the Docustay signing page inside your site in a frame is using a service, not copying code. See [embedding](/docs/embedding) for how the frame is set up. ## Which of three paths you are on 1. **Hosted by us.** Nothing to license. You use the service under the [Terms](/legal/terms). 2. **Self-host under the AGPL-3.0.** Community is free. A Pro licence key is $160 a year per install, for your own business only, and is described on [Self-host](/self-host). 3. **Commercial licence.** From about $3,000 a year, quoted for your volume, for embedding Docustay in a product, white-labelling it, reselling it, or keeping a private fork without publishing your changes. Use the [contact form](/contact) and choose "Licensing". ### When a commercial licence is needed You need one if you want to: - embed Docustay in a product you offer to others; - white-label it; - resell it; - keep a private fork without publishing your changes. ## What counts as an install One install is one running Docustay with one database. - A staging or backup copy of the same install does not count as a separate install. - A development copy on a developer's own machine does not count as a separate install. - Two production databases are two installs. ## After you cancel Cancelling Pro moves the workspace to Free. - Documents already sent or signed stay readable and downloadable. - You can export everything for 30 days. - Sending is then limited by the Free limits (see [Errors, retries and limits](/docs/errors-and-limits.md)). ## For people who contribute New server and app files start with a one-line licence header, and every SDK file starts with the MIT one: ``` // SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: MIT ``` A change is accepted from someone who agrees to the short contributor agreement, which lets the owner offer a commercial licence next to the open one. Add your agreement to the commit: ``` git commit -s -m "Fix the reminder wording" ``` The full explanation, in plain words, is on the [Licensing page](/legal/licensing). --- # API reference Generated from the OpenAPI 3.1 document at `/api/openapi.json`; do not edit by hand. Send documents for signature and follow them, from your own software. **Authentication.** Send `Authorization: Bearer `. Make keys in Settings → Developers and give each only the scopes it needs: `accounts:read`, `accounts:write`, `booking:read`, `booking:write`, `documents:read`, `documents:write`, `growth:read`, `growth:write`, `items:read`, `items:write`, `lead:write`, `money:read`, `money:write`, `network:read`, `network:write`, `notifications:read`, `notifications:write`, `people:read`, `people:write`, `platform:read`, `platform:write`, `records:read`, `records:write`, `settings:read`, `settings:write`, `plays:read`, `plays:write`, `drafts:write`, `templates:read`, `templates:write`, `webhooks:read`, `webhooks:write`, `reports:read`, `account:read`, `embedded:write`. A key that starts `dsk_test_` works in **test mode**: documents it sends are practice runs (emailed only to the sender, stamped TEST, never counted or billed), it can only see and act on test documents (any other document answers `404`), and it can sign them itself with `POST /api/v1/documents/{id}/test-sign`. A key can also be limited to a list of addresses, given an expiry, or made **draft-only**. **Draft-only keys.** A draft-only key can make and edit drafts but cannot send. Asking it to send answers `202` with an `approval`, the document stays a draft, and a person approves or declines in Settings → Developers → Approvals. Keys can also have a daily limit of drafts and sends; past it you get `429`. **Payments.** A template may contain payment fields (text tag `type=payment` with one of `amount`, `formula`, `price` or `link`). The signer pays on Stripe's hosted page; the money goes to your own connected Stripe account; Docustay's fee is 2% on the Free plan and 0% on Pro, plus Stripe's own fees. The document's `payments` list shows each payment, its receipt and the fee. Card details never reach Docustay. **Idempotency.** Requests that create a document or a template, or send one, need an `Idempotency-Key` header (8–200 characters). Repeating a request with the same key and body returns the first answer instead of doing it twice. **Your own reference.** Documents and templates take `externalId` (your id for it) and `metadata` (up to 20 short key/value pairs). List with `externalId=` or `metadata[key]=value` to find them again, and both come back in webhooks. **Errors.** Every error is `{ "error": { "kind", "message", "requestId", "retryable" } }`. `kind` is one of `invalid`, `unauthenticated`, `forbidden`, `not_found`, `conflict`, `rate_limited`, `dependency`, `timeout`. Quote the `requestId` to support, or search it in Settings → Developers → API log. **Rate limits.** Each key has its own per-minute budget. Every answer carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds); over the budget you get `429` with a `Retry-After` header. **Versions.** Every answer carries `Docustay-Version`, a date. A change that could break a caller gets a new date and is announced in the changelog before it ships; additions (a new field, a new route) are not breaking and do not change the date. **Pagination.** Lists take `limit` (1–200) and `cursor`; the answer carries `page.nextCursor` (null on the last page). **Signing links.** No response contains a signing link or access code, with one exception you control: a seat you send in `embedded` can be given an embedded signing session (`POST /api/v1/documents/{id}/embed-session`), a one-hour link for a frame on a website you listed. A key can start and follow a signature, and put your own signer in front of the form for seats you marked yourself; it cannot sign for anyone. ## GET /api/v1/documents/templates **Templates you can send** Answers: - `200` Approved, current templates. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/documents/templates \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/documents **List documents** Filter by your own reference: `externalId=…`, and `metadata[key]=value` (repeat for more keys; a document must match all). | Parameter | In | Required | Notes | |---|---|---|---| | `state` | query | no | string | | `limit` | query | no | integer | | `cursor` | query | no | string | | `externalId` | query | no | Only documents with this `externalId`. | Answers: - `200` A page of documents, newest first. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/documents \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/documents **Make a document from words and people** People first: each person has a name, an email and (optionally) a role word from the text. The document is left as a **draft** unless `sendNow` is true. Needs an `Idempotency-Key`. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `email` | object | no | Your own wording for the emails about THIS document. The workspace's saved wording (Settings → Email templates) applies underneath; what you give here wins. | | `metadata` | object | no | Your own key-value text (up to 20 keys; keys 1–40 letters, digits, dot, dash or underscore; values up to 500 characters). Returned in every response and every webhook for this document; never shown to a signer. | | `externalId` | string | no | Your own id for this thing, so you can match it to your records. Filter lists with `externalId=`. | | `title` | string | yes | | | `source` | object | yes | A written document: TipTap JSON `{type:"doc",content:[…]}`. Field tags (`fieldTag` with name, type, role, required, autofill, editable, placeholder, tooltip, locked, options, default…), merge chips (`variableChip`; `custom.` chips are the template's questions), and headings with `audience: "sender"` (Sender only: in nothing a signer sees, signs or receives). | | `people` | array of object | yes | | | `facts` | object | no | Merge text: `org.name`, `sender.name`, `custom.` … | | `variableValues` | object | no | | | `paper` | `letter` · `a4` | no | | | `send` | object | no | | | `description` | string | no | | | `sendNow` | boolean | no | Also send it. The same checks as the screen's Send apply (a payment field needs Stripe connected). | Answers: - `201` The document. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"email":{"subject":"string","message":"string","replyTo":"string","senderName":"string","locale":"string"},"metadata":{"crmDealId":"D-1042"},"externalId":"contract-2026-0042","title":"string","source":"string","people":[{"externalId":"contract-2026-0042","metadata":{"crmDealId":"D-1042"},"name":"string","email":"string","role":"string","action":"signer","order":0}],"facts":"string","variableValues":"string","paper":"letter","send":{"replyTo":"string","senderName":"string","subject":"string","message":"string","expiryDays":"string","reminderDays":[0],"signingOrder":"any","language":"string","internalNote":"string","requireDeclineReason":false},"description":"string","sendNow":false}' ``` ## GET /api/v1/documents/{id} **One document and where each person is** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Answers: - `200` The document. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `404` No such document. - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/documents/ \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/documents/{id}/signed.pdf **Download the signed copy** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Answers: - `200` The sealed PDF, with its certificate. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `404` Not signed yet, or no such document. - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/documents//signed.pdf \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/documents/send **Send a template to people to sign** Makes a document from the template, puts each person on their seat and sends it. Needs an `Idempotency-Key` header (8–200 characters): a retry with the same key and body does not send again. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `templateId` | string | yes | | | `email` | object | no | Your own wording for the emails about THIS document. The workspace's saved wording (Settings → Email templates) applies underneath; what you give here wins. | | `metadata` | object | no | Your own key-value text (up to 20 keys; keys 1–40 letters, digits, dot, dash or underscore; values up to 500 characters). Returned in every response and every webhook for this document; never shown to a signer. | | `externalId` | string | no | Your own id for this thing, so you can match it to your records. Filter lists with `externalId=`. | | `title` | string | no | | | `message` | string or null | no | | | `subject` | string | no | | | `language` | `en` · `es` · `fr` · `de` · `pt` · `it` · `nl` · `pl` · `tr` · `ru` · `ar` · `ja` · `zh` · `ko` | no | | | `expiryDays` | integer | no | | | `embedded` | array of string | no | Seats signed inside your own app instead of by emailed link (for example ["signer_1"]). Nobody is emailed for these seats: you ask for a session with POST /api/v1/documents/{id}/embed-session and show it on a website you listed under Settings → Developers → Embedding. | | `parties` | array of object | yes | One entry for each seat of the template (see GET /api/v1/documents/templates). | Answers: - `201` Sent. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents/send \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"templateId":"6f1c1f0e-7a0c-4d1d-9b5e-2f0a7b1c3d4e","email":{"subject":"string","message":"string","replyTo":"string","senderName":"string","locale":"string"},"metadata":{"crmDealId":"D-1042"},"externalId":"contract-2026-0042","title":"string","message":"string","subject":"string","language":"en","expiryDays":0,"embedded":["string"],"parties":[{"seat":"string","name":"string","email":"string","externalId":"contract-2026-0042","metadata":{"crmDealId":"D-1042"}}]}' ``` ## POST /api/v1/templates/html **Make a template from HTML with text tags** Prints your HTML to a PDF and places a field wherever a text tag sits, then keeps the result as a template you can send with `POST /api/v1/documents/send`. Needs an `Idempotency-Key` header. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `metadata` | object | no | Your own key-value text (up to 20 keys; keys 1–40 letters, digits, dot, dash or underscore; values up to 500 characters). Returned in every response and every webhook for this document; never shown to a signer. | | `externalId` | string | no | Your own id for this thing, so you can match it to your records. Filter lists with `externalId=`. | | `name` | string | yes | | | `html` | string | yes | HTML with text tags such as {{Tenant signature;type=signature;role=Tenant}}. Scripts, styles, frames, forms and remote addresses are removed; pictures must be data: addresses. Up to 2 MB. | | `paper` | `letter` · `a4` | no | | Answers: - `201` The template. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/templates/html \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"metadata":{"crmDealId":"D-1042"},"externalId":"contract-2026-0042","name":"string","html":"string","paper":"letter"}' ``` ## POST /api/v1/templates/file **Make a template from a file with text tags** Reads a PDF, Word, Excel, PowerPoint or image file, places a field wherever a text tag is written in it, and keeps the result as a template. Needs an `Idempotency-Key` header. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `metadata` | object | no | Your own key-value text (up to 20 keys; keys 1–40 letters, digits, dot, dash or underscore; values up to 500 characters). Returned in every response and every webhook for this document; never shown to a signer. | | `externalId` | string | no | Your own id for this thing, so you can match it to your records. Filter lists with `externalId=`. | | `name` | string | yes | | | `fileName` | string | yes | | | `file` | string | yes | The file, base64-encoded: a PDF, Word, Excel, PowerPoint or image file with text tags written in it. | Answers: - `201` The template. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/templates/file \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"metadata":{"crmDealId":"D-1042"},"externalId":"contract-2026-0042","name":"string","fileName":"string","file":"string"}' ``` ## POST /api/v1/templates/source **Make a template from a document source** Keeps a document written in the shared editor's format as a template (profile `typeset` by default). Needs an `Idempotency-Key` header. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `metadata` | object | no | Your own key-value text (up to 20 keys; keys 1–40 letters, digits, dot, dash or underscore; values up to 500 characters). Returned in every response and every webhook for this document; never shown to a signer. | | `externalId` | string | no | Your own id for this thing, so you can match it to your records. Filter lists with `externalId=`. | | `name` | string | yes | | | `source` | object | yes | The document source: a ProseMirror/TipTap JSON document (`{type:"doc",content:[…]}`) using headings, paragraphs, lists, tables, `fieldTag`, `signatureBlock`, `variableChip`. See the guide on document sources. | | `profile` | `typeset` · `contract` | no | | | `paper` | `letter` · `a4` | no | | Answers: - `201` The template. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/templates/source \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"metadata":{"crmDealId":"D-1042"},"externalId":"contract-2026-0042","name":"string","source":"string","profile":"typeset","paper":"letter"}' ``` ## POST /api/v1/documents/ai/draft **Ask for an AI-written draft** Starts a draft and answers at once with a job id; poll `GET /api/v1/documents/ai/jobs/{id}`. The draft is a suggestion for a person to read; nothing is saved or sent. Refused plainly when AI drafting is switched off for the workspace or a limit is reached. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `prompt` | string | no | | | `brief` | object | no | The wizard's answers (see GET /api/v1/ai/wizard for the questions). | | `mode` | `template` · `document` | no | | Answers: - `202` Started. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents/ai/draft \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"prompt":"string","brief":{"type":"string","kind":"string","answers":"string","final":"string","payment":{"collect":false,"amount":"1500","currency":"usd","payer_role":"string","description":"string","schedule_note":"string"}},"mode":"template"}' ``` ## GET /api/v1/documents/ai/jobs/{id} **Read an AI draft job** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Answers: - `200` The job. When `state` is `done`, `source` is the draft and `patches.notes` lists what to check. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `404` No such job. - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/documents/ai/jobs/ \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/usage **Documents and emails used against your plan** Documents sent this calendar month (UTC) and emails sent today, with the plan's limits. `limit` is null when the plan has no limit. Answers: - `200` Usage. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/usage \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/templates/{id} **One template and its seats** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Answers: - `200` The template. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `404` No such template. - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/templates/ \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/documents/{id}/audit-log.csv **The document's audit log as CSV** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Answers: - `200` One row per event: time (UTC), event, description, person, document, detail. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `404` No such document. - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/documents//audit-log.csv \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/documents/{id}/remind **Remind the people who still have to sign** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `seat` | string | no | Remind only this seat (for example signer_2). Leave out to remind everyone who is up. | Answers: - `200` Reminded. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `409` Nobody is waiting to be reminded. - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents//remind \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"seat":"string"}' ``` ## POST /api/v1/documents/{id}/embed-session **Start an embedded signing session for a seat** For a seat that was sent in `embedded`. Returns a link that works for one hour and is meant for an iframe or the `` element on one of your allowed websites. It is the only way a key can put someone in front of a signing form, and only for seats you marked embedded yourself. Needs an `Idempotency-Key` header. | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `seat` | string | yes | | | `origin` | string | yes | The website that will show the form; must be on your allowed list. | Answers: - `200` The session. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `409` The seat was not sent as embedded, it is not their turn, the website is not allowed, or embedding is off. - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents//embed-session \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"seat":"signer_1","origin":"https://app.example.com"}' ``` ## GET /api/v1/reports **Counts of what was sent and completed** Real documents only — documents made in test mode are never counted. Dates are in the workspace's own time zone; `to` is inclusive; the default window is the last 30 days. | Parameter | In | Required | Notes | |---|---|---|---| | `from` | query | no | string | | `to` | query | no | string | | `team` | query | no | string | Answers: - `200` The report. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/reports \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/teams **Teams and their members** Answers: - `200` The teams. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/teams \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/email-templates **The workspace's email wording** | Parameter | In | Required | Notes | |---|---|---|---| | `locale` | query | no | string | Answers: - `200` The eight message types with the standard and the saved wording. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/email-templates \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## PUT /api/v1/email-templates/{kind} **Set the wording of one email** Variables look like `{{document.title}}`; each message type accepts only its own (see GET). A blank field means the standard wording. | Parameter | In | Required | Notes | |---|---|---|---| | `kind` | path | yes | invitation, reminder, completed, declined, voided, expired, code or receipt | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `locale` | string | no | | | `subject` | string or null | no | | | `preview` | string or null | no | | | `body` | string or null | no | | | `buttonLabel` | string or null | no | | | `footer` | string or null | no | | Answers: - `200` The saved wording. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X PUT https://app.docustay.app/api/v1/email-templates/ \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Content-Type: application/json" \ -d '{"locale":"string","subject":"string","preview":"string","body":"string","buttonLabel":"string","footer":"string"}' ``` ## DELETE /api/v1/email-templates/{kind} **Back to the standard wording** | Parameter | In | Required | Notes | |---|---|---|---| | `kind` | path | yes | invitation, reminder, completed, declined, voided, expired, code or receipt | | `locale` | query | no | string | Answers: - `200` The standard wording again. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X DELETE https://app.docustay.app/api/v1/email-templates/ \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/email-templates/{kind}/preview **Preview an email with made-up names** | Parameter | In | Required | Notes | |---|---|---|---| | `kind` | path | yes | invitation, reminder, completed, declined, voided, expired, code or receipt | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `locale` | string | no | | | `subject` | string or null | no | | | `preview` | string or null | no | | | `body` | string or null | no | | | `buttonLabel` | string or null | no | | | `footer` | string or null | no | | Answers: - `200` Subject, preview line, body and the HTML. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/email-templates//preview \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"locale":"string","subject":"string","preview":"string","body":"string","buttonLabel":"string","footer":"string"}' ``` ## GET /api/v1/emails **The log of email about your documents** Every email sent about a document, to a person named on it: kind, subject, status (sent, delivered, bounced, complained, failed, paused). Filter by `documentId`, `to`, `status`, `kind`. No bodies, no links. | Parameter | In | Required | Notes | |---|---|---|---| | `documentId` | query | no | string | | `to` | query | no | string | | `status` | query | no | string | | `kind` | query | no | string | | `limit` | query | no | integer | | `cursor` | query | no | string | Answers: - `200` A page of emails, newest first. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/emails \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/documents/{id}/test-sign **Sign a test document (test mode only)** Signs one seat of a **test** document with sample answers, through the same code a person's signature uses: consent, signing order, the audit trail and your webhooks all happen. Only a test key (`dsk_test_…`) may call it, and only on a test document; a live key gets `403`. A seat with a payment field cannot be test-signed: pay it with a Stripe test card. | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `seat` | string | yes | The seat to sign, for example `signer_1`. | Answers: - `200` Signed. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents//test-sign \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"seat":"signer_1"}' ``` ## POST /api/v1/documents/{id}/void **Void a document that is still out for signature** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `reason` | string | no | | Answers: - `200` Voided. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents//void \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"reason":"string"}' ``` ## POST /api/v1/documents/check **Check a document's words before making it** Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `source` | object | yes | A written document: TipTap JSON `{type:"doc",content:[…]}`. Field tags (`fieldTag` with name, type, role, required, autofill, editable, placeholder, tooltip, locked, options, default…), merge chips (`variableChip`; `custom.` chips are the template's questions), and headings with `audience: "sender"` (Sender only: in nothing a signer sees, signs or receives). | | `facts` | object | no | | | `people` | array of object | no | With people, each person's own merge text is known, as when the document is made. | | `variableValues` | object | no | | | `requireSignature` | boolean | no | | | `forTemplate` | boolean | no | | Answers: - `200` What a person would be told. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents/check \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"source":"string","facts":"string","people":[{"externalId":"contract-2026-0042","metadata":{"crmDealId":"D-1042"},"name":"string","email":"string","role":"string","action":"signer","order":0}],"variableValues":"string","requireSignature":false,"forTemplate":false}' ``` ## GET /api/v1/documents/{id}/source **The words, people and choices of a draft** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Answers: - `200` The draft's source. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/documents//source \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/documents/{id}/revise **Change the words of a draft** A draft is never edited in place: the new words are printed into a NEW draft that takes the people and choices, and the old draft is deleted. **The id changes** (use `id` in the answer). Needs an `Idempotency-Key`. | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `source` | object | yes | A written document: TipTap JSON `{type:"doc",content:[…]}`. Field tags (`fieldTag` with name, type, role, required, autofill, editable, placeholder, tooltip, locked, options, default…), merge chips (`variableChip`; `custom.` chips are the template's questions), and headings with `audience: "sender"` (Sender only: in nothing a signer sees, signs or receives). | | `facts` | object | no | | | `variableValues` | object | no | | | `roleMap` | object | no | Old role word → new role word when you renamed a role. | Answers: - `201` The new draft. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents//revise \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"source":"string","facts":"string","variableValues":"string","roleMap":"string"}' ``` ## POST /api/v1/documents/{id}/send **Send a draft** Sends a draft made with `POST /api/v1/documents`. The same checks as the screen's Send; a payment field needs Stripe connected. Needs an `Idempotency-Key`. | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Answers: - `201` Sent. - `202` A draft-only key: the request waits for a person to approve it in the app. The document is still a draft. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents//send \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` ## POST /api/v1/templates/{id}/documents **Use a written template for people** Makes a document from a template made with `POST /api/v1/templates/source`: the people, the template's questions answered in `variableValues` (see `GET /api/v1/templates/{id}`), the template's own defaults first. A draft unless `sendNow`. Needs an `Idempotency-Key`. | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `email` | object | no | Your own wording for the emails about THIS document. The workspace's saved wording (Settings → Email templates) applies underneath; what you give here wins. | | `metadata` | object | no | Your own key-value text (up to 20 keys; keys 1–40 letters, digits, dot, dash or underscore; values up to 500 characters). Returned in every response and every webhook for this document; never shown to a signer. | | `externalId` | string | no | Your own id for this thing, so you can match it to your records. Filter lists with `externalId=`. | | `title` | string | no | | | `people` | array of object | yes | | | `variableValues` | object | no | | | `facts` | object | no | | | `send` | object | no | | | `sendNow` | boolean | no | | Answers: - `201` The document. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/templates//documents \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"email":{"subject":"string","message":"string","replyTo":"string","senderName":"string","locale":"string"},"metadata":{"crmDealId":"D-1042"},"externalId":"contract-2026-0042","title":"string","people":[{"externalId":"contract-2026-0042","metadata":{"crmDealId":"D-1042"},"name":"string","email":"string","role":"string","action":"signer","order":0}],"variableValues":"string","facts":"string","send":{"replyTo":"string","senderName":"string","subject":"string","message":"string","expiryDays":"string","reminderDays":[0],"signingOrder":"any","language":"string","internalNote":"string","requireDeclineReason":false},"sendNow":false}' ``` ## POST /api/v1/templates/{id}/defaults **Set what a document made from a template starts with** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `description` | string or null | no | | | `clientDescription` | string or null | no | | | `message` | string or null | no | | | `send` | object | no | | Answers: - `200` Saved. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/templates//defaults \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"description":"string","clientDescription":"string","message":"string","send":{"replyTo":"string","senderName":"string","subject":"string","message":"string","expiryDays":"string","reminderDays":[0],"signingOrder":"any","language":"string","internalNote":"string","requireDeclineReason":false}}' ``` ## POST /api/v1/documents/ai/edit **Ask the assistant to change a document** Starts an edit job and answers at once; poll `GET /api/v1/documents/ai/jobs/{id}`. The answer is a list of **proposed changes** (`patches.applied`); nothing is changed until you apply the ones you accept with your own copy of the source. If you pass the template's questions in `variables`, `patches.values` carries proposed answers. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `request` | string | yes | | | `source` | object | yes | A written document: TipTap JSON `{type:"doc",content:[…]}`. Field tags (`fieldTag` with name, type, role, required, autofill, editable, placeholder, tooltip, locked, options, default…), merge chips (`variableChip`; `custom.` chips are the template's questions), and headings with `audience: "sender"` (Sender only: in nothing a signer sees, signs or receives). | | `mode` | `template` · `document` | no | | | `variables` | array of object | no | | Answers: - `202` Started. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents/ai/edit \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"request":"string","source":"string","mode":"template","variables":[{"key":"string","label":"string","kind":"text"}]}' ``` ## POST /api/v1/documents/ai/jobs/{id}/cancel **Cancel an AI job** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Answers: - `200` The job. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/documents/ai/jobs//cancel \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` ## GET /api/v1/ai/wizard **The wizard's questions** The questions the screen's Describe wizard asks, so an integration asks the same ones and sends the answers as `brief` to `POST /api/v1/documents/ai/draft`. Includes the payment questions and the currencies offered. Answers: - `200` The questions. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/ai/wizard \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/payments/status **Is Stripe ready to take a payment?** `connected` (details submitted, charges on, nothing due), `needs_attention` (Stripe still needs information) or `not_connected`. A document with a payment field can only be sent when this says `connected`. Answers: - `200` The status. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/payments/status \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/plays **List plays** The built-in plays and your published ones: name, one-line description, kind and category. | Parameter | In | Required | Notes | |---|---|---|---| | `kind` | query | no | string | | `category` | query | no | string | Answers: - `200` List plays - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl https://app.docustay.app/api/v1/plays \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/plays **Save a play (a new version)** `source` is the play file: Markdown with a block of YAML at the top. Every save is a new immutable version; publish it to use it. Data only: a play cannot add tools, run code, send or take payments. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `source` | string | yes | | Answers: - `201` Save a play (a new version) - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl -X POST https://app.docustay.app/api/v1/plays \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"source":"string"}' ``` ## GET /api/v1/plays/{name} **One play** | Parameter | In | Required | Notes | |---|---|---|---| | `name` | path | yes | string | Answers: - `200` One play - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl https://app.docustay.app/api/v1/plays/ \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## DELETE /api/v1/plays/{name} **Archive a play** | Parameter | In | Required | Notes | |---|---|---|---| | `name` | path | yes | string | Answers: - `200` Archive a play - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl -X DELETE https://app.docustay.app/api/v1/plays/ \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/plays/test **Check a play without saving it** A dry run: parses the play file, reports every problem, and shows what the picker and the interview would use. Creates nothing. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `source` | string | yes | | Answers: - `200` Check a play without saving it - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl -X POST https://app.docustay.app/api/v1/plays/test \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"source":"string"}' ``` ## POST /api/v1/plays/{name}/publish **Publish a play version** | Parameter | In | Required | Notes | |---|---|---|---| | `name` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `version` | integer | no | | Answers: - `200` Publish a play version - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl -X POST https://app.docustay.app/api/v1/plays//publish \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"version":0}' ``` ## POST /api/v1/plays/{name}/duplicate **Copy a play under a new name** | Parameter | In | Required | Notes | |---|---|---|---| | `name` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `as` | string | no | | Answers: - `201` Copy a play under a new name - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl -X POST https://app.docustay.app/api/v1/plays//duplicate \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"as":"string"}' ``` ## GET /api/v1/playbooks **List playbooks** Named bundles of plays. Answers: - `200` List playbooks - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl https://app.docustay.app/api/v1/playbooks \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/playbooks **Save a playbook** Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `name` | string | yes | | | `title` | string | yes | | | `description` | string | no | | | `plays` | array of string | yes | | Answers: - `201` Save a playbook - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl -X POST https://app.docustay.app/api/v1/playbooks \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"name":"string","title":"string","description":"string","plays":["string"]}' ``` ## POST /api/v1/drafts **Start a draft from a play** Returns `status: needs_input` with up to 4 questions (each with 2 to 4 options and a `draftRef`) until the play's must-ask questions are answered, then `status: draft` with the new draft's id. Nothing is ever sent: a draft is a draft. Answer with `POST /api/v1/drafts/{draftRef}/answers`. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `play` | string | yes | | | `title` | string | no | | | `people` | array of object | yes | | | `answers` | object | no | | | `useDefaults` | boolean | no | | Answers: - `200` Start a draft from a play - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl -X POST https://app.docustay.app/api/v1/drafts \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"play":"string","title":"string","people":[{"name":"string","email":"string","role":"string","action":"signer"}],"answers":"string","useDefaults":false}' ``` ## POST /api/v1/drafts/{draftRef}/answers **Answer a play's questions** | Parameter | In | Required | Notes | |---|---|---|---| | `draftRef` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `answers` | object | yes | | | `useDefaults` | boolean | no | | Answers: - `200` Answer a play's questions - `400` Not valid. - `403` Not allowed (Pro) or the key lacks the scope. ```bash curl -X POST https://app.docustay.app/api/v1/drafts//answers \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"answers":"string","useDefaults":false}' ``` ## GET /api/v1/webhooks/topics **The events a webhook can subscribe to** Answers: - `200` The event names. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/webhooks/topics \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/webhooks **List webhooks** Answers: - `200` Your webhook endpoints, newest first. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/webhooks \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/webhooks **Add a webhook** Give an `https` `url` that can be reached from the internet, or `relay: true` for an endpoint that keeps its signed deliveries for `docustay listen` to fetch to your own computer. The signing secret is in the answer **once**. Events are signed with HMAC-SHA256 (Standard Webhooks headers `webhook-id`, `webhook-timestamp`, `webhook-signature`; also `keystone-signature`). Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `url` | string | no | | | `relay` | boolean | no | | | `name` | string | no | | | `eventTypes` | array of string | yes | From `GET /api/v1/webhooks/topics`. | Answers: - `201` Added. `secret` is shown once. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/webhooks \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"url":"string","relay":false,"name":"string","eventTypes":["string"]}' ``` ## DELETE /api/v1/webhooks/{id} **Remove a webhook** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Answers: - `200` Removed: it receives nothing more. Its past deliveries stay in the record. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X DELETE https://app.docustay.app/api/v1/webhooks/ \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/webhooks/{id}/test **Send a test event** Sends a sample signed event (`test: true` in its payload) to the webhook now and returns the receiver's answer. Nothing is stored and failures here never count toward disabling the webhook. | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `topic` | string | no | | Answers: - `200` What the receiver answered. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/webhooks//test \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"topic":"string"}' ``` ## POST /api/v1/webhooks/{id}/rotate **Rotate the signing secret** Makes a new secret (shown once). For `windowHours` (default 24) every event is signed with both the new and the old secret, so your receiver can switch without a gap. | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `windowHours` | integer | no | | Answers: - `200` The new secret. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/webhooks//rotate \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"windowHours":0}' ``` ## GET /api/v1/webhooks/{id}/deliveries **Recent deliveries** | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | Answers: - `200` The last 50 delivery attempts, newest first. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/webhooks//deliveries \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## GET /api/v1/webhooks/{id}/relay **Fetch stored deliveries of a relay webhook** For `docustay listen`. Returns deliveries stored since `after` (an id from a previous answer; leave out for the newest few), oldest first, each with the exact headers to forward so the signature checks out. Waits up to `wait` seconds (max 25) when there is nothing yet. Kept 24 hours. | Parameter | In | Required | Notes | |---|---|---|---| | `id` | path | yes | string | | `after` | query | no | string | | `wait` | query | no | integer or null | | `limit` | query | no | integer | Answers: - `200` Stored deliveries. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl https://app.docustay.app/api/v1/webhooks//relay \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ## POST /api/v1/embedded/builder-session **Start an embedded template builder session** Returns a link that works for one hour and is meant for an iframe or the `` element on one of your allowed websites. The builder lets the person write a document, drop signature and other fields on it, and save it as a template; the saved template's id comes back to your page in a `saved` event. The link holds a key of its own that can only read and write templates and stops working after an hour. Needs an `Idempotency-Key` header. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `origin` | string | yes | The website that will show the builder; must be on your allowed list. | | `name` | string | no | A name to start the template with. | Answers: - `200` The session. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/embedded/builder-session \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"origin":"https://app.example.com","name":"string"}' ``` ## POST /api/v1/embedded/scribe-session **Start an embedded Scribe session** Returns a link that works for one hour and is meant for an iframe or the `` element on one of your allowed websites. The person picks a play, names who signs, answers a few clickable questions and gets a DRAFT; the draft's id comes back to your page in a `drafted` event. The link holds a key of its own that is draft-only (it cannot send: asking to send files an approval a person decides in Docustay), allows 50 drafts a day and stops working after an hour. Needs an `Idempotency-Key` header. Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `origin` | string | yes | The website that will show Scribe; must be on your allowed list. | | `name` | string | no | | Answers: - `200` The session. - `400` The request is not valid. `message` says which field and why. - `401` The key is missing, wrong, revoked, or lacks the scope. Every credential failure gets this same answer. - `403` The key is valid but not allowed to do this (a plan limit, or the action is not permitted for keys). - `429` Too many requests. Wait `Retry-After` seconds, then try again. ```bash curl -X POST https://app.docustay.app/api/v1/embedded/scribe-session \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"origin":"https://app.example.com","name":"string"}' ``` --- # Languages A document is sent in one language, and everything the signer sees follows it: the signing page, the signing and reminder emails, the signed-copy email, and the Certificate of Completion at the end of the sealed PDF. | Code | Language | Code | Language | |---|---|---|---| | `en` | English | `pl` | Polish | | `es` | Spanish | `tr` | Turkish | | `fr` | French | `ru` | Russian | | `de` | German | `ar` | Arabic (right to left) | | `pt` | Portuguese | `ja` | Japanese | | `it` | Italian | `zh` | Chinese (simplified) | | `nl` | Dutch | `ko` | Korean | Set it with `language` when you send (`POST /api/v1/documents`), or pick it in the Send step of the app. If you leave it out, the document goes in English. ## Beta Every language except English is a **machine-written draft**, marked Beta on the signing page and in the Send step. It is meant to be understood, not to be the final word. The text you write yourself (your document, your message) is never translated. ## Fonts The Certificate of Completion is drawn in Noto Sans for languages the standard font cannot draw (Cyrillic, Latin Extended, Japanese, Chinese, Korean), and in IBM Plex Sans Arabic for Arabic. The fonts are all licensed under the SIL Open Font License 1.1 and are bundled as font files, not code. Japanese, Chinese and Korean carry the characters of the standard national lists, so a rare character in a name can fall back to a blank box. ## Choosing a language when sending ```bash curl -X POST https://docustay.app/api/v1/documents/send \ -H "Authorization: Bearer $DOCUSTAY_KEY" -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \ -d '{ "templateId": "TEMPLATE_ID", "language": "es", "parties": [{ "seat": "signer_1", "name": "Rosa Alvarez", "email": "rosa@example.com" }] }' ``` ```js await docustay.sendDocument({ templateId: "TEMPLATE_ID", language: "ar", parties: [{ seat: "signer_1", name: "Layla Haddad", email: "layla@example.com" }] }); ``` ## Right to left Arabic pages and emails are laid out right to left. The Certificate of Completion draws Arabic in joined letterforms, and the text in the PDF can be selected and copied in reading order. --- # Scribe in your own app `` shows Scribe inside your own page. The person picks a document from a list, says who signs, answers a few clickable questions and gets a **draft**. Nothing is emailed: asking to send files an approval that a person decides in Docustay. 1. **On your server**, with a key that has `embedded:write`: ``` POST /api/v1/embedded/scribe-session { "origin": "https://app.example.com" } ``` The answer is a `url` that works for one hour. The website must be on your allowed list (Developers → Embedding), else the answer is 409. 2. **In your page:** ```html ``` 3. **Listen** for `ready`, `drafted` (`{ documentId, title }`), `send_requested` (`{ documentId }`), `error` and `resize`. The key inside the link is made for this session only, sits in the `#fragment` so no server log sees it, is **draft-only** (a direct send through it files an approval and sends nothing), allows 50 drafts a day and ends in an hour. The link names the one website that may frame it and is signed: change the website and it will not frame anywhere. Plays are part of Pro. ## What the person sees The element draws one panel: a list of your documents, a place to say who signs, and a few clickable questions. Each answer is a button, not a typed reply, so a person never needs to know what to ask. At the end there is a draft with its fields placed, and two choices: change it, or ask for it to be sent. ## What stays under your control The session key your server makes is draft-only: it can read documents and templates and prepare drafts, and it cannot send. It lasts an hour, it is limited to fifty drafts a day, and it only works from the websites you list as allowed. A request to send files an approval inside Docustay that one of your people decides; nothing is emailed until they do. ## When it does not load If the panel says it cannot start, check three things in order: the session address on your server answered with a token (a key without `embedded:write` gets a refusal), the page's address is on the allowed-sites list under Developers → Embedding, and the key is a draft-only one. The browser console names the first one that fails. --- # Email templates Docustay sends email only about a document your workspace made, and only to the people named on it. You control the words of all eight messages. ## The eight messages | Message | Goes to | Has the sign button | |---|---|---| | Signing invitation | each person asked to sign | yes | | Reminder | a person who has not signed yet | yes | | Completed copy | everyone, when the document is fully signed | no | | Declined | the person who sent the document, when someone declines | no | | Cancelled | people who were asked to sign, when the sender cancels | no | | Expired | the person who sent the document, when it lapses unsigned | no | | Verification code | a person opening a document that asks for an email code | no | | Payment receipt | the person who paid while signing | no | ## What you can change Open **Settings → Email templates**. For each message you can set the subject, the preview line (the grey text next to the subject in an inbox), the body, the button label and the footer. A **live preview** shows the email with sample values filled in, and **Send me a test** sends it to you. **Reset** goes back to the built-in wording. Each language has its own wording. If a language has none saved, the built-in wording for it is used. ## Variables Put a variable in double curly brackets, for example `{{signer.name}}`. Each message lists the variables it may use, and an unknown one is refused when you save. Common ones: `{{signer.name}}`, `{{sender.name}}`, `{{org.name}}`, `{{document.title}}`. The invitation and reminder also have `{{link}}`; the code message has `{{code}}` and `{{minutes}}`; the receipt has `{{amount}}` and `{{description}}`. ## For one document only On the **Details** step of a send you can change the subject and the message for that document alone. From the API you can also set the reply-to address and the sender name, in the `email` object of the send request. See [Email API](/docs/email-api). ## Your own sender Emails come from Docustay's address by default. To send from your own domain, add it in **Settings → Email**, put the DNS records it shows at your DNS host, and press check. See [Email setup](/docs/email-setup). ## From the API Read the wording the workspace uses now, change one message, and look at it with made-up names before anyone gets it. A key with the `account:read` and `documents:write` permissions does all three. ```bash curl https://app.docustay.app/api/v1/email-templates \ -H "Authorization: Bearer $DOCUSTAY_KEY" ``` ```bash curl -X PUT https://app.docustay.app/api/v1/email-templates/invitation \ -H "Authorization: Bearer $DOCUSTAY_KEY" \ -H "Content-Type: application/json" \ -d '{"locale":"en","subject":"{{sender.name}} sent you {{document.title}} to sign","buttonLabel":"Review and sign"}' ``` `DELETE` on the same address goes back to the standard wording, and `POST` to `/preview` returns the email as it would look. --- # Email API Everything the portal does with email can be done from the API. Docustay never sends general email: only mail about a document your workspace made, to the people named on it. ## Per document Add an `email` object to a send or a draft: ```js await docustay.sendDocument({ templateId, parties, email: { subject: "Your agreement", message: "Thanks for choosing us.", replyTo: "hello@example.com", senderName: "Example Co", locale: "fr" }, }); ``` `locale` is a language code such as `en`, `es` or `fr`, or `auto` to match each person. Every field is optional. ## Templates ```js await docustay.listEmailTemplates(); // every message, with saved wording where there is any await docustay.saveEmailTemplate("invitation", { subject: "{{org.name}} sent you {{document.title}}", locale: "en" }); await docustay.previewEmailTemplate("invitation", { subject: "…" }); // what it would look like, nothing sent await docustay.resetEmailTemplate("invitation"); // back to the built-in wording ``` The message types are `invitation`, `reminder`, `completed`, `declined`, `voided`, `expired`, `code` and `receipt`. Reading needs the `templates:read` scope and changing needs `templates:write`. ## The log ```js const { emails, nextCursor } = await docustay.listEmails({ documentId, status: "bounced", limit: 50 }); ``` You can filter by document, recipient (`to`), `status` and `kind`. A status is one of `sent`, `delivered`, `bounced`, `complained`, `failed`, `skipped`, `paused` and `limit_reached`. The same list is in **Developers → Email events**. ## Webhooks Subscribe a [webhook](/docs/webhooks) to `documents.email.delivered`, `documents.email.bounced` and `documents.email.complained`. They carry ids only, never an address. `documents.email.paused` tells you sending was paused for the workspace. ## Safe by design Mail goes only to people named on the document. Sending pauses by itself for a workspace when 2% of its recent email bounces or 0.05% is reported as spam, once at least 50 emails have gone out in 30 days, so one bad list cannot harm your domain's name. **Platform → Domains and email** shows how close you are. --- # Settings reference Every setting you can give a self-hosted Docustay, as environment variables in `.env` (the compose file reads it). `./init.sh` fills in the secrets. Anything not listed in the first tables is internal or belongs to the hosted service and should stay unset. ## Secrets (init.sh makes them) | Setting | Required | Default | What it does | |---|---|---|---| | `KEYSTONE_DB_PASSWORD` | yes | `(generated)` | The password of the bundled Postgres. Used by compose to build the database URL. | | `KEYSTONE_TOKEN_PEPPER` | yes | `(generated)` | A secret mixed into every API key and session digest. Changing it signs everyone out and invalidates every API key. | | `KEYSTONE_LOCAL_AUTH_SECRET` | yes | `(generated)` | Signs the built-in sign-in tokens. Changing it signs everyone out. | | `KEYSTONE_SETUP_TOKEN` | first run | `(generated)` | The one-time key that lets you create the owner account at /setup. Unused after setup. | | `KEYSTONE_MASTER_KEY` | yes (production) | `(generated)` | Wraps the keys that seal stored secrets (webhook secrets, SMTP passwords, two-step secrets). Back it up with the database: without it sealed secrets cannot be opened. | ## Where things are | Setting | Required | Default | What it does | |---|---|---|---| | `KEYSTONE_PUBLIC_URL` | yes behind a domain | `http://localhost:8080` | The address people open Docustay at, without a path. Every signing link, reset link and email starts with it. | | `KEYSTONE_DOCUSTAY_APP_URL` | no | `= KEYSTONE_PUBLIC_URL` | The address of the signed-in app, when it differs from the signing address. | | `KEYSTONE_DOCUSTAY_SIGN_URL` | no | `= KEYSTONE_PUBLIC_URL` | The address signing links start with. | | `DOCUSTAY_PORT` | no | `8080` | The port on this machine the web app listens on. | | `PORT` | no | `8080` | The port the API process listens on inside its container. | | `KEYSTONE_TRUSTED_PROXY_HOPS` | behind a proxy | `0` | How many reverse proxies sit in front. With 0 every visitor looks like the proxy and shares one rate limit; set 1 behind one proxy. | ## Database and files | Setting | Required | Default | What it does | |---|---|---|---| | `KEYSTONE_DB_SUPERUSER_URL` | yes | `(compose)` | Connection string the migration runner uses. It creates the application roles on first run. | | `KEYSTONE_DATABASE_URL` | no | `(derived)` | Connection string for the application role, when you run the database yourself. | | `KEYSTONE_DB_SSL` | no | `on` | Set to off for a database in the same private network without TLS (the bundled compose does). | | `KEYSTONE_PG_POOL_MAX` | no | `10` | Largest number of database connections the API keeps. | | `KEYSTONE_S3_ENDPOINT` | yes | `(compose)` | Address of the S3-compatible store that keeps signed PDFs. | | `KEYSTONE_S3_BUCKET` | yes | `docustay` | The bucket (created for you by the bundled store). | | `KEYSTONE_S3_ACCESS_KEY_ID` | yes | `(generated)` | Access key for the file store. | | `KEYSTONE_S3_SECRET_ACCESS_KEY` | yes | `(generated)` | Secret key for the file store. | | `KEYSTONE_S3_REGION` | no | `us-east-1` | Region name some S3 providers require. | | `KEYSTONE_GOTENBERG_URL` | yes | `(compose)` | Address of the PDF converter (Gotenberg) that prints documents. | ## Email | Setting | Required | Default | What it does | |---|---|---|---| | `RESEND_API_KEY` | one of Resend or SMTP | | Your own Resend API key. Leave empty to use your own SMTP server (Settings → Email). | | `KEYSTONE_MAIL_SENDER` | no | | The From address used until you verify your own domain. | | `KEYSTONE_MAIL_DAILY_LIMIT` | no | `(none)` | Hard cap on emails sent per day by this install. | | `KEYSTONE_EMAIL_ALLOWLIST` | no | `(off)` | Comma list of addresses/domains mail may go to. Everything else is dropped. Use it on a test install. | ## Sign-in | Setting | Required | Default | What it does | |---|---|---|---| | `KEYSTONE_LOCAL_AUTH` | yes (self-host) | `1` | Uses the built-in sign-in (email and password). Firebase is for the hosted service only. | | `KEYSTONE_PASSKEYS` | no | `off` | Set to 1 to let people sign in with passkeys. Needs KEYSTONE_PASSKEY_ORIGINS. | | `KEYSTONE_PASSKEY_ORIGINS` | with passkeys | `= KEYSTONE_DOCUSTAY_APP_URL` | Comma list of web addresses a passkey may be used from. A passkey is tied to the address's host name. | | `DOCUSTAY_OIDC_ISSUER` | no | `(off)` | The address of your OpenID Connect provider (Keycloak, Authentik, Entra, Okta…), for example https://sso.example.com/realms/main. Setting this, the client id and the client secret turns on single sign-on. | | `DOCUSTAY_OIDC_CLIENT_ID` | no | `(none)` | The client id you made for Docustay at your provider. | | `DOCUSTAY_OIDC_CLIENT_SECRET` | no | `(none)` | The client secret for that client. Keep it as private as the other secrets in `.env`. | | `DOCUSTAY_OIDC_LABEL` | no | `your identity provider` | The name on the sign-in button: “Sign in with …”. | | `DOCUSTAY_OIDC_SCOPES` | no | `openid email profile` | What to ask the provider for. It must include `openid` and `email`. | ## Signing certificate and timestamps | Setting | Required | Default | What it does | |---|---|---|---| | `KEYSTONE_SIGNING_CERT_PEM` | no | `(made at first run)` | The certificate that seals finished documents. By default Docustay makes one for this install; set your own to use a certificate from a trusted authority. | | `KEYSTONE_SIGNING_CERT_PATH` | no | | Path to a PEM file instead of the value itself. | | `DOCUMENT_TSA_URL` | no | `(none)` | A trusted timestamp authority; when set, finished documents carry a trusted timestamp. | ## Operations | Setting | Required | Default | What it does | |---|---|---|---| | `KEYSTONE_METRICS_TOKEN` | no | `(metrics off)` | Turns on GET /metrics (Prometheus). Callers send Authorization: Bearer . At least 16 characters. | | `DOCUSTAY_TELEMETRY` | no | `on` | off stops the one anonymous daily usage ping (a random install id, the version, five counts). | | `DOCUSTAY_LICENSE_KEY` | no | | A Pro licence key. With it the worker checks the licence daily and unlocks Pro features. | | `KEYSTONE_ENVIRONMENT` | no | `production` | staging for a test install: live Stripe keys are refused and email goes only to the allowlist. | | `KEYSTONE_ALERT_EMAIL` | no | | Where the install sends operational alerts (a failing backup, a paused sender). | | `KEYSTONE_SELF_HOST` | set by compose | `1` | Marks this as a self-hosted install: hides hosted-only screens such as billing. | ## Payments (optional) | Setting | Required | Default | What it does | |---|---|---|---| | `KEYSTONE_STRIPE_SECRET_KEY` | for payment fields | | Your own Stripe secret key for collecting payments at signing. | | `KEYSTONE_STRIPE_PUBLISHABLE_KEY` | for payment fields | | The matching publishable key. | | `KEYSTONE_STRIPE_CHARGES_ENABLED` | no | `off` | A safety switch: live charges stay refused until this is on. | ## AI (optional) | Setting | Required | Default | What it does | |---|---|---|---| | `ANTHROPIC_API_KEY` | for AI features | | Your own Anthropic key. Without it the AI drafting, reviewing and chat features are hidden. | | `KEYSTONE_AI_DEFAULT_MODEL` | no | `(a current model)` | The model used for drafting. | | `KEYSTONE_AI_REVIEW_MODEL` | no | `(a current model)` | The model used for review passes. | ## Advanced settings The code also reads the variables below. They tune limits and connections you will rarely need to change. Another 70 variables (plan prices and limits, and the hosted service's own accounts for Stripe Connect, Firebase, Cloudflare, domains and the like) belong to the hosted service only; a self-hosted install ignores them. - `DOCUMENT_SIGNING_CERT_P12` - `DOCUMENT_SIGNING_CERT_PASSPHRASE` - `DOCUSTAY_API_KEY` - `FAULT_INJECT` - `GOOGLE_CALENDAR_API_BASE` - `GOOGLE_OAUTH_CLIENT_ID` - `GOOGLE_OAUTH_CLIENT_SECRET` - `GOOGLE_TOKEN_URL` - `KEYSTONE_APP_URL` - `KEYSTONE_DATA_DIR` - `KEYSTONE_DOCUSTAY_MAIL_SENDER` - `KEYSTONE_EMAIL_FROM` - `KEYSTONE_LIMITER_POOL_MAX` - `KEYSTONE_MAIL_ALERT_TO` - `KEYSTONE_MAIL_CAPTURE_SMTP` - `KEYSTONE_PRODUCT` - `KEYSTONE_VERSION` - `KEYSTONE_WORKER_DATABASE_URL` - `MICROSOFT_GRAPH_OAUTH_CLIENT_ID` - `MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET` ## An example `.env` `init.sh` writes the secrets for you. Everything else is yours to set in the same file; a small install behind its own address needs only these lines on top of the generated ones: ```bash KEYSTONE_PUBLIC_URL=https://sign.example.com KEYSTONE_TRUSTED_PROXY_HOPS=1 KEYSTONE_MAIL_SENDER=Example Co KEYSTONE_MAIL_DAILY_LIMIT=500 ``` Apply a change by recreating the containers, then check the install is up and sees the new address: ```bash docker compose up -d curl -s https://sign.example.com/readyz ``` --- # Upgrade a self-hosted install An upgrade replaces the app, worker and web containers. Your documents and settings stay in the database and the file store, so they are not touched. The database moves forward by itself: every start applies the migrations it has not had yet, before the app begins to answer. ## Before you upgrade 1. **Take a backup** of the database, the file store and the `appdata` volume. The steps are in [Back up and restore](/docs/self-host-backup). Do this every time; it is the only way back. 2. Read the [changelog](/changelog) for the versions between yours and the new one. 3. Check that nobody is in the middle of signing. A signing session in progress survives an upgrade, but the page reloads once. ## Upgrade ```bash git pull docker compose build docker compose up -d ``` `up -d` recreates only the containers whose image changed. The database, file store and converter keep running. ## Check that it worked ```bash docker compose ps docker compose exec -T app node -e "fetch('http://localhost:8080/readyz').then(r=>r.text()).then(console.log)" ``` The app's own `/readyz` check (it is not reachable from outside, which is intended) lists the number of migrations applied. It answers `"status":"ready"` once the database, the file store and the signing certificate are all reachable. Then open the app, send yourself a test document and sign it. ## If something looks wrong Look at the logs first: ```bash docker compose logs --tail 100 app worker ``` A migration that cannot apply stops the new app from starting, and the log says which one. Do not keep retrying: restore the backup you took (see [Back up and restore](/docs/self-host-backup)), go back to the version you had, and report the log. ```bash git checkout docker compose build && docker compose up -d ``` Migrations are written to only add things, but the supported way back is the backup, not running an older version on a newer database. ## Keep your secrets Never delete `.env` or the `appdata` volume during an upgrade. The first holds the secrets that protect sign-in tokens; the second holds the certificate that seals your signed PDFs and the key that protects stored passwords. --- # Back up and restore A self-hosted Docustay keeps its state in three places. Back up all three, together, or the backup may not restore. | What | Where | Why it matters | |---|---|---| | The database | the `db` container (volume `dbdata`) | People, templates, documents, settings, the audit trail. | | The file store | the `storage` container (volume `filedata`) | Uploaded files and signed PDFs. | | The app data volume and `.env` | volume `appdata` and the file `docustay/.env` | The certificate that seals signed PDFs, the key that protects stored passwords, and the secrets behind sign-in. Without them a restored copy cannot open what it stored. | ## Back up Run this from the `docustay` folder. It writes one dated folder. ```bash set -eu d="backup-$(date +%Y%m%d-%H%M)"; mkdir -p "$d" # 1. the database, as one consistent file docker compose exec -T db pg_dump -U docustay -Fc docustay > "$d/db.dump" # 2. the file store and the app data volume, as archives for v in filedata appdata; do docker run --rm -v "docustay_$v:/v:ro" -v "$PWD/$d:/out" alpine tar czf "/out/$v.tgz" -C /v . done # 3. the settings file cp .env "$d/env"; chmod 600 "$d/env" echo "backed up to $d" ``` Copy the folder somewhere that is not this machine. The folder holds secrets, so keep it as private as the install itself. ## Restore on a new machine 1. Install Docker and get the Docustay folder (`git clone`, then `cd docustay`). 2. Put the saved settings file back: `cp backup-…/env .env` 3. Start only the database and the file store, so nothing writes yet: ```bash docker compose up -d db storage ``` 4. Load the database: ```bash docker compose exec -T db pg_restore -U docustay -d docustay --clean --if-exists < backup-…/db.dump ``` 5. Load the two volumes: ```bash for v in filedata appdata; do docker run --rm -v "docustay_$v:/v" -v "$PWD/backup-…:/in:ro" alpine sh -c "cd /v && tar xzf /in/$v.tgz" done ``` 6. Start everything and check it: `docker compose up -d`, then run `docker compose ps` and open the app. Every service should be `Up`, and `app` should say `(healthy)`. ## Test the restore A backup you have not restored is a guess. Once, on a spare machine, do the restore above and open a signed document. Do it again after a major upgrade. ## How often Daily is a good start for the database. The file store changes only when documents are made or signed, so the same schedule is enough. Keep at least the last seven. --- # Put Docustay behind your own address The web container listens on port 8080 (change it with `DOCUSTAY_PORT` in `.env`). Everything people use goes through that one port. Put a reverse proxy in front of it to get your own domain and HTTPS. ## Tell Docustay its address Set the address people will type, with no trailing slash, in `.env`, then restart: ```bash KEYSTONE_PUBLIC_URL=https://sign.example.com ``` This is the start of every signing link and reset link in an email. If it is wrong, the links in emails will not open. ## Caddy Caddy gets and renews the certificate for you. ```caddyfile sign.example.com { reverse_proxy localhost:8080 } ``` ## nginx ```nginx server { listen 443 ssl http2; server_name sign.example.com; ssl_certificate /etc/letsencrypt/live/sign.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/sign.example.com/privkey.pem; client_max_body_size 50m; # uploaded documents location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; } } ``` ## Traefik (labels on the web service) ```yaml services: web: labels: - traefik.enable=true - traefik.http.routers.docustay.rule=Host(`sign.example.com`) - traefik.http.routers.docustay.tls.certresolver=letsencrypt - traefik.http.services.docustay.loadbalancer.server.port=3000 ``` ## Check - Open `https://sign.example.com/login`. The sign-in page should load over HTTPS with no certificate warning. - Send yourself a document and open the link in the email. It must start with your address. - If you use embedded signing, list your own website under Settings → Documents → Embedding. ## Keep the proxy honest Pass `X-Forwarded-Proto` and `X-Forwarded-For` as shown. Docustay uses the first to know a request was secure and the second for the address in the audit trail. Do not expose ports 5432 (database), 8333 (file store) or 3000 (web) directly. --- # Pro licence keys Community is free under the AGPL-3.0 and needs no key. Pro is a yearly licence for your own business, and it unlocks the Pro features on your install. This page follows a key from purchase to renewal. ## 1. Buy Choose Self-host Pro on the [Self-host](/self-host) page and pay by card. When the payment is confirmed, a key is made for you and emailed once to the address on the payment. We keep only a fingerprint of the key, never the key itself, so it cannot be shown again. Keep the email. ## 2. Install the key Add it to the `.env` of your install and restart: ```bash DOCUSTAY_LICENSE_KEY= ``` ```bash docker compose up -d ``` The install checks the key against the public key shipped with it (`license-public.pem`). That check happens on your machine: a key is valid if it was signed by us, has not ended, and was not cancelled. ## 3. Renewal When your yearly payment succeeds, the key is extended to the end of the paid year plus 14 days of grace, so a late card does not switch your install off. You do not need to change anything: an install that can reach docustay.app renews its record by itself. ## 4. Working offline An install with no route to the internet cannot renew by itself. Open Settings and download the usage report (it holds five whole numbers and an install id, never a document, name or address), and send it to us when we ask. You can also turn off the daily anonymous ping with `DOCUSTAY_TELEMETRY=off`; the key still works. ## 5. When the licence ends If you cancel or the grace days pass, the Pro features stop. Your documents, signed PDFs and settings are not touched, and the install keeps running as Community. Nothing is deleted. ## Lost key? Write to the address on your payment receipt. --- # Tutorial: e-signatures in a Next.js app You will end up with three small files: a route that sends a document, a page that shows the signing form, and a route that checks Docustay's webhook. The finished app is in `docustay/examples/nextjs`. It takes about ten minutes. ## 1. What you need - A Docustay account and a **test** API key (Developers). A test key sends nothing real and bills nothing. - A template. Make one in the app (Templates → Create) and copy its id from the address bar. - Your site's address listed under Settings → Documents → Embedding (`http://localhost:3000` while you try it). ## 2. Install ```bash npm install @docustay/sdk @docustay/embed ``` Put the secrets in `.env.local`. They stay on the server: ```bash DOCUSTAY_API_KEY=dsk_test_… DOCUSTAY_TEMPLATE_ID=