Send your first document with the API and receive webhooks
Create an API key, create and send a document with curl or Node, list documents, and verify signed webhook deliveries.
For developers, workspace adminsPlans: Free, Growth, Agency, Scale, Trial
Create a draft, send it for signing, and get told when it is signed.
Plans: Free, Growth, Agency, Scale, TrialRoles: Owner, AdminThe API is on every plan. Workspace owners and admins create API keys and webhooks. The base URL is https://plutosign.com/api/v1. The full reference is at /docs/api, and the OpenAPI document is at /api/v1/docs (no login).
Before you start
- An API key acts for the whole workspace. It has full workspace API permissions. Keep it secret, like a password.
- The sender's email must be confirmed before the API can send. For an API key, the sender is the workspace owner.
- Sends count toward your plan's allowances. With credits on, API sends beyond the monthly allowance (Growth 50, Agency 150, Scale 500) cost 15 credits each.
Create an API key
Go to Settings and select the API tab (/settings?tab=api). In API Keys, select Generate New Key.
Type a Key Name and select Generate Key. Copy the key now. It starts with jsign_live_. You will not be able to see it again: after you close the dialog, Plutosign shows only its prefix.
To revoke a key, select the delete button beside it and confirm Delete Key.
Authenticate
Send the key as a bearer token on every request:
export PLUTOSIGN_API_KEY="jsign_live_..."
curl https://plutosign.com/api/v1/documents \
-H "Authorization: Bearer $PLUTOSIGN_API_KEY"A missing or wrong key returns 401 with {"error": "Invalid or missing API key"}.
Create and send a document
Create a draft. title is required (up to 500 characters). content is optional editor JSON: an array of blocks. Add an Idempotency-Key header so a retry does not create a second draft.
curl -X POST https://plutosign.com/api/v1/documents \
-H "Authorization: Bearer $PLUTOSIGN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: proposal-acme-2026-11" \
-d '{
"title": "Website redesign proposal",
"content": [
{ "type": "p", "children": [{ "text": "Scope, timeline and price for Acme." }] }
]
}'The response is 201 with {"data": {"id": "…", "title": "…", "status": "draft", "createdAt": "…"}}.
Send it. Put the recipients in recipientsList (up to 50). A role is signer, approver, viewer or cc; the default is signer. Give each signer a signature field with recipientEmail.
curl -X POST https://plutosign.com/api/v1/documents/DOCUMENT_ID/send \
-H "Authorization: Bearer $PLUTOSIGN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipientsList": [
{ "email": "jordan@acme.example", "name": "Jordan Lee", "role": "signer" }
],
"fields": [
{ "type": "signature", "recipientEmail": "jordan@acme.example" }
],
"message": "Here is the proposal we discussed."
}'The response has data.recipients and data.signingLinks, with a url for each recipient. Plutosign also emails the recipients. Recipients do not need a Plutosign account. Open a signing link as a normal page: iframe embedding is not available.
The same in Node.js
Node 18 or later has fetch built in. Save this as send.mjs so top-level await works.
const BASE = "https://plutosign.com/api/v1";
const headers = {
Authorization: `Bearer ${process.env.PLUTOSIGN_API_KEY}`,
"Content-Type": "application/json",
};
async function call(path, init = {}) {
const res = await fetch(BASE + path, { ...init, headers: { ...headers, ...init.headers } });
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.code ?? ""} ${body.error ?? ""}`);
return body;
}
const { data: doc } = await call("/documents", {
method: "POST",
headers: { "Idempotency-Key": "proposal-acme-2026-11" },
body: JSON.stringify({
title: "Website redesign proposal",
content: [{ type: "p", children: [{ text: "Scope, timeline and price for Acme." }] }],
}),
});
const { data: sent } = await call(`/documents/${doc.id}/send`, {
method: "POST",
body: JSON.stringify({
recipientsList: [{ email: "jordan@acme.example", name: "Jordan Lee", role: "signer" }],
fields: [{ type: "signature", recipientEmail: "jordan@acme.example" }],
}),
});
console.log(sent.signingLinks);Idempotency
- Send any string of up to 128 characters in the
Idempotency-Keyheader onPOST /documents. - If the same key already created a document in your workspace, Plutosign returns that document with
200and does not create another one or use another document from your allowance. - Use one key per document you mean to create.
List documents
curl "https://plutosign.com/api/v1/documents?page=1&limit=50&status=completed" \
-H "Authorization: Bearer $PLUTOSIGN_API_KEY"pagestarts at 1.limitdefaults to 20 and is at most 100.statusis optional:draft,sent,viewed,completed,declined,voidedorexpired.- The newest documents come first. The response has
dataandpaginationwithpage,limit,totalandtotalPages.
Rate limits
Limits are per API key unless the table says per IP. Read and update requests share one budget.
| Requests | Limit per minute |
|---|---|
| Reads, updates and deletes (documents, contacts, templates, recipients, search, credits) | 120 |
| Creates (documents, contacts, templates, recipients) | 60 |
POST /documents/{id}/send | 30 |
GET /documents/{id}/download | 60 |
/webhooks (per IP) | 60 |
/webhooks/deliveries (per IP) | 60 |
Over the limit, you get 429 with X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). Wait until the reset, then retry.
Receive webhooks
Add an endpoint
In Settings → API → Webhooks, select Add Webhook Endpoint. Enter the Endpoint URL (public HTTPS) and the Events (comma-separated). Select Add Endpoint.
Copy the signing secret from Save your webhook signing secret. It starts with whsec_. Plutosign does not show it again.
Or use the API. The secret is in the creation response only:
curl -X POST https://plutosign.com/api/v1/webhooks \
-H "Authorization: Bearer $PLUTOSIGN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-app.example/plutosign", "events": ["document.completed", "recipient.signed"] }'Plutosign refuses private or internal addresses.
Events
| Event | When |
|---|---|
document.sent | The document was sent for signing |
document.viewed | The document was viewed |
document.signed | The document was signed |
document.completed | Everyone has signed |
document.declined | A recipient declined |
document.expired | The document expired |
recipient.viewed | A recipient viewed |
recipient.signed | A recipient signed |
recipient.declined | A recipient declined |
payment.received | A client paid through your own connected Stripe account |
credits.low | The credit balance fell below a threshold (only when credits are on) |
document.created and document.voided can be registered but are not sent today. Sends from bulk send, forms and scheduled send do not emit document.sent.
What a delivery looks like
Plutosign sends a POST with a JSON body:
{ "type": "document.completed", "data": { "documentId": "…" }, "timestamp": "2026-11-10T09:30:00.000Z", "eventId": "…" }Headers:
X-ZSign-Signature: sha256=<hex>: HMAC-SHA256 of the exact raw body, keyed with yourwhsec_secret.X-ZSign-Event,X-ZSign-TimestampandX-ZSign-Event-Id. These headers are not signed. Compare them with the body.User-Agent: ZSign-Webhooks/1.0.
Verify the signature
Verify the raw bytes before you parse JSON. This Express example uses only Node's crypto:
const crypto = require("node:crypto");
const express = require("express");
const app = express();
function verify(rawBody, signature, secret) {
if (typeof signature !== "string") return false;
const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"));
const received = Buffer.from(signature);
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
app.post("/plutosign", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.body, req.get("X-ZSign-Signature"), process.env.PLUTOSIGN_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.eventId !== req.get("X-ZSign-Event-Id") || event.timestamp !== req.get("X-ZSign-Timestamp")) {
return res.sendStatus(400);
}
// Store event.eventId with your result. Skip an eventId you already processed.
res.sendStatus(200);
});
app.listen(3000);Retries and delivery
- Plutosign delivers each event when its delivery worker runs, every few minutes. The same worker retries failed deliveries.
- Each request waits up to 10 seconds. Any non-2xx answer or network error is retried. Redirects are not followed and are not retried.
- There are up to 6 attempts in total. The waits after each failure are at least 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours.
- Each retry has the same body,
timestamp,eventIdand signature. Do not reject an event only because its timestamp is old. - Delivery is at least once, and order is not guaranteed. Deduplicate on the signed
eventId. Answer2xxquickly and do slow work later.
Check deliveries
curl "https://plutosign.com/api/v1/webhooks/deliveries?status=failed" \
-H "Authorization: Bearer $PLUTOSIGN_API_KEY"You get the latest 50 deliveries in your workspace, with the event ID, webhook ID, attempt count, response status, error code and each attempt's times. status can be pending, delivering, delivered or failed. Leave it out to see all. Payloads and secrets are not included. With an OAuth token, this endpoint needs the admin scope. After the last attempt fails, Plutosign does not retry again: fix your receiver and fetch the document's state from the API.
In the app, the endpoint's actions menu in Settings → API → Webhooks also opens its deliveries.
OAuth for apps used by other workspaces
If you build an app that other Plutosign workspaces connect to, use OAuth 2.0 (authorization code with PKCE) instead of API keys. Access tokens start with zsign_at_ and carry the scopes read, write, send or admin. OAuth apps are registered on request: email support@plutosign.com with your app name and redirect URLs.
Limits
- Up to 50 recipients per send. The email message can be up to 5,000 characters.
- With more than one signer, every signature and initials field must have a
recipientEmailfrom the send. - A document can be sent once. Sending it again returns Document has already been sent.
- The API cannot create Stripe payment requests. Set those up in the app.
- Key creation is limited to 10 per minute per person.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 Invalid or missing API key | No Authorization: Bearer header, or the key was revoked. | Send the full jsign_live_ key. Create a new key if needed. |
403 EMAIL_NOT_VERIFIED | The sender's email is not confirmed. | Confirm the email in the Plutosign dashboard, then send again. |
400 unassigned_signature_field | Several signers, and a signature field has no matching recipientEmail. | Assign each signature field to one recipient's email. |
400 At least one recipient is required in recipientsList | Recipients were sent in the create call, or not at all. | Put them in recipientsList on /send. |
402 CREDITS_REQUIRED or PRICE_CHANGED | The send needs credits, or the price rose. | Price it first with GET /documents/{id}/send-cost, pass expectedCredits, or buy credits. |
429 Rate limit exceeded | Too many requests in a minute. | Wait for X-RateLimit-Reset, then retry with backoff. |
| Webhook signature never matches. | Your framework parsed or re-encoded the body. | Compute the HMAC on the raw bytes, before JSON parsing. |
| The same event arrives twice. | Delivery is at least once. | Deduplicate on eventId. |