Skip to main content

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, Admin

The 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-Key header on POST /documents.
  • If the same key already created a document in your workspace, Plutosign returns that document with 200 and 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"
  • page starts at 1. limit defaults to 20 and is at most 100.
  • status is optional: draft, sent, viewed, completed, declined, voided or expired.
  • The newest documents come first. The response has data and pagination with page, limit, total and totalPages.

Rate limits

Limits are per API key unless the table says per IP. Read and update requests share one budget.

RequestsLimit per minute
Reads, updates and deletes (documents, contacts, templates, recipients, search, credits)120
Creates (documents, contacts, templates, recipients)60
POST /documents/{id}/send30
GET /documents/{id}/download60
/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

EventWhen
document.sentThe document was sent for signing
document.viewedThe document was viewed
document.signedThe document was signed
document.completedEveryone has signed
document.declinedA recipient declined
document.expiredThe document expired
recipient.viewedA recipient viewed
recipient.signedA recipient signed
recipient.declinedA recipient declined
payment.receivedA client paid through your own connected Stripe account
credits.lowThe 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 your whsec_ secret.
  • X-ZSign-Event, X-ZSign-Timestamp and X-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, eventId and 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. Answer 2xx quickly 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 recipientEmail from 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

SymptomCauseFix
401 Invalid or missing API keyNo Authorization: Bearer header, or the key was revoked.Send the full jsign_live_ key. Create a new key if needed.
403 EMAIL_NOT_VERIFIEDThe sender's email is not confirmed.Confirm the email in the Plutosign dashboard, then send again.
400 unassigned_signature_fieldSeveral 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 recipientsListRecipients were sent in the create call, or not at all.Put them in recipientsList on /send.
402 CREDITS_REQUIRED or PRICE_CHANGEDThe send needs credits, or the price rose.Price it first with GET /documents/{id}/send-cost, pass expectedCredits, or buy credits.
429 Rate limit exceededToo 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.