# SignWith developer docs: e-signature API and MCP server > Build with SignWith: send documents for e-signature from your app with the REST API and webhooks, or let AI assistants do it over the SignWith MCP server. Source: https://signwith.co/docs · Updated 2026-10-02 ## REST API Send documents for e-signature from your product. 20 endpoints, webhooks, cURL/Node/Python samples. - [Overview](https://signwith.co/docs/api.md): The SignWith e-signature API: send documents for signature from your app, track signers with webhooks, and pay per document. Every endpoint, with examples. - [Quick start](https://signwith.co/docs/api/quickstart.md): Send your first document for e-signature with the SignWith API: get an API key, upload a PDF as a template, place signature fields, send it and check its status. - [Text tags](https://signwith.co/docs/api/text-tags.md): Place SignWith signature, date, checkbox and other fields by writing text tags like {{Client signature;type=signature;role=Client}} into your PDF. Full syntax. - [Authentication](https://signwith.co/docs/api/authentication.md): How to authenticate with the SignWith API: Bearer API keys, live and test keys, full-access and read-only keys, key expiry, and the 401 and 403 errors. - [Making requests](https://signwith.co/docs/api/requests.md): How SignWith API requests work: the base URL, JSON and timestamps, cursor pagination with limit and next_cursor, and safe retries with the Idempotency-Key header. - [Errors](https://signwith.co/docs/api/errors.md): Every SignWith API error code with its HTTP status and meaning: the error envelope, which codes to branch on, and which errors are safe to retry. - [Rate limits](https://signwith.co/docs/api/rate-limits.md): SignWith API rate limits: 300 requests per minute per API key, the RateLimit headers on every response, the 429 error, and the reminder and feedback limits. - [Credits and billing](https://signwith.co/docs/api/credits.md): How the SignWith API uses credits: when a credit is used, checking the balance, the 402 insufficient_credits error, and buying credits from your code. - [Account](https://signwith.co/docs/api/account.md): SignWith account API: check which user, account and environment an API key belongs to, and read the credit balance. Fields, errors and code examples. - [Templates](https://signwith.co/docs/api/templates.md): SignWith templates API: upload PDFs as reusable templates, place signature fields, update, duplicate and archive them. With cURL, Node and Python examples. - [Signature requests](https://signwith.co/docs/api/signature-requests.md): Send documents for signature with the SignWith API: create, list, get, cancel and remind signature requests. Every parameter and error, with code examples. - [Signers](https://signwith.co/docs/api/signers.md): SignWith signers API: get a signer's status, field values and signed documents, fix their email, prefill fields and resend their link. With code examples. - [Documents](https://signwith.co/docs/api/documents.md): Verify a signed PDF with the SignWith API: check that SignWith issued it and that its digital signatures are valid. Request, response and code examples. - [Billing](https://signwith.co/docs/api/billing.md): Buy SignWith credits from the API: list credit packs, start a hosted checkout and poll it until credits are added. Every field, with code examples. - [Feedback](https://signwith.co/docs/api/feedback.md): Send bug reports, feature requests and questions to the SignWith team from your API client or AI agent. Request fields, limits, errors and code examples. - [Webhooks overview](https://signwith.co/docs/api/webhooks.md): SignWith webhooks send a signed POST to your server when a document is sent, viewed, signed, declined or completed. Setup, payload, headers, retries and testing. - [Events](https://signwith.co/docs/api/webhooks/events.md): Every SignWith webhook event, from signature_request.created to signer.signed and template.archived, with when it is sent, its payload object and an example. - [Verify signatures](https://signwith.co/docs/api/webhooks/verify-signatures.md): How to verify SignWith webhook signatures: check the X-SignWith-Signature header with HMAC-SHA256 over the timestamp and raw body, in Node.js and Python. ## MCP server Connect AI assistants to SignWith at `https://app.signwith.co/mcp` (Streamable HTTP; OAuth or a Bearer API key). 18 tools. - [Overview](https://signwith.co/docs/mcp.md): The SignWith MCP server lets Claude, ChatGPT, Cursor and other AI assistants send documents for e-signature, follow signers and verify PDFs. - [Tools](https://signwith.co/docs/mcp/tools.md): All 18 SignWith MCP tools: what each one does, its arguments, the API endpoint it runs, and whether View only connections and read-only keys can use it. - [Claude](https://signwith.co/docs/mcp/claude.md): Connect Claude to SignWith over MCP: add a custom connector in Claude on the web, desktop or mobile, or add the server to Claude Code with an API key or OAuth. - [ChatGPT](https://signwith.co/docs/mcp/chatgpt.md): Connect ChatGPT to SignWith over MCP with developer mode: create an app for the SignWith MCP server, sign in with OAuth, and send documents from a chat. - [Cursor](https://signwith.co/docs/mcp/cursor.md): Add the SignWith MCP server to Cursor with mcp.json and an API key, so Cursor can create templates, send documents for signature and check signers while you build. - [VS Code](https://signwith.co/docs/mcp/vscode.md): Add the SignWith MCP server to VS Code and GitHub Copilot with one click or with mcp.json, then send documents for signature and check signers from the chat. - [OAuth and protocol](https://signwith.co/docs/mcp/oauth.md): How MCP clients authenticate with the SignWith MCP server: OAuth 2.1 with PKCE, dynamic client registration, scopes, token lifetimes and protocol behaviour. ## For agents Every page has a Markdown copy at its URL plus `.md`. Index: https://signwith.co/llms.txt. Everything in one file: https://signwith.co/llms-full.txt. OpenAPI: https://signwith.co/docs/openapi.yaml. --- # SignWith API changelog > Changes to the SignWith API, webhooks and MCP server, newest first. The API is versioned in its path (/api/v1); changes listed here keep existing v1 calls working unless they say otherwise. Source: https://signwith.co/docs/changelog · Updated 2026-10-02 ## 2 October 2026: API v1 The first public release of the SignWith API. - **REST API v1** at `https://app.signwith.co/api/v1`: [templates](https://signwith.co/docs/api/templates), [signature requests](https://signwith.co/docs/api/signature-requests), [signers](https://signwith.co/docs/api/signers), [verifying signed PDFs](https://signwith.co/docs/api/documents), [credits and checkouts](https://signwith.co/docs/api/billing), and [feedback](https://signwith.co/docs/api/feedback). - **API keys** for live and test accounts, with full-access or read-only permission and an optional expiry date. See [authentication](https://signwith.co/docs/api/authentication). - **Idempotency** for every `POST` with the `Idempotency-Key` header, and **rate limits** of 300 requests per minute per key. - **Webhooks** for signature requests, signers and templates, signed with HMAC-SHA256. See [webhook events](https://signwith.co/docs/api/webhooks/events). - **MCP server** at `https://app.signwith.co/mcp` with 18 tools, for Claude, ChatGPT, Cursor and other AI assistants. See [AI assistants](https://signwith.co/docs/mcp). - **OAuth sign-in for AI assistants**, with **View only** or **Full access**, managed in **Settings → Connected apps**. The full spec is available as [openapi.yaml](https://signwith.co/docs/openapi.yaml). --- # E-signature API: send documents for signature > Send documents for signature from your own product, follow every signer with webhooks, and pay per document. Source: https://signwith.co/docs/api · Updated 2026-10-02 Base URL: `https://app.signwith.co/api/v1`. Authenticate with a Bearer [API key](https://signwith.co/docs/api/authentication). JSON in and out. 20 endpoints and 11 webhook events. OpenAPI spec: https://signwith.co/docs/openapi.yaml. ## Send a document in one request cURL: ```bash curl -X POST https://app.signwith.co/api/v1/signature_requests \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "template_id": 311, "signing_order": "sequential", "expires_at": "2026-10-27T00:00:00Z", "reply_to": "leasing@acme.co", "redirect_url": "https://acme.co/lease/signed", "message": { "subject": "Your lease for 14 Riverside Ave is ready to sign", "body": "Hi Grace, please review and sign your lease." }, "signers": [ { "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "external_id": "cust_8841", "metadata": { "crm_deal_id": "D-2291" }, "prefill": { "Tenant name": "Grace Hopper", "Monthly rent": "2150", "Start date": "2026-11-01" }, "require_email_otp": true }, { "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co" } ] }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/signature_requests', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ template_id: 311, signing_order: 'sequential', expires_at: '2026-10-27T00:00:00Z', reply_to: 'leasing@acme.co', redirect_url: 'https://acme.co/lease/signed', message: { subject: 'Your lease for 14 Riverside Ave is ready to sign', body: 'Hi Grace, please review and sign your lease.', }, signers: [ { role: 'Tenant', name: 'Grace Hopper', email: 'grace@example.com', external_id: 'cust_8841', metadata: { crm_deal_id: 'D-2291', }, prefill: { 'Tenant name': 'Grace Hopper', 'Monthly rent': '2150', 'Start date': '2026-11-01', }, require_email_otp: true, }, { role: 'Landlord', name: 'Ada Lovelace', email: 'ada@acme.co', }, ], }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/signature_requests", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "template_id": 311, "signing_order": "sequential", "expires_at": "2026-10-27T00:00:00Z", "reply_to": "leasing@acme.co", "redirect_url": "https://acme.co/lease/signed", "message": { "subject": "Your lease for 14 Riverside Ave is ready to sign", "body": "Hi Grace, please review and sign your lease.", }, "signers": [ { "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "external_id": "cust_8841", "metadata": { "crm_deal_id": "D-2291", }, "prefill": { "Tenant name": "Grace Hopper", "Monthly rent": "2150", "Start date": "2026-11-01", }, "require_email_otp": True, }, { "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co", }, ], }, ) print(response.status_code, response.json()) ``` ## All endpoints ### [Account](https://signwith.co/docs/api/account) - [Get the current API key's user and account](https://signwith.co/docs/api/account#get-the-current-api-keys-user-and-account): `GET /me` - [Get the credit balance](https://signwith.co/docs/api/account#get-the-credit-balance): `GET /credits` ### [Templates](https://signwith.co/docs/api/templates) - [List templates](https://signwith.co/docs/api/templates#list-templates): `GET /templates` - [Create a template from documents](https://signwith.co/docs/api/templates#create-a-template-from-documents): `POST /templates` - [Get a template](https://signwith.co/docs/api/templates#get-a-template): `GET /templates/{id}` - [Update a template](https://signwith.co/docs/api/templates#update-a-template): `PATCH /templates/{id}` - [Duplicate a template](https://signwith.co/docs/api/templates#duplicate-a-template): `POST /templates/{id}/duplicate` - [Archive a template](https://signwith.co/docs/api/templates#archive-a-template): `POST /templates/{id}/archive` ### [Signature requests](https://signwith.co/docs/api/signature-requests) - [List signature requests](https://signwith.co/docs/api/signature-requests#list-signature-requests): `GET /signature_requests` - [Send a signature request](https://signwith.co/docs/api/signature-requests#send-a-signature-request): `POST /signature_requests` - [Get a signature request](https://signwith.co/docs/api/signature-requests#get-a-signature-request): `GET /signature_requests/{id}` - [Cancel a signature request](https://signwith.co/docs/api/signature-requests#cancel-a-signature-request): `POST /signature_requests/{id}/cancel` - [Remind waiting signers](https://signwith.co/docs/api/signature-requests#remind-waiting-signers): `POST /signature_requests/{id}/remind` ### [Signers](https://signwith.co/docs/api/signers) - [Get a signer](https://signwith.co/docs/api/signers#get-a-signer): `GET /signers/{id}` - [Update a signer](https://signwith.co/docs/api/signers#update-a-signer): `PATCH /signers/{id}` ### [Documents](https://signwith.co/docs/api/documents) - [Verify a signed PDF](https://signwith.co/docs/api/documents#verify-a-signed-pdf): `POST /documents/verify` ### [Billing](https://signwith.co/docs/api/billing) - [List credit packs](https://signwith.co/docs/api/billing#list-credit-packs): `GET /credit_packs` - [Start a credit purchase](https://signwith.co/docs/api/billing#start-a-credit-purchase): `POST /checkouts` - [Get a checkout](https://signwith.co/docs/api/billing#get-a-checkout): `GET /checkouts/{id}` ### [Feedback](https://signwith.co/docs/api/feedback) - [Send feedback to the SignWith team](https://signwith.co/docs/api/feedback#send-feedback-to-the-signwith-team): `POST /feedback` ## Pricing No API plan or monthly fee: 3 free documents every month, then one-time credit packs from $9, down to $0.58 per document on Business. One credit covers one signed document. See https://signwith.co/docs/api/credits. ## Frequently asked questions ### Is there a free e-signature API? Every SignWith account can create API keys, and the 3 free documents each month work through the API too. After that you buy credits once, from $9, with no subscription or API plan. See [credits and billing](https://signwith.co/docs/api/credits). ### Can I test the API without sending real emails? Keys that start with `sw_test_` act on your test account, whose data is kept apart from live data. You can also create a signature request with `send_email: false` and share each signer's `signing_url` yourself. See [authentication](https://signwith.co/docs/api/authentication). ### Can I show the signing page inside my own app? Create the signature request with `send_email: false`. Signers get no email, and each signer's private `signing_url` is in the response for you to deliver. Use `redirect_url` to send them back to your app after signing. ### How do I know when a document is signed? Add a webhook endpoint and subscribe to `signature_request.completed`, or poll `GET /signature_requests/{id}` until `status` is `completed`. The completed request includes download links for the signed documents and the audit trail. ### Can Claude or ChatGPT send documents for signature? Yes. SignWith runs an MCP server with 18 tools that AI assistants use to list templates, send signature requests and follow signers. See the [MCP server docs](https://signwith.co/docs/mcp). --- # Quick start: send a document for signature > Five requests take you from an API key to a document waiting for signatures. Every example below is copied from the API spec, so you can run them as they are and then swap in your own file and people. Source: https://signwith.co/docs/api/quickstart · Updated 2026-10-02 You need a SignWith account and a terminal. The examples use cURL, Node 20 or later (save the code as a `.mjs` file), and Python with the `requests` package. Pick a language once and every example on the page switches. ## 1. Get an API key [Get your API key](https://app.signwith.co/settings/api) In SignWith, open [Settings → Developers](https://app.signwith.co/settings/api) and create a key. Keys starting with `sw_test_` act on your test account, and keys starting with `sw_live_` act on your live account, where signers get real emails. Then put the key in an environment variable, so it stays out of your code: ```bash export SIGNWITH_API_KEY="sw_live_..." ``` ## 2. Check the key works [`GET /me`](https://signwith.co/docs/api/account#get-the-current-api-keys-user-and-account) returns the user, account and environment the key belongs to. It changes nothing, so it's a safe first call. cURL: ```bash curl https://app.signwith.co/api/v1/me \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/me', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/me", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` A `200` response with your email in `user.email` means you're ready. A `401` with code `unauthenticated` means the key is missing or wrong; see [authentication](https://signwith.co/docs/api/authentication). ## 3. Upload a document as a template A template is a reusable document with roles (who signs) and fields (what they fill in). Create one from a PDF that SignWith can download from an `https://` URL. Replace the `file` URL with one of yours, or send the file itself as base64 or a multipart upload (see [create a template](https://signwith.co/docs/api/templates#create-a-template-from-documents)). cURL: ```bash curl -X POST https://app.signwith.co/api/v1/templates \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "documents": [ { "name": "Lease agreement", "file": "https://files.acme.co/templates/lease-v3.pdf" } ] }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/templates', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ name: 'Residential lease', external_id: 'lease-v3', folder: 'Leasing', documents: [ { name: 'Lease agreement', file: 'https://files.acme.co/templates/lease-v3.pdf', }, ], }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/templates", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "documents": [ { "name": "Lease agreement", "file": "https://files.acme.co/templates/lease-v3.pdf", }, ], }, ) print(response.status_code, response.json()) ``` The response is the new template. Note its `id`; the examples below use `311`. If the PDF has fillable form fields, they become template fields automatically. Otherwise the template has no fields yet, and it needs at least one before it can be sent. ## 4. Place the signature fields Send `fields` to [`PATCH /templates/{id}`](https://signwith.co/docs/api/templates#update-a-template) to place a signature box for each role. Each area gives the page and the position as fractions of the page size, measured from the top-left corner. This example puts a Tenant signature and a Landlord signature on page 4, so change `page` if your document is shorter (`422 invalid_field` tells you when a page doesn't exist). cURL: ```bash curl -X PATCH https://app.signwith.co/api/v1/templates/311 \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fields": [ { "name": "Tenant signature", "type": "signature", "role": "Tenant", "areas": [ { "page": 4, "x": 0.1, "y": 0.8, "w": 0.3, "h": 0.06 } ] }, { "name": "Landlord signature", "type": "signature", "role": "Landlord", "areas": [ { "page": 4, "x": 0.55, "y": 0.8, "w": 0.3, "h": 0.06 } ] } ] }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/templates/311', { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ fields: [ { name: 'Tenant signature', type: 'signature', role: 'Tenant', areas: [ { page: 4, x: 0.1, y: 0.8, w: 0.3, h: 0.06, }, ], }, { name: 'Landlord signature', type: 'signature', role: 'Landlord', areas: [ { page: 4, x: 0.55, y: 0.8, w: 0.3, h: 0.06, }, ], }, ], }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.patch( "https://app.signwith.co/api/v1/templates/311", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, json={ "fields": [ { "name": "Tenant signature", "type": "signature", "role": "Tenant", "areas": [ { "page": 4, "x": 0.1, "y": 0.8, "w": 0.3, "h": 0.06, }, ], }, { "name": "Landlord signature", "type": "signature", "role": "Landlord", "areas": [ { "page": 4, "x": 0.55, "y": 0.8, "w": 0.3, "h": 0.06, }, ], }, ], }, ) print(response.status_code, response.json()) ``` Roles named in `fields` are added to the template, so it now has the roles Tenant and Landlord. You can also open the template's `edit_url` and place fields in the SignWith editor instead. > **Or write the fields into the document:** If you generate the PDF yourself, put text tags where the fields go, such as `{{Tenant signature;type=signature;role=Tenant}}`. SignWith places a field at each tag and hides the tag text when you create the template, so you can skip this step. See [text tags](https://signwith.co/docs/api/text-tags) for the full syntax. ## 5. Send it for signature [`POST /signature_requests`](https://signwith.co/docs/api/signature-requests#send-a-signature-request) sends the template to one signer per role. This example uses `send_email: false`, so nobody is emailed while you test: the response includes each signer's private `signing_url` for you to open yourself. cURL: ```bash curl -X POST https://app.signwith.co/api/v1/signature_requests \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "template_id": 311, "signing_order": "parallel", "send_email": false, "signers": [ { "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com" }, { "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co" } ] }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/signature_requests', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ template_id: 311, signing_order: 'parallel', send_email: false, signers: [ { role: 'Tenant', name: 'Grace Hopper', email: 'grace@example.com', }, { role: 'Landlord', name: 'Ada Lovelace', email: 'ada@acme.co', }, ], }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/signature_requests", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "template_id": 311, "signing_order": "parallel", "send_email": False, "signers": [ { "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", }, { "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co", }, ], }, ) print(response.status_code, response.json()) ``` To have SignWith email the signers instead, leave out `send_email`. Add `signing_order: "sequential"` to invite them one at a time, `message` for your own email text, and `prefill` to fill fields in advance. The [signature requests reference](https://signwith.co/docs/api/signature-requests#send-a-signature-request) has every option. The request includes an `Idempotency-Key` header, so if the network fails you can retry the same request without sending the document twice. See [idempotency](https://signwith.co/docs/api/requests#idempotency). ## 6. Check the status Open each `signing_url` and sign. Then fetch the signature request with [`GET /signature_requests/{id}`](https://signwith.co/docs/api/signature-requests#get-a-signature-request): cURL: ```bash curl https://app.signwith.co/api/v1/signature_requests/4812 \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/signature_requests/4812', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/signature_requests/4812", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` When `status` is `completed`, the response has download links for the signed `documents` and the `audit_trail_url`. One credit covers one signed document, and sending the same template again doesn't use another; see [credits and billing](https://signwith.co/docs/api/credits). ## Next steps - Get told when people sign instead of polling: [set up webhooks](https://signwith.co/docs/api/webhooks). - Handle failures by their stable code: [errors](https://signwith.co/docs/api/errors). - Let an AI assistant send documents for you: [connect Claude, ChatGPT or Cursor](https://signwith.co/docs/mcp). --- # Text tags: place signature fields from the PDF > Write a tag into your document where a field should go, and SignWith puts the field there and hides the tag. No coordinates, no editor. Source: https://signwith.co/docs/api/text-tags · Updated 2026-10-02 Text tags are the easiest way to place fields when your system generates the PDF, for example from a Word or HTML template. They work however the PDF reaches SignWith: [`POST /templates`](https://signwith.co/docs/api/templates#create-a-template-from-documents), the MCP `create_template` tool, uploading a new template in the app, or adding a document in the template editor. ## Syntax ```text {{Field name;key=value;key=value}} ``` The first part is the field name, which you can also use as the key in `prefill`. After it come optional settings, separated by semicolons: | Setting | What it does | Default | | --- | --- | --- | | `type` | `text`, `signature`, `initials`, `date`, `checkbox`, `number`, `phone`, `select`, `image`, `file` or `stamp` | `text` | | `role` | Who fills the field, such as `Client`. A new role is added to the template. | The template's first role | | `required` | `false`, `no` or `0` makes the field optional | `true` | | `options` | Choices for a `select` field, separated by commas or `\|` | | | `width`, `height` | Size in PDF points (72 points to an inch) | The tag's own size, with a minimum per type | If the first part is a field type on its own, it's the type, and the field is named after it: `{{signature;role=Provider}}` is a signature field called "Signature". Some short forms work too: `sig` means `signature`, `initial` means `initials`, `check` means `checkbox`, and `radio` or `multiple` mean `select`. An unknown type becomes a text field. ## Examples | Tag | Field | | --- | --- | | `{{Client signature;type=signature;role=Client}}` | The client's signature | | `{{Date signed;type=date;role=Client;required=false}}` | An optional date for the client | | `{{Plan;type=select;options=Basic,Pro;role=Client}}` | A choice between Basic and Pro | | `{{signature;role=Provider}}` | The provider's signature, named "Signature" | | `{{initials;role=Client}}` on every page | One initials field, with an area on each page | ## Where fields land - **Position.** The field starts at the tag's left edge and sits on the tag's baseline. Taller fields, such as signatures, grow upward, so a tag on a signature line puts the signature on the line. - **Size.** Without `width` and `height`, a field takes the tag's size, but never less than a minimum: 160 × 36 points for signatures, 60 × 30 for initials, 12 × 12 for checkboxes, 120 × 60 for images and stamps, 120 × 20 for files, and 80 × 16 for everything else. - **Repeats.** The same name, type and role in several places is one field with several areas, which is how "initial every page" works. - **Wrapping.** A tag that wraps onto a second line in a narrow column still works; the line break counts as a space. ## Roles If every tag names a role and the PDF has no fillable form fields, the template gets exactly those roles, in the order they first appear. Tags without a role go to the template's first role. A template has at most 10 roles; tags naming more fall back to the first role. Signers are matched to roles when you send, so name the roles the way you'll fill them in [`POST /signature_requests`](https://signwith.co/docs/api/signature-requests#send-a-signature-request). ## Combining with other fields Tags work together with fillable PDF form fields, which SignWith detects automatically, and with fields you pass in `fields` when you create the template. Use whichever fits each part of the document. ## Hiding the tag text SignWith covers each tag with white so signers don't see it. For the cleanest result: - Keep each tag on a line of its own, or colour the tag text white in your source document. - Leave a little space between lines. In very tightly set text, the white cover can slightly clip neighbouring letters. ## With AI assistants Assistants connected through the [SignWith MCP server](https://signwith.co/docs/mcp) know the tag syntax. When Claude or ChatGPT writes or edits a document for you, it puts tags where the fields go and then creates the template, so the signature lines land in the right place. For a PDF with no tags or form fields, it gives you the template's `edit_url` to place fields in the SignWith editor instead of guessing positions. ## Limits - Text tags only work in PDFs, not images, because images have no text layer. - Tags are read from the page's visible area (its crop box), so text outside it is ignored. - Rotated pages are skipped: tags on them stay in the document as plain text and don't become fields. ## Try it Add `{{Client signature;type=signature;role=Client}}` to a PDF, then create a template from it: cURL: ```bash curl -X POST https://app.signwith.co/api/v1/templates \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "documents": [ { "name": "Lease agreement", "file": "https://files.acme.co/templates/lease-v3.pdf" } ] }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/templates', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ name: 'Residential lease', external_id: 'lease-v3', folder: 'Leasing', documents: [ { name: 'Lease agreement', file: 'https://files.acme.co/templates/lease-v3.pdf', }, ], }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/templates", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "documents": [ { "name": "Lease agreement", "file": "https://files.acme.co/templates/lease-v3.pdf", }, ], }, ) print(response.status_code, response.json()) ``` The response lists the fields SignWith found. Check them with [`GET /templates/{id}`](https://signwith.co/docs/api/templates#get-a-template), then [send it for signature](https://signwith.co/docs/api/quickstart). --- # SignWith API authentication: API keys > Every request to the SignWith API carries an API key in the Authorization header. Keys belong to the user who created them, and they can be live or test, full access or read-only. Source: https://signwith.co/docs/api/authentication · Updated 2026-10-02 ## Send the key as a Bearer token Put the key in the `Authorization` header of every request: ```http Authorization: Bearer sw_live_3xAmPl3K3y... ``` A request without a valid key gets `401 unauthenticated`. There is no other way to authenticate with the REST API: OAuth tokens are only for the [MCP server](https://signwith.co/docs/mcp), not for `/api/v1`. ## Create and revoke keys [Get your API key](https://app.signwith.co/settings/api) Create keys in SignWith under [Settings → Developers](https://app.signwith.co/settings/api), and revoke them there too. Each key belongs to the user who created it and acts with that user's permissions, so a key can only see the templates and signature requests its user can see. Keep keys on your server. Anyone with a key can act as its user, so never put one in a web page, a mobile app or a public repository. To replace a key, create the new one, deploy it, then revoke the old one. ## Live and test keys The prefix tells you which environment a key acts on: | Prefix | Environment | What happens | | --- | --- | --- | | `sw_live_` | Live | Sends real signing emails and uses credits. | | `sw_test_` | Test | Acts on your test account. Use it while you build; its data is kept apart from live data. | Both use the same base URL, `https://app.signwith.co/api/v1`. To check which environment, user and account a key belongs to, call [`GET /me`](https://signwith.co/docs/api/account#get-the-current-api-keys-user-and-account): cURL: ```bash curl https://app.signwith.co/api/v1/me \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/me', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/me", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` The response's `environment` is `live` or `test`, and `api_key.permission` is `full` or `read`. ## Full-access and read-only keys A **full-access** key can call every endpoint. A **read-only** key can call: - any `GET` endpoint, - [`POST /documents/verify`](https://signwith.co/docs/api/documents#verify-a-signed-pdf), which checks a PDF without changing anything, - [`POST /feedback`](https://signwith.co/docs/api/feedback#send-feedback-to-the-signwith-team), which sends feedback to the SignWith team. Every other write with a read-only key returns `403 read_only_api_key`. Use read-only keys for dashboards, reporting and anything else that only needs to look. Each endpoint in the [API reference](https://signwith.co/docs/api) says which kind of key it needs. ## Key expiry A key can have an expiry date, shown as `api_key.expires_at` in [`GET /me`](https://signwith.co/docs/api/account#get-the-current-api-keys-user-and-account) (`null` if it never expires). After that date every request returns `401 api_key_expired`, and you need a new key. ## Authentication errors | HTTP | Code | What to do | | --- | --- | --- | | 401 | `unauthenticated` | The key is missing, malformed or revoked, or its account was archived. Check the header format and the key. | | 401 | `api_key_expired` | The key passed its expiry date. Create a new one. | | 403 | `read_only_api_key` | A read-only key tried to change something. Use a full-access key. | | 403 | `forbidden` | The key's user can't access this resource. | All error codes are on the [errors page](https://signwith.co/docs/api/errors). --- # API requests: pagination and idempotency > The rules every SignWith API endpoint shares: where requests go, what they look like, how to page through lists, and how to retry without doing anything twice. Source: https://signwith.co/docs/api/requests · Updated 2026-10-02 ## Base URL and format All requests go to `https://app.signwith.co/api/v1`. They accept and return JSON (`Content-Type: application/json`) in UTF-8, except two uploads that also accept `multipart/form-data`: creating a template and verifying a document. - Timestamps are ISO 8601 strings, such as `2026-09-27T09:00:01Z`. - IDs are integers, except template field IDs, which are UUID strings, and checkout IDs, which are UUIDs. - Where an endpoint takes `PATCH`, `PUT` is accepted as an alias. - A path that doesn't exist returns `404 not_found` with the method and path in the message. Every request needs an [API key](https://signwith.co/docs/api/authentication). ## Pagination List endpoints return the newest items first, a page at a time, in this envelope: ```json { "object": "list", "data": [ ... ], "has_more": true, "next_cursor": 4812 } ``` - `limit` sets the page size, from 1 to 100 (default 20). Values below 1 fall back to 20, and values above 100 to 100. - `cursor` gets the next page: pass the `next_cursor` from the page before. - Keep going while `has_more` is `true`. On the last page `next_cursor` is `null`. This fetches every signature request, 100 at a time: ```js const items = [] let cursor = null do { const url = new URL('https://app.signwith.co/api/v1/signature_requests') url.searchParams.set('limit', '100') if (cursor) url.searchParams.set('cursor', String(cursor)) const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}` }, }) const page = await response.json() items.push(...page.data) cursor = page.has_more ? page.next_cursor : null } while (cursor) ``` ```python import os import requests items, cursor = [], None while True: params = {"limit": 100} if cursor: params["cursor"] = cursor page = requests.get( "https://app.signwith.co/api/v1/signature_requests", headers={"Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}"}, params=params, ).json() items.extend(page["data"]) if not page["has_more"]: break cursor = page["next_cursor"] ``` The list endpoints are [`GET /templates`](https://signwith.co/docs/api/templates#list-templates), [`GET /signature_requests`](https://signwith.co/docs/api/signature-requests#list-signature-requests) and [`GET /credit_packs`](https://signwith.co/docs/api/billing#list-credit-packs), which always returns every pack on one page. ## Idempotency Networks fail. To retry a `POST` without doing it twice, for example sending the same document to the same people again, send a unique `Idempotency-Key` header. A UUID v4 works well: ```http Idempotency-Key: 5f1c6c1e-0b1a-4d38-9d0e-8a7a1f2f8d11 ``` - The first successful (2xx) response is stored for **24 hours** per API key and path. A retry with the same key gets that stored response back, with the header `Idempotent-Replayed: true`. - Failed responses aren't stored, so you can retry a failed request with the same key. - If the first request is still running, a retry returns `409 idempotency_key_in_use`. Wait a moment and try again. - Reusing a key with a different request body returns `422 idempotency_key_reused`. Use a new key for each different request. Every `POST` endpoint accepts the header; the [API reference](https://signwith.co/docs/api) marks them. The code samples in these docs generate a new key for each run. ## Retrying safely | Response | Retry? | | --- | --- | | `429 rate_limited` | Yes, after the `Retry-After` seconds when the header is present. See [rate limits](https://signwith.co/docs/api/rate-limits). | | `409 idempotency_key_in_use` | Yes, with the same key, after a short wait. | | `500 internal_error` | Yes, with the same `Idempotency-Key` for a `POST`. Contact support if it keeps happening. | | `502 payment_provider_error` | Later. Nothing was charged. | | Other `4xx` | No. Fix the request first; the `error.code` says what's wrong. | --- # API error codes and what they mean > The SignWith API uses conventional HTTP status codes, and every error response has the same small envelope with a stable code your program can branch on. Source: https://signwith.co/docs/api/errors · Updated 2026-10-02 ## The error envelope Every `4xx` and `5xx` response looks like this: ```json { "error": { "code": "invalid_signer", "message": "Signer 2 needs an email or phone" } } ``` - `code` is stable. Branch on it in your code. - `message` is for people. It may change, so don't parse it; show it or log it. ## All error codes | HTTP | Code | Meaning | | --- | --- | --- | | 400 | `invalid_json` | The request body is not valid JSON. | | 401 | `unauthenticated` | The API key is missing, malformed or revoked, or its account has been archived. | | 401 | `api_key_expired` | The API key has passed its expiry date. Create a new one. | | 402 | `insufficient_credits` | Your account doesn't have enough credits to send this document. See [Buying credits](#section/Buying-credits). | | 403 | `read_only_api_key` | A read-only key was used for a write request. | | 403 | `insufficient_scope` | MCP only: the AI app was connected with **View only** access and tried to change something. | | 403 | `not_available` | MCP only: the tool isn't offered to this app (credit purchase tools in ChatGPT). | | 403 | `forbidden` | The key's user doesn't have access to this resource. | | 404 | `not_found` | The resource doesn't exist or belongs to another account, or no endpoint matches the method and path. | | 409 | `idempotency_key_in_use` | Another request with the same `Idempotency-Key` is still running. | | 422 | `idempotency_key_reused` | The `Idempotency-Key` was already used with a different request body. | | 422 | `missing_parameter` | A required parameter is missing. | | 422 | `invalid_parameter` | A parameter has an unsupported value (e.g. `status`, `signing_order`, `expires_at` not an ISO 8601 time in the future, `prefill`/`metadata` not an object, invalid signer email on update). | | 422 | `invalid_request` | The request is well-formed but can't be processed (e.g. invalid prefill value). | | 422 | `missing_documents` | A template was created without any documents. | | 422 | `too_many_documents` | More than 10 documents were sent for one template. | | 422 | `document_too_large` | A document is larger than 25 MB. | | 422 | `invalid_document` | A document is empty, could not be downloaded, or is not valid base64 / PDF. | | 422 | `invalid_file_type` | A document is not a PDF or an image. | | 422 | `invalid_field` | A template field in `fields` is invalid (unknown type, missing options or areas, page or coordinates out of range, too many roles). | | 422 | `template_has_no_fields` | The template has no fields to fill in or sign yet. Add fields with `PATCH /templates/{id}` or in the editor. | | 422 | `template_archived` | The template is archived and can't be sent. Duplicate it or pick another one. | | 422 | `pdf_encrypted` | The PDF is password protected and no `password` was sent. | | 422 | `missing_signers` | A signature request has no usable signers. | | 422 | `invalid_signer` | `signers` isn't a list of objects, a signer has no email or phone or an invalid email, has an unknown role, a role is given twice, or there are more signers than roles. | | 422 | `already_completed` | The signature request is already fully signed. | | 422 | `not_open` | The signature request was canceled, declined or has expired, so it can't be reminded and its signers can't be changed. | | 422 | `nothing_to_remind` | No signer is currently waiting to sign. | | 422 | `signer_finished` | The signer has already signed or declined. | | 422 | `invalid_discount_code` | The discount code is invalid, used up, expired or not valid for this credit pack. | | 422 | `already_purchased` | The user already has the lifetime deal. | | 422 | `billing_details_required` | The user must add a billing country in SignWith before buying credits. | | 429 | `rate_limited` | Too many requests (or more than 20 feedback messages in an hour). Wait for `Retry-After` seconds when present. | | 429 | `remind_too_soon` | Signers of this request were reminded less than an hour ago, or (`resend: true`) this signer was emailed less than 10 minutes ago. | | 500 | `internal_error` | Something went wrong on SignWith's side. Retry (with the same `Idempotency-Key` for `POST`s); contact support if it persists. | | 502 | `payment_provider_error` | The payment provider couldn't start the checkout. Try again later. | ## Handling errors in practice - **`402 insufficient_credits`** is the one most integrations should handle in the product: tell the user, or buy credits from the API and retry. See [credits and billing](https://signwith.co/docs/api/credits). - **`422` codes** mean the request itself needs fixing. The message names the parameter, signer or field, for example `Signer 2 has role "Buyer", but the template's roles are: Tenant, Landlord`. - **`429`, `409` and `5xx`** are temporary. [Retry them safely](https://signwith.co/docs/api/requests#retrying-safely) with the same `Idempotency-Key`. - **`401` and `403`** are about the key. See [authentication](https://signwith.co/docs/api/authentication). Each endpoint in the [API reference](https://signwith.co/docs/api) lists the statuses it can return. --- # API rate limits and 429 errors > Each API key can make 300 requests a minute. Every response tells you how many are left, and a 429 tells you how long to wait. Source: https://signwith.co/docs/api/rate-limits · Updated 2026-10-02 ## The limit Each API key may make **300 requests per minute**, counted in fixed one-minute windows. Every authenticated response includes three headers: | Header | Meaning | Example | | --- | --- | --- | | `RateLimit-Limit` | Requests allowed per window | `300` | | `RateLimit-Remaining` | Requests left in the current window | `287` | | `RateLimit-Reset` | Seconds until the window resets | `42` | Over the limit, the API returns `429` with code `rate_limited` and a `Retry-After` header: the number of seconds to wait. ## Waiting and retrying Read `Retry-After`, wait that long, then send the same request again. For a `POST`, keep the same `Idempotency-Key` so the retry can't do anything twice. ```js async function signwith(url, options = {}) { for (let attempt = 0; attempt < 5; attempt++) { const response = await fetch(url, { ...options, headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, ...options.headers }, }) if (response.status !== 429) return response const seconds = Number(response.headers.get('Retry-After')) || 60 await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) } throw new Error('Still rate limited after 5 attempts') } ``` If you send documents in bulk, watch `RateLimit-Remaining` and slow down before it reaches zero. ## Other limits Two endpoints have their own limits on top of the per-minute one: - **Reminders:** [`POST /signature_requests/{id}/remind`](https://signwith.co/docs/api/signature-requests#remind-waiting-signers) works at most once an hour per signature request. Sooner, it returns `429 remind_too_soon`, with `Retry-After` set to the seconds left. - **Resending one signer's link:** [`PATCH /signers/{id}`](https://signwith.co/docs/api/signers#update-a-signer) with `resend: true` emails a signer at most once every 10 minutes. Sooner, it returns `429 remind_too_soon` with `Retry-After`. - **Feedback:** [`POST /feedback`](https://signwith.co/docs/api/feedback#send-feedback-to-the-signwith-team) accepts 20 messages per user per clock hour, then returns `429 rate_limited` with `Retry-After` set to the seconds until the next hour. Webhooks don't count against your limit: SignWith calls you. Using webhooks instead of polling is the easiest way to stay well under it. See [webhooks](https://signwith.co/docs/api/webhooks). --- # E-signature API pricing and credits > The API has no plan or monthly fee of its own. It uses the same pay-per-document credits as the SignWith dashboard, and your code can check the balance and buy more. Source: https://signwith.co/docs/api/credits · Updated 2026-10-02 ## When a credit is used **One credit covers one signed document**, however many people sign it. Credits are used when a document is signed, not when it's sent. In the API, a document is a [template](https://signwith.co/docs/api/templates): - A template uses one credit the first time it's signed. - Sending the same template again, to anyone, doesn't use another credit. - Lifetime plans never run out: their balance shows `unlimited: true`. - The balance may go slightly below zero, down to `overdraft_limit`, before sending is blocked. Every account gets 3 free documents a month, and credit packs are one-time purchases with no subscription. Current packs and prices are on the [pricing page](https://signwith.co/pricing). ## Check the balance [`GET /credits`](https://signwith.co/docs/api/account#get-the-credit-balance) returns the balance and `can_send`, which says whether a request that needs a new credit can go out right now. Check it before sending in bulk, so you can ask for a top-up instead of hitting an error halfway. cURL: ```bash curl https://app.signwith.co/api/v1/credits \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/credits', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/credits", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` ## When credits run out When the account can't cover a new document, [`POST /signature_requests`](https://signwith.co/docs/api/signature-requests#send-a-signature-request) returns: ```json { "error": { "code": "insufficient_credits", "message": "Not enough credits to send this document. List credit packs with GET /api/v1/credit_packs and buy one with POST /api/v1/checkouts." } } ``` The status is `402`. Nothing was sent. Show the user the `purchase_url` from [`GET /credits`](https://signwith.co/docs/api/account#get-the-credit-balance) to buy credits in SignWith, or buy them from the API as below. ## Buy credits from the API An API client or AI agent can top up without leaving your product. Payment always happens on a hosted page: the API never handles card details. 1. List the packs on sale with [`GET /credit_packs`](https://signwith.co/docs/api/billing#list-credit-packs) and let the user choose one. 2. Start a checkout with [`POST /checkouts`](https://signwith.co/docs/api/billing#start-a-credit-purchase), passing `credit_pack_id` and, optionally, a `discount_code`. Send an `Idempotency-Key` so a retry doesn't start a second payment. 3. Send the user to the returned `checkout_url` to pay. 4. Poll [`GET /checkouts/{id}`](https://signwith.co/docs/api/billing#get-a-checkout) every few seconds, then less often, until `status` is `completed` (credits added) or `failed`. 5. Retry the request that failed. If you sent it with an `Idempotency-Key`, reuse the same key and body: failed responses aren't stored, so the retry runs normally. cURL: ```bash curl -X POST https://app.signwith.co/api/v1/checkouts \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "credit_pack_id": 4 }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/checkouts', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ credit_pack_id: 4, }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/checkouts", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "credit_pack_id": 4, }, ) print(response.status_code, response.json()) ``` Use the pack IDs that [`GET /credit_packs`](https://signwith.co/docs/api/billing#list-credit-packs) returns, rather than hard-coding them. Checkouts need a full-access key and a billing country on the user's SignWith profile; without one the API returns `422 billing_details_required`. Buying the lifetime deal twice returns `422 already_purchased`. If the payment provider can't start the payment, the API returns `502 payment_provider_error` and nothing is charged. > **AI assistants in ChatGPT:** The MCP server doesn't offer the purchase tools inside ChatGPT. There, the assistant tells the user to add credits in SignWith instead. See [ChatGPT](https://signwith.co/docs/mcp/chatgpt). --- # Account API reference > Who the API key belongs to and how many credits are left. Source: https://signwith.co/docs/api/account · Updated 2026-10-02 ## Get the current API key's user and account `GET https://app.signwith.co/api/v1/me` Read-only keys can call this. Returns the user and account the API key acts as, the environment (`live` or `test`) and details of the key itself. Call it when setting up an integration to check you're using the right key, or as a cheap health check for your stored credentials. ### Request sample cURL: ```bash curl https://app.signwith.co/api/v1/me \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/me', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/me", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` ### Response 200 The key's user, account and environment. ```json { "object": "me", "user": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "account": { "id": 7, "name": "Acme Inc." }, "environment": "live", "api_key": { "id": 118, "name": "Production CRM", "permission": "full", "expires_at": null } } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Get the credit balance `GET https://app.signwith.co/api/v1/credits` Read-only keys can call this. Returns how many credits the key's user has and whether they can send another document. One credit per signed document: a document (template) uses a credit the first time it is signed; sending the same document again doesn't use another. Check `can_send` before creating signature requests in bulk so you can prompt users to top up instead of hitting `402 insufficient_credits`. Users on a lifetime plan have `unlimited: true` and `available: null`. To buy more credits from the API see [`GET /credit_packs`](#operation/listCreditPacks); `purchase_url` is the equivalent page in the SignWith dashboard. ### Request sample cURL: ```bash curl https://app.signwith.co/api/v1/credits \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/credits', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/credits", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` ### Response 200 The credit balance. ```json { "object": "credits", "unlimited": false, "available": 12, "overdraft_limit": -3, "can_send": true, "billing": "One credit per signed document: a document (template) uses a credit the first time it is signed; sending the same document again doesn't use another.", "purchase_url": "https://app.signwith.co/credit_plans" } ``` ```json { "object": "credits", "unlimited": true, "available": null, "overdraft_limit": -3, "can_send": true, "billing": "One credit per signed document: a document (template) uses a credit the first time it is signed; sending the same document again doesn't use another.", "purchase_url": "https://app.signwith.co/credit_plans" } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## The me object Anchor: https://signwith.co/docs/api/account#the-me-object - `object` (string, required, always `me`). - `user` (object, required). A SignWith user (a member of your team). - `id` (integer, required). - `email` (string, required, email address). - `name` (string or null, required). First and last name, or `null` if not set. - `account` (object, required). - `id` (integer, required). - `name` (string or null, required). - `environment` (string, required, one of `live`, `test`). `test` for keys issued from your test account (`sw_test_`), otherwise `live`. - `api_key` (object, required). - `id` (integer, required). - `name` (string, required). The name you gave the key, or `Default key`. - `permission` (string, required, one of `full`, `read`). `read` keys can only call `GET` endpoints, `POST /documents/verify` and `POST /feedback`. - `expires_at` (string (date-time) or null, required). When the key stops working, or `null` if it never expires. ## The credits object Anchor: https://signwith.co/docs/api/account#the-credits-object - `object` (string, required, always `credits`). - `unlimited` (boolean, required). `true` for lifetime plans, which never run out of credits. - `available` (integer or null, required). Credits left. Can be slightly negative (see `overdraft_limit`). `null` when `unlimited`. - `overdraft_limit` (integer, required). How far below zero the balance may go before sending is blocked. - `can_send` (boolean, required). Whether a signature request that needs a new credit can be sent right now. - `billing` (string, required). Plain-language summary of how credits are used. - `purchase_url` (string, required, URL). Page in the SignWith dashboard where the user can buy credits. To buy from the API instead, use `GET /credit_packs` and `POST /checkouts`. --- # Templates API reference > Reusable documents with fields and roles, used to create signature requests. Source: https://signwith.co/docs/api/templates · Updated 2026-10-02 ## List templates `GET https://app.signwith.co/api/v1/templates` Read-only keys can call this. Lists the templates the key's user can access, newest first. Use it to let users pick a template in your app, or to look up a template by your own `external_id` or by folder. Archived templates are excluded unless `archived=true`. List items omit `fields` and `documents`; fetch a single template to get them. ### Query parameters | Name | Type | Description | | --- | --- | --- | | `limit` | integer | Number of items per page, 1–100. Values outside that range fall back to 20 (below 1) or 100 (above 100). Default `20`. | | `cursor` | integer | The `next_cursor` from the previous page. Omit it for the first page. | | `q` | string | Case-insensitive search on the template name. | | `archived` | boolean | `true` to list only archived templates instead of active ones. Default `false`. | | `external_id` | string | Only templates with this `external_id`. | | `folder` | string | Only templates in the folder with this exact name. | ### Request sample cURL: ```bash curl https://app.signwith.co/api/v1/templates \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/templates', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/templates", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` ### Response 200 A page of templates. ```json { "object": "list", "data": [ { "object": "template", "id": 311, "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "roles": [ "Tenant", "Landlord" ], "edit_url": "https://app.signwith.co/templates/311/edit", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "archived_at": null, "created_at": "2026-09-20T09:12:44Z", "updated_at": "2026-09-21T16:03:10Z" } ], "has_more": true, "next_cursor": 311 } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Create a template from documents `POST https://app.signwith.co/api/v1/templates` Needs a full-access key. Accepts `Idempotency-Key`. Uploads one or more documents (PDF or image, up to 10 per template, 25 MB each) and creates a template from them. Fillable PDF form fields are converted into template fields automatically. **Text tags.** Write fields into the document itself and they're placed where the tag is, with the tag text hidden: `{{Client signature;type=signature;role=Client}}`. The first part is the field name, followed by `key=value` settings: `type` (any field type; default `text`), `role` (created on the template if new), `required` (default `true`), `options` (comma-separated, for `select`) and `width`/`height` in points. `{{signature;role=Client}}` on its own is a signature field named "Signature". The same tag on several pages becomes one field (e.g. initials on every page). When every tag names a role, the template gets exactly those roles. Tags work in PDFs from any editor; keep each one on a line of its own text, or colour it white, for the cleanest result. To place signature and other fields yourself, pass `fields` (JSON requests): each field has a `type`, a `role` and one or more `areas` giving its page and position as fractions of the page size. Roles named in `fields` are created on the template. Otherwise, open the returned `edit_url` and place fields in the SignWith editor — a template needs at least one field before it can be sent. Use it to sync documents generated by your system into SignWith. Two request formats are supported: - `application/json` — `documents` is an array of `{ name, file }` where `file` is base64 content (a `data:` URI prefix is allowed) or an `https://` URL SignWith downloads. - `multipart/form-data` — upload files directly as `documents[]`. If `name` is omitted, the first document's filename is used. If `folder` doesn't exist it is created. Invalid `fields` return `422 invalid_field`. Triggers the `template.created` webhook. ### Headers | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key` | string | A unique value (e.g. a UUID) that makes retries of this request safe. A successful response is stored for 24 hours and replayed for retries with the same key. | ### Request body (`application/json`) - `name` (string). Template name. Defaults to the first document's name. - `external_id` (string). Your own ID for the template. - `folder` (string). Folder name. Created if it doesn't exist. - `password` (string). Password for encrypted PDFs. - `documents` (array of objects, required, 1 to 10 items). - `name` (string). Document name. Defaults to `Document `. - `file` (string, required). Base64-encoded PDF or image (a `data:` URI prefix is allowed), or an `https://` URL to download it from. Max 25 MB. - `fields` (array of objects). Fields to place on the documents, in addition to any fillable PDF form fields. Roles named here are added to the template (at most 10 roles). - `name` (string). Field name, used as the key in `prefill`. Defaults to e.g. `Signature 2`. - `type` (string, required, one of `text`, `signature`, `initials`, `date`, `checkbox`, `number`, `phone`, `select`, `radio`, `multiple`, `image`, `file`, `stamp`). - `role` (string). Role that fills this field. Created on the template if it doesn't exist. Defaults to the template's first role. - `required` (boolean, default `true`). - `options` (array of strings). Choices for `select`, `radio` and `multiple` fields (required for those types). - `areas` (array of objects, required, at least 1 item). Where the field goes. A field can appear in several places. - `page` (integer, required, at least 1). 1-based page number; must exist in the document. - `x` (number, required, 0 to 1). - `y` (number, required, 0 to 1). - `w` (number, required, at most 1, greater than 0). - `h` (number, required, at most 1, greater than 0). - `document` (integer, default `0`, at least 0). 0-based index of the document within the template. ### Request body (`multipart/form-data`) - `name` (string). Template name. Defaults to the first file's name. - `external_id` (string). - `folder` (string). Folder name. Created if it doesn't exist. - `password` (string). Password for encrypted PDFs. - `documents[]` (files, required, 1 to 10 items). PDF or image files, 25 MB max each. ### Request sample cURL: ```bash curl -X POST https://app.signwith.co/api/v1/templates \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "documents": [ { "name": "Lease agreement", "file": "https://files.acme.co/templates/lease-v3.pdf" } ] }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/templates', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ name: 'Residential lease', external_id: 'lease-v3', folder: 'Leasing', documents: [ { name: 'Lease agreement', file: 'https://files.acme.co/templates/lease-v3.pdf', }, ], }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/templates", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "documents": [ { "name": "Lease agreement", "file": "https://files.acme.co/templates/lease-v3.pdf", }, ], }, ) print(response.status_code, response.json()) ``` ### Response 201 The template was created. ```json { "object": "template", "id": 311, "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "roles": [ "Tenant", "Landlord" ], "edit_url": "https://app.signwith.co/templates/311/edit", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "archived_at": null, "created_at": "2026-09-20T09:12:44Z", "updated_at": "2026-09-21T16:03:10Z", "fields": [ { "id": "0f9c2b7e-5d1a-4e8b-9a61-3c2e7d4f1a90", "name": "Tenant name", "type": "text", "role": "Tenant", "required": true }, { "id": "6a3e1d42-8b7c-4f19-a0d5-2e9b4c7f8a13", "name": "Monthly rent", "type": "number", "role": "Tenant", "required": true }, { "id": "b2d8f4a1-3c6e-4a7b-8d92-5f1e0c3a9b76", "name": "Start date", "type": "date", "role": "Tenant", "required": true }, { "id": "91c7e3b5-2a4d-4f8e-b610-7d3a5c9e2f48", "name": "Pets", "type": "select", "role": "Tenant", "required": false, "options": [ "None", "Cat", "Dog" ] }, { "id": "4e8a2c6f-9b1d-4a3e-8c57-1f6d0b9e3a24", "name": "Tenant signature", "type": "signature", "role": "Tenant", "required": true }, { "id": "c5f1a9d3-7e2b-4c8a-9f60-3b8e4d2a7c15", "name": "Landlord signature", "type": "signature", "role": "Landlord", "required": true } ], "documents": [ { "id": 7730, "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTIzfX0/lease-agreement.pdf", "preview_image_url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTI0fX0/0.jpg" } ] } ``` ### Errors | Status | Meaning | | --- | --- | | 400 | The request body is not valid JSON. | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 409 | A request with the same `Idempotency-Key` is still being processed. Retry shortly. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Get a template `GET https://app.signwith.co/api/v1/templates/{id}` Read-only keys can call this. Returns a template with its roles, fields and documents. Use it to find out which roles to assign signers to and which field names you can prefill before creating a signature request. ### Path parameters | Name | Type | Description | | --- | --- | --- | | `id` (required) | integer | Template ID. | ### Request sample cURL: ```bash curl https://app.signwith.co/api/v1/templates/311 \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/templates/311', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/templates/311", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` ### Response 200 The template. ```json { "object": "template", "id": 311, "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "roles": [ "Tenant", "Landlord" ], "edit_url": "https://app.signwith.co/templates/311/edit", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "archived_at": null, "created_at": "2026-09-20T09:12:44Z", "updated_at": "2026-09-21T16:03:10Z", "fields": [ { "id": "0f9c2b7e-5d1a-4e8b-9a61-3c2e7d4f1a90", "name": "Tenant name", "type": "text", "role": "Tenant", "required": true }, { "id": "6a3e1d42-8b7c-4f19-a0d5-2e9b4c7f8a13", "name": "Monthly rent", "type": "number", "role": "Tenant", "required": true }, { "id": "b2d8f4a1-3c6e-4a7b-8d92-5f1e0c3a9b76", "name": "Start date", "type": "date", "role": "Tenant", "required": true }, { "id": "91c7e3b5-2a4d-4f8e-b610-7d3a5c9e2f48", "name": "Pets", "type": "select", "role": "Tenant", "required": false, "options": [ "None", "Cat", "Dog" ] }, { "id": "4e8a2c6f-9b1d-4a3e-8c57-1f6d0b9e3a24", "name": "Tenant signature", "type": "signature", "role": "Tenant", "required": true }, { "id": "c5f1a9d3-7e2b-4c8a-9f60-3b8e4d2a7c15", "name": "Landlord signature", "type": "signature", "role": "Landlord", "required": true } ], "documents": [ { "id": 7730, "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTIzfX0/lease-agreement.pdf", "preview_image_url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTI0fX0/0.jpg" } ] } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Update a template `PATCH https://app.signwith.co/api/v1/templates/{id}` Needs a full-access key. Renames a template, changes its `external_id` or folder, renames its roles, or replaces its fields. Use it to keep template metadata in sync with your system, or to place fields on a template created without them. Only the parameters you send are changed. `roles` is matched by position: the first name renames the first role, and so on. Extra names add new roles (without fields). Roles can't be removed through the API. `fields`, when sent, **replaces all of the template's fields** (send `[]` to remove them all). Roles named in `fields` that don't exist yet are added. Invalid fields return `422 invalid_field`. Changes only affect signature requests sent afterwards: requests already sent keep the fields and documents they were sent with. `PUT` is accepted as an alias. Triggers the `template.updated` webhook. ### Path parameters | Name | Type | Description | | --- | --- | --- | | `id` (required) | integer | Template ID. | ### Request body (`application/json`) - `name` (string). - `external_id` (string or null). Your own ID for the template. Send `null` to clear it. - `folder` (string). Folder name. Created if it doesn't exist. - `roles` (array of strings). New role names, matched by position. Extra names add roles. - `fields` (array of objects). Replaces all of the template's fields. Roles named here are added if missing. - `name` (string). Field name, used as the key in `prefill`. Defaults to e.g. `Signature 2`. - `type` (string, required, one of `text`, `signature`, `initials`, `date`, `checkbox`, `number`, `phone`, `select`, `radio`, `multiple`, `image`, `file`, `stamp`). - `role` (string). Role that fills this field. Created on the template if it doesn't exist. Defaults to the template's first role. - `required` (boolean, default `true`). - `options` (array of strings). Choices for `select`, `radio` and `multiple` fields (required for those types). - `areas` (array of objects, required, at least 1 item). Where the field goes. A field can appear in several places. - `page` (integer, required, at least 1). 1-based page number; must exist in the document. - `x` (number, required, 0 to 1). - `y` (number, required, 0 to 1). - `w` (number, required, at most 1, greater than 0). - `h` (number, required, at most 1, greater than 0). - `document` (integer, default `0`, at least 0). 0-based index of the document within the template. ### Request sample cURL: ```bash curl -X PATCH https://app.signwith.co/api/v1/templates/311 \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Residential lease (2027)", "external_id": "lease-v4", "folder": "Leasing", "roles": [ "Tenant", "Landlord" ] }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/templates/311', { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Residential lease (2027)', external_id: 'lease-v4', folder: 'Leasing', roles: ['Tenant', 'Landlord'], }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.patch( "https://app.signwith.co/api/v1/templates/311", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, json={ "name": "Residential lease (2027)", "external_id": "lease-v4", "folder": "Leasing", "roles": ["Tenant", "Landlord"], }, ) print(response.status_code, response.json()) ``` ### Response 200 The updated template. ```json { "object": "template", "id": 311, "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "roles": [ "Tenant", "Landlord" ], "edit_url": "https://app.signwith.co/templates/311/edit", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "archived_at": null, "created_at": "2026-09-20T09:12:44Z", "updated_at": "2026-09-21T16:03:10Z", "fields": [ { "id": "0f9c2b7e-5d1a-4e8b-9a61-3c2e7d4f1a90", "name": "Tenant name", "type": "text", "role": "Tenant", "required": true }, { "id": "6a3e1d42-8b7c-4f19-a0d5-2e9b4c7f8a13", "name": "Monthly rent", "type": "number", "role": "Tenant", "required": true }, { "id": "b2d8f4a1-3c6e-4a7b-8d92-5f1e0c3a9b76", "name": "Start date", "type": "date", "role": "Tenant", "required": true }, { "id": "91c7e3b5-2a4d-4f8e-b610-7d3a5c9e2f48", "name": "Pets", "type": "select", "role": "Tenant", "required": false, "options": [ "None", "Cat", "Dog" ] }, { "id": "4e8a2c6f-9b1d-4a3e-8c57-1f6d0b9e3a24", "name": "Tenant signature", "type": "signature", "role": "Tenant", "required": true }, { "id": "c5f1a9d3-7e2b-4c8a-9f60-3b8e4d2a7c15", "name": "Landlord signature", "type": "signature", "role": "Landlord", "required": true } ], "documents": [ { "id": 7730, "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTIzfX0/lease-agreement.pdf", "preview_image_url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTI0fX0/0.jpg" } ] } ``` ### Errors | Status | Meaning | | --- | --- | | 400 | The request body is not valid JSON. | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Duplicate a template `POST https://app.signwith.co/api/v1/templates/{id}/duplicate` Needs a full-access key. Accepts `Idempotency-Key`. Creates a copy of a template, including its documents, fields and roles. Use it to create a variant (for example a per-customer version) without re-uploading and re-placing fields. Optionally give the copy a new name, `external_id` or folder. Triggers the `template.created` webhook. ### Path parameters | Name | Type | Description | | --- | --- | --- | | `id` (required) | integer | Template ID. | ### Headers | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key` | string | A unique value (e.g. a UUID) that makes retries of this request safe. A successful response is stored for 24 hours and replayed for retries with the same key. | ### Request body (`application/json`) - `name` (string). Name of the copy. Defaults to the original name followed by "(Clone)". - `external_id` (string). - `folder` (string). Folder for the copy. Created if it doesn't exist. ### Request sample cURL: ```bash curl -X POST https://app.signwith.co/api/v1/templates/311/duplicate \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Residential lease — Riverside Apartments", "external_id": "lease-v3-riverside", "folder": "Leasing" }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/templates/311/duplicate', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ name: 'Residential lease — Riverside Apartments', external_id: 'lease-v3-riverside', folder: 'Leasing', }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/templates/311/duplicate", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "Residential lease — Riverside Apartments", "external_id": "lease-v3-riverside", "folder": "Leasing", }, ) print(response.status_code, response.json()) ``` ### Response 201 The new template. ```json { "object": "template", "id": 311, "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "roles": [ "Tenant", "Landlord" ], "edit_url": "https://app.signwith.co/templates/311/edit", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "archived_at": null, "created_at": "2026-09-20T09:12:44Z", "updated_at": "2026-09-21T16:03:10Z", "fields": [ { "id": "0f9c2b7e-5d1a-4e8b-9a61-3c2e7d4f1a90", "name": "Tenant name", "type": "text", "role": "Tenant", "required": true }, { "id": "6a3e1d42-8b7c-4f19-a0d5-2e9b4c7f8a13", "name": "Monthly rent", "type": "number", "role": "Tenant", "required": true }, { "id": "b2d8f4a1-3c6e-4a7b-8d92-5f1e0c3a9b76", "name": "Start date", "type": "date", "role": "Tenant", "required": true }, { "id": "91c7e3b5-2a4d-4f8e-b610-7d3a5c9e2f48", "name": "Pets", "type": "select", "role": "Tenant", "required": false, "options": [ "None", "Cat", "Dog" ] }, { "id": "4e8a2c6f-9b1d-4a3e-8c57-1f6d0b9e3a24", "name": "Tenant signature", "type": "signature", "role": "Tenant", "required": true }, { "id": "c5f1a9d3-7e2b-4c8a-9f60-3b8e4d2a7c15", "name": "Landlord signature", "type": "signature", "role": "Landlord", "required": true } ], "documents": [ { "id": 7730, "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTIzfX0/lease-agreement.pdf", "preview_image_url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTI0fX0/0.jpg" } ] } ``` ### Errors | Status | Meaning | | --- | --- | | 400 | The request body is not valid JSON. | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 409 | A request with the same `Idempotency-Key` is still being processed. Retry shortly. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Archive a template `POST https://app.signwith.co/api/v1/templates/{id}/archive` Needs a full-access key. Accepts `Idempotency-Key`. Archives a template so it no longer shows among active templates. Use it when a document version is retired. Existing signature requests are not affected. Archiving an already archived template succeeds and returns it unchanged. The `template.archived` webhook fires only the first time. Archived templates can't be used for new signature requests (`422 template_archived`), and signing links of unfinished requests from them stop working. ### Path parameters | Name | Type | Description | | --- | --- | --- | | `id` (required) | integer | Template ID. | ### Headers | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key` | string | A unique value (e.g. a UUID) that makes retries of this request safe. A successful response is stored for 24 hours and replayed for retries with the same key. | ### Request sample cURL: ```bash curl -X POST https://app.signwith.co/api/v1/templates/311/archive \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/templates/311/archive', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Idempotency-Key': crypto.randomUUID(), }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/templates/311/archive", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, ) print(response.status_code, response.json()) ``` ### Response 200 The archived template. ```json { "object": "template", "id": 311, "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "roles": [ "Tenant", "Landlord" ], "edit_url": "https://app.signwith.co/templates/311/edit", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "archived_at": null, "created_at": "2026-09-20T09:12:44Z", "updated_at": "2026-09-21T16:03:10Z", "fields": [ { "id": "0f9c2b7e-5d1a-4e8b-9a61-3c2e7d4f1a90", "name": "Tenant name", "type": "text", "role": "Tenant", "required": true }, { "id": "6a3e1d42-8b7c-4f19-a0d5-2e9b4c7f8a13", "name": "Monthly rent", "type": "number", "role": "Tenant", "required": true }, { "id": "b2d8f4a1-3c6e-4a7b-8d92-5f1e0c3a9b76", "name": "Start date", "type": "date", "role": "Tenant", "required": true }, { "id": "91c7e3b5-2a4d-4f8e-b610-7d3a5c9e2f48", "name": "Pets", "type": "select", "role": "Tenant", "required": false, "options": [ "None", "Cat", "Dog" ] }, { "id": "4e8a2c6f-9b1d-4a3e-8c57-1f6d0b9e3a24", "name": "Tenant signature", "type": "signature", "role": "Tenant", "required": true }, { "id": "c5f1a9d3-7e2b-4c8a-9f60-3b8e4d2a7c15", "name": "Landlord signature", "type": "signature", "role": "Landlord", "required": true } ], "documents": [ { "id": 7730, "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTIzfX0/lease-agreement.pdf", "preview_image_url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTI0fX0/0.jpg" } ] } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 409 | A request with the same `Idempotency-Key` is still being processed. Retry shortly. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## The template object Anchor: https://signwith.co/docs/api/templates#the-template-object - `object` (string, required, always `template`). - `id` (integer, required). - `name` (string, required). - `external_id` (string or null, required). Your own ID for the template. - `folder` (string or null, required). Name of the folder the template is in. - `roles` (array of strings, required). Role names in signing order. - `edit_url` (string, required, URL). Opens the template in the SignWith editor, where the user can place or adjust fields. - `created_by` (object or null, required). A SignWith user (a member of your team). - `id` (integer, required). - `email` (string, required, email address). - `name` (string or null, required). First and last name, or `null` if not set. - `archived_at` (string (date-time) or null, required). - `created_at` (string (date-time), required). - `updated_at` (string (date-time), required). - `fields` (array of objects, required). A field placed on a template's documents. Keys with no value are omitted. - `id` (string (uuid), required). Stable field ID. - `name` (string). Field name. Use it as the key in `prefill`. Omitted if the field has no name. - `type` (string, required). Field type, e.g. `text`, `signature`, `initials`, `date`, `number`, `checkbox`, `radio`, `select`, `multiple`, `image`, `file`, `phone`, `stamp`, `cells`. - `role` (string). Name of the role that fills this field. - `required` (boolean, required). Whether the signer must fill the field. - `options` (array of strings). Allowed values for `select`, `radio` and `multiple` fields. - `documents` (array of objects, required). A document (one uploaded file) of a template. - `id` (integer, required). - `name` (string, required). File name without extension. - `url` (string, required, URL). Download link for the original file. - `preview_image_url` (string or null, URL). Image of the first page, if available. --- # Signature requests API reference > Send a template to people for signing and track progress. Source: https://signwith.co/docs/api/signature-requests · Updated 2026-10-02 ## List signature requests `GET https://app.signwith.co/api/v1/signature_requests` Read-only keys can call this. Lists signature requests, newest first. Use it to build a dashboard of outstanding documents or to reconcile state if you missed webhooks. List items include signers but not their field values or the signed documents; fetch a single signature request for those. ### Query parameters | Name | Type | Description | | --- | --- | --- | | `limit` | integer | Number of items per page, 1–100. Values outside that range fall back to 20 (below 1) or 100 (above 100). Default `20`. | | `cursor` | integer | The `next_cursor` from the previous page. Omit it for the first page. | | `template_id` | integer | Only signature requests created from this template. | | `q` | string | Case-insensitive search on signer name, email or phone. | | `status` | string | Filter by status. `open` includes requests that are `sent` or `in_progress`; `expired` returns unfinished requests past their `expires_at`. Any other value returns `422 invalid_parameter`. | ### Request sample cURL: ```bash curl https://app.signwith.co/api/v1/signature_requests \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/signature_requests', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/signature_requests", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` ### Response 200 A page of signature requests. ```json { "object": "list", "data": [ { "object": "signature_request", "id": 4812, "status": "in_progress", "template": { "id": 311, "name": "Residential lease" }, "signing_order": "sequential", "source": "api", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "signers": [ { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": {}, "status": "signed", "signing_url": null, "sent_at": "2026-09-27T09:00:02Z", "viewed_at": "2026-09-27T09:41:18Z", "signed_at": "2026-09-27T09:44:51Z", "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:44:51Z" }, { "object": "signer", "id": 9121, "signature_request_id": 4812, "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co", "phone": null, "external_id": null, "metadata": {}, "status": "sent", "signing_url": "https://app.signwith.co/s/q8LkT2mZpV4r", "sent_at": "2026-09-27T09:44:53Z", "viewed_at": null, "signed_at": null, "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:44:53Z" } ], "expires_at": "2026-10-27T00:00:00Z", "completed_at": null, "canceled_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:44:53Z" } ], "has_more": false, "next_cursor": null } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Send a signature request `POST https://app.signwith.co/api/v1/signature_requests` Needs a full-access key. Accepts `Idempotency-Key`. Creates a signature request from a template and invites the signers. This is the main call for getting a document signed from your product. - Assign each signer to a template role with `role`. Signers without a `role` fill the template's roles in order. You can't send more signers than the template has roles, and each role can be given to only one signer. - `signers` is a list of objects. Each signer needs a valid `email` or a `phone` (`422 invalid_signer`); `prefill` and `metadata` must be objects (`422 invalid_parameter`). - `expires_at` must be an ISO 8601 date-time in the future (`422 invalid_parameter`). - Use `prefill` to fill fields for a signer ahead of time, keyed by field name (see [`GET /templates/{id}`](#operation/getTemplate) for field names). - Set `send_email: false` to create the request without emailing anyone — for example to embed or deliver the signers' `signing_url` yourself. Those signers have status `ready` when it's their turn. - Set `require_email_otp: true` to require each signer to enter a one-time code sent to their email before they can view and sign. The template must be active (`422 template_archived`) and have at least one field (`422 template_has_no_fields` — add fields with [`PATCH /templates/{id}`](#operation/updateTemplate) or in the editor). The request keeps a snapshot of the template's fields and documents, so later template edits don't change what these signers see. The first signature request from a template uses one credit when it's signed; if you have no credits left the request is rejected with `402 insufficient_credits` — see [Buying credits](#section/Buying-credits) for how to top up and retry. Send an `Idempotency-Key` so retries don't send the document twice. Triggers the `signature_request.created` webhook. ### Headers | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key` | string | A unique value (e.g. a UUID) that makes retries of this request safe. A successful response is stored for 24 hours and replayed for retries with the same key. | ### Request body (`application/json`) - `template_id` (integer, required). The template to send. - `signers` (array of objects, required, at least 1 item). The people to invite. At most one per template role. - `role` (string). The template role this signer fills. If omitted, signers fill the template's roles in the order given. - `name` (string). - `email` (string, email address). Required unless `phone` is given. - `phone` (string). Phone number in international format. - `external_id` (string). Your own ID for this signer, returned in responses and webhooks. - `metadata` (object). Arbitrary key/value data stored with the signer. - `prefill` (object). Values to fill in for a signer before they sign, keyed by field name (see the template's `fields`). Values must suit the field type: text, dates as `YYYY-MM-DD`, checkboxes as booleans, and for image or signature fields a base64 image or an https URL. - `redirect_url` (string, URL). Where to send this signer after they sign. Overrides the request-level `redirect_url`. - `require_email_otp` (boolean). Require each signer to enter a one-time code sent to their email before they can view and sign. Overrides the request-level `require_email_otp` for this signer. - `signing_order` (string, one of `sequential`, `parallel`, default `"sequential"`). `sequential` invites signers one at a time in role order; `parallel` invites everyone at once. - `send_email` (boolean, default `true`). Set to `false` to create the request without emailing signers. - `message` (object). Custom subject and body for the invitation email. - `subject` (string). - `body` (string). - `reply_to` (string, email address). Reply-to address for emails sent to signers. - `redirect_url` (string, URL). Where to send signers after they sign. - `expires_at` (string (date-time)). ISO 8601 date-time in the future (e.g. `2026-12-31T17:00:00Z`). After it the request expires and can no longer be signed. - `require_email_otp` (boolean, default `false`). Require each signer to enter a one-time code sent to their email before they can view and sign. ### Request sample cURL: ```bash curl -X POST https://app.signwith.co/api/v1/signature_requests \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "template_id": 311, "signing_order": "sequential", "expires_at": "2026-10-27T00:00:00Z", "reply_to": "leasing@acme.co", "redirect_url": "https://acme.co/lease/signed", "message": { "subject": "Your lease for 14 Riverside Ave is ready to sign", "body": "Hi Grace, please review and sign your lease." }, "signers": [ { "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "external_id": "cust_8841", "metadata": { "crm_deal_id": "D-2291" }, "prefill": { "Tenant name": "Grace Hopper", "Monthly rent": "2150", "Start date": "2026-11-01" }, "require_email_otp": true }, { "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co" } ] }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/signature_requests', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ template_id: 311, signing_order: 'sequential', expires_at: '2026-10-27T00:00:00Z', reply_to: 'leasing@acme.co', redirect_url: 'https://acme.co/lease/signed', message: { subject: 'Your lease for 14 Riverside Ave is ready to sign', body: 'Hi Grace, please review and sign your lease.', }, signers: [ { role: 'Tenant', name: 'Grace Hopper', email: 'grace@example.com', external_id: 'cust_8841', metadata: { crm_deal_id: 'D-2291', }, prefill: { 'Tenant name': 'Grace Hopper', 'Monthly rent': '2150', 'Start date': '2026-11-01', }, require_email_otp: true, }, { role: 'Landlord', name: 'Ada Lovelace', email: 'ada@acme.co', }, ], }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/signature_requests", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "template_id": 311, "signing_order": "sequential", "expires_at": "2026-10-27T00:00:00Z", "reply_to": "leasing@acme.co", "redirect_url": "https://acme.co/lease/signed", "message": { "subject": "Your lease for 14 Riverside Ave is ready to sign", "body": "Hi Grace, please review and sign your lease.", }, "signers": [ { "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "external_id": "cust_8841", "metadata": { "crm_deal_id": "D-2291", }, "prefill": { "Tenant name": "Grace Hopper", "Monthly rent": "2150", "Start date": "2026-11-01", }, "require_email_otp": True, }, { "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co", }, ], }, ) print(response.status_code, response.json()) ``` ### Response 201 The signature request was created and signers were invited. ```json { "object": "signature_request", "id": 4812, "status": "sent", "template": { "id": 311, "name": "Residential lease" }, "signing_order": "sequential", "source": "api", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "signers": [ { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": { "crm_deal_id": "D-2291" }, "status": "sent", "signing_url": "https://app.signwith.co/s/Hn3cW8yRk2Qe", "sent_at": "2026-09-27T09:00:02Z", "viewed_at": null, "signed_at": null, "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:00:02Z", "values": [ { "field": "Tenant name", "value": "Grace Hopper" }, { "field": "Monthly rent", "value": "2150" }, { "field": "Start date", "value": "2026-11-01" } ] }, { "object": "signer", "id": 9121, "signature_request_id": 4812, "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co", "phone": null, "external_id": null, "metadata": {}, "status": "waiting", "signing_url": "https://app.signwith.co/s/q8LkT2mZpV4r", "sent_at": null, "viewed_at": null, "signed_at": null, "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:00:01Z", "values": [] } ], "expires_at": "2026-10-27T00:00:00Z", "completed_at": null, "canceled_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:00:01Z" } ``` ### Errors | Status | Meaning | | --- | --- | | 400 | The request body is not valid JSON. | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 402 | Not enough credits to send this document. Buy credits with `GET /credit_packs` and `POST /checkouts`, then retry. See [Buying credits](#section/Buying-credits). | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 409 | A request with the same `Idempotency-Key` is still being processed. Retry shortly. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Get a signature request `GET https://app.signwith.co/api/v1/signature_requests/{id}` Read-only keys can call this. Returns a signature request with every signer's progress and field values. Once everyone has signed (`status: completed`) it also includes download links for the signed `documents`, the `audit_trail_url` and, when available, a `combined_document_url`. Use it after a `signature_request.completed` webhook to download the final files. ### Path parameters | Name | Type | Description | | --- | --- | --- | | `id` (required) | integer | Signature request ID. | ### Request sample cURL: ```bash curl https://app.signwith.co/api/v1/signature_requests/4812 \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/signature_requests/4812', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/signature_requests/4812", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` ### Response 200 The signature request. ```json { "object": "signature_request", "id": 4812, "status": "completed", "template": { "id": 311, "name": "Residential lease" }, "signing_order": "sequential", "source": "api", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "signers": [ { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": { "crm_deal_id": "D-2291" }, "status": "signed", "signing_url": null, "sent_at": "2026-09-27T09:00:02Z", "viewed_at": "2026-09-27T09:41:18Z", "signed_at": "2026-09-27T09:44:51Z", "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:44:51Z", "values": [ { "field": "Tenant name", "value": "Grace Hopper" }, { "field": "Monthly rent", "value": "2150" }, { "field": "Start date", "value": "2026-11-01" }, { "field": "Pets", "value": "Cat" }, { "field": "Tenant signature", "value": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTUxfX0/signature.png" } ], "documents": [ { "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTUyfX0/lease-agreement.pdf" } ] }, { "object": "signer", "id": 9121, "signature_request_id": 4812, "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co", "phone": null, "external_id": null, "metadata": {}, "status": "signed", "signing_url": null, "sent_at": "2026-09-27T09:44:53Z", "viewed_at": "2026-09-27T10:12:30Z", "signed_at": "2026-09-27T10:14:58Z", "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T10:14:58Z", "values": [ { "field": "Landlord signature", "value": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTYwfX0/signature.png" } ], "documents": [ { "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTYxfX0/lease-agreement.pdf" } ] } ], "expires_at": "2026-10-27T00:00:00Z", "completed_at": "2026-09-27T10:14:58Z", "canceled_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T10:14:58Z", "documents": [ { "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTYxfX0/lease-agreement.pdf" } ], "audit_trail_url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTYyfX0/audit-trail.pdf", "combined_document_url": null } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Cancel a signature request `POST https://app.signwith.co/api/v1/signature_requests/{id}/cancel` Needs a full-access key. Accepts `Idempotency-Key`. Cancels a signature request so its signing links stop working — for example when a deal falls through or the document was sent with a mistake. Fully signed requests can't be canceled (`422 already_completed`). Canceling an already canceled request succeeds and returns it unchanged. Triggers the `signature_request.canceled` webhook. ### Path parameters | Name | Type | Description | | --- | --- | --- | | `id` (required) | integer | Signature request ID. | ### Headers | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key` | string | A unique value (e.g. a UUID) that makes retries of this request safe. A successful response is stored for 24 hours and replayed for retries with the same key. | ### Request sample cURL: ```bash curl -X POST https://app.signwith.co/api/v1/signature_requests/4812/cancel \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/signature_requests/4812/cancel', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Idempotency-Key': crypto.randomUUID(), }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/signature_requests/4812/cancel", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, ) print(response.status_code, response.json()) ``` ### Response 200 The canceled signature request. ```json { "object": "signature_request", "id": 4812, "status": "canceled", "template": { "id": 311, "name": "Residential lease" }, "signing_order": "sequential", "source": "api", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "signers": [ { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": {}, "status": "viewed", "signing_url": "https://app.signwith.co/s/Hn3cW8yRk2Qe", "sent_at": "2026-09-27T09:00:02Z", "viewed_at": "2026-09-27T09:41:18Z", "signed_at": null, "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:41:18Z", "values": [] } ], "expires_at": null, "completed_at": null, "canceled_at": "2026-09-28T08:30:12Z", "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-28T08:30:12Z" } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 409 | A request with the same `Idempotency-Key` is still being processed. Retry shortly. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Remind waiting signers `POST https://app.signwith.co/api/v1/signature_requests/{id}/remind` Needs a full-access key. Accepts `Idempotency-Key`. Emails the signing link again to every signer who has been invited, hasn't signed or declined yet, and has an email address. Use it to nudge people who haven't acted. Signers still waiting for their turn in a sequential request are not reminded. A request can be reminded at most once per hour (`429 remind_too_soon`, with `Retry-After` set to the seconds left until it can be reminded again). Returns `422 not_open` for canceled, declined or expired requests and `422 nothing_to_remind` when nobody is waiting. ### Path parameters | Name | Type | Description | | --- | --- | --- | | `id` (required) | integer | Signature request ID. | ### Headers | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key` | string | A unique value (e.g. a UUID) that makes retries of this request safe. A successful response is stored for 24 hours and replayed for retries with the same key. | ### Request sample cURL: ```bash curl -X POST https://app.signwith.co/api/v1/signature_requests/4812/remind \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/signature_requests/4812/remind', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Idempotency-Key': crypto.randomUUID(), }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/signature_requests/4812/remind", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, ) print(response.status_code, response.json()) ``` ### Response 200 Which signers were reminded. ```json { "object": "reminder", "signature_request_id": 4812, "reminded_signer_ids": [ 9121 ] } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 409 | A request with the same `Idempotency-Key` is still being processed. Retry shortly. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## The signature request object Anchor: https://signwith.co/docs/api/signature-requests#the-signature-request-object - `object` (string, required, always `signature_request`). - `id` (integer, required). - `status` (string, required, one of `sent`, `in_progress`, `completed`, `declined`, `expired`, `canceled`). `sent` — nobody has signed yet; `in_progress` — some signers have signed; `completed` — everyone signed; `declined` — a signer declined; `expired` — passed `expires_at` before completion; `canceled` — canceled. A fully signed request is always `completed`, even if it was later archived. - `template` (object or null, required). The template this request was created from. - `id` (integer, required). - `name` (string, required). - `signing_order` (string, required, one of `sequential`, `parallel`). - `source` (string, required). Where the request was created: `api` (this API), `mcp` (an AI assistant through the SignWith MCP server), `invite` (dashboard), `link` (shared link), `bulk` or `embed`. - `created_by` (object or null, required). A SignWith user (a member of your team). - `id` (integer, required). - `email` (string, required, email address). - `name` (string or null, required). First and last name, or `null` if not set. - `signers` (array of objects, required). Signers in role order, with their field values. - `object` (string, required, always `signer`). - `id` (integer, required). - `signature_request_id` (integer, required). - `role` (string or null, required). The template role this signer fills. - `name` (string or null, required). - `email` (string or null, required, email address). - `phone` (string or null, required). - `external_id` (string or null, required). Your own ID for this signer. - `metadata` (object, required). Arbitrary key/value data you attached to the signer. - `status` (string, required, one of `waiting`, `ready`, `sent`, `viewed`, `signed`, `declined`). `waiting` — an earlier signer in a sequential request has to sign first; `ready` — it's their turn but they weren't emailed (e.g. `send_email: false`, or the email is still queued), so share `signing_url` yourself; `sent` — invited; `viewed` — opened the document; `signed` — finished signing; `declined` — declined to sign. - `signing_url` (string or null, required, URL). The signer's private signing link. Anyone with it can sign as this signer, so only share it with them. `null` once they've signed, or when the request is canceled, declined or expired, or its template is archived. - `sent_at` (string (date-time) or null, required). - `viewed_at` (string (date-time) or null, required). - `signed_at` (string (date-time) or null, required). - `declined_at` (string (date-time) or null, required). - `created_at` (string (date-time), required). - `updated_at` (string (date-time), required). - `values` (array of objects, required). Field values filled so far (prefilled or entered by the signer). - `field` (string, required). Field name (or a generated name such as `Text Field 2` for unnamed fields). - `value` (string or number or boolean or array of anys or null, required). The value entered or prefilled. - `documents` (array of objects). The signer's signed documents. Present only once they've signed. - `name` (string, required). File name without extension. - `url` (string, required, URL). Download link for the signed PDF. - `expires_at` (string (date-time) or null, required). - `completed_at` (string (date-time) or null, required). When the last signer signed. - `canceled_at` (string (date-time) or null, required). - `created_at` (string (date-time), required). - `updated_at` (string (date-time), required). - `documents` (array of objects). The final signed documents. - `name` (string, required). File name without extension. - `url` (string, required, URL). Download link for the signed PDF. - `audit_trail_url` (string or null, URL). Download link for the audit trail PDF. - `combined_document_url` (string or null, URL). Download link for all documents and the audit trail merged into one PDF, if generated. --- # Signers API reference > Individual people on a signature request. Source: https://signwith.co/docs/api/signers · Updated 2026-10-02 ## Get a signer `GET https://app.signwith.co/api/v1/signers/{id}` Read-only keys can call this. Returns one signer with their status, signing link, field values and — once they've signed — their signed documents. Use it when you track signers individually (e.g. from a `signer.*` webhook or by storing signer IDs against your own users). ### Path parameters | Name | Type | Description | | --- | --- | --- | | `id` (required) | integer | Signer ID. | ### Request sample cURL: ```bash curl https://app.signwith.co/api/v1/signers/9121 \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/signers/9121', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/signers/9121", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` ### Response 200 The signer. ```json { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": { "crm_deal_id": "D-2291" }, "status": "signed", "signing_url": null, "sent_at": "2026-09-27T09:00:02Z", "viewed_at": "2026-09-27T09:41:18Z", "signed_at": "2026-09-27T09:44:51Z", "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:44:51Z", "values": [ { "field": "Tenant name", "value": "Grace Hopper" }, { "field": "Monthly rent", "value": "2150" }, { "field": "Tenant signature", "value": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTUxfX0/signature.png" } ], "documents": [ { "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTUyfX0/lease-agreement.pdf" } ] } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Update a signer `PATCH https://app.signwith.co/api/v1/signers/{id}` Needs a full-access key. Corrects a signer's details or prefills more of their fields before they sign — for example when a customer gave the wrong email address. Only the parameters you send are changed; `prefill` values are merged with existing ones. Set `resend: true` to email the signing link again (only if the signer was already invited; at most once every 10 minutes per signer, otherwise `429 remind_too_soon` with `Retry-After`). Signers who have signed or declined can't be changed (`422 signer_finished`), nor can signers of a canceled, expired or declined request (`422 not_open`). The signer must keep an `email` or a `phone`, emails must be valid, and `prefill` must be an object (all `422 invalid_parameter`). `PUT` is accepted as an alias. ### Path parameters | Name | Type | Description | | --- | --- | --- | | `id` (required) | integer | Signer ID. | ### Request body (`application/json`) - `name` (string or null). - `email` (string or null, email address). - `phone` (string or null). - `external_id` (string or null). - `metadata` (object). Replaces the signer's metadata. - `prefill` (object). Field values to set, keyed by field name. Merged with existing prefilled values. - `resend` (boolean, default `false`). Email the signing link again after updating (only if the signer was already invited). ### Request sample cURL: ```bash curl -X PATCH https://app.signwith.co/api/v1/signers/9121 \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "ada.lovelace@acme.co", "prefill": { "Landlord name": "Ada Lovelace" }, "resend": true }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/signers/9121', { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ email: 'ada.lovelace@acme.co', prefill: { 'Landlord name': 'Ada Lovelace', }, resend: true, }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.patch( "https://app.signwith.co/api/v1/signers/9121", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, json={ "email": "ada.lovelace@acme.co", "prefill": { "Landlord name": "Ada Lovelace", }, "resend": True, }, ) print(response.status_code, response.json()) ``` ### Response 200 The updated signer. ```json { "object": "signer", "id": 9121, "signature_request_id": 4812, "role": "Landlord", "name": "Ada Lovelace", "email": "ada.lovelace@acme.co", "phone": null, "external_id": null, "metadata": {}, "status": "sent", "signing_url": "https://app.signwith.co/s/q8LkT2mZpV4r", "sent_at": "2026-09-27T09:44:53Z", "viewed_at": null, "signed_at": null, "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T11:02:17Z", "values": [ { "field": "Landlord name", "value": "Ada Lovelace" } ] } ``` ### Errors | Status | Meaning | | --- | --- | | 400 | The request body is not valid JSON. | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## The signer object Anchor: https://signwith.co/docs/api/signers#the-signer-object - `object` (string, required, always `signer`). - `id` (integer, required). - `signature_request_id` (integer, required). - `role` (string or null, required). The template role this signer fills. - `name` (string or null, required). - `email` (string or null, required, email address). - `phone` (string or null, required). - `external_id` (string or null, required). Your own ID for this signer. - `metadata` (object, required). Arbitrary key/value data you attached to the signer. - `status` (string, required, one of `waiting`, `ready`, `sent`, `viewed`, `signed`, `declined`). `waiting` — an earlier signer in a sequential request has to sign first; `ready` — it's their turn but they weren't emailed (e.g. `send_email: false`, or the email is still queued), so share `signing_url` yourself; `sent` — invited; `viewed` — opened the document; `signed` — finished signing; `declined` — declined to sign. - `signing_url` (string or null, required, URL). The signer's private signing link. Anyone with it can sign as this signer, so only share it with them. `null` once they've signed, or when the request is canceled, declined or expired, or its template is archived. - `sent_at` (string (date-time) or null, required). - `viewed_at` (string (date-time) or null, required). - `signed_at` (string (date-time) or null, required). - `declined_at` (string (date-time) or null, required). - `created_at` (string (date-time), required). - `updated_at` (string (date-time), required). - `values` (array of objects, required). Field values filled so far (prefilled or entered by the signer). - `field` (string, required). Field name (or a generated name such as `Text Field 2` for unnamed fields). - `value` (string or number or boolean or array of anys or null, required). The value entered or prefilled. - `documents` (array of objects). The signer's signed documents. Present only once they've signed. - `name` (string, required). File name without extension. - `url` (string, required, URL). Download link for the signed PDF. --- # Documents API reference > Check signed PDFs. Source: https://signwith.co/docs/api/documents · Updated 2026-10-02 ## Verify a signed PDF `POST https://app.signwith.co/api/v1/documents/verify` Read-only keys can call this. Accepts `Idempotency-Key`. Checks a PDF: whether SignWith issued it (it matches a document completed in SignWith) and whether each embedded digital signature is valid and the document hasn't been modified since. Use it when a signed document comes back to you from a third party and you need to confirm it's authentic. Send the PDF as base64 in a JSON `file` property, or upload it as a multipart `file`. This call doesn't change anything, so read-only API keys may use it. Returns `422 invalid_document` if `file` isn't a PDF. ### Headers | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key` | string | A unique value (e.g. a UUID) that makes retries of this request safe. A successful response is stored for 24 hours and replayed for retries with the same key. | ### Request body (`application/json`) - `file` (string, required, base64). The PDF, base64-encoded. ### Request body (`multipart/form-data`) - `file` (file, required). The PDF file. ### Request sample cURL: ```bash curl -X POST https://app.signwith.co/api/v1/documents/verify \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "file": "JVBERi0xLjcKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoK..." }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/documents/verify', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ file: 'JVBERi0xLjcKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoK...', }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/documents/verify", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "file": "JVBERi0xLjcKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoK...", }, ) print(response.status_code, response.json()) ``` ### Response 200 The verification result. ```json { "object": "document_verification", "issued_by_signwith": true, "signatures": [ { "signer_name": "SignWith", "signed_at": "2026-09-27T10:15:02Z", "reason": "Signed by Grace Hopper, Ada Lovelace", "valid": true, "messages": [ { "type": "info", "content": "Signature valid" }, { "type": "info", "content": "Certificate is trusted" } ] } ] } ``` ### Errors | Status | Meaning | | --- | --- | | 400 | The request body is not valid JSON. | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 409 | A request with the same `Idempotency-Key` is still being processed. Retry shortly. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## The document verification object Anchor: https://signwith.co/docs/api/documents#the-document-verification-object - `object` (string, required, always `document_verification`). - `issued_by_signwith` (boolean, required). Whether this exact file is a document completed in SignWith. - `signatures` (array of objects, required). Every digital signature found in the PDF. Empty if the PDF isn't digitally signed. - `signer_name` (string or null, required). - `signed_at` (string (date-time) or null, required). - `reason` (string or null, required). - `valid` (boolean, required). `true` if verification produced no errors. - `messages` (array of objects, required). Details from verifying the signature and certificate chain. - `type` (string, required, one of `info`, `warning`, `error`). - `content` (string, required). --- # Billing API reference > Buy credits from the API. When a request fails with `402 insufficient_credits`: `GET /credit_packs` → `POST /checkouts` → send the user the `checkout_url` → poll `GET /checkouts/{id}` until `status` is `completed` → retry the original request with the same `Idempotency-Key`. See [Buying credits](#section/Buying-credits). Source: https://signwith.co/docs/api/billing · Updated 2026-10-02 ## List credit packs `GET https://app.signwith.co/api/v1/credit_packs` Read-only keys can call this. Lists the credit packs that can be bought, cheapest first. Call it after a `402 insufficient_credits` (or when `GET /credits` shows `can_send: false`) to show the user their options before starting a checkout. The lifetime deal (`unlimited: true`) is left out for users who already have it. Returns every pack in one page (`has_more` is always `false`). ### Request sample cURL: ```bash curl https://app.signwith.co/api/v1/credit_packs \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/credit_packs', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/credit_packs", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` ### Response 200 The credit packs on sale. ```json { "object": "list", "data": [ { "object": "credit_pack", "id": 3, "name": "Basic", "description": "10 documents", "credits": 10, "unlimited": false, "price": "9.00", "currency": "USD", "price_per_credit": "0.90" }, { "object": "credit_pack", "id": 4, "name": "Pro", "description": "25 documents", "credits": 25, "unlimited": false, "price": "19.00", "currency": "USD", "price_per_credit": "0.76" }, { "object": "credit_pack", "id": 5, "name": "Business", "description": "50 documents", "credits": 50, "unlimited": false, "price": "29.00", "currency": "USD", "price_per_credit": "0.58" }, { "object": "credit_pack", "id": 9, "name": "Lifetime deal", "description": "Unlimited documents, forever", "credits": null, "unlimited": true, "price": "149.00", "currency": "USD", "price_per_credit": null } ], "has_more": false, "next_cursor": null } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## Start a credit purchase `POST https://app.signwith.co/api/v1/checkouts` Needs a full-access key. Accepts `Idempotency-Key`. Starts buying a credit pack and returns a hosted `checkout_url` for the user to pay on. Payment happens entirely on the payment provider's page; credits are added once the provider confirms the payment. Poll [`GET /checkouts/{id}`](#operation/getCheckout) to find out when that happens, then retry the request that failed with `402 insufficient_credits`. An optional `discount_code` applies a discount if it is active, not used up and valid for the chosen pack (`422 invalid_discount_code` otherwise); the returned `credit_pack` then describes the discounted offer. Requires a full-access key and a billing country on the user's SignWith profile (`422 billing_details_required`). Buying the lifetime deal twice returns `422 already_purchased`. If the payment provider can't start the payment the API returns `502 payment_provider_error`. Each call starts a new checkout, so send an `Idempotency-Key` to make retries safe. ### Headers | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key` | string | A unique value (e.g. a UUID) that makes retries of this request safe. A successful response is stored for 24 hours and replayed for retries with the same key. | ### Request body (`application/json`) - `credit_pack_id` (integer, required). ID of a pack from `GET /credit_packs`. - `discount_code` (string). Optional discount code for this pack. ### Request sample cURL: ```bash curl -X POST https://app.signwith.co/api/v1/checkouts \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "credit_pack_id": 4 }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/checkouts', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ credit_pack_id: 4, }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/checkouts", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "credit_pack_id": 4, }, ) print(response.status_code, response.json()) ``` ### Response 201 The checkout was started. Send the user to `checkout_url`. ```json { "object": "checkout", "id": "3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84", "status": "pending", "credit_pack": { "object": "credit_pack", "id": 4, "name": "Business", "description": "50 documents", "credits": 50, "unlimited": false, "price": "29.00", "currency": "USD", "price_per_credit": "0.58" }, "amount": "29.00", "currency": "USD", "discount_code": null, "checkout_url": "https://checkout.dodopayments.com/buy/pl_2x7Kq9mTbR4vLw8N", "completed_at": null, "created_at": "2026-09-27T11:20:31Z" } ``` ### Errors | Status | Meaning | | --- | --- | | 400 | The request body is not valid JSON. | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 403 | The key is read-only, or its user can't access this resource. | | 404 | The resource doesn't exist or belongs to another account. | | 409 | A request with the same `Idempotency-Key` is still being processed. Retry shortly. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | | 502 | The payment provider couldn't start the checkout. Nothing was charged; try again later. | ## Get a checkout `GET https://app.signwith.co/api/v1/checkouts/{id}` Read-only keys can call this. Returns a checkout's status. Poll it after sending the user the `checkout_url`: `pending` means the user hasn't paid yet (or the payment is still being confirmed), `completed` means the credits have been added, and `failed` means the payment failed or was canceled — start a new checkout to try again. `checkout_url` is only returned while the checkout is `pending`. Only checkouts started by the key's user can be read. ### Path parameters | Name | Type | Description | | --- | --- | --- | | `id` (required) | string (uuid) | Checkout ID (the `id` returned by `POST /checkouts`). | ### Request sample cURL: ```bash curl https://app.signwith.co/api/v1/checkouts/3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84 \ -H "Authorization: Bearer $SIGNWITH_API_KEY" ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/checkouts/3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84', { headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, }, }) console.log(response.status, await response.json()) ``` Python: ```python import os import requests response = requests.get( "https://app.signwith.co/api/v1/checkouts/3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", }, ) print(response.status_code, response.json()) ``` ### Response 200 The checkout. ```json { "object": "checkout", "id": "3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84", "status": "pending", "credit_pack": { "object": "credit_pack", "id": 4, "name": "Business", "description": "50 documents", "credits": 50, "unlimited": false, "price": "29.00", "currency": "USD", "price_per_credit": "0.58" }, "amount": "29.00", "currency": "USD", "discount_code": null, "checkout_url": "https://checkout.dodopayments.com/buy/pl_2x7Kq9mTbR4vLw8N", "completed_at": null, "created_at": "2026-09-27T11:20:31Z" } ``` ```json { "object": "checkout", "id": "3f6b2c1a-8d4e-4b7a-9c20-5e1f7a9d3b84", "status": "completed", "credit_pack": { "object": "credit_pack", "id": 4, "name": "Business", "description": "50 documents", "credits": 50, "unlimited": false, "price": "29.00", "currency": "USD", "price_per_credit": "0.58" }, "amount": "29.00", "currency": "USD", "discount_code": null, "checkout_url": null, "completed_at": "2026-09-27T11:24:09Z", "created_at": "2026-09-27T11:20:31Z" } ``` ### Errors | Status | Meaning | | --- | --- | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 404 | The resource doesn't exist or belongs to another account. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## The credit pack object Anchor: https://signwith.co/docs/api/billing#the-credit-pack-object - `object` (string, required, always `credit_pack`). - `id` (integer, required). - `name` (string, required). - `description` (string or null, required). - `credits` (integer or null, required). Credits added when bought. `null` for the lifetime deal. - `unlimited` (boolean, required). `true` for the lifetime deal, which removes the credit limit. - `price` (string, required). Price as a decimal string with two places. - `currency` (string, required, always `USD`). - `price_per_credit` (string or null, required). `price` divided by `credits`, as a decimal string. `null` for the lifetime deal. ## The checkout object Anchor: https://signwith.co/docs/api/billing#the-checkout-object - `object` (string, required, always `checkout`). - `id` (string (uuid), required). Checkout ID. Use it with `GET /checkouts/{id}`. - `status` (string, required, one of `pending`, `completed`, `failed`). `pending` — waiting for payment or confirmation; `completed` — paid and credits added; `failed` — the payment failed or was canceled. - `credit_pack` (object, required). The pack being bought (the discounted offer when a `discount_code` was applied). - `object` (string, required, always `credit_pack`). - `id` (integer, required). - `name` (string, required). - `description` (string or null, required). - `credits` (integer or null, required). Credits added when bought. `null` for the lifetime deal. - `unlimited` (boolean, required). `true` for the lifetime deal, which removes the credit limit. - `price` (string, required). Price as a decimal string with two places. - `currency` (string, required, always `USD`). - `price_per_credit` (string or null, required). `price` divided by `credits`, as a decimal string. `null` for the lifetime deal. - `amount` (string, required). Amount charged, as a decimal string. - `currency` (string, required, always `USD`). - `discount_code` (string or null, required). - `checkout_url` (string or null, required, URL). Hosted payment page to send the user to. Only present while `status` is `pending`. - `completed_at` (string (date-time) or null, required). When the payment was confirmed. - `created_at` (string (date-time), required). --- # Feedback API reference > Report bugs, feature requests, feedback or questions to the SignWith team on the user's behalf. Meant for API clients and AI agents such as the SignWith MCP server. Source: https://signwith.co/docs/api/feedback · Updated 2026-10-02 ## Send feedback to the SignWith team `POST https://app.signwith.co/api/v1/feedback` Read-only keys can call this. Accepts `Idempotency-Key`. Sends a bug report, feature request, general feedback or question to the SignWith team on the user's behalf. This is how API clients and AI agents — for example the SignWith MCP server (recorded with source `mcp`) — pass on problems or ideas the user mentions, without the user having to leave their tool. The team is notified straight away. Include what happened and, in `context`, identifiers that help reproduce it (the failing `error_code`, `signature_request_id`, etc.). Don't include passwords, API keys or document contents. Read-only keys may call it. Limited to 20 messages per user per hour (`429 rate_limited`). ### Headers | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key` | string | A unique value (e.g. a UUID) that makes retries of this request safe. A successful response is stored for 24 hours and replayed for retries with the same key. | ### Request body (`application/json`) - `type` (string, one of `bug`, `feature_request`, `feedback`, `question`, default `"feedback"`). What kind of message this is. - `message` (string, required, 1 to 5,000 characters). The feedback in plain language. Up to 5,000 characters. - `context` (object). Optional details that help the team investigate. Only the keys below are kept; others are dropped. - `client` (string). Name of the client or AI assistant, e.g. `claude-desktop`. - `client_version` (string). - `tool` (string). The tool or operation the user was using. - `signature_request_id` (integer or string). - `template_id` (integer or string). - `error_code` (string). The API `error.code` the user ran into, if any. - `request_id` (string). ### Request sample cURL: ```bash curl -X POST https://app.signwith.co/api/v1/feedback \ -H "Authorization: Bearer $SIGNWITH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "type": "bug", "message": "Prefilling the \"Start date\" field with 2026-11-01 shows an empty date to the signer.", "context": { "client": "claude-desktop", "client_version": "1.4.2", "tool": "send_signature_request", "signature_request_id": 4812, "template_id": 311 } }' ``` Node: ```javascript const response = await fetch('https://app.signwith.co/api/v1/feedback', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SIGNWITH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ type: 'bug', message: 'Prefilling the "Start date" field with 2026-11-01 shows an empty date to the signer.', context: { client: 'claude-desktop', client_version: '1.4.2', tool: 'send_signature_request', signature_request_id: 4812, template_id: 311, }, }), }) console.log(response.status, await response.json()) ``` Python: ```python import os import uuid import requests response = requests.post( "https://app.signwith.co/api/v1/feedback", headers={ "Authorization": f"Bearer {os.environ['SIGNWITH_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "type": "bug", "message": "Prefilling the \"Start date\" field with 2026-11-01 shows an empty date to the signer.", "context": { "client": "claude-desktop", "client_version": "1.4.2", "tool": "send_signature_request", "signature_request_id": 4812, "template_id": 311, }, }, ) print(response.status_code, response.json()) ``` ### Response 201 The feedback was received. ```json { "object": "feedback", "id": 57, "type": "bug", "status": "received", "message": "Thanks! The SignWith team has been notified." } ``` ### Errors | Status | Meaning | | --- | --- | | 400 | The request body is not valid JSON. | | 401 | The API key is missing, invalid or expired, or its account has been archived. | | 409 | A request with the same `Idempotency-Key` is still being processed. Retry shortly. | | 422 | The request is valid JSON but can't be processed. See `error.code`; the codes each endpoint can return are described in the endpoint's description and in the error table above. | | 429 | Rate limit exceeded (`rate_limited`, with `Retry-After`); more than 20 `POST /feedback` messages in an hour (`rate_limited`, with `Retry-After`: seconds until the next clock hour); or — for `POST /signature_requests/{id}/remind` — signers were reminded less than an hour ago (`remind_too_soon`, with `Retry-After`). | | 500 | Something went wrong on SignWith's side. It's safe to retry with the same `Idempotency-Key`. | ## The feedback object Anchor: https://signwith.co/docs/api/feedback#the-feedback-object - `object` (string, required, always `feedback`). - `id` (integer, required). - `type` (string, required, one of `bug`, `feature_request`, `feedback`, `question`). - `status` (string, required, always `received`). - `message` (string, required). A confirmation you can show to the user. --- # E-signature webhooks for signing events > Webhooks tell your server the moment something happens to a signature request: a signer opens it, signs it or declines, or the last person signs. No polling needed. Source: https://signwith.co/docs/api/webhooks · Updated 2026-10-02 ## Add an endpoint In SignWith, open **Settings → Webhooks**, add your URL and choose the events you want. SignWith then sends a `POST` with a JSON body to that URL for each event. Webhook URLs must be public `http://` or `https://` addresses. URLs that point at private, loopback or link-local networks (such as `localhost`, `10.0.0.0/8` or `192.168.0.0/16`) are rejected when you save the endpoint and again at delivery time. To test on your own machine, expose it with a tunnel such as ngrok. ## Events | Event | When it's sent | | --- | --- | | [`signature_request.created`](https://signwith.co/docs/api/webhooks/events#signature_request-created) | A signature request was sent | | [`signature_request.completed`](https://signwith.co/docs/api/webhooks/events#signature_request-completed) | Every signer has signed (on by default) | | [`signature_request.canceled`](https://signwith.co/docs/api/webhooks/events#signature_request-canceled) | A signature request was canceled | | [`signer.viewed`](https://signwith.co/docs/api/webhooks/events#signer-viewed) | A signer opened the document | | [`signer.started`](https://signwith.co/docs/api/webhooks/events#signer-started) | A signer started filling in fields | | [`signer.signed`](https://signwith.co/docs/api/webhooks/events#signer-signed) | A signer finished signing (on by default) | | [`signer.declined`](https://signwith.co/docs/api/webhooks/events#signer-declined) | A signer declined to sign | | [`template.created`](https://signwith.co/docs/api/webhooks/events#template-created) | A template was created | | [`template.updated`](https://signwith.co/docs/api/webhooks/events#template-updated) | A template was changed | | [`template.archived`](https://signwith.co/docs/api/webhooks/events#template-archived) | A template was archived | | [`webhook.test`](https://signwith.co/docs/api/webhooks/events#webhook-test) | Test event from the dashboard | Each event's payload and an example are on the [webhook events](https://signwith.co/docs/api/webhooks/events) page. ## The payload ```json { "id": "evt_7Qm2Vt9sKd3LpXa8RzYw1bNc", "type": "signer.signed", "created_at": "2026-09-27T10:15:00Z", "data": { "object": "signer", "id": 9121, ... } } ``` - `id` is unique per event and stays the same across retries. Store it and ignore events you've already handled. - `type` is the event name, also sent in the `X-SignWith-Event` header. - `data` uses the same shapes as API responses and shows the object as it is at delivery time. If you need the latest state, fetch it with the API. ## Headers | Header | Example | Meaning | | --- | --- | --- | | `X-SignWith-Event` | `signer.signed` | Event type (same as `type`). | | `X-SignWith-Delivery` | `2b1e…` | Unique ID of this delivery attempt. | | `X-SignWith-Signature` | `t=1790503200,v1=5d41…` | Timestamp and HMAC-SHA256 signature. | | `User-Agent` | `SignWith Webhook` | | Any custom headers you set on the endpoint are sent too. **Verify every request** before you trust it: anyone can send a `POST` to a public URL. [Verifying webhook signatures](https://signwith.co/docs/api/webhooks/verify-signatures) has the code for Node and Python. ## Respond quickly, and expect retries Return any `2xx` status within 30 seconds. Do slow work, such as downloading the signed PDF, after you respond, for example in a background job. Other responses, timeouts and connection errors are retried with exponential back-off: after 1, 2, 4, 8 minutes and so on. Each event is delivered at most **10 times in total**, the first attempt plus up to 9 retries. Because an event can arrive more than once, use its `id` to handle it only once. ## A typical handler When a document is completed, download the signed files: 1. Subscribe to `signature_request.completed`. 2. Verify the signature, check you haven't seen the event `id` before, and respond `200`. 3. Read `data.documents` and `data.audit_trail_url` from the payload. If a link is `null`, fetch the signature request again shortly afterwards with [`GET /signature_requests/{id}`](https://signwith.co/docs/api/signature-requests#get-a-signature-request). ## Send a test event The **Send test event** button on a webhook's settings page sends a `webhook.test` event straight away, whatever events the endpoint is subscribed to. It's not retried. Use it to check that your endpoint is reachable and that signature verification works. --- # Webhook events and payloads > SignWith sends these events to your webhook endpoints. Each one wraps the object it is about, in the same shape the API returns. Source: https://signwith.co/docs/api/webhooks/events · Updated 2026-10-02 Every event has the same envelope: `id`, `type`, `created_at` and `data`. See [webhooks](https://signwith.co/docs/api/webhooks) for the headers, retries and how to add an endpoint, and [verify webhook signatures](https://signwith.co/docs/api/webhooks/verify-signatures) before you trust a payload. ## `signature_request.created` A signature request was sent. Sent when a signature request is created — from the API, the dashboard or a shared signing link. `data` is a [SignatureRequest](#/components/schemas/SignatureRequest). ```json { "id": "evt_4Kc9QmT2vXw8ZpLs7NbR3yAd", "type": "signature_request.created", "created_at": "2026-09-27T09:00:01Z", "data": { "object": "signature_request", "id": 4812, "status": "sent", "template": { "id": 311, "name": "Residential lease" }, "signing_order": "sequential", "source": "api", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "signers": [ { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": {}, "status": "sent", "signing_url": "https://app.signwith.co/s/Hn3cW8yRk2Qe", "sent_at": "2026-09-27T09:00:02Z", "viewed_at": null, "signed_at": null, "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:00:02Z", "values": [] } ], "expires_at": null, "completed_at": null, "canceled_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:00:01Z" } } ``` ## `signature_request.completed` Every signer has signed. Sent when the last signer signs. `data` is a [SignatureRequest](#/components/schemas/SignatureRequest) with `status: completed`, including links to the signed documents and audit trail. If a link is `null`, fetch the signature request again shortly afterwards. Subscribed by default for new endpoints. ```json { "id": "evt_9RtB2wLx5HqN8cVm3KpZ7sYe", "type": "signature_request.completed", "created_at": "2026-09-27T10:15:00Z", "data": { "object": "signature_request", "id": 4812, "status": "completed", "template": { "id": 311, "name": "Residential lease" }, "signing_order": "sequential", "source": "api", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "signers": [ { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": { "crm_deal_id": "D-2291" }, "status": "signed", "signing_url": null, "sent_at": "2026-09-27T09:00:02Z", "viewed_at": "2026-09-27T09:41:18Z", "signed_at": "2026-09-27T09:44:51Z", "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:44:51Z", "values": [ { "field": "Tenant name", "value": "Grace Hopper" }, { "field": "Monthly rent", "value": "2150" }, { "field": "Start date", "value": "2026-11-01" }, { "field": "Pets", "value": "Cat" }, { "field": "Tenant signature", "value": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTUxfX0/signature.png" } ], "documents": [ { "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTUyfX0/lease-agreement.pdf" } ] }, { "object": "signer", "id": 9121, "signature_request_id": 4812, "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co", "phone": null, "external_id": null, "metadata": {}, "status": "signed", "signing_url": null, "sent_at": "2026-09-27T09:44:53Z", "viewed_at": "2026-09-27T10:12:30Z", "signed_at": "2026-09-27T10:14:58Z", "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T10:14:58Z", "values": [ { "field": "Landlord signature", "value": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTYwfX0/signature.png" } ], "documents": [ { "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTYxfX0/lease-agreement.pdf" } ] } ], "expires_at": "2026-10-27T00:00:00Z", "completed_at": "2026-09-27T10:14:58Z", "canceled_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T10:14:58Z", "documents": [ { "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTYxfX0/lease-agreement.pdf" } ], "audit_trail_url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTYyfX0/audit-trail.pdf", "combined_document_url": null } } ``` ## `signature_request.canceled` A signature request was canceled. Sent when a signature request is canceled from the API or the dashboard. `data` is a [SignatureRequest](#/components/schemas/SignatureRequest) with `status: canceled`. ```json { "id": "evt_2HsW7kQp9ZxT4mBn6LcR8vYd", "type": "signature_request.canceled", "created_at": "2026-09-28T08:30:12Z", "data": { "object": "signature_request", "id": 4812, "status": "canceled", "template": { "id": 311, "name": "Residential lease" }, "signing_order": "sequential", "source": "api", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "signers": [ { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": {}, "status": "viewed", "signing_url": "https://app.signwith.co/s/Hn3cW8yRk2Qe", "sent_at": "2026-09-27T09:00:02Z", "viewed_at": "2026-09-27T09:41:18Z", "signed_at": null, "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:41:18Z", "values": [] } ], "expires_at": null, "completed_at": null, "canceled_at": "2026-09-28T08:30:12Z", "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-28T08:30:12Z" } } ``` ## `signer.viewed` A signer opened the document. Sent when a signer opens their signing link. `data` is a [Signer](#/components/schemas/Signer) plus a short `signature_request` summary. ```json { "id": "evt_6NpQ3xVb8KsR2tLm9WcZ4yHe", "type": "signer.viewed", "created_at": "2026-09-27T09:41:18Z", "data": { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": {}, "status": "viewed", "signing_url": "https://app.signwith.co/s/Hn3cW8yRk2Qe", "sent_at": "2026-09-27T09:00:02Z", "viewed_at": "2026-09-27T09:41:18Z", "signed_at": null, "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:41:18Z", "values": [ { "field": "Tenant name", "value": "Grace Hopper" } ], "signature_request": { "id": 4812, "status": "sent" } } } ``` ## `signer.started` A signer started filling in fields. Sent when a signer saves their first field value. Useful for showing "in progress" in your UI. `data` is a [Signer](#/components/schemas/Signer) plus a short `signature_request` summary. ```json { "id": "evt_3TmK8vRx2NqW7pLb5HcZ9sYd", "type": "signer.started", "created_at": "2026-09-27T09:42:03Z", "data": { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": {}, "status": "viewed", "signing_url": "https://app.signwith.co/s/Hn3cW8yRk2Qe", "sent_at": "2026-09-27T09:00:02Z", "viewed_at": "2026-09-27T09:41:18Z", "signed_at": null, "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:42:03Z", "values": [ { "field": "Tenant name", "value": "Grace Hopper" }, { "field": "Phone number", "value": "+14155550123" } ], "signature_request": { "id": 4812, "status": "sent" } } } ``` ## `signer.signed` A signer finished signing. Sent when a signer completes their part. `data` is a [Signer](#/components/schemas/Signer) with `status: signed`, their field values and signed documents, plus a short `signature_request` summary. Subscribed by default for new endpoints. ```json { "id": "evt_7Qm2Vt9sKd3LpXa8RzYw1bNc", "type": "signer.signed", "created_at": "2026-09-27T09:44:51Z", "data": { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": { "crm_deal_id": "D-2291" }, "status": "signed", "signing_url": null, "sent_at": "2026-09-27T09:00:02Z", "viewed_at": "2026-09-27T09:41:18Z", "signed_at": "2026-09-27T09:44:51Z", "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:44:51Z", "values": [ { "field": "Tenant name", "value": "Grace Hopper" }, { "field": "Tenant signature", "value": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTUxfX0/signature.png" } ], "documents": [ { "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTUyfX0/lease-agreement.pdf" } ], "signature_request": { "id": 4812, "status": "in_progress" } } } ``` ## `signer.declined` A signer declined to sign. Sent when a signer declines. The signature request's status becomes `declined`. `data` is a [Signer](#/components/schemas/Signer) plus a short `signature_request` summary. Subscribed by default for new endpoints. ```json { "id": "evt_8LwR4nKx6TqB2mVp9ZcH3sYe", "type": "signer.declined", "created_at": "2026-09-27T12:05:40Z", "data": { "object": "signer", "id": 9121, "signature_request_id": 4812, "role": "Landlord", "name": "Ada Lovelace", "email": "ada@acme.co", "phone": null, "external_id": null, "metadata": {}, "status": "declined", "signing_url": null, "sent_at": "2026-09-27T09:44:53Z", "viewed_at": "2026-09-27T12:03:11Z", "signed_at": null, "declined_at": "2026-09-27T12:05:40Z", "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T12:05:40Z", "values": [], "signature_request": { "id": 4812, "status": "declined" } } } ``` ## `template.created` A template was created. Sent when a template is created — uploaded in the dashboard, created through the API, or duplicated. `data` is a [Template](#/components/schemas/Template). ```json { "id": "evt_5KpT9wRm3XqN7vLb2HcZ8sYd", "type": "template.created", "created_at": "2026-09-20T09:12:44Z", "data": { "object": "template", "id": 311, "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "roles": [ "Tenant", "Landlord" ], "edit_url": "https://app.signwith.co/templates/311/edit", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "archived_at": null, "created_at": "2026-09-20T09:12:44Z", "updated_at": "2026-09-21T16:03:10Z", "fields": [ { "id": "0f9c2b7e-5d1a-4e8b-9a61-3c2e7d4f1a90", "name": "Tenant name", "type": "text", "role": "Tenant", "required": true }, { "id": "6a3e1d42-8b7c-4f19-a0d5-2e9b4c7f8a13", "name": "Monthly rent", "type": "number", "role": "Tenant", "required": true }, { "id": "b2d8f4a1-3c6e-4a7b-8d92-5f1e0c3a9b76", "name": "Start date", "type": "date", "role": "Tenant", "required": true }, { "id": "91c7e3b5-2a4d-4f8e-b610-7d3a5c9e2f48", "name": "Pets", "type": "select", "role": "Tenant", "required": false, "options": [ "None", "Cat", "Dog" ] }, { "id": "4e8a2c6f-9b1d-4a3e-8c57-1f6d0b9e3a24", "name": "Tenant signature", "type": "signature", "role": "Tenant", "required": true }, { "id": "c5f1a9d3-7e2b-4c8a-9f60-3b8e4d2a7c15", "name": "Landlord signature", "type": "signature", "role": "Landlord", "required": true } ], "documents": [ { "id": 7730, "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTIzfX0/lease-agreement.pdf", "preview_image_url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTI0fX0/0.jpg" } ] } } ``` ## `template.updated` A template was changed. Sent when a template's name, folder, roles, fields or documents change. `data` is a [Template](#/components/schemas/Template). ```json { "id": "evt_1MvK6pTx8RqW3nLb9HcZ2sYe", "type": "template.updated", "created_at": "2026-09-21T16:03:10Z", "data": { "object": "template", "id": 311, "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "roles": [ "Tenant", "Landlord" ], "edit_url": "https://app.signwith.co/templates/311/edit", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "archived_at": null, "created_at": "2026-09-20T09:12:44Z", "updated_at": "2026-09-21T16:03:10Z", "fields": [ { "id": "0f9c2b7e-5d1a-4e8b-9a61-3c2e7d4f1a90", "name": "Tenant name", "type": "text", "role": "Tenant", "required": true }, { "id": "6a3e1d42-8b7c-4f19-a0d5-2e9b4c7f8a13", "name": "Monthly rent", "type": "number", "role": "Tenant", "required": true }, { "id": "b2d8f4a1-3c6e-4a7b-8d92-5f1e0c3a9b76", "name": "Start date", "type": "date", "role": "Tenant", "required": true }, { "id": "91c7e3b5-2a4d-4f8e-b610-7d3a5c9e2f48", "name": "Pets", "type": "select", "role": "Tenant", "required": false, "options": [ "None", "Cat", "Dog" ] }, { "id": "4e8a2c6f-9b1d-4a3e-8c57-1f6d0b9e3a24", "name": "Tenant signature", "type": "signature", "role": "Tenant", "required": true }, { "id": "c5f1a9d3-7e2b-4c8a-9f60-3b8e4d2a7c15", "name": "Landlord signature", "type": "signature", "role": "Landlord", "required": true } ], "documents": [ { "id": 7730, "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTIzfX0/lease-agreement.pdf", "preview_image_url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6MTI0fX0/0.jpg" } ] } } ``` ## `template.archived` A template was archived. Sent when a template is archived. `data` is a [Template](#/components/schemas/Template) with `archived_at` set. ```json { "id": "evt_4NqB8vKx2TmR6pLw9HcZ3sYd", "type": "template.archived", "created_at": "2026-09-29T14:20:00Z", "data": { "object": "template", "id": 311, "name": "Residential lease", "external_id": "lease-v3", "folder": "Leasing", "roles": [ "Tenant", "Landlord" ], "edit_url": "https://app.signwith.co/templates/311/edit", "created_by": { "id": 42, "email": "ada@acme.co", "name": "Ada Lovelace" }, "archived_at": "2026-09-29T14:20:00Z", "created_at": "2026-09-20T09:12:44Z", "updated_at": "2026-09-29T14:20:00Z", "fields": [], "documents": [] } } ``` ## `webhook.test` Test event from the dashboard. Sent immediately when you click **Send test event** on a webhook's settings page, regardless of which events the endpoint is subscribed to. It is not retried. `data` has the same shape as a `signer.signed` event, using the most recently signed signer in your account — or an empty object if nobody has signed yet. Use it to check that your endpoint is reachable and that signature verification works; don't treat it as a real signing. Account with signed documents: ```json { "id": "evt_9ZcT3mKx7RqB2vLp8HnW4sYd", "type": "webhook.test", "created_at": "2026-09-27T13:00:00Z", "data": { "object": "signer", "id": 9120, "signature_request_id": 4812, "role": "Tenant", "name": "Grace Hopper", "email": "grace@example.com", "phone": null, "external_id": "cust_8841", "metadata": { "crm_deal_id": "D-2291" }, "status": "signed", "signing_url": null, "sent_at": "2026-09-27T09:00:02Z", "viewed_at": "2026-09-27T09:41:18Z", "signed_at": "2026-09-27T09:44:51Z", "declined_at": null, "created_at": "2026-09-27T09:00:01Z", "updated_at": "2026-09-27T09:44:51Z", "values": [ { "field": "Tenant name", "value": "Grace Hopper" }, { "field": "Tenant signature", "value": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTUxfX0/signature.png" } ], "documents": [ { "name": "Lease agreement", "url": "https://app.signwith.co/file/eyJfcmFpbHMiOnsiZGF0YSI6NTUyfX0/lease-agreement.pdf" } ], "signature_request": { "id": 4812, "status": "in_progress" } } } ``` New account: ```json { "id": "evt_2BxK8nTq5RmW7vLp3HcZ9sYe", "type": "webhook.test", "created_at": "2026-09-27T13:00:00Z", "data": {} } ``` --- # Verify webhook signatures in Node and Python > Every webhook SignWith sends is signed with your endpoint secret. Check the signature before you act on an event, so nobody else can fake one. Source: https://signwith.co/docs/api/webhooks/verify-signatures · Updated 2026-10-02 ## How the signature works Each request has an `X-SignWith-Signature` header with a Unix timestamp and a signature: ```http X-SignWith-Signature: t=1790503200,v1=5d41402abc4b2a76b9719d911017c592... ``` To check it: 1. Split the header on `,` and read `t` (the timestamp) and `v1` (the signature). 2. Compute HMAC-SHA256 of the string `"."`, using the endpoint's signing secret as the key, and hex-encode it. The secret is on the webhook's settings page in SignWith. 3. Compare your result with `v1` using a constant-time comparison. 4. Reject the request if `t` is more than a few minutes old (the examples allow 300 seconds), so an old request can't be replayed. Use the **raw** request body, exactly as received. If your framework parses the JSON first and you sign the re-serialized object, the bytes differ and the check fails. ## Node.js The verifier: ```js const crypto = require('crypto'); function verifySignWithWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) { const parts = Object.fromEntries( signatureHeader.split(',').map((part) => part.trim().split('=', 2)) ); const timestamp = Number(parts.t); if (!timestamp || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(parts.v1, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` In an Express app, read the raw body for this route, not the parsed JSON: ```js app.post('/webhooks/signwith', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifySignWithWebhook( req.body.toString('utf8'), req.get('X-SignWith-Signature'), process.env.SIGNWITH_WEBHOOK_SECRET ); if (!ok) return res.status(400).send('Invalid signature'); const event = JSON.parse(req.body); // handle event.type ... res.sendStatus(200); }); ``` ## Python The verifier: ```python import hashlib import hmac import time def verify_signwith_webhook(raw_body, signature_header, secret, tolerance_seconds=300): parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p) timestamp, received = parts.get("t", ""), parts.get("v1", "") if not timestamp.isdigit() or not received: return False if abs(time.time() - int(timestamp)) > tolerance_seconds: return False expected = hmac.new( secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, received) ``` In a Flask app, `request.get_data()` gives the raw body as bytes: ```python import os from flask import Flask, abort, request app = Flask(__name__) @app.post("/webhooks/signwith") def signwith_webhook(): if not verify_signwith_webhook( request.get_data(), request.headers.get("X-SignWith-Signature", ""), os.environ["SIGNWITH_WEBHOOK_SECRET"], ): abort(400) event = request.get_json() # handle event["type"] ... return "", 200 ``` ## Test your verification Click **Send test event** on the webhook's settings page in SignWith. It sends a `webhook.test` event straight away, signed like every other event. If your endpoint answers `400`, compare the body you sign with the exact bytes received, and check you copied the secret of the right endpoint. ## Common mistakes - **Signing parsed JSON.** Sign the raw body. Re-serializing changes spacing and key order. - **A plain `==` comparison.** Use `timingSafeEqual` or `hmac.compare_digest`, which don't leak timing. - **No timestamp check.** Without it, a captured request can be replayed later. - **The wrong secret.** Each endpoint has its own signing secret. --- # E-signature MCP server for AI assistants > SignWith runs a remote MCP server, so AI assistants such as Claude, ChatGPT and Cursor can send documents for signature, chase signers and check signed PDFs for you. Source: https://signwith.co/docs/mcp · Updated 2026-10-02 ## Server details | | | | --- | --- | | URL | `https://app.signwith.co/mcp` | | Transport | Streamable HTTP, stateless | | Sign-in | OAuth 2.1, or an API key as a Bearer token | | Tools | 18, see [MCP tools](https://signwith.co/docs/mcp/tools) | ## Connect your assistant - [Claude](https://signwith.co/docs/mcp/claude): Connect Claude to SignWith over MCP: add a custom connector in Claude on the web, desktop or mobile, or add the server to Claude Code with an API key or OAuth. - [ChatGPT](https://signwith.co/docs/mcp/chatgpt): Connect ChatGPT to SignWith over MCP with developer mode: create an app for the SignWith MCP server, sign in with OAuth, and send documents from a chat. - [Cursor](https://signwith.co/docs/mcp/cursor): Add the SignWith MCP server to Cursor with mcp.json and an API key, so Cursor can create templates, send documents for signature and check signers while you build. - [VS Code](https://signwith.co/docs/mcp/vscode): Add the SignWith MCP server to VS Code and GitHub Copilot with one click or with mcp.json, then send documents for signature and check signers from the chat. Any other client that supports remote MCP servers over Streamable HTTP works the same way: give it the URL above, and either sign in with OAuth or send an API key header. ## What you can ask Once SignWith is connected, ask in plain language: - "Send the consulting agreement template to `grace@example.com`." - "Which lease requests are still waiting for a signature?" - "Remind the signers on the Riverside lease." - "Is this PDF really signed with SignWith?" - "Turn this contract into a template with signature lines for the client and me." When an assistant writes or edits a document for you, it uses [text tags](https://signwith.co/docs/api/text-tags) to place the signature and other fields. Each tool runs the matching endpoint of the [SignWith API](https://signwith.co/docs/api), with the same rules, credits and errors. Anything an assistant creates is recorded with `source: "mcp"`, and you can see every call under **Settings → API activity**. ## Two ways to sign in **OAuth (recommended).** Add `https://app.signwith.co/mcp` as a connector in your assistant. It sends you to SignWith to sign in and approve the connection, choosing **Full access** or **View only**. No key is pasted anywhere. Manage or disconnect apps in **Settings → Connected apps**. **API key.** Clients that let you set request headers can send an [API key](https://signwith.co/docs/api/authentication) instead: ```http Authorization: Bearer sw_live_... ``` [Get your API key](https://app.signwith.co/settings/api) ## Access and confirmations A **View only** connection or a read-only API key lists only the tools it can use. The assistant can look at templates and signature requests, verify PDFs and send feedback, but it can't send, change or cancel anything. Tools that email people or can't be undone (sending, reminding, canceling and archiving) are marked for the assistant to confirm with you first. Most clients, including Claude and ChatGPT, ask you before running a tool like this unless you've chosen to always allow it. ## Credits Sending through an assistant uses credits exactly like the API: see [credits and billing](https://signwith.co/docs/api/credits). If an account runs out, the assistant can show the credit packs and give you a checkout link to pay in your browser; it never pays itself. Inside ChatGPT the purchase tools aren't offered, and the assistant asks you to add credits in SignWith instead. ## When a tool call fails A failed call returns a normal result with `isError: true` and the API's error `code` and `message`, so the assistant can explain the problem or fix its arguments and try again. Calls missing required arguments fail the same way and name the missing arguments. Two codes only come from MCP: `insufficient_scope` when a View only connection tries to change something, and `not_available` for a tool that isn't offered to that app. All codes are on the [errors page](https://signwith.co/docs/api/errors). Building your own MCP client? The [OAuth and protocol reference](https://signwith.co/docs/mcp/oauth) has the authorization flow and protocol details. ## Frequently asked questions ### Does SignWith have an MCP server? Yes. The SignWith MCP server is at `https://app.signwith.co/mcp` and has 18 tools for templates, signature requests, signers, credits and verifying signed PDFs. ### Which AI assistants can use SignWith? Any assistant that supports remote MCP servers over Streamable HTTP, including Claude, ChatGPT, Cursor and VS Code. See the setup guides for [Claude](https://signwith.co/docs/mcp/claude), [ChatGPT](https://signwith.co/docs/mcp/chatgpt), [Cursor](https://signwith.co/docs/mcp/cursor) and [VS Code](https://signwith.co/docs/mcp/vscode). ### Can an AI assistant send documents without asking me? Sending, reminding, canceling and archiving are marked as actions to confirm, and most clients, including Claude and ChatGPT, ask you first unless you have chosen to always allow them. A View only connection cannot send anything at all. ### Do I need an API key to connect an AI assistant? No. Assistants that support OAuth, such as Claude and ChatGPT, send you to SignWith to sign in and approve the connection. Clients that only take headers, such as some editors, can use an [API key](https://signwith.co/docs/api/authentication) instead. --- # SignWith MCP tools reference > Every tool the SignWith MCP server offers, with its arguments and the API endpoint it runs. AI assistants read these definitions from the server; this page is for you. Source: https://signwith.co/docs/mcp/tools · Updated 2026-10-02 The server lists these tools in `tools/list`. A View only connection or a read-only API key lists only the tools marked **View only: yes**, and ChatGPT doesn't get the three billing tools. ## At a glance | Tool | What it does | View only | | --- | --- | --- | | `get_account` | Who is signed in, live or test environment, and how many credits are left. | Yes | | `list_templates` | List templates, newest first; search by name, folder or archived. | Yes | | `get_template` | A template's roles (who signs) and fields (what they fill in). | Yes | | `create_template` | Create a template from PDF or image documents. Fields come from text tags in the PDF, fillable form fields, or `fields` with page positions. | No | | `update_template` | Rename a template or its roles, move it to a folder, or replace its fields. | No | | `duplicate_template` | Copy a template to make a variant. | No | | `archive_template` | Archive a template so it no longer appears in lists. | No | | `send_signature_request` | Send a template to people to sign. Takes an idempotency_key so a retry never sends twice. | No | | `list_signature_requests` | List signature requests by status or template, or search signers. | Yes | | `get_signature_request` | Who has signed, viewed or declined, and the signed documents once complete. | Yes | | `remind_signers` | Re-send the signing email to everyone whose turn it is. At most once an hour. | No | | `cancel_signature_request` | Cancel a signature request so nobody can sign it. Can't be undone. | No | | `update_signer` | Fix a signer's name, email or phone, prefill fields, or resend their link. | No | | `list_credit_packs` | The credit packs the user can buy, with prices. | Yes | | `buy_credits` | Start a purchase and return a checkout link for the user to pay in their browser. | No | | `get_checkout` | Whether a credit purchase has been paid. | Yes | | `verify_document` | Check whether a PDF was signed with SignWith and its digital signatures are valid. | Yes | | `send_feedback` | Pass a bug report, feature request or question to the SignWith team. | Yes | ## `get_account` **Get account.** Who is signed in, live or test environment, and how many credits are left. View only: yes; runs `GET /me` and `GET /credits`. No arguments. ## `list_templates` **List templates.** List templates, newest first; search by name, folder or archived. View only: yes; runs `GET /templates`. | Argument | Type | Description | | --- | --- | --- | | `q` | string | Search by template name. | | `folder` | string | Only templates in this folder. | | `archived` | boolean | List archived templates instead. | | `limit` | integer | Page size, 1 to 100 (default 20). | | `cursor` | integer | `next_cursor` from the previous page. | ## `get_template` **Get template.** A template's roles (who signs) and fields (what they fill in). View only: yes; runs `GET /templates/{id}`. | Argument | Type | Description | | --- | --- | --- | | `template_id` (required) | integer | The template. | ## `create_template` **Create template.** Create a template from PDF or image documents. Fields come from text tags in the PDF, fillable form fields, or `fields` with page positions. View only: no; runs `POST /templates`. | Argument | Type | Description | | --- | --- | --- | | `documents` (required) | array of objects | 1 to 10 documents, each `{ name, file }`, where `file` is base64 content or an https URL to download. [Text tags](https://signwith.co/docs/api/text-tags) in a PDF become fields. | | `name` | string | Template name. | | `folder` | string | Folder name. | | `fields` | array of objects | Fields to place: each has `type` (text, signature, initials, date, checkbox, number, phone, select, radio, multiple, image, file, stamp), `areas` (`page`, `x`, `y`, `w`, `h` as fractions of the page, optional `document` index), and optional `name`, `role`, `required` and `options`. | ## `update_template` **Update template.** Rename a template or its roles, move it to a folder, or replace its fields. View only: no; runs `PATCH /templates/{id}`. | Argument | Type | Description | | --- | --- | --- | | `template_id` (required) | integer | The template. | | `name` | string | New name. | | `folder` | string | New folder. | | `roles` | array of strings | Role names in signing order. | | `fields` | array of objects | Replaces all existing fields. Fields to place: each has `type` (text, signature, initials, date, checkbox, number, phone, select, radio, multiple, image, file, stamp), `areas` (`page`, `x`, `y`, `w`, `h` as fractions of the page, optional `document` index), and optional `name`, `role`, `required` and `options`. | ## `duplicate_template` **Duplicate template.** Copy a template to make a variant. View only: no; runs `POST /templates/{id}/duplicate`. | Argument | Type | Description | | --- | --- | --- | | `template_id` (required) | integer | The template to copy. | | `name` | string | Name of the copy. | ## `archive_template` **Archive template.** Archive a template so it no longer appears in lists. View only: no; asks you to confirm first; runs `POST /templates/{id}/archive`. | Argument | Type | Description | | --- | --- | --- | | `template_id` (required) | integer | The template. | ## `send_signature_request` **Send for signature.** Send a template to people to sign. Takes an idempotency_key so a retry never sends twice. View only: no; asks you to confirm first; runs `POST /signature_requests`. | Argument | Type | Description | | --- | --- | --- | | `template_id` (required) | integer | The template to send. | | `signers` (required) | array of objects | One per role: `role`, `email` or `phone`, and optional `name`, `prefill` (values keyed by field name), `external_id` and `require_email_otp`. | | `signing_order` | string | `sequential` (default) or `parallel`. | | `message` | object | Custom email `subject` and `body`. | | `send_email` | boolean | Email signers their links (default true). | | `expires_at` | string (date-time) | When the request expires. | | `redirect_url` | string (URL) | Where signers go after signing. | | `require_email_otp` | boolean | Require an email one-time code for every signer. | | `idempotency_key` | string | Reuse the same key when retrying so nothing is sent twice. | ## `list_signature_requests` **List signature requests.** List signature requests by status or template, or search signers. View only: yes; runs `GET /signature_requests`. | Argument | Type | Description | | --- | --- | --- | | `status` | string | `open`, `completed`, `declined`, `expired` or `canceled`. | | `template_id` | integer | Only requests from this template. | | `q` | string | Search signers by name, email or phone. | | `limit` | integer | Page size, 1 to 100 (default 20). | | `cursor` | integer | `next_cursor` from the previous page. | ## `get_signature_request` **Get signature request.** Who has signed, viewed or declined, and the signed documents once complete. View only: yes; runs `GET /signature_requests/{id}`. | Argument | Type | Description | | --- | --- | --- | | `signature_request_id` (required) | integer | The signature request. | ## `remind_signers` **Remind signers.** Re-send the signing email to everyone whose turn it is. At most once an hour. View only: no; asks you to confirm first; runs `POST /signature_requests/{id}/remind`. | Argument | Type | Description | | --- | --- | --- | | `signature_request_id` (required) | integer | The signature request. | ## `cancel_signature_request` **Cancel signature request.** Cancel a signature request so nobody can sign it. Can't be undone. View only: no; asks you to confirm first; runs `POST /signature_requests/{id}/cancel`. | Argument | Type | Description | | --- | --- | --- | | `signature_request_id` (required) | integer | The signature request. | ## `update_signer` **Update signer.** Fix a signer's name, email or phone, prefill fields, or resend their link. View only: no; runs `PATCH /signers/{id}`. | Argument | Type | Description | | --- | --- | --- | | `signer_id` (required) | integer | The signer. | | `name` | string | Corrected name. | | `email` | string | Corrected email. | | `phone` | string | Corrected phone. | | `prefill` | object | Field values to set, keyed by field name. | | `resend` | boolean | Email the signing link again. | ## `list_credit_packs` **List credit packs.** The credit packs the user can buy, with prices. View only: yes; not offered in ChatGPT; runs `GET /credit_packs`. No arguments. ## `buy_credits` **Buy credits.** Start a purchase and return a checkout link for the user to pay in their browser. View only: no; not offered in ChatGPT; runs `POST /checkouts`. | Argument | Type | Description | | --- | --- | --- | | `credit_pack_id` (required) | integer | A pack from list_credit_packs. | | `discount_code` | string | Optional discount code. | ## `get_checkout` **Get checkout.** Whether a credit purchase has been paid. View only: yes; not offered in ChatGPT; runs `GET /checkouts/{id}`. | Argument | Type | Description | | --- | --- | --- | | `checkout_id` (required) | string | From buy_credits. | ## `verify_document` **Verify signed PDF.** Check whether a PDF was signed with SignWith and its digital signatures are valid. View only: yes; runs `POST /documents/verify`. | Argument | Type | Description | | --- | --- | --- | | `file` (required) | string | The PDF, base64-encoded. | ## `send_feedback` **Send feedback to SignWith.** Pass a bug report, feature request or question to the SignWith team. View only: yes; runs `POST /feedback`. | Argument | Type | Description | | --- | --- | --- | | `message` (required) | string | What happened, up to 5,000 characters. | | `type` | string | `bug`, `feature_request`, `feedback` or `question`. | | `context` | object | Optional `client`, `tool`, `signature_request_id`, `template_id` and `error_code`. | --- # Connect Claude to SignWith (MCP) > Connect SignWith to Claude, and Claude can send documents for signature, check who has signed and remind signers, right in the conversation. Source: https://signwith.co/docs/mcp/claude · Updated 2026-10-02 The SignWith MCP server is at `https://app.signwith.co/mcp`. How you add it depends on where you use Claude. Steps checked against Claude's documentation on 1 October 2026. ## Claude Code Claude Code connects with an API key in a header, or with OAuth sign-in. **With an API key.** Create a key in [Settings → Developers](https://app.signwith.co/settings/api), put it in an environment variable, then run: ```bash claude mcp add --transport http signwith https://app.signwith.co/mcp \ --header "Authorization: Bearer $SIGNWITH_API_KEY" ``` Add `--scope user` to use SignWith in all your projects, or `--scope project` to share it with your team through the project's `.mcp.json`. In a shared `.mcp.json`, refer to the key by variable so it isn't committed: ```json { "mcpServers": { "signwith": { "type": "http", "url": "https://app.signwith.co/mcp", "headers": { "Authorization": "Bearer ${SIGNWITH_API_KEY}" } } } } ``` **With OAuth.** Add the server without a header, then run `/mcp` inside Claude Code and follow the steps in your browser to sign in to SignWith: ```bash claude mcp add --transport http signwith https://app.signwith.co/mcp ``` **Check it works.** Run `claude mcp list`, or `/mcp` inside a session, and look for `signwith`. Then ask Claude: "Which SignWith templates do I have?" ## Claude on the web, desktop and mobile In the Claude apps, SignWith is a custom connector. Custom connectors are available on Free (one connector), Pro, Max, Team and Enterprise plans. Set it up on the web or desktop; it then works on mobile too. - [Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=SignWith&connectorUrl=https%3A%2F%2Fapp.signwith.co%2Fmcp) The button opens Claude's **Add custom connector** dialog with SignWith filled in; review it and click **Add**. Or add it by hand. **Free, Pro and Max:** 1. In Claude, open **Customize → Connectors**. 2. Choose **Add custom connector**. 3. Enter `https://app.signwith.co/mcp` as the server URL, and click **Add**. 4. Click **Connect**. Claude sends you to SignWith: sign in, and choose **Full access** or **View only**. **Team and Enterprise:** an owner first adds the connector under **Organization settings → Connectors** (**Add**, then **Custom**). Each member then finds it under **Customize → Connectors** and clicks **Connect**. To use it in a chat, turn SignWith on from the **+** menu, under **Connectors**. Some organizations can also add an API key as a request header on a custom connector, a Claude beta feature that isn't available to everyone. If you have it, choose **No sign-in**, add the `authorization` header, and type the value as `Bearer ` followed by your key. ## What to try - "List my SignWith templates." - "Send the NDA template to `grace@example.com`, with Grace Hopper as the name." - "Has everyone signed the lease for 14 Riverside Ave?" - "Remind the people who haven't signed yet." Claude asks for your OK before it sends, reminds or cancels anything, unless you've chosen to always allow that tool. Each document uses credits as described in [credits and billing](https://signwith.co/docs/api/credits). ## Troubleshooting - **401 or "unauthorized" with an API key:** check the header is exactly `Authorization: Bearer sw_...` and that the key isn't revoked or expired. See [authentication](https://signwith.co/docs/api/authentication). - **Claude can read but not send:** the connection is View only, or the key is read-only. Reconnect with Full access, or use a full-access key. - **"insufficient_credits":** the account is out of credits. Claude can show credit packs and a checkout link, or you can buy credits in SignWith. All tools and access rules are on the [MCP overview](https://signwith.co/docs/mcp). --- # Connect ChatGPT to SignWith (MCP) > Add SignWith to ChatGPT as an app, and ChatGPT can send documents for signature, follow signers and verify signed PDFs while you chat. Source: https://signwith.co/docs/mcp/chatgpt · Updated 2026-10-02 ChatGPT connects to remote MCP servers as apps you create in developer mode. Steps checked against OpenAI's documentation on 1 October 2026; ChatGPT's labels change often, so look for the closest match if a name differs. ChatGPT signs in to SignWith with OAuth, so you never paste an API key: you approve the connection on SignWith's own sign-in page. ## Before you start - Developer mode is available on ChatGPT Plus, Pro, Business, Enterprise and Education, on the web. - On a Business, Enterprise or Education workspace, an admin may need to allow developer mode first. ## Add SignWith 1. In ChatGPT, open **Settings → Security and login** and turn on **Developer mode**. 2. Go to **Plugins** and select the **+** button to create an app. 3. Name it SignWith, enter `https://app.signwith.co/mcp` as the MCP server URL, and choose **OAuth** for authentication. 4. Create the app. ChatGPT sends you to SignWith: sign in, and choose **Full access** or **View only**. The new app appears under **Drafts** in your app settings, where you can see its tools and turn individual tools off. ## Use it in a chat Apps in developer mode aren't on in every chat automatically. In the message box, open the **+** menu, choose **Developer mode**, and select SignWith. Then ask, for example: - "Show my SignWith templates." - "Send the lease template to Grace Hopper, `grace@example.com`, as the tenant." - "Which signature requests are still open?" ChatGPT asks you to confirm before it runs a tool that changes something, such as sending a document. ## Credits in ChatGPT Inside ChatGPT, SignWith doesn't offer its credit-purchase tools. If the account runs out of credits, ChatGPT tells you to add credits in SignWith, then you can ask it to try again. See [credits and billing](https://signwith.co/docs/api/credits). ## Troubleshooting - **Sign-in fails:** check you're signing in to the SignWith account you use for documents, then create the app again. - **ChatGPT can read but not send:** you chose View only. Remove the app's connection and connect again with Full access. - **Disconnect:** remove the app in ChatGPT's app settings. All tools and access rules are on the [MCP overview](https://signwith.co/docs/mcp). --- # Connect Cursor to SignWith (MCP) > Add SignWith to Cursor and its agent can look up your templates, send test signature requests and check signers while you build your integration. Source: https://signwith.co/docs/mcp/cursor · Updated 2026-10-02 Cursor reads MCP servers from an `mcp.json` file. Steps checked against Cursor's documentation on 1 October 2026. ## One-click install - [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=signwith&config=eyJ1cmwiOiJodHRwczovL2FwcC5zaWdud2l0aC5jby9tY3AifQ%3D%3D) The button opens Cursor and adds SignWith to your MCP servers. When Cursor first connects, it sends you to SignWith to sign in and choose **Full access** or **View only**. ## Add SignWith with an API key 1. Create a key in [Settings → Developers](https://app.signwith.co/settings/api). A test key (`sw_test_`) keeps your experiments apart from live data. 2. Set it as an environment variable in the shell Cursor starts from: ```bash export SIGNWITH_API_KEY="sw_test_..." ``` 3. Add SignWith to `~/.cursor/mcp.json` to use it in every project, or to `.cursor/mcp.json` in one project: ```json { "mcpServers": { "signwith": { "url": "https://app.signwith.co/mcp", "headers": { "Authorization": "Bearer ${env:SIGNWITH_API_KEY}" } } } } ``` `${env:SIGNWITH_API_KEY}` reads the variable, so the key isn't saved in the file. Don't commit a key to a shared `.cursor/mcp.json`. 4. Open Cursor's MCP settings and check that `signwith` is enabled and lists its tools. ## Or add it by hand with OAuth Leave out `headers`, and Cursor signs in to SignWith with OAuth when it first connects: ```json { "mcpServers": { "signwith": { "url": "https://app.signwith.co/mcp" } } } ``` ## What to try - "List my SignWith templates and show the roles of the lease template." - "Create a test signature request from my lease template without sending emails, and give me the signing links." - "Has anyone signed the test request yet?" By default, Cursor asks before it runs a tool, so nothing is sent without your OK. For the API itself, see the [quick start](https://signwith.co/docs/api/quickstart) and the [API reference](https://signwith.co/docs/api). ## Troubleshooting - **The server shows an error:** check the variable is set in the environment Cursor was started from, then restart Cursor. - **401 unauthenticated:** the key is wrong, revoked or expired. See [authentication](https://signwith.co/docs/api/authentication). - **It can read but not send:** the key is read-only. Use a full-access key. All tools and access rules are on the [MCP overview](https://signwith.co/docs/mcp). --- # Connect VS Code to SignWith (MCP) > Add SignWith to VS Code, and GitHub Copilot can look up your templates, send signature requests and check signers from the chat panel. Source: https://signwith.co/docs/mcp/vscode · Updated 2026-10-02 VS Code reads MCP servers from `.vscode/mcp.json` in a workspace, or from your user configuration. Steps checked against the VS Code documentation on 1 October 2026. ## One-click install - [Add to VS Code](vscode:mcp/install?%7B%22name%22%3A%22signwith%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fapp.signwith.co%2Fmcp%22%7D) The button opens VS Code and adds SignWith. On first use, VS Code opens your browser to sign in to SignWith, where you choose **Full access** or **View only**. ## Add it by hand Run **MCP: Add Server** from the Command Palette, or add SignWith to `.vscode/mcp.json`: ```json { "servers": { "signwith": { "type": "http", "url": "https://app.signwith.co/mcp" } } } ``` VS Code handles the OAuth sign-in in your browser the first time it connects. ## Use an API key instead To connect with an [API key](https://signwith.co/docs/api/authentication), add a header. With an `inputs` prompt, VS Code asks for the key the first time and keeps it out of the file: ```json { "inputs": [ { "type": "promptString", "id": "signwith-api-key", "description": "SignWith API key", "password": true } ], "servers": { "signwith": { "type": "http", "url": "https://app.signwith.co/mcp", "headers": { "Authorization": "Bearer ${input:signwith-api-key}" } } } } ``` [Get your API key](https://app.signwith.co/settings/api) ## Check it works Run **MCP: List Servers** and check `signwith` is running. Then open the chat in agent mode and ask: "Which SignWith templates do I have?" Copilot asks before it runs a tool that sends or changes something. ## Troubleshooting - **401 unauthenticated with an API key:** the key is wrong, revoked or expired. See [authentication](https://signwith.co/docs/api/authentication). - **It can read but not send:** the connection is View only, or the key is read-only. All tools and access rules are on the [MCP overview](https://signwith.co/docs/mcp) and [MCP tools](https://signwith.co/docs/mcp/tools). --- # MCP OAuth and protocol reference for client developers > For developers building an MCP client or agent: how SignWith authorizes connections, and how the server behaves at the protocol level. Source: https://signwith.co/docs/mcp/oauth · Updated 2026-10-02 SignWith follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization). Clients that support it can connect with just the server URL, `https://app.signwith.co/mcp`. ## Discovery An unauthenticated request returns `401` with a `WWW-Authenticate` header that points to the protected resource metadata: ```http WWW-Authenticate: Bearer resource_metadata="https://app.signwith.co/.well-known/oauth-protected-resource/mcp", scope="read write" ``` The authorization server metadata is at `https://app.signwith.co/.well-known/oauth-authorization-server`. ## Authorization | | | | --- | --- | | Flow | Authorization code with PKCE (`S256` only) | | Client registration | Dynamic Client Registration at `/oauth/register`, or Client ID Metadata Documents | | Resource indicators | Tokens are bound to `https://app.signwith.co/mcp` | | Scopes | `read` and `write` | | Access tokens | 1 hour | | Refresh tokens | 60 days, rotated on use | | Revocation | `/oauth/revoke` | | Responses | Include the `iss` parameter | On the consent screen, people choose **Full access** (`read` and `write`) or **View only** (`read`). They can see and disconnect connected apps in **Settings → Connected apps**. OAuth tokens only work on `/mcp`. To call the REST API, use an [API key](https://signwith.co/docs/api/authentication). ### Errors and limits - If an authorization request from a client that registered itself (Dynamic Client Registration or a Client ID Metadata Document) is invalid, SignWith usually shows the error on its own page instead of redirecting back to the client. Claude and ChatGPT still get the standard OAuth error redirect. Check the request against the authorization server metadata. - The unauthenticated OAuth endpoints are rate limited per IP address: `/oauth/authorize` and `/oauth/token` at 60 requests a minute each, and `/oauth/register` at 30 an hour. Over the limit, `/oauth/token` returns `429` with the error `slow_down`. ## API keys instead of OAuth A client can skip OAuth and send an API key in the `Authorization` header: ```http Authorization: Bearer sw_live_... ``` A read-only key behaves like a View only connection. ## Protocol behaviour - Transport is Streamable HTTP, and the server is stateless. It only answers POST requests; GET and DELETE return `405`. - Supported protocol versions: `2025-06-18`, `2025-03-26` and `2024-11-05`. - Methods: `initialize`, `ping`, `tools/list` and `tools/call`. - JSON-RPC messages without an `id` are notifications: they aren't executed and get no response. A request made only of notifications returns HTTP `202`. An empty batch returns error `-32600`. - A failed tool call returns a normal result with `isError: true`, and the API's error `code` and `message` in the content. - Each tool call runs the matching [REST API](https://signwith.co/docs/api) request, so rate limits (300 requests a minute per connection), credits and validation are the same. The tools and their arguments are on the [MCP tools](https://signwith.co/docs/mcp/tools) page.