API Reference
Send documents from your SignFastAi templates programmatically.
The SignFastAi public API lets you create and send documents from a saved template ("My Templates" in the dashboard), check a document's signing status, and download the final signed PDF — without opening a browser. It's built for automations (Zapier-style scripts, your own tooling) that need to trigger signature requests from another system.
Overview
Availability
The public API is a Pro / Lifetime feature. Every endpoint requires a valid API key from an account on one of those plans.
Base URL
All endpoints are under:
https://<your-domain>/api/v1Replace <your-domain> with the domain you access SignFastAi at.
Quick example
curl https://<your-domain>/api/v1/presets \
-H "x-api-key: YOUR_API_KEY"What's not covered
This is intentionally a minimal surface — create/send, status, and download, plus webhooks so you don't have to poll. There's no endpoint (yet) to manage templates, clients, or in-person signing sessions themselves; those are dashboard-only for now.
Authentication
Creating a key
- Sign in and open Settings → API Keys (
/settings/apikeys). This page requires a Pro or Lifetime plan — free-plan accounts see an upgrade prompt instead. - Click Create API Key and give it a name.
- Copy the key value shown in the dialog. You can show and copy it again at any time from the API Keys list (eye and copy icons in the Key column), so there's no need to save it somewhere else first. If a key is ever exposed, delete it and create a new one.
Using a key
Send the key on every request as the x-api-key header:
curl https://<your-domain>/api/v1/presets \
-H "x-api-key: YOUR_API_KEY"There is no Authorization: Bearer support — use x-api-key specifically.
Rate limits
Each key is limited to 1000 requests per rolling 24 hours. This is an
abuse backstop, not your real send limit — the number of documents you
can actually send per month is governed by your plan (see
Sign limit), independently of this request-rate cap.
Exceeding it returns a 429 — see Errors.
Authentication errors
| Status | Code | Meaning |
|---|---|---|
| 401 | MISSING_API_KEY | No x-api-key header was sent |
| 401 | INVALID_API_KEY (or another key-specific code) | The key doesn't exist, was revoked, or is malformed |
| 403 | UPGRADE_REQUIRED | The key is valid, but its owner's plan doesn't include API access |
| 429 | RATE_LIMITED | Too many requests in the current 24-hour window |
Presets
List your templates
GET /api/v1/presetsReturns every template ("preset") saved on your account, with its signer
roles. You need this before calling POST /api/v1/documents
— recipients are matched to a role by its id, not by its display label,
so you look the ID up here first.
Why match by role ID, not by name? Two roles on the same template can share a display label (e.g. two "Signer" roles). IDs are always unique, so there's no ambiguity about which role a recipient is being assigned to.
Presets response
{
"success": true,
"data": {
"presets": [
{
"id": "b3f6b6b0-...",
"title": "Freelance Agreement",
"roles": [
{
"id": "0e2b0f2e-...",
"role": "Client",
"requireAccessCode": false
},
{
"id": "9a1c9d3f-...",
"role": "Witness",
"requireAccessCode": true
}
]
}
]
}
}rolesis ordered the same way the template's signers sign in.requireAccessCode: truemeans a recipient assigned to that role must also include anaccessCodewhen you send the document — see Access codes.
There's no pagination — an account's number of templates is small (the free plan caps at one; this endpoint itself requires Pro/Lifetime anyway).
Documents
Create and send
POST /api/v1/documentsCreates one document from a template and sends it immediately — every recipient is emailed their signing link as part of this call (unless the template has signing order turned on, in which case only the first signer is notified; the rest are notified automatically as each prior signer completes, same as sending from the dashboard).
Body
{
"presetId": "b3f6b6b0-...",
"title": "Freelance Agreement — Acme Corp",
"recipients": [
{
"roleId": "0e2b0f2e-...",
"name": "Jane Client",
"email": "jane@acme.example"
},
{
"roleId": "9a1c9d3f-...",
"name": "Bob Witness",
"email": "bob@acme.example",
"accessCode": "4471"
}
]
}| Field | Required | Notes |
|---|---|---|
presetId | yes | From GET /api/v1/presets |
title | no | Overrides the template's saved title for this document |
recipients | yes | One entry per role on the template — see below |
recipients[].roleId | yes | Must be a role ID belonging to presetId |
recipients[].name | yes | |
recipients[].email | yes | |
recipients[].accessCode | conditional | Required if that role has requireAccessCode: true |
Every role on the template needs exactly one recipient — no more, no fewer. The API validates this before creating anything:
- a
roleIdthat doesn't belong topresetId→UNKNOWN_ROLE_ID - the same
roleIdused twice →DUPLICATE_ROLE_ID - a role with no recipient →
MISSING_ROLE_RECIPIENT - a
requireAccessCoderole with no (or blank)accessCode→ACCESS_CODE_REQUIRED
See Errors for the full response shape of each.
Access codes
Unlike the dashboard's Bulk Send feature, which only supports single-role templates and refuses templates that require an access code, the API supports multi-role templates and lets you supply each recipient's access code directly — there's no UI step to collect it, so it has to come from you.
Sign limit
Before creating the document, the API checks your plan's monthly send
limit (the same one enforced when sending from the dashboard). If sending
this document would exceed it, you get a 409 SIGN_LIMIT_EXCEEDED and
nothing is created.
Create response
{
"success": true,
"data": {
"documentId": "c1d2e3f4-...",
"status": "sent",
"signers": [
{
"id": "...",
"name": "Jane Client",
"email": "jane@acme.example",
"role": "Client",
"status": "sent",
"signingUrl": "https://<your-domain>/sign/s/..."
},
{
"id": "...",
"name": "Bob Witness",
"email": "bob@acme.example",
"role": "Witness",
"status": "pending",
"signingUrl": null
}
]
}
}signingUrl is only populated for signers notified in this call —
null for anyone still queued behind a signing-order template (they get
a real link, and get notified, once it's their turn).
Check status
GET /api/v1/documents/:idReturns the document's current status and each signer's status/timestamps.
404s (as DOCUMENT_NOT_FOUND) if the document doesn't exist or
belongs to a different account — the API never confirms which.
{
"success": true,
"data": {
"id": "c1d2e3f4-...",
"title": "Freelance Agreement — Acme Corp",
"status": "sent",
"sentAt": "2026-09-23T09:00:00.000Z",
"completedAt": null,
"signers": [
{
"id": "...",
"name": "Jane Client",
"email": "jane@acme.example",
"role": "Client",
"status": "viewed",
"viewedAt": "2026-09-23T09:05:00.000Z",
"signedAt": null,
"declinedAt": null,
"declineReason": null
}
]
}
}Download the signed PDF
GET /api/v1/documents/:id/downloadOnly available once status is completed. Returns the final PDF
directly (Content-Type: application/pdf), not a JSON envelope. Calling
it earlier returns 409 DOCUMENT_NOT_COMPLETED.
The signed PDF is the original document with a certificate-of-completion page appended. Response headers:
Content-Disposition— the original file name (filename*carries the UTF-8 name for non-ASCII file names).X-Document-Sha256— the SHA-256 of the file, so you can verify the download wasn't altered or truncated.
Fetching the file from storage can occasionally fail transiently
(502 STORAGE_FETCH_FAILED, or a 500) — retrying the request is safe.
Webhooks
Instead of polling GET /documents/{id}, register a URL and we'll POST an
event to it whenever something happens to one of your documents — whether
it was created through the API, the dashboard, or a bulk send.
Manage endpoints under Settings → API Keys → Webhooks. You can add up to 5 endpoints, choose which events each one receives, send a test event, and see every delivery (with the response we got) for 30 days. Webhooks are a Pro / Lifetime feature.
Events
| Event | Sent when |
|---|---|
document.sent | A document was sent for signature |
signer.viewed | A signer opened the document |
signer.signed | A signer finished signing |
document.completed | Every signer has signed and the final PDF is ready to download |
document.declined | A signer declined to sign |
document.completed is sent only after the final PDF exists, so you can
call GET /documents/{id}/download as soon as you receive it.
Payload
Each request is a POST with a JSON body:
{
"id": "evt_2f6c1b0e-7a1d-4a55-9a52-2f7c0f8f1a11",
"type": "document.signed",
"createdAt": "2026-09-24T09:15:02.000Z",
"data": {
"document": {
"id": "0b0c7a4e-...",
"title": "Service Agreement — Acme",
"status": "sent",
"createdVia": "api",
"sentAt": "2026-09-24T09:10:00.000Z",
"completedAt": null,
"finalFileHash": null,
"signers": [
{ "id": "…", "name": "Ada Lovelace", "email": "ada@example.com", "status": "signed" }
]
},
"signer": { "id": "…", "name": "Ada Lovelace", "email": "ada@example.com", "status": "signed" }
}
}data.signer is present for signer.* events. The same event can be
delivered more than once (for example after a retry), so treat id as an
idempotency key.
Verifying the signature
Every request carries these headers:
| Header | Value |
|---|---|
X-SignFast-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-SignFast-Event | The event type, e.g. document.completed |
X-SignFast-Delivery | A unique id for this delivery |
The signature is HMAC-SHA256(secret, "<t>.<raw request body>"), where
secret is your endpoint's signing secret (shown when you create it, and
available any time from the endpoint list). Compute it over the raw
body, compare in constant time, and reject timestamps more than a few
minutes old:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}Delivery and retries
Respond with any 2xx status within 8 seconds to acknowledge. Anything
else (or a timeout) counts as a failure: we retry after about 5 seconds and
again after about 30 seconds, then mark the delivery as failed. Failed
deliveries can be re-sent from the dashboard. Redirects are not followed.
Endpoint URLs must be https:// and publicly reachable — addresses on
private networks, localhost and cloud-metadata ranges are rejected.
Request log
Every request made with your API keys is recorded (method, path, status code, error code, duration) and shown under Settings → API Keys → Request log, filterable by key and by success or error. Records are kept for 30 days. The API Keys list also shows each key's last-used time and request count for the past 30 days, and documents created through the API are marked "API" in the documents list.
The Usage tab charts your requests per hour (last 24 hours) or per day (7 or 30 days), split into successes, client errors and server errors, with response-time figures and a breakdown by endpoint and by key. The request log can be exported as CSV, and you can turn on an error alert: we email you when the failure rate over the last hour crosses a threshold you choose (at most once every 6 hours).
Errors
Response shape
Every JSON response (everything except the PDF download) uses the same envelope:
{ "success": true, "data": { /* ... */ } }{
"success": false,
"error": {
"code": "MISSING_ROLE_RECIPIENT",
"message": "Every template role needs a recipient",
"details": { "missingRoleIds": ["9a1c9d3f-..."] }
}
}details is only present on errors where there's something
programmatically useful to act on (which role IDs were the problem, what
your plan's limit is, and so on) — check it before falling back to
parsing message.
Error codes
| Status | Code | Where | Meaning |
|---|---|---|---|
| 401 | MISSING_API_KEY | any endpoint | No x-api-key header sent |
| 401 | INVALID_API_KEY (or similar) | any endpoint | Key doesn't exist, was revoked, or is malformed |
| 403 | UPGRADE_REQUIRED | any endpoint | Key is valid, but the account isn't Pro/Lifetime |
| 429 | RATE_LIMITED | any endpoint | Over 1000 requests in the last 24h on this key |
| 400 | INVALID_BODY | POST /documents | Body failed schema validation |
| 404 | PRESET_NOT_FOUND | POST /documents | presetId doesn't exist or isn't yours |
| 400 | UNKNOWN_ROLE_ID | POST /documents | A roleId doesn't belong to this template |
| 400 | DUPLICATE_ROLE_ID | POST /documents | The same roleId was used for two recipients |
| 400 | MISSING_ROLE_RECIPIENT | POST /documents | A template role has no recipient |
| 400 | ACCESS_CODE_REQUIRED | POST /documents | A requireAccessCode role's recipient is missing accessCode |
| 409 | SIGN_LIMIT_EXCEEDED | POST /documents | Sending this would exceed your plan's monthly send limit |
| 404 | DOCUMENT_NOT_FOUND | GET /documents/:id, .../download | Document doesn't exist or isn't yours |
| 409 | DOCUMENT_NOT_COMPLETED | GET /documents/:id/download | Not every signer has signed yet |
| 502 | STORAGE_FETCH_FAILED | GET /documents/:id/download | Transient storage error — retry |
| 500 | INTERNAL_ERROR | any endpoint | Unexpected server error |