BENJO Sign API

Send documents for e-signature from your own system: create a template once, create a document from it for each customer with their details prefilled, and get a callback when the customer opens, signs or declines — with the filled values and a link to the signed PDF.

  • Base URL https://sign.example.com/api/v1 — JSON in, JSON out.
  • Spec /openapi.json (OpenAPI 3.1, generated from the server's own route definitions).
  • Objects templates (tpl_…), documents (doc_…), signers (sg_…), webhook subscriptions (whk_…), deliveries (dlv_…).
  • Negotiation signers can comment (in general, on a field, a passage or a point on a page) and ask for changes; you answer with POST /documents/{id}/comments/{threadId}/reply or publish a new version with POST /documents/{id}/versions.
  • Placeholders in an uploaded PDF or Word file, {signature} / {{signature}}, {name}, {date}, {initials}, {stamp}, {id}, {email}, {phone} (or Hebrew: {חתימה}…) become fields where they are; {signature:client} assigns the signer role. The placeholder text is removed from the document.
  • Your ids every document can carry your externalId (unique), text metadata and a free callback value — all three come back in every response, webhook and callback.

Authentication

Create a key in the dashboard: API keys → New key. The secret is shown once (only a hash is stored). Send it as a bearer token:

Authorization: Bearer bsk_live_…

A key belongs to one organisation and acts as the member who created it (if that member leaves, the key stops working). Pick only the scopes you need:

ScopeAllows

A call outside the key's scopes answers 403 missing_scope. Revoke a key at any time in the dashboard.

Quickstart

1. Create a template

From editor HTML (or a Word file / a PDF). Variables are written as tags: {{client_name}}, {{price;type=currency}}, and a signature for the signer role client: {{client_sig;type=signature;role=client}}.

curl -X POST /api/v1/templates \
  -H "Authorization: Bearer $SIGN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Service agreement",
    "html": "<h1>Service agreement</h1><p>Client: {{client_name}}</p><p>Price: {{price;type=currency}}</p><p>{{client_sig;type=signature;role=client}}</p>",
    "roles": [{ "key": "client", "label": "Client" }]
  }'

The answer's data.id is the template id; data.variables lists what you can prefill (source: "creator") and what the signer fills (source: "signer:client").

2. Create a document and send it

curl -X POST /api/v1/documents \
  -H "Authorization: Bearer $SIGN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4711" \
  -d '{
    "templateId": "tpl_8KqL2mZs0aPd4XyB",
    "values": { "client_name": "Dana Levi", "price": "12500" },
    "signers": [{ "role": "client", "name": "Dana Levi", "email": "dana@example.com" }],
    "externalId": "crm-4711",
    "metadata": { "deal": "D-88" },
    "callbackUrl": "https://example.com/hooks/sign",
    "callback": "dana@example.com",
    "send": true,
    "deliver": "email"
  }'
const res = await fetch("/api/v1/documents", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SIGN_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "order-4711",
  },
  body: JSON.stringify({
    templateId: "tpl_8KqL2mZs0aPd4XyB",
    values: { client_name: "Dana Levi", price: "12500" },
    signers: [{ role: "client", name: "Dana Levi", email: "dana@example.com" }],
    externalId: "crm-4711",
    callbackUrl: "https://example.com/hooks/sign",
    callback: "dana@example.com",
    send: true,
    deliver: "email", // or "link": the answer carries data.links[].url and you deliver them
  }),
});
const { data } = await res.json(); // data.id = "doc_…", data.status = "sent"

3. Receive the callback

Every event of the document is POSTed to callbackUrl in the simple format (one object in an array) and signed — verify the signature, answer any 2xx within 10 seconds, and de-duplicate on event.event_name + submission_id + document_id.

import express from "express";
import crypto from "node:crypto";
const app = express();
app.post("/hooks/sign", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(process.env.SIGN_CALLBACK_SECRET, req.body.toString("utf8"), req.get("X-Sign-Signature"))) return res.sendStatus(401);
  const [ev] = JSON.parse(req.body.toString("utf8"));
  if (ev.event.event_name === "completed") {
    // ev.submission_file_link → the signed PDF (a link valid for 1 hour; GET /api/v1/documents/{id}/signed.pdf any time)
    // ev.inputData            → every field by its label
    // ev.external_id / ev.metadata / ev.user_data.callback → your own ids
  }
  res.sendStatus(200);
});

4. Download the signed PDF

curl /api/v1/documents/doc_Hs7Q2nX4LpV9aZr1/signed.pdf \
  -H "Authorization: Bearer $SIGN_API_KEY" -o signed.pdf

Callbacks — the simple format

Set callbackUrl when you create a document (or later with PATCH /documents/{id} or in POST /documents/{id}/send). The service POSTs to it on the document's events:

callbackUrlhttps only; private, loopback, link-local and other internal addresses are refused (checked again on every delivery, after DNS).
callbackEventsall (default) — every event below; completed — only when every signer signed.
callbackFormatsimple (default) — this format; full — the webhook envelope.
callbackA free value (up to 500 characters — an e-mail, an order id…). It is echoed back unchanged as user_data.callback.

The body is a JSON array with one object per event:

KeyWhat it holds
event.event_nameopened — the first time each signer opens the document (once per signer) · submitted — a signer finished signing (once per signer) · completed — all signers finished · declined · expired · cancelled · comment — a signer or the organisation wrote in a comment thread · changes_requested — a signer asked for changes (signing pauses for everyone until the organisation replies, revises, or continues without changes) · revised — the organisation published a new version · also created and sent.
event.event_timestampWhen it happened — ISO 8601, UTC, with milliseconds.
inputDataEvery field by its label as the signer sees it (the field's name when it has no label; a repeated label gets " (name)" appended), with the value filled so far or null. Signature / initials / stamp / image: a short-lived signed URL of the picture. Checkbox: true/false. Multi-select: an array. Files: an array of signed URLs. Numbers: numbers. Masked fields: the masked value (e.g. *****6789). Included on every event, also opened and comment.
user_data.callbackYour callback value, or null.
user_data.sent_by_channelEmail when we e-mailed the signer their link, else Link (you delivered it). SMS / WhatsApp are reserved for those delivery channels.
submission_idThe signer's id (sg_…) — stable per signer. On completed: the last signer; on declined: the signer who declined; on events with no signer (created, sent, expired, cancelled, revised): the document id.
submission_file_linkOn submitted / completed once the signed PDF exists: a signed URL of the PDF (valid for 1 hour; a retry or resend gets a fresh one). Otherwise: the document's page in the dashboard. Once the document's files were deleted from the server: the copy in the owner's Google Drive, or null — never a link to a deleted file (also when an older delivery is resent).
form_id, form_nameThe template's id and name — also after a revision (the contract got its own edited copy). For a one-time document: the document's.
document_id, external_id, metadataThe document and your own ids.
commentcomment only: { id, threadId, text, at, author: { kind: "signer" | "organisation", role }, anchor: { kind: "general" | "field" | "text" | "page", text }, reply, version }. A signer's changes_requested comes right after the comment that explains it.
revisionrevised only: { version, fromVersion, kind: "edited" | "replaced", note, signaturesInvalidated }. The signers get a new link (or the same one keeps working) and see the changes highlighted — inserted text with a yellow marker, deleted text struck through — and sign the new version.
reasondeclined only: what the signer wrote.

Deliveries carry the same headers and HMAC signature as webhooks, signed with your organisation's callback secret (dashboard → Webhooks → Callback signing secret; rotate with POST /api/v1/callback-secret/rotate). Retries, the delivery log and resend work exactly like webhooks.

A payload per event

Retention: files on the server, copies in Google Drive

A finished document's files — the signed PDF, the audit certificate, signer images and attached files — stay on the server 90 days after it completed (or closed without signing: declined / expired / cancelled); the owner may choose 30–90 days. Then the file bytes are deleted; the document's record stays: its ids, signers, status and times, the files' SHA-256 hashes, the audit trail, and the Drive link. Documents not saved to Google Drive are deleted only after the owners were e-mailed twice (14 and 3 days before). Drafts and documents still out for signature are never touched.

Every document view (GET /api/v1/documents/{id}) and the full webhook format carry storage once the document finished:

"storage": {
  "server": "kept",                       // or "deleted"
  "deleteAt": "2027-01-12T10:00:16.310Z",  // when the files will be deleted (null once deleted)
  "deletedAt": null,
  "drive": { "status": "saved", "signedPdf": "https://drive.google.com/file/d/…/view", "auditPdf": "https://drive.google.com/file/d/…/view" }   // or null (Drive not connected); status pending | saved | failed
}

After the deletion GET …/signed.pdf / …/audit.pdf (and the signed download links) answer 410 { "error": { "code": "purged", "message": "…", "driveLink": "https://drive.google.com/…" | null, "deletedAt": "…" } }, files.signedPdf is null, and the simple format's submission_file_link is the Drive link or null. Download what you need to keep (or connect Google Drive in the dashboard → Connections) before deleteAt.

Webhooks

Subscriptions receive the events you choose for every document of the organisation. Create them in the dashboard (Webhooks) or with the API:

curl -X POST /api/v1/webhooks \
  -H "Authorization: Bearer $SIGN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/hooks/sign", "events": ["document.signed", "document.completed", "document.declined"], "format": "full" }'

The answer carries the subscription's signing secret once. format is full (default, below) or simple (the callback format). POST /webhooks/{id}/test sends a signed webhook.test ping right away.

Events

EventWhensimple event_name

The full format

{ id, type, createdAt, organizationId, data: { document, signer?, reason?, comment? } }. id is stable per event — a resend keeps it, so de-duplicate on it. data.document always has the id, your externalId and metadata, the status, a summary of every signer, and — once completed — downloads with signed URLs of the signed PDF and the audit certificate (valid for 1 hour from the delivery attempt; null once the files were deleted), and storage (below).

Verifying signatures

Every delivery (webhook or callback) has these headers:

X-Sign-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
X-Sign-EventThe event (document.completed…).
X-Sign-DeliveryThe delivery id (dlv_…) — the same on every retry of one delivery.
X-Sign-TimestampThe same t.

Compute the HMAC over the raw body bytes (before any JSON parsing), compare in constant time, and refuse timestamps older than 5 minutes:

import crypto from "node:crypto";

export function verify(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=", 2).map((s) => s.trim())));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Retries, the delivery log and resend

  • Success is any 2xx within 10 seconds. Redirects are not followed (a 3xx counts as a failure).
  • Retries: up to 10 attempts, waiting 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h, 12 h between them. Every attempt is signed again with a fresh timestamp and fresh file links.
  • Auto-pause: a subscription whose deliveries end failed 15 times in a row is paused (owners are e-mailed). Resume with PATCH /webhooks/{id} {"enabled": true} or in the dashboard.
  • Blocked: a URL that resolves to a private / internal address is never called (status blocked).
  • Log: GET /deliveries (filters subscriptionId, documentId, status), GET /deliveries/{id} shows the payload.
  • Resend: POST /deliveries/{id}/resend delivers the same event again (same event id), 30 per hour.

Errors

{ "error": { "code": "invalid", "message": "some fields are not valid", "details": ["signers: add at least one signer"], "requestId": "req_9f2c1a7d" } }
StatusCodes
400invalid, invalid_values, invalid_signers, template_incomplete, missing_values, blocked_url…
401unauthorized — no key, a wrong or revoked key
403missing_scope, forbidden
404not_found
409duplicate_external_id, already_sent, not_editable, template_archived…
413too_large
422idempotency_mismatch — the same Idempotency-Key with a different body
429rate_limited — wait Retry-After seconds
5xxinternal, engine_unavailable — safe to retry with the same Idempotency-Key

Every response has an X-Request-Id header (also in the error body) — quote it when you contact support.

Rate limits, idempotency and pagination

Rate limit: 120 requests per minute per key (configurable by the operator). Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds); over the limit: 429 with Retry-After.

Idempotency: send Idempotency-Key: <your unique key> on POSTs. A retry with the same key within 24 hours returns the first answer (header Idempotent-Replayed: true); the same key with a different body answers 422. Use your own order / record id.

Pagination: lists take limit (1–100, default 20) and cursor, and answer { data, pagination: { limit, nextCursor, total } }. Pass nextCursor as cursor until it is null.

API reference

Generated from /openapi.json.

Loading…