# JustDocs Developers Build on JustDocs. Connect AI agents through the MCP server, call the REST API, and receive signed webhooks. A person approves before anything is sent. ## Connect in a minute Add `https://tryjustdocs.com/mcp` to a client that signs in with OAuth (Claude, ChatGPT, Muse): it asks you to sign in and choose what it may do. See [Sign in with OAuth](https://tryjustdocs.com/docs/mcp/oauth). Or make an **agent key** on your workspace's **API & agents** page, on any plan, then add the server to your client: ```bash [Claude Code] claude mcp add --transport http justdocs https://tryjustdocs.com/mcp \ --header "Authorization: Bearer $JUSTDOCS_AGENT_KEY" ``` ```json [Cursor] { "mcpServers": { "justdocs": { "url": "https://tryjustdocs.com/mcp", "headers": { "Authorization": "Bearer ${env:JUSTDOCS_AGENT_KEY}" } } } } ``` ```bash [REST] curl https://tryjustdocs.com/api/v1/templates \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" ``` Then ask your agent to _"draft a mutual NDA for Acme from our template and send it to Ada"_. It prepares the document and asks to send it. You approve it under **Approvals**, and only then is the email sent. [Every client →](https://tryjustdocs.com/docs/mcp/connect) ## Where to start - [Quick start](https://tryjustdocs.com/docs/get-started/quickstart) From any workspace to an approved send. - [Connect a client](https://tryjustdocs.com/docs/mcp/connect) Claude, Cursor, VS Code, connector platforms, or any MCP client. - [Endpoint reference](https://tryjustdocs.com/docs/api/reference/) Every operation, with request and response fields and examples. - [Webhook events](https://tryjustdocs.com/docs/webhooks/events) What each event means and the payload it carries. - [Send with approval](https://tryjustdocs.com/docs/guides/send-with-approval) The complete flow, from template to signed copy. - [Agent security checklist](https://tryjustdocs.com/docs/guides/agent-security) What to set before you give an agent a key. ## Built for agents - **One set of commands.** Each MCP tool is a REST operation with the same name, inputs and checks, so what you test with `curl` is what your agent gets. - **Least privilege.** Keys carry permissions (`read`, `documents`, `send`), recipient domains, a daily send limit and an expiry. You can revoke a key at any time. - **Human approval.** An agent can't send a document to anyone until an owner or admin approves. An approval expires after seven days. - **No signing by machines.** Recipients sign from the private link JustDocs emails them. No tool or endpoint returns that link or signs for anyone. - **Readable by assistants.** Every page is also available as Markdown, and the whole site is in [llms.txt](https://tryjustdocs.com/docs/llms.txt). # Build on JustDocs JustDocs is where a team writes documents, sends them for signature and collects the details that fill them in. The developer platform lets your software and AI agents do the same work through the same rules: - **The [REST API](https://tryjustdocs.com/docs/api/)** at `https://tryjustdocs.com/api/v1`, for your own software. - **The [MCP server](https://tryjustdocs.com/docs/mcp/)** at `https://tryjustdocs.com/mcp`, for AI agents. It has the same commands, as tools. - **[Webhooks](https://tryjustdocs.com/docs/webhooks/)**, so your systems hear when something is sent, viewed, signed, completed, answered or approved. Agents work on every plan, Free included, in personal workspaces and teams, through the MCP server or the REST API with an agent key. API keys for your own software, and webhooks, come with the **Teams & Platform** plan, in a team workspace. ## The records | Record | What it is | | --- | --- | | Template | A reusable document with **fill-ins** (named blanks such as `client_name`) and, often, an intake form whose questions answer them. Written in the editor, made from a PDF, saved from the library, or created through the API with [`create_template`](https://tryjustdocs.com/docs/guides/create-a-template). | | Document | A document started from a template, with its fill-ins completed. It has a `draft_revision` that goes up when it changes. | | Signing request | One or more documents sent to one or more **recipients**. Each recipient signs as a **role** (such as `client`), in order or all at once. | | Intake link | A form emailed to one person. Their answers fill the documents, then either you review them or they go straight on to sign. | | Approval | A send that waits for an owner or admin, because an agent key, or a key set to need approval, asked for it. | A signing request moves from `draft` to `sent`, then ends `completed`, `declined`, `voided` or `expired`. Each recipient goes from `pending` to `notified`, `viewed` and `signed`, or `declined`. ## Keys, permissions and approvals Keys are made on the workspace's **API & agents** page: in your personal workspace by you, and in a team by its owners and admins. A key works only in the workspace it was made in, and [`get_workspace`](https://tryjustdocs.com/docs/api/reference/get-workspace) says which. A key acts for the person who made it. It sends invitations in their name and stops working if they stop being an owner or admin. - **API keys** are for your team's own software, on Teams & Platform. Their sends go out at once, unless you set them to need approval. - **Agent keys** are for AI agents, on every plan, and are the only keys the MCP server takes. Everything an agent key asks to send waits for a person's approval. Below Teams & Platform, a workspace can have 3. - **Connected apps** are AI clients that [sign in with OAuth](https://tryjustdocs.com/docs/mcp/oauth) instead of being given a key, such as Claude, ChatGPT or Muse. Connecting one makes an agent key for it, in the name of the person who connected it. Each key has [permissions](https://tryjustdocs.com/docs/api/keys#permissions), and can be limited to recipient domains and a number of sends a day. Each key also expires. See [Keys and permissions](https://tryjustdocs.com/docs/api/keys). ## What no key can do - Sign, accept or decline on anyone's behalf. - Read a recipient's signing link. Recipients get it by email only. - Reach another workspace's records. - Act as a person who is no longer an owner or admin. ## Next - [Quick start](https://tryjustdocs.com/docs/get-started/quickstart) A key, a client and a first approved send. - [Plans and limits](https://tryjustdocs.com/docs/get-started/plans-and-limits) What each plan includes, and every limit in one table. # Quick start This takes about ten minutes. At the end, an agent will have prepared a document from your template and asked to send it, and you'll have approved it. ## 1. Choose a workspace Agent keys work on every plan, Free included. [Sign in](https://tryjustdocs.com/login) and use your personal workspace, or switch to a team where you're an owner or admin. Keys and templates each belong to one workspace, and the app opens in your personal one, shown as **Personal**. Make the template and the key in the same workspace. Below **Teams & Platform**, a workspace can have 3 active agent keys. API keys for your own software, and webhooks, come with Teams & Platform, which a team chooses under **Settings → Plan & usage**. Confirm your email address if you haven't. Keys send invitations in your name, so the app asks for this first. ## 2. Have a template Agents start documents from the workspace's templates. Use one you have, or make one: - **From the library.** Open **Library**, choose a document or form, then **Save as template**. Its fill-ins stay blank, for each document. (**Create document**, then **Save as template** on the document's page, works too, but leaves a draft document behind, which you can delete.) - **In the editor.** Write it with fill-ins such as `client_name`, and give each signature field a role, such as `client`. Then save it as a template. - **From a PDF.** Upload it and place its fields. - **Through the API.** The agent can make one itself with `create_template`, from text or a library entry. See [Create a template](https://tryjustdocs.com/docs/guides/create-a-template). ## 3. Make an agent key 1. Open **API & agents** in the sidebar, then **New key**. 2. Name it after the agent, such as _Sales assistant_. 3. On Teams & Platform, choose **An AI agent** under **For**. On other plans, every new key is an agent key. 4. Under **It may**, tick what it needs: reading, creating templates and starting documents, and sending. 5. Optionally limit **Recipient domains** (for example `example.com`) and **Sends a day**. 6. Choose **Create key** and copy the key. It starts with `jd_live_` and isn't shown again. The key works only in the workspace you made it in, which the page names. Its row shows **Agent key** and **Sends wait for approval**. An agent can check where it is with `get_workspace`. **Keep the key secret** Store it in your client's secret settings or an environment variable such as `JUSTDOCS_AGENT_KEY`. Never put it in a file you commit. If it leaks, revoke it on the same page. ## 4. Connect your client ```bash [Claude Code] export JUSTDOCS_AGENT_KEY=jd_live_… claude mcp add --transport http justdocs https://tryjustdocs.com/mcp \ --header "Authorization: Bearer $JUSTDOCS_AGENT_KEY" ``` ```json [Cursor (~/.cursor/mcp.json)] { "mcpServers": { "justdocs": { "url": "https://tryjustdocs.com/mcp", "headers": { "Authorization": "Bearer ${env:JUSTDOCS_AGENT_KEY}" } } } } ``` ```json [VS Code (.vscode/mcp.json)] { "inputs": [ { "type": "promptString", "id": "justdocs-key", "description": "JustDocs agent key", "password": true } ], "servers": { "justdocs": { "type": "http", "url": "https://tryjustdocs.com/mcp", "headers": { "Authorization": "Bearer ${input:justdocs-key}" } } } } ``` For Claude Desktop, claude.ai, connector platforms and other clients, see [Connect a client](https://tryjustdocs.com/docs/mcp/connect). ## 5. Ask for something Try: > List our templates. Start a Mutual NDA for Acme Inc., effective today, and send it to Ada Lovelace (ada@example.com) as the client. The agent calls `list_templates`, `get_template` and `start_document` (and `create_template` first, if there's no suitable template), then `create_signing_request` with `send: true`. JustDocs creates the request and holds the send. The agent is told: ```text Waiting for an owner or admin of the workspace to approve this. Nothing has been sent yet; check get_approval. ``` ## 6. Approve it Open **Approvals** in the sidebar. Pending approvals are also listed on **Home**, with a link there. Check the title and the addresses, then choose **Approve**. JustDocs sends the request in the key creator's name, after checking the key and its limits again. The agent can follow along with `get_approval` and `get_signing_request`. Ada gets the email, opens her private link and signs. When everyone has signed, the request is `completed`. ## Next - Hear about each step as it happens, on Teams & Platform: [Track completion with webhooks](https://tryjustdocs.com/docs/guides/track-with-webhooks). - Call the same commands from your own code: [REST API](https://tryjustdocs.com/docs/api/). - Before you give an agent a key in production: [Agent security checklist](https://tryjustdocs.com/docs/guides/agent-security). # Plans and limits Agents work on every plan, Free included, in personal workspaces and teams: agent keys, apps that [sign in with OAuth](https://tryjustdocs.com/docs/mcp/oauth), the MCP server, and the REST API with an agent key. Everything an agent asks to send waits for a person's approval. Below **Teams & Platform**, a workspace can have 3 active agent keys, and a connected app counts as one. **API keys**, whose sends can go out without approval, and **webhooks** come with the **Teams & Platform** plan. Whatever the plan, every workspace can have 25 active keys of either kind, including API keys left from Teams & Platform until they're revoked or expire. Prices are on the [pricing page](https://tryjustdocs.com/#pricing). If a workspace leaves Teams & Platform, its API keys stop working with `403 API keys come with Teams & Platform; this workspace is no longer on it.` Its webhooks also stop. Its agent keys and connected apps keep working, even past 3, but no new one can be made or connected until fewer than 3 are active. If the workspace comes back to the plan, its endpoints and any API keys that haven't expired work again. ## Limits These values come from the code that enforces them. | Limit | Value | | --- | --- | | Plan | Agent keys, connected apps and the MCP server on every plan; API keys and webhooks with Teams & Platform | | Sender seats on Teams & Platform | 5, more can be added | | Completed requests on Teams & Platform | 300 a month, pooled across the team | | Calls through one key | 120 a minute, REST and MCP together | | Calls from one network address | 2000 a minute, across all keys (an IPv6 address counts with its /56 block) | | Request body | 2 MB | | Active agent keys per workspace, below Teams & Platform | 3, connected apps included. A workspace that moves down keeps the keys it has. | | Active keys per workspace, on every plan | 25, API and agent keys together, counting API keys left from Teams & Platform until they're revoked or expire | | Key lifetime | 90 days by default, up to 365 | | Sends through one key | 20 a day by default, up to 1,000 | | Recipient domains per key | Up to 20 (none means any domain) | | Approving an app's sign-in | 10 minutes, in the browser that started it | | OAuth authorization code | 1 minute, used once | | OAuth access token | 1 hour, or until its key expires if sooner | | OAuth refresh token | 30 days from when it's issued, never past its key's expiry. Each refresh replaces it. | | Sign-ins kept per connected app | 10, the most recent. A new one ends the oldest. | | New apps registered from one network address | 60 an hour. The same app registering again doesn't count. | | Registered apps nobody connects | Removed after 30 days unused | | Approvals waiting per workspace | 500 | | An approval waits | 7 days, then expires | | Documents per request | 10 | | Recipients per request | 20 | | Request expiry | 30 days by default, up to 365 | | Intake link expiry | 14 days by default, up to 60 | | Saved templates per workspace | 3 on Free, 10 on Signing packs, no limit on Business or Teams & Platform. Templates kept from a larger plan stay. | | A template's text, through create_template | Up to 500,000 characters | | Fill-ins per template | Up to 200 | | Signing roles per template | At least 1, up to 20 | | Fields per signing role | Up to 500 | | Webhook endpoints per workspace, on Teams & Platform | 10 | | Delivery attempts per event | 7: at once, then after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h | | Delivery timeout | 10 seconds | | An endpoint pauses after | 20 failed attempts in a row | | Signature tolerance | 5 minutes | ## How the limits apply - **Calls a minute** are counted per key, across the REST API and the MCP server. Each network address (for IPv6, its /56 block) also has a ceiling across all keys, far above the per-key limit, so made-up keys can't be tried without limit. Going over either returns `429` until the minute rolls over. See [Errors and rate limits](https://tryjustdocs.com/docs/api/errors#rate-limits). - **Sends a day** are counted per key over the last 24 hours. They include signing requests and intake links sent through the key, and approved sends. Going over returns `429 This key has sent its 20 for the last 24 hours.` The plan also caps new signing requests a day, and how many any one address can receive. - **Recipient domains** apply to every address on a request or intake link, including subdomains. An address outside them returns `422` and names the address. - **Approvals waiting.** A workspace can have a limited number of approvals waiting at once. Beyond that, asking for another returns `429`. Decide some first. - **Active agent keys.** Below Teams & Platform, once 3 agent keys are active, making another or connecting a new app is refused with `402` and the reason. Revoke or disconnect one first; a team can also move to Teams & Platform. Revoked and expired keys don't count, nor do API keys left from Teams & Platform (they still count toward the 25 active keys of every plan until they're revoked or expire). Someone connecting an app they already connected updates their key, so it's never refused. - **Saved templates.** Each plan keeps a number of saved templates, however they're made: in the editor, from a PDF, from the library or with `create_template`. At the limit, saving another returns `402`. Delete one you no longer use, or change the plan. Templates kept from a larger plan stay. - **Completed requests.** Sending a request holds one of the month's completed requests, as does an intake link that goes straight on to signing. If the request doesn't complete, the hold is released. When none are left, sends return `402`. The allowance is shared by the whole workspace, whichever key or person sends. # MCP server JustDocs runs a remote [Model Context Protocol](https://modelcontextprotocol.io) server. With it, an AI agent can find your workspace's templates or create them, start documents, and ask to send them for signature or for details. A person approves each send first. | | | | --- | --- | | Endpoint | `https://tryjustdocs.com/mcp` | | Transport | Streamable HTTP, stateless. Every message is a `POST`, and replies are JSON. | | Authentication | [Sign in with OAuth](https://tryjustdocs.com/docs/mcp/oauth), or an agent key. Either is sent as `Authorization: Bearer` on every request. | | Registry | [`com.tryjustdocs/justdocs`](https://registry.modelcontextprotocol.io/v0/servers?search=com.tryjustdocs/justdocs) in the official MCP Registry | | Tools | [14](https://tryjustdocs.com/docs/mcp/tools). Each one is a REST operation with the same name. | | Plan | Every plan. Below Teams & Platform, up to 3 active agent keys per workspace | ## How it works 1. You give the agent an **agent key**, and choose what it may do: in your personal workspace, or in a team where you're an owner or admin. Either you make the key on the workspace's **API & agents** page, or the agent's client signs in with OAuth and you approve it on a JustDocs page, which makes the key for that app. 2. The agent's client connects to `/mcp` with the key or its access token. It sees only the tools its permissions allow. 3. The agent reads or creates templates, starts documents and creates signing requests or intake links, as the person who made the key, in the one workspace the key belongs to. 4. When it asks to send, JustDocs holds the send as an **approval** and tells the agent nothing has gone out yet. 5. The workspace's owner, or a team's owner or admin, approves or declines it on the workspace's **Approvals** page. JustDocs then sends it, checking the key and its limits again, and the agent sees the result through `get_approval`. Recipients sign from the private link JustDocs emails them. No tool signs, and no tool returns that link. ## The tools | Tool | Does | | --- | --- | | [`get_workspace`](https://tryjustdocs.com/docs/mcp/tools#get_workspace) | Learn which workspace the key works in, its plan, and whether its sends wait for approval | | [`list_templates`](https://tryjustdocs.com/docs/mcp/tools#list_templates), [`get_template`](https://tryjustdocs.com/docs/mcp/tools#get_template) | Find a template and learn its fill-ins, roles and questions | | [`create_template`](https://tryjustdocs.com/docs/mcp/tools#create_template) | Make a template from text or a library entry, when there's no suitable one. It emails no one. See [Create a template](https://tryjustdocs.com/docs/guides/create-a-template). | | [`start_document`](https://tryjustdocs.com/docs/mcp/tools#start_document), [`get_document`](https://tryjustdocs.com/docs/mcp/tools#get_document) | Start a document from a template, filled in | | [`create_signing_request`](https://tryjustdocs.com/docs/mcp/tools#create_signing_request), [`send_signing_request`](https://tryjustdocs.com/docs/mcp/tools#send_signing_request) | Prepare a request, and ask to send it | | [`list_signing_requests`](https://tryjustdocs.com/docs/mcp/tools#list_signing_requests), [`get_signing_request`](https://tryjustdocs.com/docs/mcp/tools#get_signing_request) | Follow requests and each recipient's progress | | [`void_signing_request`](https://tryjustdocs.com/docs/mcp/tools#void_signing_request) | Cancel a sent request that isn't complete. This takes effect at once, without approval. | | [`create_intake_request`](https://tryjustdocs.com/docs/mcp/tools#create_intake_request), [`get_intake_request`](https://tryjustdocs.com/docs/mcp/tools#get_intake_request) | Ask someone for the details that fill the documents | | [`get_approval`](https://tryjustdocs.com/docs/mcp/tools#get_approval) | See whether a person approved what the agent asked to send | ## Next - [Connect a client](https://tryjustdocs.com/docs/mcp/connect) Claude, ChatGPT, Cursor, VS Code, connector platforms, or any MCP client. - [Sign in with OAuth](https://tryjustdocs.com/docs/mcp/oauth) What the person approving sees, tokens, and the endpoints for client developers. - [Tools](https://tryjustdocs.com/docs/mcp/tools) Every input, permission and annotation. - [Safety model](https://tryjustdocs.com/docs/mcp/safety) Approvals, limits and what an agent can never do. - [Troubleshooting](https://tryjustdocs.com/docs/mcp/troubleshooting) What each error means and how to fix it. # Connect a client Every client needs the server's URL: ```text https://tryjustdocs.com/mcp ``` Give it exactly like that: no trailing slash, and not `www.`. The server's [resource metadata](https://tryjustdocs.com/docs/mcp/oauth) names this URL, and clients refuse to sign in when the URL you gave differs from it. Then the client authenticates in one of two ways: - **Sign in with OAuth.** The client opens a JustDocs page, where you choose a workspace (your personal one, or a team you own or administer) and what the app may do. No key is copied anywhere. Claude, ChatGPT, Muse and Claude Code work this way. See [Sign in with OAuth](https://tryjustdocs.com/docs/mcp/oauth). - **An agent key**, sent as `Authorization: Bearer `. Make it on the workspace's **API & agents** page; on Teams & Platform, choose **For: An AI agent**. The server refuses other keys with `403`. Every client can use one. Either way the client acts as an agent key: everything it asks to send waits for an owner or admin to approve. Below, a key is in an environment variable named `JUSTDOCS_AGENT_KEY`. Keep it out of files you commit. ## Claude (claude.ai and Claude Desktop) 1. Open **Settings → Connectors** and choose **Add custom connector**. 2. Name it _JustDocs_ and give the URL `https://tryjustdocs.com/mcp`. Leave the OAuth client ID and secret under **Advanced settings** empty: Claude identifies itself. 3. Choose **Connect**. A JustDocs page opens. Sign in, choose the workspace and limits, and connect. The page shows Claude as verified on `claude.ai`, returning you to `claude.ai`. Connectors you add on claude.ai also appear in Claude Desktop. ### With an agent key instead If you'd rather use a key, choose the option without sign-in when you add the connector. Under **Request headers**, add `Authorization` with the value `Bearer jd_live_…`. Type the word `Bearer`, then a space, then the key. If the dialog only offers a URL and OAuth, run the server through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) in Claude Desktop's configuration file instead (**Settings → Developer → Edit Config**): ```json { "mcpServers": { "justdocs": { "command": "npx", "args": ["-y", "mcp-remote", "https://tryjustdocs.com/mcp", "--header", "Authorization:${JUSTDOCS_AUTH}"], "env": { "JUSTDOCS_AUTH": "Bearer jd_live_…" } } } } ``` There is no space after `Authorization:` in `args`, on purpose. Some systems split arguments at spaces, so the space goes in the environment value instead. Restart Claude Desktop afterwards. ## ChatGPT ChatGPT connects to MCP servers in **developer mode**. Its menus change from time to time, so the names below may differ slightly. 1. In ChatGPT's settings, open **Apps**, then **Advanced settings**, and turn on **Developer mode**. 2. Create an app (a connector). Name it _JustDocs_, give the URL `https://tryjustdocs.com/mcp`, and choose **OAuth** for authentication. Leave any client ID and secret empty: ChatGPT identifies itself. 3. Connect. A JustDocs page opens. Sign in, choose the workspace and limits, and connect. The page shows ChatGPT as verified on `chatgpt.com`, returning you to `chatgpt.com`. ## Muse Add a custom MCP server in Muse with the URL `https://tryjustdocs.com/mcp`, and choose OAuth for authentication. Muse registers itself with JustDocs and opens the JustDocs page, where you sign in and approve it. An app that registers itself can claim any name, so the page shows Muse as an **unverified app**. Check that **Sends you back to** names Muse's own address before you connect. ## Claude Code Add the server, then sign in from inside Claude Code: ```bash claude mcp add --transport http justdocs https://tryjustdocs.com/mcp ``` Run `/mcp`, choose `justdocs`, and choose **Authenticate**. Your browser opens the JustDocs page. The page warns that the sign-in returns to a program on your computer. That program is Claude Code, where you started the sign-in, so you can continue. Once you connect, `/mcp` shows `justdocs` as connected with its tools. ### With an agent key instead ```bash claude mcp add --transport http justdocs https://tryjustdocs.com/mcp \ --header "Authorization: Bearer $JUSTDOCS_AGENT_KEY" ``` To share the setup with a project without sharing the key, commit a `.mcp.json` that reads the key from each person's environment: ```json { "mcpServers": { "justdocs": { "type": "http", "url": "https://tryjustdocs.com/mcp", "headers": { "Authorization": "Bearer ${JUSTDOCS_AGENT_KEY}" } } } } ``` Leave out `headers` to have each person sign in with OAuth instead. ## Cursor Add the server to `~/.cursor/mcp.json` for every project, or to `.cursor/mcp.json` for one: ```json { "mcpServers": { "justdocs": { "url": "https://tryjustdocs.com/mcp", "headers": { "Authorization": "Bearer ${env:JUSTDOCS_AGENT_KEY}" } } } } ``` Cursor reads `${env:…}` from the environment it was started in. Restart it after you change the file. ## VS Code Add `.vscode/mcp.json`. VS Code asks for the key the first time and stores it securely: ```json { "inputs": [ { "type": "promptString", "id": "justdocs-key", "description": "JustDocs agent key", "password": true } ], "servers": { "justdocs": { "type": "http", "url": "https://tryjustdocs.com/mcp", "headers": { "Authorization": "Bearer ${input:justdocs-key}" } } } } ``` The top-level key is `servers`, not `mcpServers`. A client that can sign in to MCP servers with OAuth can leave out the header and sign in instead. JustDocs returns sign-ins only to an `https` address or to a program on your own computer, never to a custom app scheme such as `myapp://`. A client that needs one of those has to use a key. ## Connector platforms Some platforms let you register a remote MCP server for their users. If the platform supports OAuth for MCP servers, choose it and give the URL `https://tryjustdocs.com/mcp`. The platform identifies or registers itself, and each of its users signs in and approves it on the JustDocs page. Each person's connection is their own agent key. Otherwise, choose authentication with an API key, and have the platform send the agent key as `Authorization: Bearer `. If the platform asks for a header name and a value separately, the name is `Authorization` and the value is `Bearer ` followed by the key. Each workspace makes its own agent key, so the platform must keep each user's key separate. ## Any other client The server speaks [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) without sessions. Every JSON-RPC message is a `POST` to `/mcp`, and replies come back as JSON, not as a stream. `GET` and `DELETE` return `405`. Send `Accept: application/json, text/event-stream`, as the transport requires. Send an agent key, or an access token from [signing in](https://tryjustdocs.com/docs/mcp/oauth), as the bearer token: ```bash curl https://tryjustdocs.com/mcp \ -H "Authorization: Bearer $JUSTDOCS_AGENT_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` With the official TypeScript SDK: ```ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "my-agent", version: "1.0.0" }); await client.connect(new StreamableHTTPClientTransport(new URL("https://tryjustdocs.com/mcp"), { requestInit: { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_AGENT_KEY}` } }, })); const { tools } = await client.listTools(); ``` The SDK can also sign in with OAuth: give the transport an `authProvider` instead of the header, and it follows the server's `401` to sign in. See [Sign in with OAuth](https://tryjustdocs.com/docs/mcp/oauth) for what JustDocs expects. With the official Python SDK: ```python import asyncio, os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client async def main(): headers = {"Authorization": f"Bearer {os.environ['JUSTDOCS_AGENT_KEY']}"} async with streamablehttp_client("https://tryjustdocs.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() print([tool.name for tool in tools.tools]) asyncio.run(main()) ``` ## Check the connection Ask the agent to _"list our JustDocs templates"_. If it can't, see [Troubleshooting](https://tryjustdocs.com/docs/mcp/troubleshooting). If the list is empty, the key may be in another workspace from the one with your templates: the agent can check with `get_workspace` (see [Workspaces](https://tryjustdocs.com/docs/mcp/troubleshooting#workspaces)). The key's **last used** time on the API & agents page tells you whether the requests are reaching JustDocs. An app that signed in is listed there as a **Connected app**. # Sign in with OAuth An MCP client can sign in to JustDocs instead of being given a key. The client opens a JustDocs page, where you choose a workspace (your personal one, or a team you own or administer) and what the app may do, and the client gets tokens. Claude, ChatGPT, Muse and Claude Code connect this way. [Agent keys](https://tryjustdocs.com/docs/mcp/connect) still work for every client. | | | | --- | --- | | MCP server | `https://tryjustdocs.com/mcp` | | Resource metadata | `https://tryjustdocs.com/.well-known/oauth-protected-resource/mcp` | | Authorization server metadata | `https://tryjustdocs.com/.well-known/oauth-authorization-server` | | Flow | Authorization code with PKCE (`S256`), and refresh tokens that rotate | | Clients | Public clients, identified by a Client ID Metadata Document or by Dynamic Client Registration | | Permissions | `read`, `documents` and `send` | | Plan | Every plan. Below Teams & Platform, a connected app is one of the workspace's 3 active agent keys | ## How it works 1. You give your client the server's URL, `https://tryjustdocs.com/mcp`. Its first request has no token, so the server answers `401` with a header that says where to sign in: ```http WWW-Authenticate: Bearer realm="JustDocs", resource_metadata="https://tryjustdocs.com/.well-known/oauth-protected-resource/mcp", scope="read documents send" ``` 2. The client reads that document, which names JustDocs as the authorization server, then reads the authorization server's metadata. 3. The client identifies itself, with its own Client ID Metadata Document or by registering. 4. It opens a JustDocs page in your browser. You sign in if you aren't already, then choose a workspace and what the app may do. 5. JustDocs sends you back to the app with a one-time code. The app exchanges it for an access token and a refresh token. 6. The app calls `/mcp` with `Authorization: Bearer `. When the token expires, it refreshes it without asking you again. Give the URL exactly as `https://tryjustdocs.com/mcp`, with no trailing slash and without `www.`. The resource metadata names that URL, and clients refuse to sign in when it differs from the one you gave. ## What the person approving sees The page shows: - **The app's name.** Beside it is **Verified** and a domain, but only when the app is described by a document at its own address and every web address it returns people to is on that same domain. An app that registered itself is shown as **Unverified app**, because anyone can register any name. - **Where the sign-in returns you**, exactly. This is the host that will receive the app's code. - **Warnings**, when they apply: - the app is described on one domain but returns you to another; - the sign-in returns to a program on your computer. Any program on the computer can use that address, so continue only if you just started the sign-in from a tool you trust. - **The account you're signed in as**, with **Not you? Sign out**. - **The workspace** to connect: your personal workspace, or a team where you're an owner or admin, once your email address is confirmed. Below Teams & Platform, a workspace that already has 3 active agent keys can't take another, unless you're reconnecting this app. Others are listed with the reason. - **The same limits as a key:** the permissions, sends a day, recipient domains and the days it works. Only the permissions the app asked for are offered. You can untick any of them, but you can't add one. **Connect** returns you to the app with its code. **Cancel** returns you to the app with `access_denied`. **Connect** works once the page has been in front of you for a moment, focused or with the pointer over it, so a click meant for something else can't approve it. A click sooner asks you to press it again. If the app's request had a mistake, such as a missing PKCE challenge, the page says so and offers only to return to the app with the error. If JustDocs doesn't recognise the app, or the address it wants to return you to, a page explains that instead. It never sends you anywhere. ## What connecting makes Connecting makes an **agent key** for the app, in your name: - **Every send waits for approval.** Anything the app asks to send waits for an owner or admin, as with any agent key. It can never sign. See the [safety model](https://tryjustdocs.com/docs/mcp/safety). - **It counts toward the workspace's active keys:** 3 agent keys below Teams & Platform, and 25 keys of either kind on every plan. If none are left, the page says so. Revoke a key first. - **It is yours alone.** There is one key per person, per app, per workspace. Connecting the same app to the same workspace again updates your key's limits instead of making another. Another admin who connects the same app gets their own key, which acts in their name. - **It is listed** on the workspace's **API & agents** page as a **Connected app**, with its domain when the app is verified. - **It stops** as any key does: when it expires, when it's disconnected, or when you stop being an owner or admin. A key that was disconnected or has expired is never used again. Connecting again makes a new one. ## Tokens A token response looks like this: ```json { "access_token": "jd_at_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "jd_rt_…", "scope": "read documents send" } ``` | Lifetime | | | --- | --- | | Approving the sign-in | 10 minutes, in the browser that started it | | Authorization code | 1 minute, used once | | Access token | 1 hour, or until the key expires if that's sooner | | Refresh token | 30 days from when it's issued, never past the key's expiry | | Sign-ins per connected app | The 10 most recent. A new sign-in ends the oldest. | These are also on [Plans and limits](https://tryjustdocs.com/docs/get-started/plans-and-limits#limits). - **Refresh tokens rotate.** Each refresh returns a new refresh token, and the old one stops working. An app that refreshes twice at once, within 2 minutes, gets a second pair. Whichever pair it uses first continues, and the other stops. - **A reused refresh token ends the sign-in.** Any other use of a refresh token that was already replaced means it leaked, so both tokens of that sign-in stop working. The same happens when a code is exchanged twice. - **`scope`** is what the token can do: the key's permissions, within what was approved. A refresh can send `scope` to narrow it, never to widen it. - **No `offline_access` is needed.** Every sign-in gets a refresh token. ## Permissions The app asks for permissions with `scope`, separated by spaces: | Permission | Allows | | --- | --- | | `read` | Templates, documents, signing requests, intake links and approvals | | `documents` | Creating templates, and starting documents from them | | `send` | Asking to send signing requests and intake links, which waits for approval, and voiding requests | - A request with no `scope` asks for all three. - Values JustDocs doesn't know, such as `offline_access` or `openid`, are ignored. If none is left, the app gets `invalid_scope`. - The person approving can narrow what was asked, never widen it. - The app sees only the tools its token's permissions allow. See [Keys and permissions](https://tryjustdocs.com/docs/api/keys#permissions) for which tools each permission covers. ## Ending a sign-in - **From the app:** the app sends its access or refresh token to the revocation endpoint. That ends that one sign-in, both of its tokens. The key stays, with any other sign-ins. - **From JustDocs:** **Disconnect** the app on the workspace's **API & agents** page. Every sign-in for that key stops at once. To use the app again, connect it again from the app. ## Errors an app can get From the token endpoint, as `400` JSON with `error` and `error_description`: | Error | When | What to do | | --- | --- | --- | | `invalid_grant` | The code is unknown, expired or used. The `code_verifier` or `redirect_uri` doesn't match. The refresh token is unknown, expired or replaced. The sign-in ended, or the key was disconnected or expired. | Start a new sign-in. | | `invalid_target` | `resource` isn't `https://tryjustdocs.com/mcp`, or was sent twice. | Send that URL once, or leave `resource` out. | | `invalid_scope` | A refresh asked for a permission the sign-in doesn't have. | Ask for the same permissions or fewer. | | `invalid_client` | The request authenticated as a client, with a secret, an assertion or `Authorization: Basic`. Or the `client_id` is unknown. | Send `client_id` only: every client here is public. Basic authentication gets `401`. | | `invalid_request` | A required parameter is missing, or the body isn't form encoded. | Check the request. | | `unsupported_grant_type` | Any grant other than these two. | Use `authorization_code` or `refresh_token`. | A dead token gets `invalid_grant`, never a `429` of its own, so an app knows to sign in again rather than wait. On `/mcp`, an access token that's expired, revoked or for another server gets `401` with `error="invalid_token"` in `WWW-Authenticate`. Refresh, and sign in again if the refresh fails. When a person returns to the app from the JustDocs page, the redirect can carry `access_denied` (they cancelled), or `invalid_request`, `unsupported_response_type`, `invalid_scope` or `invalid_target` (the request had that mistake). ## For client developers | Endpoint | URL | | | --- | --- | --- | | Authorization | `https://tryjustdocs.com/oauth/authorize` | `GET`, in the person's browser. Answers by query string. | | Token | `https://tryjustdocs.com/oauth/token` | `POST`, `application/x-www-form-urlencoded` | | Registration | `https://tryjustdocs.com/oauth/register` | `POST`, JSON ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)) | | Revocation | `https://tryjustdocs.com/oauth/revoke` | `POST`, form encoded ([RFC 7009](https://www.rfc-editor.org/rfc/rfc7009)) | The authorization server's issuer is `https://tryjustdocs.com`. The metadata documents and the token, registration and revocation endpoints answer any origin, without credentials, so a client in a browser can call them. ### Identify the app Every client is public: it proves itself with PKCE and its registered addresses, never a secret. **With a Client ID Metadata Document**, which JustDocs prefers, the `client_id` is the `https` address of a JSON document about the app: - The address has a path, no query or fragment, and the default port. It must be written the one normal way, with a lowercase host. - The document's `client_id` equals that address exactly. - `client_name` is required. Names longer than 80 characters are cut, and names that contain "JustDocs" are refused. - `redirect_uris` lists the addresses the app returns people to. Addresses JustDocs can't use are left out, and at least one must remain. - The app must be able to sign in without a secret. Either `token_endpoint_auth_methods_supported` includes `none`, or `token_endpoint_auth_method` is `none` or absent. A document with a client secret is refused. - Other fields, such as `logo_uri` and `jwks_uri`, are ignored. JustDocs never fetches them. - JustDocs fetches the document as JSON, without following redirects. It must be at most 5 KB and arrive within 5 seconds. It's kept for its `Cache-Control` max-age, from 5 minutes to a day. If it later names another `client_id`, the app's sign-ins end. **With Dynamic Client Registration**, the app posts its metadata to the registration endpoint: - `redirect_uris` must list 1 to 10 addresses, or the app gets `invalid_redirect_uri`. - `client_name` is optional, and 1 to 80 characters. A longer name, or one that contains "JustDocs", gets `invalid_client_metadata`. Without a name, the app is named after its first address's host. - Whatever authentication method, grant types or response types are asked for, the app is registered as public (`none`), with codes and refresh tokens. Other fields are ignored. - The reply is `201` with a `client_id` that starts `jdc_`, and no secret. - Registering the same name and addresses again returns the same `client_id`. Loopback addresses are compared without their port. - One network address can register 60 new apps an hour. Registering the same app again doesn't count. Past the limit, the app gets `429` with `Retry-After`. - A registered app that nobody connects is removed after 30 days unused. Register again if the authorization page says the app isn't recognised. **Redirect addresses** must be `https`, or `http` to a loopback address on the person's own computer. A loopback address matches on any port. Custom schemes (`myapp://`) and fragments are refused. ### The authorization request | Parameter | | | --- | --- | | `response_type` | `code` | | `client_id` | The document's address, or the registered `client_id` | | `redirect_uri` | One of the app's addresses. It can be left out only when the app has exactly one, on the web. | | `code_challenge`, `code_challenge_method` | Required. The method is `S256`. | | `state` | Recommended, up to 1,024 characters. It comes back only if sent. | | `scope` | Optional. See [permissions](#permissions). | | `resource` | Optional. If sent, it must be `https://tryjustdocs.com/mcp`. | Other parameters, such as `prompt` and `nonce`, are ignored. Every answer to the app carries `iss=https://tryjustdocs.com` ([RFC 9207](https://www.rfc-editor.org/rfc/rfc9207)), errors included. Check it. JustDocs never redirects to an app by itself. An unknown `client_id`, or an address the app didn't register, gets an error page. A mistake in the request reaches the app only after a signed-in person chooses to return to it. ### The token request | Grant | Parameters | | --- | --- | | `authorization_code` | `code`, `code_verifier`, `client_id`. `redirect_uri` is optional, and must match exactly if sent. `resource` is optional. | | `refresh_token` | `refresh_token`, `client_id`. `scope` is optional, to narrow. `resource` is optional. | The token and revocation endpoints share the per-address ceiling on calls listed in [Plans and limits](https://tryjustdocs.com/docs/get-started/plans-and-limits#limits). ### Revocation Post `token` and `client_id`, form encoded. Either token of a sign-in ends both. The answer is `200`, including for a token that's unknown, expired or already revoked. A token issued to another client gets `invalid_client`, and nothing is revoked. # Tools The MCP server has 14 tools. A client sees only the tools its key's permissions allow, and each tool is the same command as a [REST operation](https://tryjustdocs.com/docs/api/reference/), with the same checks. Every input schema below is what `tools/list` returns. | Tool | Permission | Kind | Approval | | --- | --- | --- | --- | | [`get_workspace`](#get_workspace) | `read` | Read | — | | [`list_templates`](#list_templates) | `read` | Read | — | | [`get_template`](#get_template) | `read` | Read | — | | [`get_document`](#get_document) | `read` | Read | — | | [`list_signing_requests`](#list_signing_requests) | `read` | Read | — | | [`get_signing_request`](#get_signing_request) | `read` | Read | — | | [`get_intake_request`](#get_intake_request) | `read` | Read | — | | [`get_approval`](#get_approval) | `read` | Read | — | | [`create_template`](#create_template) | `documents` | Write | None: at once | | [`start_document`](#start_document) | `documents` | Write | None: at once | | [`create_signing_request`](#create_signing_request) | `send` | Sensitive write | Waits for a person | | [`send_signing_request`](#send_signing_request) | `send` | Sensitive write | Waits for a person | | [`void_signing_request`](#void_signing_request) | `send` | Sensitive write | None: at once | | [`create_intake_request`](#create_intake_request) | `send` | Sensitive write | Waits for a person | **Kind** is how connector platforms class a tool: a _read_ changes nothing; a _write_ changes something only inside the workspace; a _sensitive write_ reaches other people. There is no tool that signs, accepts or declines for anyone, and no tool returns a recipient's signing link. **Instructions the server gives every client** ```text JustDocs prepares documents and sends them for e-signature for a person or a team. - Every tool works in the one workspace this key belongs to; get_workspace says which, and whether your sends wait for approval. - Start documents from the workspace's templates (list_templates, get_template, start_document). - With no suitable template, create one (create_template) from text or a library entry. - Signing requests and intake links you create are held until an owner or admin of the workspace approves them; check with get_approval. - You can never sign, accept or decline for anyone: recipients sign from the link JustDocs emails them, and no tool returns that link. - Pass the same idempotency_key when you retry a create or send, so it happens once. ``` ## Reading Tools a key with the `read` permission sees. None of them change anything. ### `get_workspace` Read permission: read **Get the workspace.** The workspace this key works in, its plan and saved templates, and what the key may do: its permissions, whether its sends wait for approval, and where it may send. _None._ **Annotations** `readOnlyHint: true` · `destructiveHint: false` · `openWorldHint: false` **Same as** [`GET /workspace`](https://tryjustdocs.com/docs/api/reference/get-workspace) **Input schema** ```json { "type": "object", "properties": {}, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `list_templates` Read permission: read **List templates.** The workspace's templates you can start documents from, 100 at a time, newest first. | Input | Type | Required | Description | | --- | --- | --- | --- | | `cursor` | string | No | next_cursor from the previous page. Up to 512 characters. | **Annotations** `readOnlyHint: true` · `destructiveHint: false` · `openWorldHint: false` **Same as** [`GET /templates`](https://tryjustdocs.com/docs/api/reference/list-templates) **Input schema** ```json { "type": "object", "properties": { "cursor": { "type": "string", "maxLength": 512, "description": "next_cursor from the previous page" } }, "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `get_template` Read permission: read **Get a template.** A template with its fill-ins and the questions that answer them. | Input | Type | Required | Description | | --- | --- | --- | --- | | `template_id` | string (uuid) | Yes | The template's id. | **Annotations** `readOnlyHint: true` · `destructiveHint: false` · `openWorldHint: false` **Same as** [`GET /templates/{id}`](https://tryjustdocs.com/docs/api/reference/get-template) **Input schema** ```json { "type": "object", "properties": { "template_id": { "type": "string", "format": "uuid", "description": "The template's id" } }, "required": [ "template_id" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `get_document` Read permission: read **Get a document.** A document's status, revision and fill-in values. | Input | Type | Required | Description | | --- | --- | --- | --- | | `document_id` | string (uuid) | Yes | The document's id. | **Annotations** `readOnlyHint: true` · `destructiveHint: false` · `openWorldHint: false` **Same as** [`GET /documents/{id}`](https://tryjustdocs.com/docs/api/reference/get-document) **Input schema** ```json { "type": "object", "properties": { "document_id": { "type": "string", "format": "uuid", "description": "The document's id" } }, "required": [ "document_id" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `list_signing_requests` Read permission: read **List signing requests.** The workspace's signing requests you can see, newest first. | Input | Type | Required | Description | | --- | --- | --- | --- | | `cursor` | string (uuid) | No | next_cursor from the previous page. | **Annotations** `readOnlyHint: true` · `destructiveHint: false` · `openWorldHint: false` **Same as** [`GET /signing-requests`](https://tryjustdocs.com/docs/api/reference/list-signing-requests) **Input schema** ```json { "type": "object", "properties": { "cursor": { "type": "string", "format": "uuid", "description": "next_cursor from the previous page" } }, "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `get_signing_request` Read permission: read **Get a signing request.** A signing request's status, recipients and approval. It never includes signing links. | Input | Type | Required | Description | | --- | --- | --- | --- | | `signing_request_id` | string (uuid) | Yes | The signing request's id. | **Annotations** `readOnlyHint: true` · `destructiveHint: false` · `openWorldHint: false` **Same as** [`GET /signing-requests/{id}`](https://tryjustdocs.com/docs/api/reference/get-signing-request) **Input schema** ```json { "type": "object", "properties": { "signing_request_id": { "type": "string", "format": "uuid", "description": "The signing request's id" } }, "required": [ "signing_request_id" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `get_intake_request` Read permission: read **Get an intake link.** Where an intake link stands. | Input | Type | Required | Description | | --- | --- | --- | --- | | `intake_request_id` | string (uuid) | Yes | The intake link's id. | **Annotations** `readOnlyHint: true` · `destructiveHint: false` · `openWorldHint: false` **Same as** [`GET /intake-requests/{id}`](https://tryjustdocs.com/docs/api/reference/get-intake-request) **Input schema** ```json { "type": "object", "properties": { "intake_request_id": { "type": "string", "format": "uuid", "description": "The intake link's id" } }, "required": [ "intake_request_id" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `get_approval` Read permission: read **Get an approval.** Whether an owner or admin of the workspace has approved, declined or not yet decided something you asked to send. | Input | Type | Required | Description | | --- | --- | --- | --- | | `approval_id` | string (uuid) | Yes | The approval's id. | **Annotations** `readOnlyHint: true` · `destructiveHint: false` · `openWorldHint: false` **Same as** [`GET /approvals/{id}`](https://tryjustdocs.com/docs/api/reference/get-approval) **Input schema** ```json { "type": "object", "properties": { "approval_id": { "type": "string", "format": "uuid", "description": "The approval's id" } }, "required": [ "approval_id" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## Documents With the `documents` permission: create templates and start documents from them. Neither emails anyone, so neither waits for approval. ### `create_template` Write permission: documents **Create a template.** Saves a template in the workspace, from text you write with its fill-ins and signing fields, or copied from a library entry. It emails no one; sending a document started from it waits for approval as usual. | Input | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | No | The template's name: needed with content, and a library entry's own name by default. 1–255 characters. | | `content` | string | No | The template's text, in blocks separated by a blank line: # Heading (## and ### for smaller ones), a list whose every line starts with "- " or "1. ", --- for a rule, and anything else a paragraph. Inside text: `**bold**` for bold; {{key}} or {{key\|Label}} for a fill-in completed for each document (key: lowercase letters, numbers and _, starting with a letter); [[type:role]] for a signing field and [[type:role?]] for an optional one, either with \|Label to name it (type: signature, initials, date_signed, text, checkbox, full_name; role: lowercase letters, numbers and -). Up to 500,000 characters. | | `fill_ins` | array of objects | No | Labels for fill-ins in the text. Without one, a fill-in takes its first written label, or one made from its key. Up to 200 items. | | `fill_ins[].key` | string | Yes | Matches `^[a-z][a-z0-9_]{0,39}$`. | | `fill_ins[].label` | string | Yes | 1–100 characters. | | `roles` | array of string | No | Who signs, by role. A listed role with no field in the text gets a Signature, Name and Date block at the end. By default, the roles of the text's fields. 1–20 items. | | `library_entry` | string | No | A library entry to copy instead of writing content, by slug: mutual-nda, consulting-agreement, service-agreement, client-proposal, founder-agreement, offer-letter, business-acknowledgment, master-services-agreement, w9-request, vendor-information-form, property-agreement, commission-agreement, consent-form, activity-waiver, approval-form, event-registration, internal-acknowledgment. 1–100 characters. | | `idempotency_key` | string | No | Retrying with the same key returns the first result instead of doing it again. Matches `^[A-Za-z0-9_-]{8,100}$`. | **Annotations** `readOnlyHint: false` · `destructiveHint: false` · `openWorldHint: false` **Same as** [`POST /templates`](https://tryjustdocs.com/docs/api/reference/create-template) **Input schema** ```json { "type": "object", "properties": { "title": { "type": "string", "minLength": 1, "maxLength": 255, "description": "The template's name: needed with content, and a library entry's own name by default." }, "content": { "type": "string", "maxLength": 500000, "description": "The template's text, in blocks separated by a blank line: # Heading (## and ### for smaller ones), a list whose every line starts with \"- \" or \"1. \", --- for a rule, and anything else a paragraph. Inside text: `**bold**` for bold; {{key}} or {{key|Label}} for a fill-in completed for each document (key: lowercase letters, numbers and _, starting with a letter); [[type:role]] for a signing field and [[type:role?]] for an optional one, either with |Label to name it (type: signature, initials, date_signed, text, checkbox, full_name; role: lowercase letters, numbers and -)." }, "fill_ins": { "type": "array", "items": { "type": "object", "properties": { "key": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,39}$" }, "label": { "type": "string", "minLength": 1, "maxLength": 100 } }, "required": [ "key", "label" ], "additionalProperties": false }, "maxItems": 200, "description": "Labels for fill-ins in the text. Without one, a fill-in takes its first written label, or one made from its key." }, "roles": { "type": "array", "items": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,39}$" }, "minItems": 1, "maxItems": 20, "description": "Who signs, by role. A listed role with no field in the text gets a Signature, Name and Date block at the end. By default, the roles of the text's fields." }, "library_entry": { "type": "string", "minLength": 1, "maxLength": 100, "description": "A library entry to copy instead of writing content, by slug: mutual-nda, consulting-agreement, service-agreement, client-proposal, founder-agreement, offer-letter, business-acknowledgment, master-services-agreement, w9-request, vendor-information-form, property-agreement, commission-agreement, consent-form, activity-waiver, approval-form, event-registration, internal-acknowledgment." }, "idempotency_key": { "type": "string", "pattern": "^[A-Za-z0-9_-]{8,100}$", "description": "Retrying with the same key returns the first result instead of doing it again." } }, "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `start_document` Write permission: documents **Start a document.** Starts a document from a template, with fill-in values or answers to its questions. | Input | Type | Required | Description | | --- | --- | --- | --- | | `template_id` | string (uuid) | Yes | — | | `title` | string | No | 1–255 characters. | | `merge_values` | object of string | No | — | | `answers` | object of any | No | — | | `idempotency_key` | string | No | Retrying with the same key returns the first result instead of doing it again. Matches `^[A-Za-z0-9_-]{8,100}$`. | **Annotations** `readOnlyHint: false` · `destructiveHint: false` · `openWorldHint: false` **Same as** [`POST /documents`](https://tryjustdocs.com/docs/api/reference/start-document) **Input schema** ```json { "type": "object", "properties": { "template_id": { "type": "string", "format": "uuid" }, "title": { "type": "string", "minLength": 1, "maxLength": 255 }, "merge_values": { "type": "object", "additionalProperties": { "type": "string", "maxLength": 2000 } }, "answers": { "type": "object", "additionalProperties": { "anyOf": [ { "type": "string", "maxLength": 5000 }, { "type": "number" }, { "type": "boolean" } ] }, "propertyNames": { "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$" } }, "idempotency_key": { "type": "string", "pattern": "^[A-Za-z0-9_-]{8,100}$", "description": "Retrying with the same key returns the first result instead of doing it again." } }, "required": [ "template_id" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## Sending With the `send` permission. Anything that would email someone waits for an owner or admin of the workspace to approve it. ### `create_signing_request` Sensitive write permission: send needs approval **Create a signing request.** Creates a signing request for documents and recipients. With send: true it asks the workspace to approve sending it; nothing is emailed until an owner or admin approves. | Input | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | No | 1–255 characters. | | `message` | string | No | Up to 2,000 characters. | | `routing` | `sequential` or `parallel` | No | Default `sequential`. | | `expires_in_days` | integer | No | 1–365. Default `30`. | | `documents` | array of objects | Yes | 1–10 items. | | `documents[].document_id` | string (uuid) | Yes | — | | `documents[].draft_revision` | integer | No | At least 1. | | `recipients` | array of objects | Yes | 1–20 items. | | `recipients[].role_key` | string | Yes | Matches `^[a-z0-9][a-z0-9-]{0,39}$`. | | `recipients[].name` | string | Yes | 1–200 characters. | | `recipients[].email` | string (email) | Yes | Up to 255 characters. | | `recipients[].routing_order` | integer | No | 1–20. | | `send` | boolean | No | Default `false`. | | `idempotency_key` | string | No | Retrying with the same key returns the first result instead of doing it again. Matches `^[A-Za-z0-9_-]{8,100}$`. | **Annotations** `readOnlyHint: false` · `destructiveHint: true` · `openWorldHint: true` **Same as** [`POST /signing-requests`](https://tryjustdocs.com/docs/api/reference/create-signing-request) **Input schema** ```json { "type": "object", "properties": { "title": { "type": "string", "minLength": 1, "maxLength": 255 }, "message": { "type": "string", "maxLength": 2000 }, "routing": { "type": "string", "enum": [ "sequential", "parallel" ], "default": "sequential" }, "expires_in_days": { "type": "integer", "minimum": 1, "maximum": 365, "default": 30 }, "documents": { "type": "array", "items": { "type": "object", "properties": { "document_id": { "type": "string", "format": "uuid" }, "draft_revision": { "type": "integer", "exclusiveMinimum": 0 } }, "required": [ "document_id" ], "additionalProperties": false }, "minItems": 1, "maxItems": 10 }, "recipients": { "type": "array", "items": { "type": "object", "properties": { "role_key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,39}$" }, "name": { "type": "string", "minLength": 1, "maxLength": 200 }, "email": { "type": "string", "format": "email", "maxLength": 255 }, "routing_order": { "type": "integer", "minimum": 1, "maximum": 20 } }, "required": [ "role_key", "name", "email" ], "additionalProperties": false }, "minItems": 1, "maxItems": 20 }, "send": { "type": "boolean", "default": false }, "idempotency_key": { "type": "string", "pattern": "^[A-Za-z0-9_-]{8,100}$", "description": "Retrying with the same key returns the first result instead of doing it again." } }, "required": [ "documents", "recipients" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `send_signing_request` Sensitive write permission: send needs approval **Ask to send a signing request.** Asks the workspace to approve sending a draft signing request. Nothing is emailed until an owner or admin approves. | Input | Type | Required | Description | | --- | --- | --- | --- | | `signing_request_id` | string (uuid) | Yes | The signing request's id. | | `idempotency_key` | string | No | Retrying with the same key returns the first result instead of doing it again. Matches `^[A-Za-z0-9_-]{8,100}$`. | **Annotations** `readOnlyHint: false` · `destructiveHint: true` · `openWorldHint: true` **Same as** [`POST /signing-requests/{id}/send`](https://tryjustdocs.com/docs/api/reference/send-signing-request) **Input schema** ```json { "type": "object", "properties": { "signing_request_id": { "type": "string", "format": "uuid", "description": "The signing request's id" }, "idempotency_key": { "type": "string", "pattern": "^[A-Za-z0-9_-]{8,100}$", "description": "Retrying with the same key returns the first result instead of doing it again." } }, "required": [ "signing_request_id" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `void_signing_request` Sensitive write permission: send **Void a signing request.** Cancels a sent signing request that hasn't been completed. It takes effect at once, without approval, and recipients who were invited are told. | Input | Type | Required | Description | | --- | --- | --- | --- | | `signing_request_id` | string (uuid) | Yes | The signing request's id. | | `reason` | string | No | Up to 500 characters. | **Annotations** `readOnlyHint: false` · `destructiveHint: true` · `idempotentHint: true` · `openWorldHint: true` **Same as** [`POST /signing-requests/{id}/void`](https://tryjustdocs.com/docs/api/reference/void-signing-request) **Input schema** ```json { "type": "object", "properties": { "signing_request_id": { "type": "string", "format": "uuid", "description": "The signing request's id" }, "reason": { "type": "string", "maxLength": 500 } }, "required": [ "signing_request_id" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### `create_intake_request` Sensitive write permission: send needs approval **Ask someone to fill in documents.** Asks the workspace to approve emailing someone a form whose answers fill the documents' blanks. Nothing is created or emailed until an owner or admin approves. | Input | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | No | 1–255 characters. | | `message` | string | No | Up to 2,000 characters. | | `documents` | array of objects | Yes | 1–10 items. | | `documents[].document_id` | string (uuid) | Yes | — | | `documents[].draft_revision` | integer | Yes | At least 1. | | `respondent` | object | Yes | — | | `respondent.name` | string | Yes | 1–200 characters. | | `respondent.email` | string (email) | Yes | Up to 255 characters. | | `expires_in_days` | integer | No | 1–60. Default `14`. | | `after` | `review` or `sign` | No | Default `review`. | | `signing` | object | No | — | | `signing.respondent_role` | string | Yes | Matches `^[a-z0-9][a-z0-9-]{0,39}$`. | | `signing.recipients` | array of objects | No | Up to 19 items. Default `[]`. | | `signing.recipients[].role_key` | string | Yes | Matches `^[a-z0-9][a-z0-9-]{0,39}$`. | | `signing.recipients[].name` | string | Yes | 1–200 characters. | | `signing.recipients[].email` | string (email) | Yes | Up to 255 characters. | | `signing.routing` | `sequential` or `parallel` | No | Default `sequential`. | | `signing.expires_in_days` | integer | No | 1–365. Default `30`. | | `idempotency_key` | string | No | Retrying with the same key returns the first result instead of doing it again. Matches `^[A-Za-z0-9_-]{8,100}$`. | **Annotations** `readOnlyHint: false` · `destructiveHint: true` · `openWorldHint: true` **Same as** [`POST /intake-requests`](https://tryjustdocs.com/docs/api/reference/create-intake-request) **Input schema** ```json { "type": "object", "properties": { "title": { "type": "string", "minLength": 1, "maxLength": 255 }, "message": { "type": "string", "maxLength": 2000 }, "documents": { "type": "array", "items": { "type": "object", "properties": { "document_id": { "type": "string", "format": "uuid" }, "draft_revision": { "type": "integer", "exclusiveMinimum": 0 } }, "required": [ "document_id", "draft_revision" ], "additionalProperties": false }, "minItems": 1, "maxItems": 10 }, "respondent": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 200 }, "email": { "type": "string", "format": "email", "maxLength": 255 } }, "required": [ "name", "email" ], "additionalProperties": false }, "expires_in_days": { "type": "integer", "minimum": 1, "maximum": 60, "default": 14 }, "after": { "type": "string", "enum": [ "review", "sign" ], "default": "review" }, "signing": { "type": "object", "properties": { "respondent_role": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,39}$" }, "recipients": { "type": "array", "items": { "type": "object", "properties": { "role_key": { "$ref": "#/properties/signing/properties/respondent_role" }, "name": { "type": "string", "minLength": 1, "maxLength": 200 }, "email": { "type": "string", "format": "email", "maxLength": 255 } }, "required": [ "role_key", "name", "email" ], "additionalProperties": false }, "maxItems": 19, "default": [] }, "routing": { "type": "string", "enum": [ "sequential", "parallel" ], "default": "sequential" }, "expires_in_days": { "type": "integer", "minimum": 1, "maximum": 365, "default": 30 } }, "required": [ "respondent_role" ], "additionalProperties": false }, "idempotency_key": { "type": "string", "pattern": "^[A-Za-z0-9_-]{8,100}$", "description": "Retrying with the same key returns the first result instead of doing it again." } }, "required": [ "documents", "respondent" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` # Safety model An agent that can email documents to people can do harm if it's wrong, misled or compromised. JustDocs is built so that an agent can't put a document in front of anyone without a person agreeing first. ## A person approves every send Anything an agent key asks to send waits for the workspace's owner, or a team's owner or admin. An app that [signs in with OAuth](https://tryjustdocs.com/docs/mcp/oauth) is an agent key too: | The agent calls | What happens | | --- | --- | | `create_signing_request` with `send: true` | The request is created as a draft. The send waits for approval. | | `send_signing_request` | The send waits for approval. | | `create_intake_request` | Nothing is created or emailed until approval. | | `create_signing_request` with `send: false` | A draft is created. Nothing is emailed. | | `create_template` | A template is saved in the workspace, from the agent's text or a library entry. Nothing is emailed, so it needs no approval. In a team, it's shared with the team for use, as a template saved in the app is. | | `start_document` | A document is created in the workspace. Nothing is emailed. | | `void_signing_request` | **Takes effect at once, without approval.** A sent request that isn't complete is cancelled, and recipients who were invited are emailed that it was. | A template an agent wrote reaches no one until someone sends a document started from it, and every send through an agent key waits for approval, whoever made the template. The approver sees the title, every address, and the key that asked. Approving sends it in the name of the person who made the key. At that moment the key is checked **again**: it must not be revoked or expired, its creator must still be an owner or admin, and for an API key the workspace must still be on Teams & Platform. Its recipient domains and daily limit are checked again too. If the send fails, the approval ends `failed` with the reason. An approval that nobody decides expires after seven days. JustDocs checks for expired approvals every ten minutes, and again whenever approvals are read, so the `approval.decided` webhook for it arrives within about ten minutes of the deadline. If someone sends the request from the app instead, the waiting approval expires at once and can no longer be approved. The agent learns the outcome from [`get_approval`](https://tryjustdocs.com/docs/mcp/tools#get_approval), or your systems from the `approval.decided` [webhook](https://tryjustdocs.com/docs/webhooks/events#approval-decided). ### Voiding Voiding is the one action with the `send` permission that doesn't wait. It can only stop something: a voided request can't be signed, and the only email it causes is the notice to recipients who were already invited. It reaches every request the key's creator could void in the app, which for an owner or admin is any request in the workspace. If an agent shouldn't cancel requests, don't give its key `send`. ## What an agent can never do - **Sign, accept or decline** for anyone. There is no such tool. - **See a signing link.** Recipients get their private link by email only. No response includes it. - **Reach outside its workspace.** A key belongs to one workspace and sees only what its creator can see there. `get_workspace` says which. An id from another workspace is answered as not found, in the same words whether or not it exists. - **Outlive its creator's role.** If the person who made the key stops being an owner or admin, the key stops working. - **Get a key from a person or another key.** Only owners and admins make keys, in the app, or connect an app on JustDocs's own page. ## Least privilege When you make a key, choose only what the agent needs: | Setting | Limits | | --- | --- | | Permissions | `read` only, or with `documents` to create templates and start documents, or with `send` to ask to send (and to void). Tools outside the key's permissions aren't listed. | | Recipient domains | The only domains it may send to, including their subdomains. | | Sends a day | Sends over any 24 hours, approved ones included. | | Days it works | When the key expires, at most a year. | Revoke a key from the API & agents page at any time. It stops working at once. ## Apps that sign in An app that [signs in with OAuth](https://tryjustdocs.com/docs/mcp/oauth) gets no more than an agent key would. What it can do: - **Act as an agent key** made by the person who approved it. It has the permissions, recipient domains, sends a day and expiry they chose, and every send waits for approval. - **Use only the permissions it asked for** and the person kept. A refresh can narrow them, never add one. - **Reach the MCP server only.** Its tokens are bound to `https://tryjustdocs.com/mcp`, and the REST API refuses them. What it can't do: - **Sign, see a signing link or approve its own sends.** Approvals are made in JustDocs, by a person. - **Borrow someone else's approval.** Connections are per person: another admin who connects the same app gets a separate key, acting in their name. - **Keep working after it's disconnected.** **Disconnect** on the API & agents page stops it at once, as does the key expiring or the person stopping being an owner or admin. - **Hold a long-lived secret.** Access tokens last an hour. Refresh tokens rotate, and an old one used again ends the sign-in, so a stolen refresh token can't quietly keep working beside the app. JustDocs never gives apps a client secret. The page where a person connects an app shows: - the account that's signed in; - the app's verified domain, or that it's unverified; - exactly where the sign-in returns, with a warning when that's another domain or a program on the computer. Only the browser that started a sign-in can see or approve it, and only for 10 minutes. JustDocs never sends anyone back to an app until a signed-in person chooses to connect or cancel. ## A trail of everything - Every request records which key acted, and when. The key's **last used** time and address, updated at most once a minute, are shown on the API & agents page, so you can spot use from somewhere unexpected. - Approvals keep who asked, who decided, when, and what happened. - Webhooks can mirror every send, view, signature and decision into your own systems. ## Instructions sent to the agent On connecting, the server tells the agent how JustDocs works. Every tool works in the key's one workspace. With no suitable template, it can create one. Sends are held for approval. It can never sign for anyone. It should retry with the same `idempotency_key`. See the [exact text](https://tryjustdocs.com/docs/mcp/tools). Tool descriptions are fixed in the server, never taken from documents or user input. That leaves less room for prompt injection through a template or an answer. ## Annotations Each tool declares all three [MCP annotations](https://tryjustdocs.com/docs/mcp/tools) explicitly: `readOnlyHint` for the eight reading tools, and `openWorldHint` with `destructiveHint` for the tools that reach other people — the sending tools, whose email can't be unsent once a person approves it, and `void_signing_request`. Clients use these to ask you before running a tool. Many clients confirm every write. JustDocs's approvals apply whatever the client does. # Troubleshooting The server answers problems with an HTTP status and a message that says what to do. Errors inside a tool call (the wrong input, a missing record) come back as a tool result with `isError: true` and the message as text. The agent can read it and try again. ## Connecting | Status | Message | Fix | | --- | --- | --- | | `401` | Send your API key as Authorization: Bearer <key>. | The client sent no `Authorization` header. Check the header name, and that the value starts with `Bearer` and a space. | | `401` | This API key is invalid, expired or revoked. | Make a new key. Check that you copied all of it, with no spaces or line breaks. | | `401` | This access token is invalid or has expired. Sign in again from the app. | An app that signed in with OAuth refreshes its token and tries again. If it can't, sign in again from the app. See [Signing in](#signing-in). | | `401` | This key's creator is no longer an owner or admin of the workspace, so the key no longer works. | An owner or admin makes a new key, or connects the app again. | | `403` | The MCP server takes agent keys. Use /api/v1 with this key, or create an agent key on the workspace's API & agents page. | Make an agent key. API keys work with the [REST API](https://tryjustdocs.com/docs/api/) only. | | `405` | Method not allowed. Send MCP requests as POST. | The client tried a `GET` stream or a session. Use the Streamable HTTP transport: everything is a `POST`. | | `406` | Not Acceptable | Send `Accept: application/json, text/event-stream`. | | `429` | More than … calls a minute through this key. | Too many calls through the key. Wait until the minute rolls over. See [rate limits](https://tryjustdocs.com/docs/api/errors#rate-limits). | | `429` | Too many calls from this network address. Wait a minute, then try again. | Your address passed the ceiling across all keys. Wait for the `Retry-After` seconds. | Every `401` carries a `WWW-Authenticate` header that says where to sign in, with `error="invalid_token"` added when a key or token was sent: ```http WWW-Authenticate: Bearer realm="JustDocs", resource_metadata="https://tryjustdocs.com/.well-known/oauth-protected-resource/mcp", scope="read documents send" ``` An OAuth client starts signing in from `resource_metadata`. A client using a key can ignore it. ## Signing in These come up when a client [signs in with OAuth](https://tryjustdocs.com/docs/mcp/oauth). | What you see | Fix | | --- | --- | | The client couldn't sign in, or couldn't find the server's OAuth details | Check the URL is exactly `https://tryjustdocs.com/mcp`: no trailing slash, not `www.`, nothing after `/mcp`. The resource metadata names that URL, and clients refuse to sign in when the URL you gave differs from it. Remove the connector and add it again with that URL. | | This app's registration wasn't recognized. Remove it in the app and add it again. | JustDocs couldn't read the document that describes the app, usually because the app's site didn't answer it just then. Wait a minute and try connecting again before removing anything. If it keeps happening, remove the connector in the app and add it again. A registered app that nobody connects is removed after 30 days, so an app that waited that long has to register again. | | The app asked to send you back to an address it didn't register. Try connecting again from the app. | Remove the connector and add it again. If it keeps happening, the app's setup is wrong. Tell its maker. | | The app didn't say who it is. Try connecting again from the app. | Start again from the app. | | This sign-in has ended | Sign-ins last 10 minutes, and work only in the browser that started them. Start again from the app, and finish in the browser window it opens. | | … sent a request JustDocs can't accept | The app's request had a mistake, named on the page. Return to the app and try again. If it keeps happening, the app needs fixing. | | A workspace can't be chosen | You can connect apps to your personal workspace, or to a team where you're an owner or admin, once your email address is confirmed. Below Teams & Platform, a workspace with 3 active agent keys can't take another. The page gives the reason beside each workspace. | | Your personal workspace can have 3 active agent keys, and a connected app counts as one. Revoke one first. | Revoke a key or disconnect an app you no longer use on the **API & agents** page, then connect again. | | On its plan this team can have 3 active agent keys, and a connected app counts as one. Revoke one first, or move the team to Teams & Platform. | Revoke a key or disconnect an app on the team's **API & agents** page, or move the team to Teams & Platform, then connect again. | | A workspace can have 25 active keys, and a connected app uses one. Revoke one first. | Revoke a key you no longer use on the **API & agents** page, then connect again. | | Approve the app from its JustDocs page. | Approve on the page the app opened, not from another site or tab. | | Take a moment to read this, then press Connect again. | **Connect** works once the page has been in front of you for a moment, focused or with the pointer over it. Press it again. | | Too many sign-ins started from this address. Wait a minute, then try again. | Wait a minute. | | The app asks you to sign in again, or reports `invalid_grant` | The sign-in ended: the app was disconnected on the API & agents page, its key expired, it was replaced by newer sign-ins, its refresh token went unused for 30 days, or a refresh token was used twice. Sign in again from the app. | | `401` with `error="invalid_token"` right after signing in | Access tokens last an hour. The app should refresh and try again; if the refresh fails, sign in again. | | The app is connected, but its sends never go out | Working as intended. A connected app is an agent key, so its sends wait for an owner or admin under **Approvals**. | | Connecting again didn't add a key | Working as intended. Connecting the same app to the same workspace again updates your key's limits. | An app that registers itself can get `429 More than 60 new apps registered from this address in an hour.` The same app registering again doesn't count. Wait for the `Retry-After` seconds. ## Tool results | Message | Meaning | | --- | --- | | Tool … not found | The key's permissions don't include that tool, so the server doesn't offer it. Keys can't be changed: make a new key with the permission. (Through the REST API, the same call returns `403 This key doesn't have the "send" permission.`) | | Waiting for an owner or admin of the workspace to approve this. Nothing has been sent yet; check get_approval. | Working as intended. An owner or admin decides under **Approvals**. | | This key can only send to example.com. Not allowed: … | An address is outside the key's recipient domains. | | This key has sent its 20 for the last 24 hours. | The key's daily send limit. Raise it on a new key, or wait. | | This workspace has 500 approvals waiting. Ask again once some are decided. | Decide some approvals first. | | Add a recipient for each signing role in the document: client, provider | Each role in the documents needs a recipient with that `role_key`. | | This request is already waiting for approval, asked for by another key. | Another key's approval is pending for this request. | | This Idempotency-Key was already used for a different call (or request) | The same `idempotency_key` was used for something else. Use a new one for each distinct action. | | A call with this Idempotency-Key is still in progress; retry shortly | The first call is still running. Retry with the same key in a moment. | | No template with this id in this key's workspace, "Northwind" (or its creator can't see it). A key works only in the workspace it was made in; get_workspace says which. | The id is in another workspace, or nowhere, or the key's creator can't see it. A template made in your personal workspace isn't in a team's, and the other way round. Use `list_templates`, or make the key (or connect the app) in the workspace that has the template. `get_document` and `create_signing_request` say the same of a document. See [Workspaces](#workspaces). | | This id is a document, not a template; list_templates lists templates. | `get_template` takes a template's id and was given a document's. Use `list_templates`. `start_document` says "This document is not a template" of the same mistake. | | This workspace's plan keeps up to 3 saved templates. Delete one you no longer use, or upgrade the plan. | `create_template` reached the plan's limit. A person deletes a template in the app, or an owner changes the plan. See [Plans and limits](https://tryjustdocs.com/docs/get-started/plans-and-limits). | | This document is too long to save. Split it into smaller documents, or shorten long fill-in values that repeat. | The template's text, or a document's fill-in values, made it too long. | ### Creating templates Messages from [`create_template`](https://tryjustdocs.com/docs/guides/create-a-template). A mistake in the text names its line. | Message | Meaning | | --- | --- | | Line 3: "{{Client Name}}" isn't a fill-in: write {{key}} or {{key\|Label}}, key in lowercase letters, numbers and _, label up to 100 characters | A fill-in's key is lowercase, such as `{{client_name}}`, with any label after a `\|`. | | … isn't a signing field: write [[type:role]] or [[type:role\|Label]], role in lowercase letters, numbers and -, label up to 100 characters | The same for a field, such as `[[signature:client]]`. | | … isn't a field type. Use signature, initials, date_signed, text, checkbox, full_name | A field's type is misspelled. | | Say who signs: list roles, or write fields such as [[signature:client]] | The text has no signing fields and `roles` wasn't given. | | The text has fields for …, which roles doesn't list | A field's role isn't in `roles`, often a typo. Add it to `roles` or correct the field. | | Every field for client is optional. … | Give each role at least one required field: one without `?`. | | fill_ins lists …, which the text doesn't use. | Each key in `fill_ins` must appear in the text. | | Paid documents are drafted by the legal questionnaire and bought one document at a time, so they can't be saved as templates | That `library_entry` is a paid document. Choose another, or write the text. | | … is a packet; save its entries as templates one at a time: … | Copy each entry the message names. | | Library entry not found | No library entry has that slug. The [reference](https://tryjustdocs.com/docs/api/reference/create-template) lists the ones that can be copied. | | … is planned and not available yet | That library entry can't be used yet. Choose another, or write the text. | ## Workspaces An agent that finds no templates, or can't find one it was given, is usually in another workspace from the one it expects. A key works only in the workspace it was made in: 1. Call `get_workspace`. Its `name` is the key's workspace, `"Personal"` for a personal workspace. `list_templates` names it too, beside the list. 2. If that isn't where the template is, make a key on that workspace's **API & agents** page, or connect the app again and choose that workspace. A template can also be made where the key is, with `create_template` or the library's **Save as template**. ## The agent doesn't see the tools - Check the client lists `justdocs` as connected. In Claude Code, run `/mcp`. - A key with only `read` sees eight tools. For the rest, make a key with `documents` and `send` as well. - Some clients cache the tool list. Reconnect or restart the client after you change the key. ## Nothing reaches JustDocs The key's **last used** time on the API & agents page is updated when the key is used, at most once a minute. A connected app's key is updated the same way. If it never changes, the client isn't reaching the server. Check the URL (`https://tryjustdocs.com/mcp`, no trailing slash or path) and any proxy between the client and the internet. ## Still stuck Email [support@tryjustdocs.com](mailto:support@tryjustdocs.com) with the time, the tool and the message. For a connected app, name the app and the workspace. Never send a key or token itself; a key's first characters (`jd_live_…`) are enough. # REST API Your software can create templates and start documents from them, send them for signature, send intake links, and follow what happens. A key works in the one workspace it was made in; [`GET /workspace`](https://tryjustdocs.com/docs/api/reference/get-workspace) says which. | | | | --- | --- | | Base URL | `https://tryjustdocs.com/api/v1` | | Authentication | `Authorization: Bearer `. Use an agent key (any plan) or an API key (Teams & Platform) from the workspace's **API & agents** page. See [Keys](https://tryjustdocs.com/docs/api/keys). | | Format | JSON in and out, UTF-8. Send `Content-Type: application/json` with a body. | | Description | OpenAPI 3.1 at [`https://tryjustdocs.com/openapi.json`](https://tryjustdocs.com/openapi.json) | | Plan | Agent keys on every plan. API keys on Teams & Platform | ## A first call ```bash [curl] curl https://tryjustdocs.com/api/v1/templates \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/templates", { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}` }, }); const { templates, next_cursor } = await response.json(); ``` ```python [Python] import os import requests response = requests.get( "https://tryjustdocs.com/api/v1/templates", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"}, timeout=30, ) templates = response.json()["templates"] ``` ## Operations | | | | --- | --- | | Templates | [List](https://tryjustdocs.com/docs/api/reference/list-templates) · [Get](https://tryjustdocs.com/docs/api/reference/get-template) | | Documents | [Start from a template](https://tryjustdocs.com/docs/api/reference/start-document) · [Get](https://tryjustdocs.com/docs/api/reference/get-document) | | Signing requests | [Create](https://tryjustdocs.com/docs/api/reference/create-signing-request) · [List](https://tryjustdocs.com/docs/api/reference/list-signing-requests) · [Get](https://tryjustdocs.com/docs/api/reference/get-signing-request) · [Send](https://tryjustdocs.com/docs/api/reference/send-signing-request) · [Void](https://tryjustdocs.com/docs/api/reference/void-signing-request) | | Intake links | [Send](https://tryjustdocs.com/docs/api/reference/create-intake-request) · [Get](https://tryjustdocs.com/docs/api/reference/get-intake-request) | | Approvals | [Get](https://tryjustdocs.com/docs/api/reference/get-approval) | Each operation has the same name as an [MCP tool](https://tryjustdocs.com/docs/mcp/tools), and the same rules. ## Conventions - **Ids** are UUIDs. Times are ISO 8601 in UTC, such as `2026-11-03T16:20:00.000Z`. - **Unknown fields are refused** with `400`, so a typo can't be silently ignored. - **Create and send calls** take an [`Idempotency-Key`](https://tryjustdocs.com/docs/api/idempotency), so a retry never sends twice. - **Lists** return a page and a [`next_cursor`](https://tryjustdocs.com/docs/api/pagination). - **Errors** are JSON with a `message` that says what to do. See [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). - **`202 Accepted`** means a person needs to [approve](https://tryjustdocs.com/docs/api/keys#approval) the send first. - **Voiding** takes effect at once, even with keys whose sends need approval. It can only cancel. ## Using OpenAPI Point your tools at `https://tryjustdocs.com/openapi.json` to generate a client, import into an API client, or give an assistant the full description. Every `operationId` is the name of the matching MCP tool. ## Next - [Keys and permissions](https://tryjustdocs.com/docs/api/keys) API keys, agent keys, permissions and approval. - [Endpoint reference](https://tryjustdocs.com/docs/api/reference/) Fields, responses and examples for every operation. # Keys and permissions Every call carries a workspace key: ```http Authorization: Bearer jd_live_… ``` ## Making a key Keys are made on the workspace's **API & agents** page, with **New key**: in your personal workspace by you, and in a team by its owners and admins. Agent keys work on every plan, 3 active at a time below Teams & Platform. API keys need Teams & Platform. The key appears once, right after it's made. Copy it then. JustDocs keeps only a hash and can't show it again. A key **acts for the person who made it**. Requests it sends go out in their name. It sees what they can see in the workspace. It stops working if they stop being an owner or admin. They need a confirmed email address to make one. ## Which workspace a key works in A key works only in the workspace it was made in: the personal workspace, or the team, whose **API & agents** page made it, which the page names ("Keys made here work only in Northwind"). An app that signs in with OAuth works in the workspace chosen when it was connected. A template or document in any other workspace, the same person's personal workspace included, is not found: ```text No template with this id in this key's workspace, "Northwind" (or its creator can't see it). A key works only in the workspace it was made in; get_workspace says which. ``` [`GET /workspace`](https://tryjustdocs.com/docs/api/reference/get-workspace), or the `get_workspace` tool, says where a key is: the workspace's `name` (`"Personal"` for a personal workspace), its plan, how many templates it keeps, and what the key may do. `list_templates` names the workspace too, so an empty list says where it looked. ## API keys and agent keys | | API key | Agent key | | --- | --- | --- | | For | Your team's own software | An AI agent | | Plan | Teams & Platform | Every plan. Up to 3 active per workspace below Teams & Platform | | REST API | Yes | Yes | | MCP server | No | Yes | | Sends | Go out at once, unless **Its sends need approval too** is ticked | Always wait for approval | Each key's row on the **API & agents** page says what it is and what happens to its sends: | Label | Means | | --- | --- | | **Agent key** | A key for an AI agent, made by hand or by connecting an app. A connected app is also marked **Connected app**. | | **API key** | A key for your own software, on Teams & Platform. | | **Sends wait for approval** | Every send and intake link it asks for waits for an owner or admin. Always so for an agent key, and for an API key made to need approval. | | **Sends go out at once** | An API key whose sends don't wait. Highlighted, because nothing stands between it and a recipient but its limits. | `get_workspace` gives the same as `key.kind_label` and `key.sends_label`, with `key.sends_need_approval` as `true` or `false`. The row's second line lists the key's permissions, sends a day, and its recipient domains, or "any recipient domain" when it has none. ## Permissions | Permission | Allows | Tools and operations | | --- | --- | --- | | `read` | Read templates, documents, requests and intake links | [`get_workspace`](https://tryjustdocs.com/docs/mcp/tools#get_workspace), [`list_templates`](https://tryjustdocs.com/docs/mcp/tools#list_templates), [`get_template`](https://tryjustdocs.com/docs/mcp/tools#get_template), [`get_document`](https://tryjustdocs.com/docs/mcp/tools#get_document), [`list_signing_requests`](https://tryjustdocs.com/docs/mcp/tools#list_signing_requests), [`get_signing_request`](https://tryjustdocs.com/docs/mcp/tools#get_signing_request), [`get_intake_request`](https://tryjustdocs.com/docs/mcp/tools#get_intake_request), [`get_approval`](https://tryjustdocs.com/docs/mcp/tools#get_approval) | | `documents` | Create templates and start documents from them | [`create_template`](https://tryjustdocs.com/docs/mcp/tools#create_template), [`start_document`](https://tryjustdocs.com/docs/mcp/tools#start_document) | | `send` | Send signing requests and intake links | [`create_signing_request`](https://tryjustdocs.com/docs/mcp/tools#create_signing_request), [`send_signing_request`](https://tryjustdocs.com/docs/mcp/tools#send_signing_request), [`void_signing_request`](https://tryjustdocs.com/docs/mcp/tools#void_signing_request), [`create_intake_request`](https://tryjustdocs.com/docs/mcp/tools#create_intake_request) | Keys start with `jd_live_`. A call without the permission gets `403 This key doesn't have the "send" permission.` ## Limits on a key | Setting | | | --- | --- | | Recipient domains | Only these domains and their subdomains, such as `example.com`. Empty means any. | | Sends a day | Sends in any 24 hours, through the API, the MCP server and approvals. | | Days it works | The key expires after this. | Defaults and maximums are on [Plans and limits](https://tryjustdocs.com/docs/get-started/plans-and-limits). ## Approval Sends through an **agent key**, or an **API key that needs approval**, wait for a person: - `POST /signing-requests` with `"send": true` creates the request as a draft and returns `202` with an `approval` whose status is `pending`. - `POST /signing-requests/{id}/send` returns `202` with the same. - `POST /intake-requests` returns `202 { "status": "awaiting_approval", "approval": … }`. The intake link is created only once approved. An owner or admin approves or declines on the workspace's **Approvals** page. **Home** lists what's waiting. Approving sends it at once, as the key's creator, after checking the key and its limits again. Follow the outcome with [`GET /approvals/{id}`](https://tryjustdocs.com/docs/api/reference/get-approval) or the `approval.decided` [webhook](https://tryjustdocs.com/docs/webhooks/events#approval-decided): | Status | Meaning | | --- | --- | | `pending` | Waiting for a decision. | | `approved` | Approved and sent. | | `declined` | Declined. Nothing was sent. | | `expired` | Not decided within seven days, or the request was sent another way. In that case, `result` says so. | | `failed` | Approved, but the send didn't go through. `result` says why, such as a limit reached. | Asking again to send the same signing request while its approval is pending returns that approval, not a second one. ## Revoking and rotating **Revoke** on the API & agents page stops a key at once. It can't be undone. To rotate a key, make the new one, switch your software over, then revoke the old one. The page shows each key's last use and the address it came from. ## Keeping keys safe - Keep keys in a secret store or environment variables. Never put them in client-side code, URLs or files you commit. - Give each integration and each agent its own key, so you can revoke one without the others. - Give each key only the permissions, domains and daily sends it needs. - See the [agent security checklist](https://tryjustdocs.com/docs/guides/agent-security). # Idempotency A network can fail after JustDocs has done the work but before you hear back. Retrying could then create a second document or send a request twice. An **idempotency key** prevents that. Calls with the same key do the work once. ## Which calls take one | Operation | REST | MCP | | --- | --- | --- | | Create a template | `POST /templates` | `create_template` | | Start a document | `POST /documents` | `start_document` | | Create a signing request | `POST /signing-requests` | `create_signing_request` | | Send a signing request | `POST /signing-requests/{id}/send` | `send_signing_request` | | Send an intake link | `POST /intake-requests` | `create_intake_request` | In the REST API, send it as a header. In MCP, pass the `idempotency_key` argument. ```http Idempotency-Key: nda-acme-2026-11-03 ``` A key is 8 to 100 letters, digits, `-` or `_`. A UUID works, and so does your own record's id. ## How it behaves - **The first call** does the work and remembers what it made. - **A retry with the same key** returns what the first call made, without doing it again. This includes a send that's waiting for approval. - **A retry while the first call is still running** gets `409 A call with this Idempotency-Key is still in progress; retry shortly`. Wait a moment and retry with the same key. - **The same key on something different** is refused with `409`. Its message says the key "was already used for a different call" or "for a different request". Not every combination of operations is compared, so don't rely on this check. Give each action its own key. - **If the first call fails**, nothing is remembered, and you can retry with the same key after fixing the problem. A template whose text has a mistake is refused before the key is used at all. - **Creating and sending at once** (`send: true`) makes the request first. If the request was made but the send failed, a retry with the same key returns that request and tries the send again. It doesn't apply a changed body. To change the request, use a new key. Keys belong to the API key that sent them, so two of your integrations can't collide. ## Choosing keys Use one key per action you mean to happen once, and reuse it only for retries of that action. A good key comes from your own data, such as `deal-4821-nda-send`. Then a retry after a crash finds the first result, even in a new process. ```js async function sendOnce(dealId, body) { for (let attempt = 1; ; attempt++) { const response = await fetch("https://tryjustdocs.com/api/v1/signing-requests", { method: "POST", headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `deal-${dealId}-nda`, }, body: JSON.stringify(body), }).catch(() => null); if (response && response.status !== 409 && response.status < 500) return response; if (attempt === 5) throw new Error("JustDocs didn't answer"); await new Promise((done) => setTimeout(done, 2 ** attempt * 500)); } } ``` The `409` this retries is the "still in progress" one. A `409` for a key used on something different won't change on retry, so stop after a few attempts. # Errors and rate limits Errors are JSON with a `message` written for a person. Show it, log it, or hand it to your agent. ```json { "message": "This key can only send to example.com. Not allowed: ada@elsewhere.com" } ``` Input that doesn't match the schema returns `400` with the problems listed: ```json { "message": "Validation error", "errors": [ { "code": "invalid_string", "validation": "email", "path": ["recipients", 0, "email"], "message": "Invalid email" } ] } ``` ## Statuses | Status | Meaning | What to do | | --- | --- | --- | | `200` `201` | Done. | | | `202` | Accepted, but the send waits for a person's [approval](https://tryjustdocs.com/docs/api/keys#approval). | Follow the `approval`. | | `400` | The input is invalid, or an unknown field was sent. | Fix the input; `errors` says where. | | `401` | No key, or a key that's unknown, expired or revoked, or whose creator is no longer an owner or admin. The `WWW-Authenticate` header is `Bearer realm="JustDocs"`, with `error="invalid_token"` when a key was sent. | Check the header, or make a new key. | | `402` | The plan doesn't allow it: no completed requests left this month, more members than sender seats, more signers than the plan allows, a document that must be bought first, or as many saved templates as the plan keeps (`This workspace's plan keeps up to 3 saved templates. Delete one you no longer use, or upgrade the plan.`). | Read the message. An owner can change the plan, or a person can delete a template in the app. | | `403` | The key lacks the permission, or it's an API key and the workspace isn't on Teams & Platform. | Use a key with the permission. | | `404` | Not found in the key's workspace, or not visible to the key's creator. A key works only in the workspace it was made in, so a template or document from another workspace is not found: `No template with this id in this key's workspace, "Northwind" (or its creator can't see it). A key works only in the workspace it was made in; get_workspace says which.` | Check the id, and check the workspace with [`GET /workspace`](https://tryjustdocs.com/docs/api/reference/get-workspace). See [Which workspace a key works in](https://tryjustdocs.com/docs/api/keys#workspace). | | `409` | The record's state doesn't allow this, such as sending a request that's already sent. Or an [idempotency key](https://tryjustdocs.com/docs/api/idempotency) conflict. | Fetch the record, or see Idempotency. | | `413` | The body is larger than 2 MB, or a template or document is too long to save. | Send less: split a long template into smaller ones. | | `422` | The request is valid but can't go ahead. For example, an address outside the key's recipient domains, an address that can't receive email, or a document role without a recipient. | Read the message. | | `429` | Too many calls, or a limit on sending: the key's daily sends, the plan's or a recipient's daily requests, or too many approvals waiting. | Slow down, or wait. The message says until when. | | `5xx` | Something went wrong at JustDocs. | Retry with backoff, with the same idempotency key. | ## Rate limits Two limits apply to every call, each over a minute: - **Per key**, shared between the REST API and the MCP server. See [Plans and limits](https://tryjustdocs.com/docs/get-started/plans-and-limits). - **Per network address**, a ceiling across all keys and calls, far above what one key may make. An IPv6 address counts with the rest of its /56 block. It stops anyone from trying made-up keys without limit. Past it, every call from that address gets `429` for the rest of the minute, valid keys included. Responses carry the standard headers for the key's limit: | Header | | | --- | --- | | `RateLimit-Limit` | Calls allowed through the key in the window | | `RateLimit-Remaining` | Calls left | | `RateLimit-Reset` | Seconds until the window resets | Past either limit, calls get `429` until the window resets. Back off for `RateLimit-Reset` seconds. The address ceiling's `429` carries `Retry-After` instead, in seconds. The **daily send limit** is separate. It counts sends through the key over the last 24 hours and returns `429 This key has sent its 20 for the last 24 hours.` It doesn't reset at a fixed time. Each send counts for 24 hours. See [Plans and limits](https://tryjustdocs.com/docs/get-started/plans-and-limits) for the numbers. # Pagination List operations return newest first, one page at a time, with a `next_cursor`: ```json { "signing_requests": [ … ], "next_cursor": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b" } ``` Pass it back as `cursor` for the next page. When `next_cursor` is `null`, you have everything. | Operation | Page size | | --- | --- | | [List templates](https://tryjustdocs.com/docs/api/reference/list-templates) | 100 | | [List signing requests](https://tryjustdocs.com/docs/api/reference/list-signing-requests) | 50 | Treat a cursor as opaque. Don't build one yourself, and don't keep it for long. Records created after you start come before the first page, so you won't see them until you start again from the top. ```js [Node.js] async function allSigningRequests() { const all = []; let cursor = null; do { const url = new URL("https://tryjustdocs.com/api/v1/signing-requests"); if (cursor) url.searchParams.set("cursor", cursor); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}` } }); const page = await response.json(); all.push(...page.signing_requests); cursor = page.next_cursor; } while (cursor); return all; } ``` ```python [Python] import os import requests def all_signing_requests(): headers = {"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"} cursor, everything = None, [] while True: params = {"cursor": cursor} if cursor else {} page = requests.get("https://tryjustdocs.com/api/v1/signing-requests", headers=headers, params=params, timeout=30).json() everything += page["signing_requests"] cursor = page["next_cursor"] if not cursor: return everything ``` In MCP, pass `next_cursor` as the `cursor` argument of `list_templates` or `list_signing_requests`. # Endpoint reference Base URL `https://tryjustdocs.com/api/v1`. Every call takes a workspace key as `Authorization: Bearer `; see [Keys and permissions](https://tryjustdocs.com/docs/api/keys). These pages are generated from the API's OpenAPI description, which you can also load into your own tools from [https://tryjustdocs.com/openapi.json](https://tryjustdocs.com/openapi.json). ## Workspace | Operation | Endpoint | Permission | | --- | --- | --- | | [Get the key's workspace](https://tryjustdocs.com/docs/api/reference/get-workspace) | GET `/workspace` | `read` | ## Templates | Operation | Endpoint | Permission | | --- | --- | --- | | [Create a template](https://tryjustdocs.com/docs/api/reference/create-template) | POST `/templates` | `documents` | | [List templates](https://tryjustdocs.com/docs/api/reference/list-templates) | GET `/templates` | `read` | | [Get a template](https://tryjustdocs.com/docs/api/reference/get-template) | GET `/templates/{id}` | `read` | ## Documents | Operation | Endpoint | Permission | | --- | --- | --- | | [Start a document from a template](https://tryjustdocs.com/docs/api/reference/start-document) | POST `/documents` | `documents` | | [Get a document](https://tryjustdocs.com/docs/api/reference/get-document) | GET `/documents/{id}` | `read` | ## Signing requests | Operation | Endpoint | Permission | | --- | --- | --- | | [Create a signing request, and optionally send it](https://tryjustdocs.com/docs/api/reference/create-signing-request) | POST `/signing-requests` | `send` | | [List signing requests](https://tryjustdocs.com/docs/api/reference/list-signing-requests) | GET `/signing-requests` | `read` | | [Get a signing request](https://tryjustdocs.com/docs/api/reference/get-signing-request) | GET `/signing-requests/{id}` | `read` | | [Send a draft signing request](https://tryjustdocs.com/docs/api/reference/send-signing-request) | POST `/signing-requests/{id}/send` | `send` | | [Void a sent signing request](https://tryjustdocs.com/docs/api/reference/void-signing-request) | POST `/signing-requests/{id}/void` | `send` | ## Intake links | Operation | Endpoint | Permission | | --- | --- | --- | | [Send an intake link](https://tryjustdocs.com/docs/api/reference/create-intake-request) | POST `/intake-requests` | `send` | | [Get an intake link](https://tryjustdocs.com/docs/api/reference/get-intake-request) | GET `/intake-requests/{id}` | `read` | ## Approvals | Operation | Endpoint | Permission | | --- | --- | --- | | [Get an approval this key asked for](https://tryjustdocs.com/docs/api/reference/get-approval) | GET `/approvals/{id}` | `read` | # Get the key's workspace GET `/api/v1/workspace` Its name, plan and saved templates, and what the key may do. Needs the `read` permission. | | | | --- | --- | | Permission | `read` | | MCP tool | [`get_workspace`](https://tryjustdocs.com/docs/mcp/tools#get_workspace) | ## Request No parameters or body. ## Responses | Status | Meaning | | --- | --- | | `200` | The workspace. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `200` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `name` | string | "Personal" for a personal workspace, else the team's name. | | `personal` | boolean | — | | `plan` | `free` or `packs` or `business` or `teams` | — | | `plan_name` | string | — | | `key` | object | The key making the call. | | `key.id` | string (uuid) | — | | `key.name` | string | — | | `key.kind` | `api` or `agent` | — | | `key.kind_label` | `API key` or `Agent key` | — | | `key.connected_app` | boolean | Made by connecting an app (OAuth) rather than by hand. | | `key.scopes` | array of `read` or `documents` or `send` | What it may do; for a connected app, what the person allowed. | | `key.sends_need_approval` | boolean | Whether its sends and intake links wait for an owner or admin to approve them. | | `key.sends_label` | `Sends wait for approval` or `Sends go out at once` | — | | `key.recipient_domains` | array of string or null | The domains it may send to; null for any. | | `key.daily_send_limit` | integer | — | | `key.expires_at` | string (date-time) | — | | `templates` | object | — | | `templates.saved` | integer | — | | `templates.limit` | integer or null | The most the plan keeps; null for no limit. | **Example** ```json { "id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "name": "Northwind", "personal": false, "plan": "teams", "plan_name": "Teams & Platform", "key": { "id": "d2f4b6a8-3c5e-4f7a-b9d1-2e4c6a8b1d3f", "name": "Sales agent", "kind": "agent", "kind_label": "Agent key", "connected_app": false, "scopes": [ "read", "documents", "send" ], "sends_need_approval": true, "sends_label": "Sends wait for approval", "recipient_domains": [ "example.com" ], "daily_send_limit": 20, "expires_at": "2027-02-01T16:20:00.000Z" }, "templates": { "saved": 4, "limit": null } } ``` ## Examples ```bash [curl] curl https://tryjustdocs.com/api/v1/workspace \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/workspace", { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, }, }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.get( "https://tryjustdocs.com/api/v1/workspace", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"}, timeout=30, ) response.raise_for_status() result = response.json() ``` # Create a template POST `/api/v1/templates` From text with fill-ins and signing fields, or copied from a library entry. It emails no one, so it needs no approval. Needs the `documents` permission. | | | | --- | --- | | Permission | `documents` | | MCP tool | [`create_template`](https://tryjustdocs.com/docs/mcp/tools#create_template) | | Retries | Safe with an [`Idempotency-Key`](https://tryjustdocs.com/docs/api/idempotency) | ## Request ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Makes a retried call safe: the same key returns the first result instead of creating or sending again. Matches `^[A-Za-z0-9_-]{8,100}$`. | ### Body Give `content` and a `title`, or a `library_entry` to copy, not both. | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | No | The template's name: needed with `content`, and a library entry's own name by default. 1–255 characters. | | `content` | string | No | The template's text, in blocks separated by a blank line: # Heading (## and ### for smaller ones), a list whose every line starts with "- " or "1. ", --- for a rule, and anything else a paragraph. Inside text: `**bold**` for bold; {{key}} or {{key\|Label}} for a fill-in completed for each document (key: lowercase letters, numbers and _, starting with a letter); [[type:role]] for a signing field and [[type:role?]] for an optional one, either with \|Label to name it (type: signature, initials, date_signed, text, checkbox, full_name; role: lowercase letters, numbers and -). 1–500000 characters. | | `fill_ins` | array of objects | No | Labels for fill-ins in the text. Without one, a fill-in takes its first written label, or one made from its key. Up to 200 items. | | `fill_ins[].key` | string | Yes | Matches `^[a-z][a-z0-9_]{0,39}$`. | | `fill_ins[].label` | string | Yes | 1–100 characters. | | `roles` | array of string | No | Who signs, by role. A listed role with no field in the text gets a Signature, Name and Date block at the end. By default, the roles of the text's fields. 1–20 items. | | `library_entry` | `mutual-nda` or `consulting-agreement` or `service-agreement` or `client-proposal` or `founder-agreement` or `offer-letter` or `business-acknowledgment` or `master-services-agreement` or `w9-request` or `vendor-information-form` or `property-agreement` or `commission-agreement` or `consent-form` or `activity-waiver` or `approval-form` or `event-registration` or `internal-acknowledgment` | No | A library entry to copy instead of writing `content`, by slug. | ## Responses | Status | Meaning | | --- | --- | | `201` | The new template. | | `400` | The input is invalid. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `402` | The workspace's plan doesn't allow it: no completed requests left this month, more members than sender seats, more signers than the plan allows, a document that must be bought first, or the workspace's saved templates are at its plan's limit. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `409` | The request is in a state that doesn't allow this, or the Idempotency-Key was used for something else. | | `413` | The body is larger than 2 MB, or the template is too long to save. | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `201` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `title` | string | — | | `source_type` | string | — | | `status` | string | — | | `draft_revision` | integer | — | | `fill_ins` | array of objects | — | | `fill_ins[].key` | string | — | | `fill_ins[].label` | string | — | | `fill_ins[].value` | string or null | — | | `team_access` | string | — | | `created_at` | string (date-time) or null | — | | `updated_at` | string (date-time) or null | — | | `roles` | array of string | Who signs, by role, in the order the fields appear: a signing request names a recipient for each. | | `form` | object or null | The intake form whose answers fill the template, if it has fill-ins. | **Example** ```json { "id": "5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", "title": "Mutual NDA", "source_type": "template", "status": "draft", "draft_revision": 4, "fill_ins": [ { "key": "client_name", "label": "Client name", "value": null }, { "key": "effective_date", "label": "Effective date", "value": null } ], "team_access": "view", "created_at": "2026-10-02T14:03:19.000Z", "updated_at": "2026-10-28T09:12:44.000Z", "roles": [ "client" ], "form": { "version": 1, "title": "Mutual NDA", "steps": [ { "id": "parties", "title": "The other party", "questions": [ { "key": "client_name", "label": "Client name", "type": "text", "required": true, "fills": [ "client_name" ] }, { "key": "effective_date", "label": "Effective date", "type": "date", "required": true, "fills": [ "effective_date" ] } ] } ] } } ``` ## Examples ```bash [curl] curl -X POST https://tryjustdocs.com/api/v1/templates \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" \ -H "Idempotency-Key: deal-4821-create-template" \ -H "Content-Type: application/json" \ -d '{ "title": "Mutual NDA", "content": "# Mutual NDA\n\nThis agreement between Northwind Ltd and {{client_name}} starts on {{effective_date}}.\n\n- Each party keeps the other'\''s confidential information private.\n- Either party may end it with 30 days'\'' notice.\n\n**Client**\nSignature: [[signature:client]]\nDate: [[date_signed:client]]" }' ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/templates", { method: "POST", headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, "Idempotency-Key": "deal-4821-create-template", "Content-Type": "application/json", }, body: JSON.stringify({ title: "Mutual NDA", content: "# Mutual NDA\n\nThis agreement between Northwind Ltd and {{client_name}} starts on {{effective_date}}.\n\n- Each party keeps the other's confidential information private.\n- Either party may end it with 30 days' notice.\n\n**Client**\nSignature: [[signature:client]]\nDate: [[date_signed:client]]" }), }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.post( "https://tryjustdocs.com/api/v1/templates", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}", "Idempotency-Key": "deal-4821-create-template"}, json={ "title": "Mutual NDA", "content": "# Mutual NDA\n\nThis agreement between Northwind Ltd and {{client_name}} starts on {{effective_date}}.\n\n- Each party keeps the other's confidential information private.\n- Either party may end it with 30 days' notice.\n\n**Client**\nSignature: [[signature:client]]\nDate: [[date_signed:client]]", }, timeout=30, ) response.raise_for_status() result = response.json() ``` # List templates GET `/api/v1/templates` 100 at a time, newest first. Needs the `read` permission. | | | | --- | --- | | Permission | `read` | | MCP tool | [`list_templates`](https://tryjustdocs.com/docs/mcp/tools#list_templates) | ## Request ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `cursor` | string | No | The `next_cursor` of the previous page. | ## Responses | Status | Meaning | | --- | --- | | `200` | A page of templates, and the workspace they are in. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `200` body | Field | Type | Description | | --- | --- | --- | | `templates` | array of objects | — | | `templates[].id` | string (uuid) | — | | `templates[].title` | string | — | | `templates[].kind` | `written` or `pdf` | — | | `templates[].draft_revision` | integer | — | | `templates[].team_access` | string | — | | `templates[].updated_at` | string (date-time) or null | — | | `next_cursor` | string or null | — | | `workspace` | object | — | | `workspace.id` | string (uuid) | — | | `workspace.name` | string | — | **Example** ```json { "templates": [ { "id": "5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", "title": "Mutual NDA", "kind": "written", "draft_revision": 4, "team_access": "view", "updated_at": "2026-10-28T09:12:44.000Z" } ], "next_cursor": null, "workspace": { "id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "name": "Northwind" } } ``` ## Examples ```bash [curl] curl https://tryjustdocs.com/api/v1/templates \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/templates", { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, }, }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.get( "https://tryjustdocs.com/api/v1/templates", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"}, timeout=30, ) response.raise_for_status() result = response.json() ``` # Get a template GET `/api/v1/templates/{id}` With its fill-ins and the intake form that answers them, so you know what to supply. Needs the `read` permission. | | | | --- | --- | | Permission | `read` | | MCP tool | [`get_template`](https://tryjustdocs.com/docs/mcp/tools#get_template) | ## Request ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | ## Responses | Status | Meaning | | --- | --- | | `200` | The template. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `200` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `title` | string | — | | `source_type` | string | — | | `status` | string | — | | `draft_revision` | integer | — | | `fill_ins` | array of objects | — | | `fill_ins[].key` | string | — | | `fill_ins[].label` | string | — | | `fill_ins[].value` | string or null | — | | `team_access` | string | — | | `created_at` | string (date-time) or null | — | | `updated_at` | string (date-time) or null | — | | `roles` | array of string | Who signs, by role, in the order the fields appear: a signing request names a recipient for each. | | `form` | object or null | The intake form whose answers fill the template, if it has fill-ins. | **Example** ```json { "id": "5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", "title": "Mutual NDA", "source_type": "template", "status": "draft", "draft_revision": 4, "fill_ins": [ { "key": "client_name", "label": "Client name", "value": null }, { "key": "effective_date", "label": "Effective date", "value": null } ], "team_access": "view", "created_at": "2026-10-02T14:03:19.000Z", "updated_at": "2026-10-28T09:12:44.000Z", "roles": [ "client" ], "form": { "version": 1, "title": "Mutual NDA", "steps": [ { "id": "parties", "title": "The other party", "questions": [ { "key": "client_name", "label": "Client name", "type": "text", "required": true, "fills": [ "client_name" ] }, { "key": "effective_date", "label": "Effective date", "type": "date", "required": true, "fills": [ "effective_date" ] } ] } ] } } ``` ## Examples ```bash [curl] curl https://tryjustdocs.com/api/v1/templates/5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/templates/5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, }, }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.get( "https://tryjustdocs.com/api/v1/templates/5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"}, timeout=30, ) response.raise_for_status() result = response.json() ``` # Start a document from a template POST `/api/v1/documents` Fills it with values or form answers. Needs the `documents` permission. | | | | --- | --- | | Permission | `documents` | | MCP tool | [`start_document`](https://tryjustdocs.com/docs/mcp/tools#start_document) | | Retries | Safe with an [`Idempotency-Key`](https://tryjustdocs.com/docs/api/idempotency) | ## Request ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Makes a retried call safe: the same key returns the first result instead of creating or sending again. Matches `^[A-Za-z0-9_-]{8,100}$`. | ### Body Give `merge_values` or `answers`, not both. | Field | Type | Required | Description | | --- | --- | --- | --- | | `template_id` | string (uuid) | Yes | — | | `title` | string | No | 1–255 characters. | | `merge_values` | object of string | No | Fill-in values by key. | | `answers` | object | No | Answers to the template's intake form, by question key. | ## Responses | Status | Meaning | | --- | --- | | `201` | The new document. | | `400` | The input is invalid. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `409` | The request is in a state that doesn't allow this, or the Idempotency-Key was used for something else. | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `201` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `title` | string | — | | `source_type` | string | — | | `status` | string | — | | `draft_revision` | integer | — | | `fill_ins` | array of objects | — | | `fill_ins[].key` | string | — | | `fill_ins[].label` | string | — | | `fill_ins[].value` | string or null | — | | `team_access` | string | — | | `created_at` | string (date-time) or null | — | | `updated_at` | string (date-time) or null | — | **Example** ```json { "id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", "title": "Mutual NDA — Acme", "source_type": "editor", "status": "draft", "draft_revision": 1, "fill_ins": [ { "key": "client_name", "label": "Client name", "value": "Acme Inc." }, { "key": "effective_date", "label": "Effective date", "value": "November 3, 2026" } ], "team_access": "view", "created_at": "2026-11-03T16:20:00.000Z", "updated_at": "2026-11-03T16:20:00.000Z" } ``` ## Examples ```bash [curl] curl -X POST https://tryjustdocs.com/api/v1/documents \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" \ -H "Idempotency-Key: deal-4821-start-document" \ -H "Content-Type: application/json" \ -d '{ "template_id": "5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", "title": "Mutual NDA — Acme", "merge_values": { "client_name": "Acme Inc.", "effective_date": "November 3, 2026" } }' ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/documents", { method: "POST", headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, "Idempotency-Key": "deal-4821-start-document", "Content-Type": "application/json", }, body: JSON.stringify({ template_id: "5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", title: "Mutual NDA — Acme", merge_values: { client_name: "Acme Inc.", effective_date: "November 3, 2026" } }), }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.post( "https://tryjustdocs.com/api/v1/documents", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}", "Idempotency-Key": "deal-4821-start-document"}, json={ "template_id": "5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", "title": "Mutual NDA — Acme", "merge_values": { "client_name": "Acme Inc.", "effective_date": "November 3, 2026", }, }, timeout=30, ) response.raise_for_status() result = response.json() ``` # Get a document GET `/api/v1/documents/{id}` Needs the `read` permission. | | | | --- | --- | | Permission | `read` | | MCP tool | [`get_document`](https://tryjustdocs.com/docs/mcp/tools#get_document) | ## Request ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | ## Responses | Status | Meaning | | --- | --- | | `200` | The document. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `200` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `title` | string | — | | `source_type` | string | — | | `status` | string | — | | `draft_revision` | integer | — | | `fill_ins` | array of objects | — | | `fill_ins[].key` | string | — | | `fill_ins[].label` | string | — | | `fill_ins[].value` | string or null | — | | `team_access` | string | — | | `created_at` | string (date-time) or null | — | | `updated_at` | string (date-time) or null | — | **Example** ```json { "id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", "title": "Mutual NDA — Acme", "source_type": "editor", "status": "draft", "draft_revision": 1, "fill_ins": [ { "key": "client_name", "label": "Client name", "value": "Acme Inc." }, { "key": "effective_date", "label": "Effective date", "value": "November 3, 2026" } ], "team_access": "view", "created_at": "2026-11-03T16:20:00.000Z", "updated_at": "2026-11-03T16:20:00.000Z" } ``` ## Examples ```bash [curl] curl https://tryjustdocs.com/api/v1/documents/8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/documents/8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, }, }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.get( "https://tryjustdocs.com/api/v1/documents/8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"}, timeout=30, ) response.raise_for_status() result = response.json() ``` # Create a signing request, and optionally send it POST `/api/v1/signing-requests` Needs the `send` permission. | | | | --- | --- | | Permission | `send` | | MCP tool | [`create_signing_request`](https://tryjustdocs.com/docs/mcp/tools#create_signing_request) | | Retries | Safe with an [`Idempotency-Key`](https://tryjustdocs.com/docs/api/idempotency) | | Approval | Agent keys, and keys made to need approval, wait for an owner or admin: [`202`](https://tryjustdocs.com/docs/api/keys#approval) | ## Request ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Makes a retried call safe: the same key returns the first result instead of creating or sending again. Matches `^[A-Za-z0-9_-]{8,100}$`. | ### Body | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | No | 1–255 characters. | | `message` | string | No | Up to 2,000 characters. | | `routing` | `sequential` or `parallel` | No | Default `sequential`. | | `expires_in_days` | integer | No | 1–365. Default `30`. | | `documents` | array of objects | Yes | 1–10 items. | | `documents[].document_id` | string (uuid) | Yes | — | | `documents[].draft_revision` | integer | No | Defaults to the current revision. At least 1. | | `recipients` | array of objects | Yes | 1–20 items. | | `recipients[].role_key` | string | Yes | The role this person signs as, matching the documents' roles. Matches `^[a-z0-9][a-z0-9-]{0,39}$`. | | `recipients[].name` | string | Yes | 1–200 characters. | | `recipients[].email` | string (email) | Yes | Up to 255 characters. | | `recipients[].routing_order` | integer | No | 1–20. | | `send` | boolean | No | Send at once instead of leaving a draft. Default `false`. | ## Responses | Status | Meaning | | --- | --- | | `201` | Created (and sent, with `send: true`). | | `202` | Created; the send waits for a person's approval. | | `400` | The input is invalid. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `402` | The workspace's plan doesn't allow it: no completed requests left this month, more members than sender seats, more signers than the plan allows, a document that must be bought first, or the workspace's saved templates are at its plan's limit. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `409` | The request is in a state that doesn't allow this, or the Idempotency-Key was used for something else. | | `422` | Valid, but it can't go ahead: an address outside the key's recipient domains or one that can't receive email, or a signing role without a recipient. | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `201` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `title` | string | — | | `status` | `draft` or `sent` or `completed` or `declined` or `voided` or `expired` | — | | `routing` | `sequential` or `parallel` | — | | `message` | string or null | — | | `sender` | object or null | — | | `sender.name` | string or null | — | | `sender.email` | string | — | | `created_at` | string (date-time) or null | — | | `sent_at` | string (date-time) or null | — | | `completed_at` | string (date-time) or null | — | | `expires_at` | string (date-time) or null | — | | `documents` | array of objects | — | | `documents[].id` | string (uuid) | — | | `documents[].document_id` | string (uuid) | — | | `documents[].title` | string | — | | `documents[].completed_copy` | boolean | — | | `recipients` | array of objects | — | | `recipients[].id` | string (uuid) | — | | `recipients[].role` | string | — | | `recipients[].name` | string | — | | `recipients[].email` | string | — | | `recipients[].routing_order` | integer | — | | `recipients[].status` | `pending` or `notified` or `viewed` or `signed` or `declined` | — | | `recipients[].signed_at` | string (date-time) or null | — | | `recipients[].declined_at` | string (date-time) or null | — | | `recipients[].delivery` | string or null | The latest email delivery status. | | `approval` | object or null | The latest approval, when the request was sent through an agent key or a key that needs approval. | | `approval.id` | string (uuid) | — | | `approval.kind` | `signing.send` or `intake.create` | — | | `approval.status` | `pending` or `approved` or `declined` or `expired` or `failed` | — | | `approval.title` | string | — | | `approval.addresses` | array of string | — | | `approval.credential` | string or null | — | | `approval.signing_request_id` | string or null | — | | `approval.intake_request_id` | string or null | — | | `approval.decided_by` | string or null | — | | `approval.decided_at` | string (date-time) or null | — | | `approval.result` | string or null | — | | `approval.expires_at` | string (date-time) | — | | `approval.created_at` | string (date-time) | — | **Example** ```json { "id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "title": "Mutual NDA — Acme", "status": "sent", "routing": "sequential", "message": "Please review and sign.", "sender": { "name": "Olga Owner", "email": "olga@example.com" }, "created_at": "2026-11-03T16:20:00.000Z", "sent_at": "2026-11-03T16:41:12.000Z", "completed_at": null, "expires_at": "2026-12-03T16:41:12.000Z", "documents": [ { "id": "0c2e4a6b-8d1f-4b3c-9e5a-7c9e1b3d5f2a", "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", "title": "Mutual NDA — Acme", "completed_copy": false } ], "recipients": [ { "id": "b6d8f1a3-5c7e-4d9b-a2f4-6c8e1b3d5f7a", "role": "client", "name": "Ada Lovelace", "email": "ada@example.com", "routing_order": 1, "status": "notified", "signed_at": null, "declined_at": null, "delivery": "delivered" } ], "approval": { "id": "a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", "kind": "signing.send", "status": "approved", "title": "Mutual NDA — Acme", "addresses": [ "ada@example.com" ], "credential": "Sales agent", "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "intake_request_id": null, "decided_by": "Olga Owner", "decided_at": "2026-11-03T16:41:10.000Z", "result": null, "expires_at": "2026-11-10T16:20:01.000Z", "created_at": "2026-11-03T16:20:01.000Z" } } ``` ### `202` body The same as `201`. ## Examples ```bash [curl] curl -X POST https://tryjustdocs.com/api/v1/signing-requests \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" \ -H "Idempotency-Key: deal-4821-create-signing-request" \ -H "Content-Type: application/json" \ -d '{ "title": "Mutual NDA — Acme", "message": "Please review and sign.", "documents": [ { "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b" } ], "recipients": [ { "role_key": "client", "name": "Ada Lovelace", "email": "ada@example.com" } ], "send": true }' ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/signing-requests", { method: "POST", headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, "Idempotency-Key": "deal-4821-create-signing-request", "Content-Type": "application/json", }, body: JSON.stringify({ title: "Mutual NDA — Acme", message: "Please review and sign.", documents: [ { document_id: "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b" } ], recipients: [ { role_key: "client", name: "Ada Lovelace", email: "ada@example.com" } ], send: true }), }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.post( "https://tryjustdocs.com/api/v1/signing-requests", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}", "Idempotency-Key": "deal-4821-create-signing-request"}, json={ "title": "Mutual NDA — Acme", "message": "Please review and sign.", "documents": [ { "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", }, ], "recipients": [ { "role_key": "client", "name": "Ada Lovelace", "email": "ada@example.com", }, ], "send": True, }, timeout=30, ) response.raise_for_status() result = response.json() ``` # List signing requests GET `/api/v1/signing-requests` Newest first. Needs the `read` permission. | | | | --- | --- | | Permission | `read` | | MCP tool | [`list_signing_requests`](https://tryjustdocs.com/docs/mcp/tools#list_signing_requests) | ## Request ### Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `cursor` | string | No | The `next_cursor` of the previous page. | ## Responses | Status | Meaning | | --- | --- | | `200` | A page of signing requests. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `200` body | Field | Type | Description | | --- | --- | --- | | `signing_requests` | array of objects | — | | `signing_requests[].id` | string (uuid) | — | | `signing_requests[].title` | string | — | | `signing_requests[].status` | string | — | | `signing_requests[].created_at` | string (date-time) or null | — | | `signing_requests[].sent_at` | string (date-time) or null | — | | `signing_requests[].completed_at` | string (date-time) or null | — | | `signing_requests[].recipients_total` | integer | — | | `signing_requests[].recipients_signed` | integer | — | | `next_cursor` | string or null | — | **Example** ```json { "signing_requests": [ { "id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "title": "Mutual NDA — Acme", "status": "sent", "created_at": "2026-11-03T16:20:00.000Z", "sent_at": "2026-11-03T16:41:12.000Z", "completed_at": null, "recipients_total": 1, "recipients_signed": 0 } ], "next_cursor": null } ``` ## Examples ```bash [curl] curl https://tryjustdocs.com/api/v1/signing-requests \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/signing-requests", { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, }, }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.get( "https://tryjustdocs.com/api/v1/signing-requests", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"}, timeout=30, ) response.raise_for_status() result = response.json() ``` # Get a signing request GET `/api/v1/signing-requests/{id}` With each recipient's status and the latest approval. Needs the `read` permission. | | | | --- | --- | | Permission | `read` | | MCP tool | [`get_signing_request`](https://tryjustdocs.com/docs/mcp/tools#get_signing_request) | ## Request ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | ## Responses | Status | Meaning | | --- | --- | | `200` | The signing request. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `200` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `title` | string | — | | `status` | `draft` or `sent` or `completed` or `declined` or `voided` or `expired` | — | | `routing` | `sequential` or `parallel` | — | | `message` | string or null | — | | `sender` | object or null | — | | `sender.name` | string or null | — | | `sender.email` | string | — | | `created_at` | string (date-time) or null | — | | `sent_at` | string (date-time) or null | — | | `completed_at` | string (date-time) or null | — | | `expires_at` | string (date-time) or null | — | | `documents` | array of objects | — | | `documents[].id` | string (uuid) | — | | `documents[].document_id` | string (uuid) | — | | `documents[].title` | string | — | | `documents[].completed_copy` | boolean | — | | `recipients` | array of objects | — | | `recipients[].id` | string (uuid) | — | | `recipients[].role` | string | — | | `recipients[].name` | string | — | | `recipients[].email` | string | — | | `recipients[].routing_order` | integer | — | | `recipients[].status` | `pending` or `notified` or `viewed` or `signed` or `declined` | — | | `recipients[].signed_at` | string (date-time) or null | — | | `recipients[].declined_at` | string (date-time) or null | — | | `recipients[].delivery` | string or null | The latest email delivery status. | | `approval` | object or null | The latest approval, when the request was sent through an agent key or a key that needs approval. | | `approval.id` | string (uuid) | — | | `approval.kind` | `signing.send` or `intake.create` | — | | `approval.status` | `pending` or `approved` or `declined` or `expired` or `failed` | — | | `approval.title` | string | — | | `approval.addresses` | array of string | — | | `approval.credential` | string or null | — | | `approval.signing_request_id` | string or null | — | | `approval.intake_request_id` | string or null | — | | `approval.decided_by` | string or null | — | | `approval.decided_at` | string (date-time) or null | — | | `approval.result` | string or null | — | | `approval.expires_at` | string (date-time) | — | | `approval.created_at` | string (date-time) | — | **Example** ```json { "id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "title": "Mutual NDA — Acme", "status": "sent", "routing": "sequential", "message": "Please review and sign.", "sender": { "name": "Olga Owner", "email": "olga@example.com" }, "created_at": "2026-11-03T16:20:00.000Z", "sent_at": "2026-11-03T16:41:12.000Z", "completed_at": null, "expires_at": "2026-12-03T16:41:12.000Z", "documents": [ { "id": "0c2e4a6b-8d1f-4b3c-9e5a-7c9e1b3d5f2a", "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", "title": "Mutual NDA — Acme", "completed_copy": false } ], "recipients": [ { "id": "b6d8f1a3-5c7e-4d9b-a2f4-6c8e1b3d5f7a", "role": "client", "name": "Ada Lovelace", "email": "ada@example.com", "routing_order": 1, "status": "notified", "signed_at": null, "declined_at": null, "delivery": "delivered" } ], "approval": { "id": "a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", "kind": "signing.send", "status": "approved", "title": "Mutual NDA — Acme", "addresses": [ "ada@example.com" ], "credential": "Sales agent", "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "intake_request_id": null, "decided_by": "Olga Owner", "decided_at": "2026-11-03T16:41:10.000Z", "result": null, "expires_at": "2026-11-10T16:20:01.000Z", "created_at": "2026-11-03T16:20:01.000Z" } } ``` ## Examples ```bash [curl] curl https://tryjustdocs.com/api/v1/signing-requests/c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/signing-requests/c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, }, }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.get( "https://tryjustdocs.com/api/v1/signing-requests/c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"}, timeout=30, ) response.raise_for_status() result = response.json() ``` # Send a draft signing request POST `/api/v1/signing-requests/{id}/send` Needs the `send` permission. | | | | --- | --- | | Permission | `send` | | MCP tool | [`send_signing_request`](https://tryjustdocs.com/docs/mcp/tools#send_signing_request) | | Retries | Safe with an [`Idempotency-Key`](https://tryjustdocs.com/docs/api/idempotency) | | Approval | Agent keys, and keys made to need approval, wait for an owner or admin: [`202`](https://tryjustdocs.com/docs/api/keys#approval) | ## Request ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Makes a retried call safe: the same key returns the first result instead of creating or sending again. Matches `^[A-Za-z0-9_-]{8,100}$`. | ## Responses | Status | Meaning | | --- | --- | | `200` | Sent. | | `202` | The send waits for a person's approval. | | `400` | The input is invalid. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `402` | The workspace's plan doesn't allow it: no completed requests left this month, more members than sender seats, more signers than the plan allows, a document that must be bought first, or the workspace's saved templates are at its plan's limit. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `409` | The request is in a state that doesn't allow this, or the Idempotency-Key was used for something else. | | `422` | Valid, but it can't go ahead: an address outside the key's recipient domains or one that can't receive email, or a signing role without a recipient. | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `200` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `title` | string | — | | `status` | `draft` or `sent` or `completed` or `declined` or `voided` or `expired` | — | | `routing` | `sequential` or `parallel` | — | | `message` | string or null | — | | `sender` | object or null | — | | `sender.name` | string or null | — | | `sender.email` | string | — | | `created_at` | string (date-time) or null | — | | `sent_at` | string (date-time) or null | — | | `completed_at` | string (date-time) or null | — | | `expires_at` | string (date-time) or null | — | | `documents` | array of objects | — | | `documents[].id` | string (uuid) | — | | `documents[].document_id` | string (uuid) | — | | `documents[].title` | string | — | | `documents[].completed_copy` | boolean | — | | `recipients` | array of objects | — | | `recipients[].id` | string (uuid) | — | | `recipients[].role` | string | — | | `recipients[].name` | string | — | | `recipients[].email` | string | — | | `recipients[].routing_order` | integer | — | | `recipients[].status` | `pending` or `notified` or `viewed` or `signed` or `declined` | — | | `recipients[].signed_at` | string (date-time) or null | — | | `recipients[].declined_at` | string (date-time) or null | — | | `recipients[].delivery` | string or null | The latest email delivery status. | | `approval` | object or null | The latest approval, when the request was sent through an agent key or a key that needs approval. | | `approval.id` | string (uuid) | — | | `approval.kind` | `signing.send` or `intake.create` | — | | `approval.status` | `pending` or `approved` or `declined` or `expired` or `failed` | — | | `approval.title` | string | — | | `approval.addresses` | array of string | — | | `approval.credential` | string or null | — | | `approval.signing_request_id` | string or null | — | | `approval.intake_request_id` | string or null | — | | `approval.decided_by` | string or null | — | | `approval.decided_at` | string (date-time) or null | — | | `approval.result` | string or null | — | | `approval.expires_at` | string (date-time) | — | | `approval.created_at` | string (date-time) | — | **Example** ```json { "id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "title": "Mutual NDA — Acme", "status": "sent", "routing": "sequential", "message": "Please review and sign.", "sender": { "name": "Olga Owner", "email": "olga@example.com" }, "created_at": "2026-11-03T16:20:00.000Z", "sent_at": "2026-11-03T16:41:12.000Z", "completed_at": null, "expires_at": "2026-12-03T16:41:12.000Z", "documents": [ { "id": "0c2e4a6b-8d1f-4b3c-9e5a-7c9e1b3d5f2a", "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", "title": "Mutual NDA — Acme", "completed_copy": false } ], "recipients": [ { "id": "b6d8f1a3-5c7e-4d9b-a2f4-6c8e1b3d5f7a", "role": "client", "name": "Ada Lovelace", "email": "ada@example.com", "routing_order": 1, "status": "notified", "signed_at": null, "declined_at": null, "delivery": "delivered" } ], "approval": { "id": "a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", "kind": "signing.send", "status": "approved", "title": "Mutual NDA — Acme", "addresses": [ "ada@example.com" ], "credential": "Sales agent", "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "intake_request_id": null, "decided_by": "Olga Owner", "decided_at": "2026-11-03T16:41:10.000Z", "result": null, "expires_at": "2026-11-10T16:20:01.000Z", "created_at": "2026-11-03T16:20:01.000Z" } } ``` ### `202` body The same as `200`. ## Examples ```bash [curl] curl -X POST https://tryjustdocs.com/api/v1/signing-requests/c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b/send \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" \ -H "Idempotency-Key: deal-4821-send-signing-request" ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/signing-requests/c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b/send", { method: "POST", headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, "Idempotency-Key": "deal-4821-send-signing-request", }, }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.post( "https://tryjustdocs.com/api/v1/signing-requests/c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b/send", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}", "Idempotency-Key": "deal-4821-send-signing-request"}, timeout=30, ) response.raise_for_status() result = response.json() ``` # Void a sent signing request POST `/api/v1/signing-requests/{id}/void` Takes effect at once, without approval: recipients who were invited are told. Needs the `send` permission. | | | | --- | --- | | Permission | `send` | | MCP tool | [`void_signing_request`](https://tryjustdocs.com/docs/mcp/tools#void_signing_request) | ## Request ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | ### Body (optional) | Field | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | No | Up to 500 characters. | ## Responses | Status | Meaning | | --- | --- | | `200` | Voided. | | `400` | The input is invalid. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `409` | The request is in a state that doesn't allow this, or the Idempotency-Key was used for something else. | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `200` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `title` | string | — | | `status` | `draft` or `sent` or `completed` or `declined` or `voided` or `expired` | — | | `routing` | `sequential` or `parallel` | — | | `message` | string or null | — | | `sender` | object or null | — | | `sender.name` | string or null | — | | `sender.email` | string | — | | `created_at` | string (date-time) or null | — | | `sent_at` | string (date-time) or null | — | | `completed_at` | string (date-time) or null | — | | `expires_at` | string (date-time) or null | — | | `documents` | array of objects | — | | `documents[].id` | string (uuid) | — | | `documents[].document_id` | string (uuid) | — | | `documents[].title` | string | — | | `documents[].completed_copy` | boolean | — | | `recipients` | array of objects | — | | `recipients[].id` | string (uuid) | — | | `recipients[].role` | string | — | | `recipients[].name` | string | — | | `recipients[].email` | string | — | | `recipients[].routing_order` | integer | — | | `recipients[].status` | `pending` or `notified` or `viewed` or `signed` or `declined` | — | | `recipients[].signed_at` | string (date-time) or null | — | | `recipients[].declined_at` | string (date-time) or null | — | | `recipients[].delivery` | string or null | The latest email delivery status. | | `approval` | object or null | The latest approval, when the request was sent through an agent key or a key that needs approval. | | `approval.id` | string (uuid) | — | | `approval.kind` | `signing.send` or `intake.create` | — | | `approval.status` | `pending` or `approved` or `declined` or `expired` or `failed` | — | | `approval.title` | string | — | | `approval.addresses` | array of string | — | | `approval.credential` | string or null | — | | `approval.signing_request_id` | string or null | — | | `approval.intake_request_id` | string or null | — | | `approval.decided_by` | string or null | — | | `approval.decided_at` | string (date-time) or null | — | | `approval.result` | string or null | — | | `approval.expires_at` | string (date-time) | — | | `approval.created_at` | string (date-time) | — | **Example** ```json { "id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "title": "Mutual NDA — Acme", "status": "sent", "routing": "sequential", "message": "Please review and sign.", "sender": { "name": "Olga Owner", "email": "olga@example.com" }, "created_at": "2026-11-03T16:20:00.000Z", "sent_at": "2026-11-03T16:41:12.000Z", "completed_at": null, "expires_at": "2026-12-03T16:41:12.000Z", "documents": [ { "id": "0c2e4a6b-8d1f-4b3c-9e5a-7c9e1b3d5f2a", "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", "title": "Mutual NDA — Acme", "completed_copy": false } ], "recipients": [ { "id": "b6d8f1a3-5c7e-4d9b-a2f4-6c8e1b3d5f7a", "role": "client", "name": "Ada Lovelace", "email": "ada@example.com", "routing_order": 1, "status": "notified", "signed_at": null, "declined_at": null, "delivery": "delivered" } ], "approval": { "id": "a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", "kind": "signing.send", "status": "approved", "title": "Mutual NDA — Acme", "addresses": [ "ada@example.com" ], "credential": "Sales agent", "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "intake_request_id": null, "decided_by": "Olga Owner", "decided_at": "2026-11-03T16:41:10.000Z", "result": null, "expires_at": "2026-11-10T16:20:01.000Z", "created_at": "2026-11-03T16:20:01.000Z" } } ``` ## Examples ```bash [curl] curl -X POST https://tryjustdocs.com/api/v1/signing-requests/c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b/void \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "Sent to the wrong address" }' ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/signing-requests/c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b/void", { method: "POST", headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ reason: "Sent to the wrong address" }), }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.post( "https://tryjustdocs.com/api/v1/signing-requests/c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b/void", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"}, json={ "reason": "Sent to the wrong address", }, timeout=30, ) response.raise_for_status() result = response.json() ``` # Send an intake link POST `/api/v1/intake-requests` Needs the `send` permission. | | | | --- | --- | | Permission | `send` | | MCP tool | [`create_intake_request`](https://tryjustdocs.com/docs/mcp/tools#create_intake_request) | | Retries | Safe with an [`Idempotency-Key`](https://tryjustdocs.com/docs/api/idempotency) | | Approval | Agent keys, and keys made to need approval, wait for an owner or admin: [`202`](https://tryjustdocs.com/docs/api/keys#approval) | ## Request ### Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Makes a retried call safe: the same key returns the first result instead of creating or sending again. Matches `^[A-Za-z0-9_-]{8,100}$`. | ### Body An intake link: a named person without an account answers a form, and their answers fill the documents. | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | No | 1–255 characters. | | `message` | string | No | Up to 2,000 characters. | | `documents` | array of objects | Yes | 1–10 items. | | `documents[].document_id` | string (uuid) | Yes | — | | `documents[].draft_revision` | integer | Yes | At least 1. | | `respondent` | object | Yes | — | | `respondent.name` | string | Yes | 1–200 characters. | | `respondent.email` | string (email) | Yes | Up to 255 characters. | | `expires_in_days` | integer | No | 1–60. Default `14`. | | `after` | `review` or `sign` | No | After they answer: the sender reviews first, or the respondent goes straight on to sign (give `signing`). Default `review`. | | `signing` | object | No | — | | `signing.respondent_role` | string | Yes | Matches `^[a-z0-9][a-z0-9-]{0,39}$`. | | `signing.recipients` | array of objects | No | Up to 19 items. | | `signing.recipients[].role_key` | string | Yes | The role this person signs as, matching the documents' roles. Matches `^[a-z0-9][a-z0-9-]{0,39}$`. | | `signing.recipients[].name` | string | Yes | 1–200 characters. | | `signing.recipients[].email` | string (email) | Yes | Up to 255 characters. | | `signing.routing` | `sequential` or `parallel` | No | Default `sequential`. | | `signing.expires_in_days` | integer | No | 1–365. Default `30`. | ## Responses | Status | Meaning | | --- | --- | | `201` | Created and emailed to the respondent. | | `202` | Waits for a person's approval. | | `400` | The input is invalid. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `402` | The workspace's plan doesn't allow it: no completed requests left this month, more members than sender seats, more signers than the plan allows, a document that must be bought first, or the workspace's saved templates are at its plan's limit. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `409` | The request is in a state that doesn't allow this, or the Idempotency-Key was used for something else. | | `422` | Valid, but it can't go ahead: an address outside the key's recipient domains or one that can't receive email, or a signing role without a recipient. | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `201` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `title` | string or null | — | | `message` | string or null | — | | `status` | `open` or `submitted` or `voided` or `expired` | — | | `after` | `review` or `sign` | — | | `respondent` | object | — | | `respondent.name` | string | — | | `respondent.email` | string | — | | `document_ids` | array of string (uuid) | — | | `signing_request_id` | string or null | — | | `submitted_at` | string (date-time) or null | — | | `expires_at` | string (date-time) or null | — | | `created_at` | string (date-time) or null | — | | `answers` | object or null | — | **Example** ```json { "id": "e7b2d4f6-8a1c-4e3b-9d5f-7a2c4e6b8d1f", "title": "Your details for the NDA", "message": null, "status": "submitted", "after": "review", "respondent": { "name": "Ada Lovelace", "email": "ada@example.com" }, "document_ids": [ "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b" ], "signing_request_id": null, "submitted_at": "2026-11-04T10:02:31.000Z", "expires_at": "2026-11-17T16:20:00.000Z", "created_at": "2026-11-03T16:20:00.000Z", "answers": { "client_name": "Acme Inc.", "effective_date": "2026-11-03" } } ``` ### `202` body | Field | Type | Description | | --- | --- | --- | | `status` | `awaiting_approval` | — | | `approval` | object | — | | `approval.id` | string (uuid) | — | | `approval.kind` | `signing.send` or `intake.create` | — | | `approval.status` | `pending` or `approved` or `declined` or `expired` or `failed` | — | | `approval.title` | string | — | | `approval.addresses` | array of string | — | | `approval.credential` | string or null | — | | `approval.signing_request_id` | string or null | — | | `approval.intake_request_id` | string or null | — | | `approval.decided_by` | string or null | — | | `approval.decided_at` | string (date-time) or null | — | | `approval.result` | string or null | — | | `approval.expires_at` | string (date-time) | — | | `approval.created_at` | string (date-time) | — | **Example** ```json { "status": "awaiting_approval", "approval": { "id": "a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", "kind": "signing.send", "status": "approved", "title": "Mutual NDA — Acme", "addresses": [ "ada@example.com" ], "credential": "Sales agent", "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "intake_request_id": null, "decided_by": "Olga Owner", "decided_at": "2026-11-03T16:41:10.000Z", "result": null, "expires_at": "2026-11-10T16:20:01.000Z", "created_at": "2026-11-03T16:20:01.000Z" } } ``` ## Examples ```bash [curl] curl -X POST https://tryjustdocs.com/api/v1/intake-requests \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" \ -H "Idempotency-Key: deal-4821-create-intake-request" \ -H "Content-Type: application/json" \ -d '{ "documents": [ { "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", "draft_revision": 1 } ], "respondent": { "name": "Ada Lovelace", "email": "ada@example.com" }, "after": "review" }' ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/intake-requests", { method: "POST", headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, "Idempotency-Key": "deal-4821-create-intake-request", "Content-Type": "application/json", }, body: JSON.stringify({ documents: [ { document_id: "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", draft_revision: 1 } ], respondent: { name: "Ada Lovelace", email: "ada@example.com" }, after: "review" }), }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.post( "https://tryjustdocs.com/api/v1/intake-requests", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}", "Idempotency-Key": "deal-4821-create-intake-request"}, json={ "documents": [ { "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", "draft_revision": 1, }, ], "respondent": { "name": "Ada Lovelace", "email": "ada@example.com", }, "after": "review", }, timeout=30, ) response.raise_for_status() result = response.json() ``` # Get an intake link GET `/api/v1/intake-requests/{id}` With the answers, once submitted. Needs the `read` permission. | | | | --- | --- | | Permission | `read` | | MCP tool | [`get_intake_request`](https://tryjustdocs.com/docs/mcp/tools#get_intake_request) | ## Request ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | ## Responses | Status | Meaning | | --- | --- | | `200` | The intake link. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `200` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `title` | string or null | — | | `message` | string or null | — | | `status` | `open` or `submitted` or `voided` or `expired` | — | | `after` | `review` or `sign` | — | | `respondent` | object | — | | `respondent.name` | string | — | | `respondent.email` | string | — | | `document_ids` | array of string (uuid) | — | | `signing_request_id` | string or null | — | | `submitted_at` | string (date-time) or null | — | | `expires_at` | string (date-time) or null | — | | `created_at` | string (date-time) or null | — | | `answers` | object or null | — | **Example** ```json { "id": "e7b2d4f6-8a1c-4e3b-9d5f-7a2c4e6b8d1f", "title": "Your details for the NDA", "message": null, "status": "submitted", "after": "review", "respondent": { "name": "Ada Lovelace", "email": "ada@example.com" }, "document_ids": [ "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b" ], "signing_request_id": null, "submitted_at": "2026-11-04T10:02:31.000Z", "expires_at": "2026-11-17T16:20:00.000Z", "created_at": "2026-11-03T16:20:00.000Z", "answers": { "client_name": "Acme Inc.", "effective_date": "2026-11-03" } } ``` ## Examples ```bash [curl] curl https://tryjustdocs.com/api/v1/intake-requests/e7b2d4f6-8a1c-4e3b-9d5f-7a2c4e6b8d1f \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/intake-requests/e7b2d4f6-8a1c-4e3b-9d5f-7a2c4e6b8d1f", { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, }, }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.get( "https://tryjustdocs.com/api/v1/intake-requests/e7b2d4f6-8a1c-4e3b-9d5f-7a2c4e6b8d1f", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"}, timeout=30, ) response.raise_for_status() result = response.json() ``` # Get an approval this key asked for GET `/api/v1/approvals/{id}` Needs the `read` permission. | | | | --- | --- | | Permission | `read` | | MCP tool | [`get_approval`](https://tryjustdocs.com/docs/mcp/tools#get_approval) | ## Request ### Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | ## Responses | Status | Meaning | | --- | --- | | `200` | The approval. | | `401` | No key, or a key that is unknown, expired or revoked, or whose creator is no longer an owner or admin. | | `403` | The key lacks the permission, or it is an API key and the workspace isn't on Teams & Platform. | | `404` | Not found in the key's workspace (a key works only in the workspace it was made in; `get_workspace` says which). | | `429` | More than 120 calls a minute through this key, more than 2000 from one network address (for IPv6, its /56 block) across all keys, or a limit on sending reached: the key's daily sends, the plan's or a recipient's daily requests, or the workspace's waiting approvals. | Errors share one shape, `{ "message": "…" }`: see [Errors and rate limits](https://tryjustdocs.com/docs/api/errors). ### `200` body | Field | Type | Description | | --- | --- | --- | | `id` | string (uuid) | — | | `kind` | `signing.send` or `intake.create` | — | | `status` | `pending` or `approved` or `declined` or `expired` or `failed` | — | | `title` | string | — | | `addresses` | array of string | — | | `credential` | string or null | — | | `signing_request_id` | string or null | — | | `intake_request_id` | string or null | — | | `decided_by` | string or null | — | | `decided_at` | string (date-time) or null | — | | `result` | string or null | — | | `expires_at` | string (date-time) | — | | `created_at` | string (date-time) | — | **Example** ```json { "id": "a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", "kind": "signing.send", "status": "approved", "title": "Mutual NDA — Acme", "addresses": [ "ada@example.com" ], "credential": "Sales agent", "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "intake_request_id": null, "decided_by": "Olga Owner", "decided_at": "2026-11-03T16:41:10.000Z", "result": null, "expires_at": "2026-11-10T16:20:01.000Z", "created_at": "2026-11-03T16:20:01.000Z" } ``` ## Examples ```bash [curl] curl https://tryjustdocs.com/api/v1/approvals/a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" ``` ```js [Node.js] const response = await fetch("https://tryjustdocs.com/api/v1/approvals/a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}`, }, }); if (!response.ok) throw new Error((await response.json()).message); const result = await response.json(); ``` ```python [Python] import os import requests response = requests.get( "https://tryjustdocs.com/api/v1/approvals/a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", headers={"Authorization": f"Bearer {os.environ['JUSTDOCS_API_KEY']}"}, timeout=30, ) response.raise_for_status() result = response.json() ``` # Webhooks Webhooks tell your systems when something happens, so you don't have to poll. A request is sent, viewed, signed, completed, declined, voided or expires. An intake link is answered. An agent asks to send something, or a person decides. ## Add an endpoint 1. Open your team, then **Webhooks**, then **Add endpoint**. Only owners and admins can manage webhooks, on Teams & Platform. 2. Give a public `https://` URL that you control. 3. Choose the [events](https://tryjustdocs.com/docs/webhooks/events) it receives. 4. Copy the **signing secret**. It starts with `whsec_` and is shown once. You need it to [verify](https://tryjustdocs.com/docs/webhooks/verify) deliveries. **Send a test** delivers a `ping` so you can check your endpoint. **Deliveries** lists the latest attempts with their status codes and errors. ## What a delivery looks like ```http POST /justdocs HTTP/1.1 Content-Type: application/json User-Agent: JustDocs-Webhooks/1 JustDocs-Signature: t=1793722800,v1=6c1f…e9 JustDocs-Event-Id: evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "request.completed", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "status": "completed" } } ``` Events carry identifiers and statuses, never document content, signatures or anyone's answers. When you need more, fetch the record through the [API](https://tryjustdocs.com/docs/api/reference/) with your key. That also means a leaked delivery reveals very little. ## Your endpoint's job 1. **[Verify the signature](https://tryjustdocs.com/docs/webhooks/verify)** against the raw body before you trust anything in it. 2. **Answer `2xx` quickly, once the event is safely queued or stored.** Any status from 200 to 299 counts as delivered. Anything else, a redirect, or no answer within 10 seconds counts as a failure, and the delivery is [retried](https://tryjustdocs.com/docs/webhooks/retries). So if you can't keep an event, answer with an error. 3. **Do the work afterwards**, on a queue, so a slow job doesn't cause a retry. 4. **Drop duplicates** by the event `id` when you do the work, not when the event arrives. A retry has the same `id`, and so does the same event on each of your endpoints. Record the `id` in the same transaction as the work it causes, so an event whose work failed isn't dropped later as a duplicate. 5. **Don't rely on order.** Retries can arrive after later events. Use `created_at`, or fetch the record's current state. ## Rules for endpoints - HTTPS only, on the public internet. Addresses that resolve to private or reserved networks are refused, both when you add the endpoint and on every delivery. - Redirects aren't followed. - A workspace can have a limited number of endpoints. See [Plans and limits](https://tryjustdocs.com/docs/get-started/plans-and-limits). - Webhooks are part of Teams & Platform. A workspace that leaves the plan stops getting them, and gets them again if it comes back. ## Next - [Events](https://tryjustdocs.com/docs/webhooks/events) Every event and its payload. - [Verify signatures](https://tryjustdocs.com/docs/webhooks/verify) Node.js, Python and Go. - [Retries and pausing](https://tryjustdocs.com/docs/webhooks/retries) The schedule, and what happens when an endpoint keeps failing. - [Track completion](https://tryjustdocs.com/docs/guides/track-with-webhooks) A complete receiver, start to finish. # Events JustDocs sends 10 events. Each endpoint chooses the ones it receives. Events carry identifiers and statuses, never document content or anyone's answers: fetch the record through the [API](https://tryjustdocs.com/docs/api/reference/) when you need more. | Event | When | | --- | --- | | [`request.sent`](#request-sent) | A signing request was sent: invitations to the first recipients (or all of them, with parallel routing) are on their way, and may arrive just after this event. With an approval, this is when an owner or admin approved it. | | [`request.viewed`](#request-viewed) | A recipient opened their signing link for the first time. | | [`request.signed`](#request-signed) | A recipient finished signing. The request completes separately, once everyone has signed and the completed copy is sealed. | | [`request.completed`](#request-completed) | Everyone signed and the completed copy is ready. Fetch the request for its documents. | | [`request.declined`](#request-declined) | A recipient declined to sign, or reported the request as unwanted. The request stops. | | [`request.voided`](#request-voided) | The request was voided in the app or through a key before it completed. Voiding never waits for approval. | | [`request.expired`](#request-expired) | The request passed its expiry date before everyone signed. | | [`intake.submitted`](#intake-submitted) | The respondent submitted an intake link's form, and their answers filled the documents. | | [`approval.requested`](#approval-requested) | A key asked to send something that waits for a person: a signing request, or an intake link. | | [`approval.decided`](#approval-decided) | An approval was decided: approved, declined, expired (after 7 days, or because the request was sent in the app instead), or failed (approved, but the send itself didn't go through). A 7-day expiry is noticed within ten minutes. | ## The envelope Every delivery is a `POST` with a JSON body in the same envelope: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | The event's id, `evt_…`. The same on every endpoint and every retry, so you can drop duplicates. | | `type` | string | Yes | The event, such as `request.completed`. | | `created_at` | string (date-time) | Yes | When it happened. | | `workspace_id` | string (uuid) | Yes | The team workspace. | | `data` | object | Yes | What happened, below. | With these headers: | Header | Value | | --- | --- | | `JustDocs-Signature` | `t=,v1=`. See [Verify signatures](https://tryjustdocs.com/docs/webhooks/verify). | | `JustDocs-Event-Id` | The envelope's `id`. | | `Content-Type` | `application/json` | | `User-Agent` | `JustDocs-Webhooks/1` | ## Signing requests `data` carries: | Field | Type | Required | Description | | --- | --- | --- | --- | | `signing_request_id` | string (uuid) | Yes | The signing request. | | `status` | string | Yes | The request's status just after the event: `sent`, `completed`, `declined`, `voided` or `expired`. | | `recipient.id` | string (uuid) | No | The recipient it was about, on `request.viewed`, `request.signed` and `request.declined`. | | `recipient.role` | string | No | Their role key. | ### `request.sent` A signing request was sent: invitations to the first recipients (or all of them, with parallel routing) are on their way, and may arrive just after this event. With an approval, this is when an owner or admin approved it. ```json { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "request.sent", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "status": "sent" } } ``` ### `request.viewed` A recipient opened their signing link for the first time. ```json { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "request.viewed", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "status": "sent", "recipient": { "id": "b6d8f1a3-5c7e-4d9b-a2f4-6c8e1b3d5f7a", "role": "client" } } } ``` ### `request.signed` A recipient finished signing. The request completes separately, once everyone has signed and the completed copy is sealed. ```json { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "request.signed", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "status": "sent", "recipient": { "id": "b6d8f1a3-5c7e-4d9b-a2f4-6c8e1b3d5f7a", "role": "client" } } } ``` ### `request.completed` Everyone signed and the completed copy is ready. Fetch the request for its documents. ```json { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "request.completed", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "status": "completed" } } ``` ### `request.declined` A recipient declined to sign, or reported the request as unwanted. The request stops. ```json { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "request.declined", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "status": "declined", "recipient": { "id": "b6d8f1a3-5c7e-4d9b-a2f4-6c8e1b3d5f7a", "role": "client" } } } ``` ### `request.voided` The request was voided in the app or through a key before it completed. Voiding never waits for approval. ```json { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "request.voided", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "status": "voided" } } ``` ### `request.expired` The request passed its expiry date before everyone signed. ```json { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "request.expired", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "status": "expired" } } ``` ## Intake links `data` carries: | Field | Type | Required | Description | | --- | --- | --- | --- | | `intake_request_id` | string (uuid) | Yes | The intake link. | | `status` | string | Yes | `submitted`. | ### `intake.submitted` The respondent submitted an intake link's form, and their answers filled the documents. ```json { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "intake.submitted", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "intake_request_id": "e7b2d4f6-8a1c-4e3b-9d5f-7a2c4e6b8d1f", "status": "submitted" } } ``` ## Approvals `data` carries: | Field | Type | Required | Description | | --- | --- | --- | --- | | `approval_id` | string (uuid) | Yes | The approval. | | `kind` | string | Yes | `signing.send` or `intake.create`. | | `status` | string | Yes | `pending` on `approval.requested`; then `approved`, `declined`, `expired` or `failed`. | | `signing_request_id` | string (uuid) or null | Yes | The signing request, for `signing.send`. | | `intake_request_id` | string (uuid) or null | Yes | The intake link, for `intake.create` once it exists. | | `credential_id` | string (uuid) | Yes | The key that asked. | | `reason` | string | No | Why it `failed`, or why it `expired` when the request was sent another way. | ### `approval.requested` A key asked to send something that waits for a person: a signing request, or an intake link. ```json { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "approval.requested", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "approval_id": "a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", "kind": "signing.send", "status": "pending", "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "intake_request_id": null, "credential_id": "d2f4b6a8-3c5e-4f7a-b9d1-2e4c6a8b1d3f" } } ``` ### `approval.decided` An approval was decided: approved, declined, expired (after 7 days, or because the request was sent in the app instead), or failed (approved, but the send itself didn't go through). A 7-day expiry is noticed within ten minutes. ```json { "id": "evt_3b8e1f7c-2a4d-4c6e-9f1b-5d7a9c2e4b6f", "type": "approval.decided", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "approval_id": "a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", "kind": "signing.send", "status": "approved", "signing_request_id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "intake_request_id": null, "credential_id": "d2f4b6a8-3c5e-4f7a-b9d1-2e4c6a8b1d3f" } } ``` ## Test events **Send a test** on an endpoint, on the team's **Webhooks** page, delivers a `ping` to that endpoint alone, signed like any other event: ```json { "id": "evt_7d9f2b4a-6c8e-4a1d-b3f5-7e9a2c4d6f8b", "type": "ping", "created_at": "2026-11-03T16:20:00.000Z", "workspace_id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "data": { "message": "A test event from JustDocs" } } ``` # Verify signatures Anyone can send a request to your endpoint. Before you trust a delivery, check that it came from JustDocs and wasn't changed or replayed. Every delivery has a header like this: ```http JustDocs-Signature: t=1793722800,v1=6c1f0a…e9 ``` - `t` is when it was signed, in Unix seconds. - `v1` is the hex HMAC-SHA256 of `.`, keyed with your endpoint's whole signing secret, including `whsec_`. ## The check 1. Read the **raw body** as bytes, before any JSON parsing. Parsing and re-serializing changes it, and the signature won't match. 2. Split the header into `t` and `v1`. 3. Compute `HMAC-SHA256(secret, t + "." + body)` as lowercase hex. 4. Compare it to `v1` in **constant time**. 5. Reject it if `t` is more than 5 minutes from your clock. That stops an old delivery being replayed. ## Examples Each example answers `204` only once the event is on a durable queue. If it can't be queued, the endpoint answers with an error and JustDocs delivers it again. A `2xx` is final: an event acknowledged and then lost, for example by a crash before its work starts, is never sent again. ```js [Node.js (Express)] import crypto from "node:crypto"; import express from "express"; const SECRET = process.env.JUSTDOCS_WEBHOOK_SECRET; // whsec_… const TOLERANCE_SECONDS = 300; export function verify(header, rawBody, secret = SECRET, now = Date.now() / 1000) { const parts = Object.fromEntries((header ?? "").split(",").map((part) => part.split("=", 2))); const timestamp = Number(parts.t); if (!parts.v1 || !Number.isFinite(timestamp) || Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false; const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex"); const given = Buffer.from(parts.v1, "hex"); return given.length === expected.length / 2 && crypto.timingSafeEqual(given, Buffer.from(expected, "hex")); } const app = express(); // The raw body, not express.json(): the signature covers the exact bytes. app.post("/justdocs", express.raw({ type: "application/json" }), async (req, res) => { if (!verify(req.get("JustDocs-Signature"), req.body.toString("utf8"))) return res.sendStatus(400); const event = JSON.parse(req.body); try { await queue.add(event); // a durable queue; the work happens there } catch { return res.sendStatus(500); // not queued: JustDocs delivers it again } res.sendStatus(204); }); ``` ```python [Python (Flask)] import hashlib import hmac import json import os import time from flask import Flask, request SECRET = os.environ["JUSTDOCS_WEBHOOK_SECRET"].encode() # whsec_… TOLERANCE_SECONDS = 300 def verify(header: str | None, raw_body: bytes, secret: bytes = SECRET) -> bool: parts = dict(part.split("=", 1) for part in (header or "").split(",") if "=" in part) try: timestamp = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - timestamp) > TOLERANCE_SECONDS: return False expected = hmac.new(secret, f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) app = Flask(__name__) @app.post("/justdocs") def justdocs(): raw = request.get_data() # the exact bytes if not verify(request.headers.get("JustDocs-Signature"), raw): return "", 400 event = json.loads(raw) enqueue(event) # a durable queue; if this raises, Flask answers 500 and JustDocs delivers it again return "", 204 ``` ```go [Go] package webhooks import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "math" "net/http" "os" "strconv" "strings" "time" ) const tolerance = 300 // seconds func Verify(header string, body []byte, secret string, now time.Time) bool { var t, v1 string for _, part := range strings.Split(header, ",") { key, value, _ := strings.Cut(part, "=") switch key { case "t": t = value case "v1": v1 = value } } timestamp, err := strconv.ParseInt(t, 10, 64) if err != nil || v1 == "" || math.Abs(float64(now.Unix()-timestamp)) > tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(body) given, err := hex.DecodeString(v1) return err == nil && hmac.Equal(given, mac.Sum(nil)) } func Handler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r, 1<<20)) if err != nil || !Verify(r.Header.Get("JustDocs-Signature"), body, os.Getenv("JUSTDOCS_WEBHOOK_SECRET"), time.Now()) { w.WriteHeader(http.StatusBadRequest) return } // A durable queue, not a goroutine: work held only in this process is lost if it stops. if err := enqueue(r.Context(), body); err != nil { w.WriteHeader(http.StatusInternalServerError) // not queued: JustDocs delivers it again return } w.WriteHeader(http.StatusNoContent) } ``` ## Test your check Run your check against this delivery, signed by the same code that signs real ones. Pin your clock to the timestamp, or turn off the age check for this test: | | | | --- | --- | | Secret | `whsec_test` | | Timestamp | `1700000000` | | Body | the line below, exactly | | `JustDocs-Signature` | `t=1700000000,v1=c3dda4885510629413595eb52b3a6af2475f6abe7b73e77757ba1531afa56e0c` | ```json {"id":"evt_test","type":"ping","created_at":"2023-11-14T22:13:20.000Z","workspace_id":"f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e","data":{"message":"A test event from JustDocs"}} ``` Your check should accept it. It should reject it once you change one character of the body, and reject it again when your clock is more than five minutes from the timestamp. Use **Send a test** on the Webhooks page to check the real thing end to end. ## Rotating the secret **New secret** on an endpoint makes a new secret and shows it once. Deliveries from then on are signed with it, including retries of earlier events. Update your endpoint as soon as you rotate. Deliveries signed with the new secret fail your check until you do, and are then retried. To rotate with no gap, have your check accept either secret for a few minutes. # Retries and pausing ## Retries A delivery fails when your endpoint answers outside `200`–`299`, redirects, or doesn't answer within the timeout. JustDocs then tries again on a schedule. Each retry waits longer than the last: | Attempt | Waits | After the event | | --- | --- | --- | | 1 | — | At once | | 2 | 1 min | 1 min | | 3 | 5 min | 6 min | | 4 | 30 min | 36 min | | 5 | 2 h | 2 h 36 min | | 6 | 6 h | 8 h 36 min | | 7 | 12 h | 20 h 36 min | Each attempt waits up to 10 seconds for an answer. An endpoint pauses after 20 failed attempts in a row. After the last attempt, the delivery is marked `failed` and isn't tried again. The attempts cover about 20 hours, so an endpoint that's down for an evening catches up without losing events. Every attempt has the same event `id` and `JustDocs-Event-Id`, and a new `t` in its signature. ## Pausing If an endpoint fails many attempts in a row, across all its events, JustDocs **pauses** it: - Its waiting deliveries are marked `failed`, and new events aren't queued for it. - The person who added it gets an email, or the team's owner if that person is no longer an owner or admin. It names the endpoint and the last problem. - The endpoint shows **Paused** on the Webhooks page. Fix the endpoint, then choose **Resume**. Deliveries start again with the next event. Events from while it was paused aren't re-sent. To catch up, [list signing requests](https://tryjustdocs.com/docs/api/reference/list-signing-requests) and fetch what changed. A success at any point resets the count of failures. ## Seeing what happened **Deliveries** on each endpoint lists the latest deliveries: the event, its status (`pending`, `delivered` or `failed`), how many attempts it took, and the last status code or error. For example: | Error | Meaning | | --- | --- | | `HTTP 500` | Your endpoint answered with an error. | | `Redirected (HTTP 301); redirects aren't followed` | Give the final URL instead. | | `timed out after 10 seconds` | Answer sooner, and do the work afterwards. | | `refused: … resolves to a private address` | The URL must resolve to a public address. | # Create a template Agents and your software start documents from the workspace's templates. When there isn't a suitable one, `create_template` makes it, from text you write or as a copy of a library entry. It needs the `documents` permission. It emails no one, so it never waits for approval. Sending a document started from it waits for approval, as every send by an agent key does. People can make templates in the app as well: - **From the library.** Open **Library**, choose an entry, then **Save as template**. Its fill-ins stay blank, to fill in each time. (Choosing **Create document** and then **Save as template** on the document's page also works, but it leaves the draft document behind, which you can delete.) - **In the editor.** Write a document with fill-ins and signing fields, then save it as a template. - **From a PDF.** Upload it, place its fields, and keep it as a template. ## 1. Check the workspace A key works only in the workspace it was made in. `get_workspace` says which, with its plan, how many templates it keeps, and whether the key's sends wait for approval: ```bash [REST] curl https://tryjustdocs.com/api/v1/workspace \ -H "Authorization: Bearer $JUSTDOCS_AGENT_KEY" ``` ```text [MCP] get_workspace {} ``` ```json { "id": "f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e", "name": "Northwind", "personal": false, "plan": "teams", "plan_name": "Teams & Platform", "key": { "name": "Sales agent", "kind": "agent", "kind_label": "Agent key", "sends_need_approval": true, "sends_label": "Sends wait for approval" }, "templates": { "saved": 4, "limit": null } } ``` A personal workspace is named `"Personal"`. If `list_templates` comes back empty, or a template id you were given isn't found, compare `name` with the workspace where the template was made. The full response is in the [reference](https://tryjustdocs.com/docs/api/reference/get-workspace). ## 2. Write the text The text is plain, in blocks separated by a blank line: | Write | For | | --- | --- | | `# Title` | A heading. `##` and `###` make smaller ones. A heading is a block of its own. | | `- item` or `1. item` | A list. Every line of the block starts the same way. | | `---` | A horizontal rule. | | Anything else | A paragraph. Its lines are kept as line breaks. | Inside the text: | Write | For | | --- | --- | | `**words**` | Bold. | | `{{client_name}}` | A fill-in, completed for each document. Its label is made from its key: _Client name_. | | `{{client_name\|Client's legal name}}` | A fill-in with its own label. | | `[[signature:client]]` | A signing field for the role `client`. | | `[[text:client\|Job title]]` | A signing field with its own label. | | `[[initials:client?]]` | An optional field. A field is optional when its role ends in `?`, before any label. | | `[[text:client?\|Job title]]` | An optional field with its own label. | A fill-in's key is lowercase letters, numbers and `_`, starting with a letter, up to 40 characters. A field's type is `signature`, `initials`, `date_signed`, `full_name`, `text` or `checkbox`, and its role is lowercase letters, numbers and `-`, up to 40 characters. A fill-in used more than once is one fill-in, completed once. Text left looking like a fill-in or a field is refused with its line rather than saved as words. For example, `{{Client Name}}` on line 3 gets: ```text Line 3: "{{Client Name}}" isn't a fill-in: write {{key}} or {{key|Label}}, key in lowercase letters, numbers and _, label up to 100 characters ``` For example: ```text # Consulting agreement This agreement is between Northwind Ltd and {{client_name|Client's legal name}}, starting on {{start_date}}. 1. Northwind provides the services described in {{scope}}. 2. The client pays {{fee}} a month, within 30 days of each invoice. 3. Either party may end this agreement with 30 days' notice. **For the client** Signature: [[signature:client]] Name: [[full_name:client]] Date: [[date_signed:client]] ``` ## 3. Say who signs A template made through the API has at least one signing role, and at most 20. By default, the roles are the ones the text's fields use, in the order they first appear. List them in `roles` to add signers without writing their fields. A listed role with no field in the text gets a block at the end, after one rule. For the role `provider`, that is: ```text --- **Provider** Signature: [[signature:provider]] Name: [[full_name:provider]] Date: [[date_signed:provider]] ``` With `roles`, a field for a role that isn't listed is refused, so a typo such as `[[signature:cleint]]` is caught. Each role needs at least one required field, so no one can sign without leaving a mark. A role can have up to 500 fields. ## 4. Label the fill-ins `fill_ins` gives labels by key, such as `{ "key": "fee", "label": "Monthly fee" }`. A fill-in takes its label from `fill_ins` first, then from the first label written in the text, then from its key. Each key in `fill_ins` must appear in the text. ## 5. Create it ```bash [REST] curl -X POST https://tryjustdocs.com/api/v1/templates \ -H "Authorization: Bearer $JUSTDOCS_AGENT_KEY" \ -H "Idempotency-Key: consulting-template-v1" \ -H "Content-Type: application/json" \ -d @template.json ``` ```text [MCP] create_template { "title": "Consulting agreement", "content": "# Consulting agreement\n\nThis agreement is between Northwind Ltd and {{client_name|Client's legal name}}, starting on {{start_date}}.\n\n1. Northwind provides the services described in {{scope}}.\n2. The client pays {{fee}} a month, within 30 days of each invoice.\n3. Either party may end this agreement with 30 days' notice.\n\n**For the client**\nSignature: [[signature:client]]\nName: [[full_name:client]]\nDate: [[date_signed:client]]", "fill_ins": [{ "key": "fee", "label": "Monthly fee" }], "roles": ["client", "provider"], "idempotency_key": "consulting-template-v1" } ``` For REST, `template.json` holds the same fields as the MCP call, without `idempotency_key`. The answer is `201` with the new template: its `id`, `fill_ins`, `roles` (here `client` and `provider`, whose block was added at the end) and `form`, the questions that answer its fill-ins. In a team, the template is shared with the team for use, as one saved in the app is. A retry with the same idempotency key returns the same template. A mistake in the text is refused before the key is used, so you can fix it and retry with the same key. ## Copy a library entry Give `library_entry` instead of `content`, with an optional `title` (the entry's own name by default): ```bash [REST] curl -X POST https://tryjustdocs.com/api/v1/templates \ -H "Authorization: Bearer $JUSTDOCS_AGENT_KEY" \ -H "Idempotency-Key: mutual-nda-template" \ -H "Content-Type: application/json" \ -d '{ "library_entry": "mutual-nda" }' ``` ```text [MCP] create_template { "library_entry": "mutual-nda", "idempotency_key": "mutual-nda-template" } ``` The entry brings its own text, fill-ins and roles, so `content`, `fill_ins` and `roles` aren't taken with it. The written documents and official forms in the library can be copied, the W-9 (`w9-request`) included; the reference for [`create_template`](https://tryjustdocs.com/docs/api/reference/create-template) lists every slug. The paid company and equity documents, which the legal questionnaire drafts, can't be saved as templates. Nor can the questionnaire itself, packets (save their entries one at a time), _Your Own Document_ (upload the PDF in the app instead), or entries that aren't available yet. ## Then use it Start a document from it with `start_document`, then ask to send it with `create_signing_request`. See [Send an agreement with approval](https://tryjustdocs.com/docs/guides/send-with-approval). There is no update or delete through the API. A person can edit or delete the template in the app. ## Limits and errors | | | | --- | --- | | Title | 1 to 255 characters. Needed with `content`. | | Text | Up to 500,000 characters, in a body of up to 2 MB. | | Fill-ins | Up to 200. | | Labels | Up to 100 characters, for a fill-in or a field. | | Roles | 1 to 20, each with up to 500 fields. | | Saved templates | The plan's limit. See [Plans and limits](https://tryjustdocs.com/docs/get-started/plans-and-limits). | | Status | When | | --- | --- | | `400` | The text has a mistake, named with its line; no one signs it; a role's fields are all optional; a field's role isn't in `roles`; a key in `fill_ins` isn't in the text; or the library entry can't be saved as a template. | | `402` | The workspace keeps as many saved templates as its plan allows. Delete one you no longer use in the app, or upgrade the plan. | | `403` | The key doesn't have the `documents` permission. | | `404` | There's no library entry with that slug. | | `409` | The library entry isn't available yet, or the idempotency key was used for something else. | | `413` | The body is larger than 2 MB, or the template is too long to save. | # Send an agreement with approval This guide follows one agreement, a mutual NDA for Acme, from template to signed copy. It uses an **agent key**, so a person approves the send. The calls are the same through the REST API and the MCP server. Each step shows both. You need a workspace on any plan, a template with a signing role (here `client`), and an agent key with the `read`, `documents` and `send` permissions. The key and the template must be in the same workspace: `get_workspace` says which one the key is in. No template yet? Save one from the library with **Save as template**, or have the agent make one with `create_template`, from text or a library entry. See [Create a template](https://tryjustdocs.com/docs/guides/create-a-template). The steps below assume a template that signs as `client`, with the fill-ins `client_name` and `effective_date`. With another one, use the `roles` and `fill_ins` that `get_template` returns: a request needs a recipient for each role. The library's Mutual NDA, for example, signs as `company` and `counterparty`. ## 1. Find the template ```bash [REST] curl https://tryjustdocs.com/api/v1/templates \ -H "Authorization: Bearer $JUSTDOCS_AGENT_KEY" ``` ```text [MCP] list_templates {} ``` Pick the template by `title`, then fetch it for its fill-ins and the questions that answer them: ```bash [REST] curl https://tryjustdocs.com/api/v1/templates/5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d \ -H "Authorization: Bearer $JUSTDOCS_AGENT_KEY" ``` ```text [MCP] get_template { "template_id": "5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d" } ``` `fill_ins` lists each blank by `key`, such as `client_name` and `effective_date`. `roles` lists who signs, such as `client`: the request needs a recipient for each. `form` holds the template's questions, if it has them. ## 2. Start the document Give the fill-in values by key with `merge_values`, or answer the template's questions with `answers` and let JustDocs fill the blanks. Use one or the other, not both. ```bash [REST] curl -X POST https://tryjustdocs.com/api/v1/documents \ -H "Authorization: Bearer $JUSTDOCS_AGENT_KEY" \ -H "Idempotency-Key: deal-4821-nda-doc" \ -H "Content-Type: application/json" \ -d '{ "template_id": "5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", "title": "Mutual NDA — Acme", "merge_values": { "client_name": "Acme Inc.", "effective_date": "November 3, 2026" } }' ``` ```text [MCP] start_document { "template_id": "5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", "title": "Mutual NDA — Acme", "merge_values": { "client_name": "Acme Inc.", "effective_date": "November 3, 2026" }, "idempotency_key": "deal-4821-nda-doc" } ``` The response is the new document, with its `id`. ## 3. Ask to send it Create the signing request with a recipient for each role in the document, and `send: true`: ```bash [REST] curl -X POST https://tryjustdocs.com/api/v1/signing-requests \ -H "Authorization: Bearer $JUSTDOCS_AGENT_KEY" \ -H "Idempotency-Key: deal-4821-nda-send" \ -H "Content-Type: application/json" \ -d '{ "title": "Mutual NDA — Acme", "message": "Please review and sign.", "documents": [{ "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b" }], "recipients": [{ "role_key": "client", "name": "Ada Lovelace", "email": "ada@example.com" }], "send": true }' ``` ```text [MCP] create_signing_request { "title": "Mutual NDA — Acme", "message": "Please review and sign.", "documents": [{ "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b" }], "recipients": [{ "role_key": "client", "name": "Ada Lovelace", "email": "ada@example.com" }], "send": true, "idempotency_key": "deal-4821-nda-send" } ``` Because the key is an agent key, nothing is emailed yet. The REST API answers `202 Accepted`. The request is a `draft`, and its `approval` is `pending`: ```json { "id": "c3e9a7b1-2d4f-4a6c-8e1b-5f7d9a2c4e6b", "status": "draft", "approval": { "id": "a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e", "kind": "signing.send", "status": "pending", "addresses": ["ada@example.com"], "expires_at": "2026-11-10T16:20:01.000Z" } } ``` The MCP tool returns the same, after a line telling the agent that nothing has been sent and that it should check `get_approval`. With more than one signer, `routing` decides the order. `sequential` (the default) emails each `routing_order` in turn. `parallel` emails everyone at once. ## 4. A person approves The workspace's owner, or a team's owners and admins, see the request on the workspace's **Approvals** page, with its title, every address, and the key that asked. **Home** lists it too, with a link there. **Approve** sends it at once, in the name of the person who made the key. The key and its limits are checked again first. **Decline** sends nothing. On Teams & Platform, a webhook for `approval.requested` can alert the approvers in your own tools, such as chat or a ticket. ## 5. Follow it Poll the approval or, on Teams & Platform, listen for webhooks: ```bash [REST] curl https://tryjustdocs.com/api/v1/approvals/a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e \ -H "Authorization: Bearer $JUSTDOCS_AGENT_KEY" ``` ```text [MCP] get_approval { "approval_id": "a4c6e8b2-1d3f-4b5a-8c7e-9f1b3d5a7c2e" } ``` | Event | Means | | --- | --- | | `approval.decided` with `status: "approved"` | Approved, and the request was sent. | | `request.sent` | The request is sent, and the first recipients' invitations are on their way. | | `request.viewed`, `request.signed` | Each recipient's progress. | | `request.completed` | Everyone signed. The completed copy is ready. | | `approval.decided` with `declined`, `expired` or `failed` | Nothing was sent through the approval. `reason` says why it failed, or that it expired because someone sent the request from the app. | `get_signing_request` shows each recipient's `status` and the latest email `delivery`. When the request is `completed`, each document has `completed_copy: true`, and the signed copies are in JustDocs. ## If something changes - **Wrong recipient?** Void it with [`void_signing_request`](https://tryjustdocs.com/docs/api/reference/void-signing-request) and a reason. Voiding takes effect at once, without approval, and recipients who were invited are told. Then create a new one. - **Still a draft?** A request created with `send: false` can be sent later with [`send_signing_request`](https://tryjustdocs.com/docs/api/reference/send-signing-request), which also waits for approval. - **Retrying after a timeout?** Use the same idempotency key. You get the first result, and no second request or approval is made. # Collect details with an intake link Often you don't have the details a document needs, such as the other party's legal name, address or start date. An **intake link** asks for them. JustDocs emails one person a short form made from the documents' blank fill-ins. Their answers fill the documents. They don't need an account. ## 1. Start the documents with blanks Start each document from its template **without** the values you want to ask for. Every fill-in left blank becomes a question. ```bash curl -X POST https://tryjustdocs.com/api/v1/documents \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template_id": "5d0f8c3a-6b1e-4f2a-9c7d-3e8b1a2f4c6d", "title": "Consulting agreement — Acme" }' ``` Note the document's `id` and `draft_revision`. The intake link names the exact revision, so the form matches the document. If the document changes before you send the link, you get `409`. Written and uploaded documents can be filled in this way. If every fill-in already has a value, there's nothing to ask, and you get `409`. ## 2. Choose what happens after they answer | `after` | What happens | | --- | --- | | `review` (default) | Their answers fill the documents, and the person who sent the link is emailed to review them and send them for signing. | | `sign` | Their answers fill the documents, and they go straight on to sign them. Anyone else in `signing.recipients` signs too. | ## 3. Send the link **Review first:** ```bash curl -X POST https://tryjustdocs.com/api/v1/intake-requests \ -H "Authorization: Bearer $JUSTDOCS_API_KEY" \ -H "Idempotency-Key: deal-4821-intake" \ -H "Content-Type: application/json" \ -d '{ "title": "Your details for the consulting agreement", "documents": [{ "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", "draft_revision": 1 }], "respondent": { "name": "Ada Lovelace", "email": "ada@example.com" }, "after": "review" }' ``` **Straight on to signing.** Say which role the respondent signs as, and who else signs: ```json { "documents": [{ "document_id": "8a1f5c2e-3b4d-4e6f-9a7b-1c2d3e4f5a6b", "draft_revision": 1 }], "respondent": { "name": "Ada Lovelace", "email": "ada@example.com" }, "after": "sign", "signing": { "respondent_role": "client", "recipients": [{ "role_key": "provider", "name": "Olga Owner", "email": "olga@example.com" }], "routing": "sequential" } } ``` Every signing role in the documents needs someone. If one is missing, you get `422 Say who signs as …`. The link expires after `expires_in_days`, 14 days by default. See [limits](https://tryjustdocs.com/docs/get-started/plans-and-limits). ## With an agent key `create_intake_request` through an agent key, or a key that needs approval, returns `202`: ```json { "status": "awaiting_approval", "approval": { "id": "a4c6e8b2-…", "kind": "intake.create", "status": "pending" } } ``` Nothing is created or emailed until an owner or admin approves. Then the link is created and sent, and the approval's `intake_request_id` names it. ## 4. Get the answers Listen for `intake.submitted`, or poll [`GET /intake-requests/{id}`](https://tryjustdocs.com/docs/api/reference/get-intake-request). Once it's `submitted`, the response includes `answers`, and the documents are filled. With `after: "sign"`, `signing_request_id` names the signing request that was created, which you can follow like any other. | Status | | | --- | --- | | `open` | Waiting for answers. | | `submitted` | Answered. The documents are filled. | | `voided` | Cancelled by the sender. | | `expired` | Not answered in time. | # Track completion with webhooks Your CRM should know when a contract is signed, without polling. This guide builds a receiver that marks a deal signed when its request completes. ## 1. Link your records to JustDocs When you create a signing request, store its `id` with your record: ```js const request = await createSigningRequest(deal); // POST /api/v1/signing-requests await db.deals.update(deal.id, { justdocs_request_id: request.id }); ``` ## 2. Add the endpoint On the team's **Webhooks** page, add your URL, such as `https://crm.example.com/webhooks/justdocs`. Choose `request.sent`, `request.signed`, `request.completed`, `request.declined`, `request.voided` and `request.expired`. Store the secret as `JUSTDOCS_WEBHOOK_SECRET`. ## 3. Receive, verify, queue ```js import crypto from "node:crypto"; import express from "express"; const app = express(); function verify(header, raw) { const parts = Object.fromEntries((header ?? "").split(",").map((part) => part.split("=", 2))); if (!parts.v1 || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false; const expected = crypto.createHmac("sha256", process.env.JUSTDOCS_WEBHOOK_SECRET).update(`${parts.t}.${raw}`).digest(); const given = Buffer.from(parts.v1, "hex"); return given.length === expected.length && crypto.timingSafeEqual(given, expected); } app.post("/webhooks/justdocs", express.raw({ type: "application/json" }), async (req, res) => { const raw = req.body.toString("utf8"); if (!verify(req.get("JustDocs-Signature"), raw)) return res.sendStatus(400); const event = JSON.parse(raw); try { await queue.add("justdocs-event", event); } catch { // Not queued: answering with an error makes JustDocs deliver it again. return res.sendStatus(500); } res.sendStatus(204); }); ``` Answer within a few seconds, and only once the event is queued. The work happens on the queue. Don't drop duplicates here. If you record an event as seen and then fail to queue it, its retry looks like a duplicate and the update is lost. Step 4 drops them when the work is done. ## 4. Act on each event once Events say **what** happened, not every detail. Fetch the request for its current state. That also handles events arriving out of order. ```js queue.process("justdocs-event", async (event) => { if (!event.type.startsWith("request.")) return; const deal = await db.deals.findBy({ justdocs_request_id: event.data.signing_request_id }); if (!deal) return; const response = await fetch(`https://tryjustdocs.com/api/v1/signing-requests/${event.data.signing_request_id}`, { headers: { Authorization: `Bearer ${process.env.JUSTDOCS_API_KEY}` }, }); if (!response.ok) throw new Error(`JustDocs answered ${response.status}`); // the queue tries the job again const request = await response.json(); await db.transaction(async (tx) => { // Each event id is applied once, however many times it arrives. The id is // recorded with the update it caused: if the update fails, neither is kept, // and the next attempt applies it. const fresh = await tx.webhookEvents.insertIfAbsent({ id: event.id, type: event.type, processed_at: new Date() }); if (!fresh) return; await tx.deals.update(deal.id, { contract_status: request.status, // draft, sent, completed, declined, voided, expired signed_by: request.recipients.filter((recipient) => recipient.status === "signed").map((recipient) => recipient.email), contract_completed_at: request.completed_at, }); }); }); ``` Writing the request's current state is safe to repeat. The id check is what keeps anything else an event sets off, such as a task for your team, to once. ## 5. Test it 1. Choose **Send a test** on the endpoint. Your receiver should answer `204` to the `ping`, and ignore it. 2. Send yourself a request from the app and sign it. Watch `request.sent`, `request.viewed`, `request.signed` and `request.completed` arrive. 3. Check **Deliveries** on the endpoint for status codes and errors. ## If your receiver goes down Failed deliveries are [retried](https://tryjustdocs.com/docs/webhooks/retries) for about 20 hours. If the endpoint keeps failing, it's paused and you're emailed. After you resume it, catch up by [listing signing requests](https://tryjustdocs.com/docs/api/reference/list-signing-requests) and comparing their statuses with your records. # Agent security checklist An agent with a JustDocs key can prepare documents and ask to email them to people. Work through this list before you connect one in production. ## Before you make the key - [ ] **Use an agent key**, not an API key. Its sends always wait for a person. - [ ] **One key per agent and per place it runs.** Then you can revoke one without the others, and **last used** shows which one acted. - [ ] **Only the permissions it needs.** An agent that answers questions needs `read` only. One that drafts, or saves templates, needs `documents`. Give `send` only if it should ask to send. `send` also lets it void requests at once, without approval. - [ ] **Recipient domains**, if it only deals with known companies, such as your own domain for internal agreements. - [ ] **A low daily send limit.** Start with a few a day and raise it when you trust the pattern. - [ ] **A short expiry.** 30 to 90 days, then make a new key on purpose. ## Where the key lives - [ ] In the client's secret settings, a secret manager, or an environment variable. Never in a prompt, a document, a template, or a file you commit. - [ ] Never in a browser or a mobile app. Anyone who can open it can read the key. - [ ] If you run a connector for many users, keep each workspace's key separate and encrypted. ## Approvals - [ ] **Decide who approves.** Every owner and admin can. Keep that group small and aware. - [ ] **Read before approving.** The approval shows the title and every address. Open the request to check the document. - [ ] **Alert on `approval.requested`** with a [webhook](https://tryjustdocs.com/docs/webhooks/events#approval-requested) on Teams & Platform, so sends don't sit for days. Approvals expire after seven days. ## Prompt injection Documents, answers and emails can contain text written to steer an agent. JustDocs limits what that text can achieve: - No tool can sign, or return a signing link. - Every send waits for a person, who sees the real addresses. The one action that doesn't wait, voiding, can only cancel. - Recipient domains and daily limits apply, and are checked again when a person approves. Still, tell your agent to treat document content as data, not instructions. Approve only what you expected. ## Watch it - [ ] Check each key's **last used** time and address now and then. An address you don't recognise means the key is being used from somewhere you don't know about. - [ ] On Teams & Platform, send webhooks to your logs or security tooling: `approval.requested`, `approval.decided`, `request.sent`. ## If a key leaks 1. **Revoke it** on the API & agents page. It stops working at once. 2. **Decline** any pending approvals it asked for under **Approvals**. 3. **Void** any requests it shouldn't have sent. 4. Make a new key with the same limits, and update the client. 5. Tell us at [security@tryjustdocs.com](mailto:security@tryjustdocs.com) if you think JustDocs itself is involved. # Changelog We add to the API without breaking it. New fields, events and tools can appear at any time, so ignore what you don't recognise. A change that would break existing code gets a new version and notice here first. ## November 2026 - **Agents create templates** (November 10, 2026). `create_template` (`POST /templates`) saves a template from text with fill-ins and signing fields, or as a copy of a library entry such as `mutual-nda` or `w9-request`. The `documents` permission now allows it, so keys and connected apps that have `documents` can create templates as well as start documents from them. It emails no one and needs no approval; sending a document started from it through an agent key waits for approval as before. See [Create a template](https://tryjustdocs.com/docs/guides/create-a-template). In the app, the library's free documents and forms can be saved straight as templates with **Save as template**. - **Which workspace a key works in** (November 10, 2026). `get_workspace` (`GET /workspace`) names the key's workspace (`"Personal"` for a personal one), its plan and saved templates, and what the key may do, including whether its sends wait for approval. `list_templates` adds `workspace`, `get_template` and `create_template` return the template's `roles`, and a template or document the key can't reach gets a `404` that names the key's workspace. The MCP server now has 14 tools. Each key's row on the **API & agents** page is labelled **Agent key** or **API key**, and **Sends wait for approval** or **Sends go out at once**. See [Which workspace a key works in](https://tryjustdocs.com/docs/api/keys#workspace). - **Agents on every plan** (November 5, 2026). Agent keys, the MCP server, apps that sign in with OAuth, and the REST API with an agent key now work on every plan, Free included, in personal workspaces and teams. Everything an agent asks to send still waits for a person's approval. Below Teams & Platform, a workspace can have 3 active agent keys, and a connected app counts as one. API keys, whose sends can go out without approval, and webhooks stay with Teams & Platform. A workspace that leaves Teams & Platform keeps its agent keys and connected apps. See [Plans and limits](https://tryjustdocs.com/docs/get-started/plans-and-limits). - **Sign in with OAuth** (November 3, 2026). MCP clients such as Claude, ChatGPT, Muse and Claude Code can connect to the MCP server by signing in, without a key. An owner or admin approves the app on a JustDocs page and chooses its workspace, permissions and limits. The app then acts as an agent key in their name, so its sends still wait for approval. The server supports OAuth 2.1 with PKCE, rotating refresh tokens, Client ID Metadata Documents and Dynamic Client Registration. Agent keys work as before. See [Sign in with OAuth](https://tryjustdocs.com/docs/mcp/oauth). - **Developer docs** launched at [tryjustdocs.com/docs](https://tryjustdocs.com/docs/). They include a reference generated from the code for every MCP tool, REST operation and webhook event, plus guides and Markdown copies of every page for assistants. - **MCP Registry.** The server is listed in the official MCP Registry as `com.tryjustdocs/justdocs`. ## October 2026 - **API v1, the MCP server and webhooks** came to Teams & Platform: - REST operations for templates, documents, signing requests, intake links and approvals, described by OpenAPI 3.1. - Twelve MCP tools over Streamable HTTP, for agent keys. - Approvals: what an agent asks to send waits for an owner or admin. - Signed webhooks for ten events, retried, with pausing and a delivery log. - API and agent keys with permissions, recipient domains, daily send limits and expiry. # Support and links ## Help - **Support:** [support@tryjustdocs.com](mailto:support@tryjustdocs.com). Include the time, the operation or tool, and the error message. Never send a key or webhook secret. The key's first characters are enough. - **Security:** report a vulnerability to [security@tryjustdocs.com](mailto:security@tryjustdocs.com). ## For tools and assistants | | | | --- | --- | | [`/openapi.json`](https://tryjustdocs.com/openapi.json) | The REST API, as OpenAPI 3.1 | | [`/mcp`](https://tryjustdocs.com/mcp) | The MCP server (Streamable HTTP, `POST` with an agent key or after [signing in with OAuth](https://tryjustdocs.com/docs/mcp/oauth)) | | [MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=com.tryjustdocs/justdocs) | `com.tryjustdocs/justdocs` | | [`/docs/llms.txt`](https://tryjustdocs.com/docs/llms.txt) | These docs, as a list of Markdown pages | | [`/docs/llms-full.txt`](https://tryjustdocs.com/docs/llms-full.txt) | These docs in one file | | [`/llms.txt`](https://tryjustdocs.com/llms.txt) | JustDocs as a whole, for assistants | Every page here is also available as Markdown. Add `.md` to its address, such as [`/docs/mcp/connect.md`](https://tryjustdocs.com/docs/mcp/connect.md). For a section's page, drop the final slash: [`/docs/mcp.md`](https://tryjustdocs.com/docs/mcp.md). The home page is [`/docs/index.md`](https://tryjustdocs.com/docs/index.md). ## Policies - [Terms of Service](https://tryjustdocs.com/tos) - [Privacy Policy](https://tryjustdocs.com/privacy) - [Data Processing Addendum](https://tryjustdocs.com/dpa)