Skip to content
PDFBetter

API v1 · Business plan

Fill, export and send PDFs from your own code.

The PDFBetter API is a JSON REST API at https://api.pdfbetter.com/v1. Create documents from files, URLs or templates, fill their fields, export PDFs, send them for e-signature and receive signed webhooks when anything changes — with the same rules, limits and audit trail as the app.

Requires BusinessKeys: Settings → API keys·OpenAPI 3.1

# 1. Create a document from a PDF on the web
curl -X POST "https://api.pdfbetter.com/v1/documents/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/files/vendor-agreement.pdf"}'

# 2. See what can be filled, then fill it
curl "https://api.pdfbetter.com/v1/documents/Xq3T9vLmA2pR7sKd/fields/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

curl -X PUT "https://api.pdfbetter.com/v1/documents/Xq3T9vLmA2pR7sKd/fields/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"values": {"company_name": "Acme Ltd"}}'

# 3. Send it for signature (or POST …/export/ for a PDF)
curl -X POST "https://api.pdfbetter.com/v1/signature-requests/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"documentId": "Xq3T9vLmA2pR7sKd", "recipients": [{"name": "Dana Smith", "email": "dana@example.com"}], "useDocumentFields": true}'
  1. Your server

    POST /v1/documents/

    Upload a PDF, import a URL or start from a template.

  2. PDFBetter

    POST /v1/signature-requests/

    Fill fields, then send with sequential or parallel routing.

  3. The signing page on a phone: the agreement with Sam Rivera's signature and today's date filled in, and a Finish button

    Signer

    Signs in the browser

  4. Your webhook

    signature_request.completed

    HMAC-signed; fetch the signed PDF and certificate.

A typical signing round trip. Every endpoint and event is listed in the reference below.

Start here

Overview

Every request is authenticated with an API key that belongs to a Business account and acts as that account. Responses are JSON objects with an object type; lists are wrapped as { "object": "list", "data": [...] }.

  • Documents — upload a PDF, import one from an https URL, or start from a template; then list and fill its fields and export a PDF.
  • Signature requests — send for e-signature with sequential or parallel routing, remind, cancel, and download the signed PDF and certificate of completion.
  • Forms — read responses to your published forms; pause or close forms.
  • Bulk fill — one filled PDF per spreadsheet row.
  • Webhooks — signed notifications for signing, forms, exports and bulk fills.

The API is server-to-server only. It isn't a way around plan limits: exports, signature requests and bulk rows count exactly as they do in the app.

5 minutes

Quickstart

  1. Create a key in Settings → API keys with the scopes you need, and store it as PDFBETTER_API_KEY on your server.
  2. Create a document, check its fields, fill them, then export or send it — the commands at the top of this page do exactly that.
  3. Add a webhook endpoint for signature_request.completed to fetch the signed PDF the moment everyone has signed.

Keys

Authentication and scopes

Send the key as a bearer token: Authorization: Bearer pb_live_…. Keys are shown once when created; we store only a hash. Revoke a key in Settings and it stops working immediately. Unknown, malformed and revoked keys all get the same 401.

Each key carries scopes. A write scope includes the matching read scope.

ScopeAllows
documents:readList and read documents, their fields and bulk-fill jobs
documents:writeCreate, fill, rename, delete and export documents; start bulk fills; create documents from templates
signatures:readList and read signature requests, timelines and signed files
signatures:writeCreate, send, remind and cancel signature requests
forms:readList forms and read responses
forms:writePause, close, reopen and rename forms
templates:readList your templates and the template library
webhooks:writeManage webhook endpoints
tools:runRun PDF tools (merge, split, rotate, convert, OCR, compress) through the MCP endpoint
ai:useSummaries, cited questions and semantic comparison (uses AI credits)
extraction:readRead extraction schemas, runs and reviewed values
extraction:writeCreate schemas, run extractions and record review decisions
workflows:readList workflows and read run status
workflows:writeCreate workflows, start runs and approve review steps
workspaces:readList workspaces and saved outputs
workspaces:writeCreate workspaces, add documents and generate cited outputs
analytics:readRead share-link activity and form submissions

If the account leaves the Business plan, requests fail with 402 plan_required until it's back.

Requests and responses

  • Base URL https://api.pdfbetter.com/v1/. Paths end with a slash; a path without one gets a 308 redirect that keeps the method and body (use curl -L or add the slash).
  • Bodies are JSON (Content-Type: application/json) except file uploads (multipart/form-data). Unknown properties are rejected so typos surface as 400 errors instead of being ignored.
  • Timestamps are ISO 8601 in UTC; webhook created is Unix seconds.
  • Every response has an X-Request-Id header. Quote it when you contact support.
  • Download links (exports, signed files, response PDFs) are pre-signed and expire after a few minutes — fetch the file right away, don't store the URL.

Rate limits and plan limits

Each key may make 120 requests per 60 seconds. Every authenticated response includes RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds until the window resets) and RateLimit-Policy. Over the limit you get 429 rate_limited with Retry-After.

Some actions also have per-account budgets shared with the app, so the API can't be used to get around them:

ActionLimit per account
Create documents (any source, incl. templates)30 per 10 minutes
Upload tickets60 per 10 minutes
Exports30 per 10 minutes
Create signature requests60 per hour
Send signature requests30 per hour
Reminders10 per hour per request
Start bulk fills10 per hour, one running at a time

Monthly allowances on Business (per account; see pricing): files up to 500 MB and 5,000 pages per document.

AllowanceBusiness per month
PDF exportsUnlimited (fair use)
Signature requests sentUnlimited (fair use)
Form responses10,000
Bulk-fill rows5,000

Errors

Errors use standard HTTP status codes and one JSON shape. Branch on error.code; the message is for people and may change.

{
  "error": {
    "code": "invalid_fields",
    "message": "Some values need attention. Nothing was saved.",
    "details": {
      "errors": {
        "company_name": "This field is required."
      }
    }
  }
}
StatusCodeMeaning
400invalid_requestA parameter is missing or invalid — details.issues lists each one with its path.
400invalid_jsonThe body isn't valid JSON.
400invalid_cursorPass nextCursor back unchanged.
400invalid_idempotency_keyIdempotency-Key must be 1–255 visible ASCII characters.
401invalid_api_keyMissing, malformed, unknown or revoked key (deliberately indistinguishable).
402plan_requiredThe key owner's plan no longer includes API access, or the action needs a capability the plan lacks.
402quota_exceededA monthly allowance is used up. details has the feature, limit and usage.
403insufficient_scopeThe key lacks the scope in details.scope.
403email_unverifiedConfirm the account email before sending anything to other people.
403browser_requests_not_supportedCross-origin browser calls are refused — call the API from your server.
404not_foundIt doesn't exist or isn't yours (we don't distinguish).
409invalid_stateNot allowed in the current state, e.g. reminding a completed request.
409revision_conflictThe document changed since expectedRevision.
409idempotency_conflictA request with this Idempotency-Key is still running.
413file_too_largeOver your plan's file-size limit (or 50 MB for URL imports).
413too_many_pagesOver your plan's page limit per document.
415unsupported_media_typeNot a PDF.
422invalid_fieldsField values failed validation — details.errors maps field name → message. Nothing was saved.
422idempotency_key_reusedThe key was used for a different request.
422url_fetch_failedThe URL couldn't be fetched: private address, redirect, timeout or non-200 status.
423pdf_encryptedPassword-protected PDF. Unlock it first.
429rate_limitedToo many requests — wait for Retry-After seconds.
500internal_errorOur fault. Retry with the same Idempotency-Key; quote X-Request-Id to support.

Idempotency

Network failures happen. Send an Idempotency-Key header (any unique string up to 255 characters, e.g. a UUID) with POST requests and retry safely: the first successful response is stored for 24 hours and replayed for retries with the same key, marked with Idempotent-Replayed: true.

  • Failed requests (4xx/5xx) aren't stored, so you can fix the problem and retry with the same key.
  • Reusing a key with a different body or path returns 422 idempotency_key_reused; a retry while the first request is still running returns 409 idempotency_conflict.
  • Keys are scoped to your account. Endpoints whose response holds a signing secret or a short-lived download/upload URL (webhook endpoint creation and rotation, export, uploads) ignore the header; retrying them is safe.

Pagination

List endpoints return newest first with limit (1–100, default 25). When hasMore is true, pass nextCursor as ?cursor= to get the next page. Cursors are opaque and stable while you page: new items don't shift pages you've already read.

Reference

Documents

Create documents from a file, a URL or a template; fill fields; export PDFs.

Create a document

POST/v1/documents/

From a multipart upload (file, up to ~4 MB per request — use an upload for larger files), an https url (≤ 50 MB; private addresses refused, redirects not followed), a library template slug, or an uploadId. Same file-size, page and encryption checks as the app.

Scope documents:write· Accepts Idempotency-Key· Returns 201

Request body example
{
  "url": "https://example.com/files/vendor-agreement.pdf",
  "title": "Vendor agreement"
}
curl -X POST "https://api.pdfbetter.com/v1/documents/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/files/vendor-agreement.pdf",
    "title": "Vendor agreement"
  }'

Response 201

{
  "object": "document",
  "id": "Xq3T9vLmA2pR7sKd",
  "title": "Vendor agreement",
  "kind": "document",
  "status": "draft",
  "pageCount": 4,
  "tags": [
    "vendors"
  ],
  "folderId": null,
  "sourceName": "vendor-agreement.pdf",
  "sourceSize": 184233,
  "createdFromTemplate": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "updatedAt": "2026-10-15T09:30:00.000Z"
}

Create an upload (large files)

POST/v1/uploads/

Returns a presigned URL valid for 10 minutes. PUT the PDF bytes to it with the returned headers, then call Create a document with { "uploadId" }. Your plan's file-size limit applies.

Scope documents:write· Returns 201

Idempotency-Key is ignored: the response holds a short-lived URL, so a replay would hand back an expired link. Retrying is safe — re-exporting unchanged content is free.

Request body example
{
  "name": "annual-report.pdf",
  "size": 48211337
}
curl -X POST "https://api.pdfbetter.com/v1/uploads/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "annual-report.pdf",
    "size": 48211337
  }'

Response 201

{
  "object": "upload",
  "id": "Up6tR3eW9qA2sD5f",
  "url": "https://files.pdfbetter.com/…presigned-put…",
  "method": "PUT",
  "headers": {
    "Content-Type": "application/pdf"
  },
  "expiresAt": "2026-10-15T09:40:00.000Z"
}

List documents

GET/v1/documents/

Your documents, newest first.

Scope documents:read· Returns 200

ParameterInDescription
limitqueryItems per page, 1–100 (default 25)
cursorquerynextCursor from the previous page
kindquerydocument, template or form (document, template, form)
qquerySearch titles and tags
curl "https://api.pdfbetter.com/v1/documents/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "list",
  "data": [
    {
      "object": "document",
      "id": "Xq3T9vLmA2pR7sKd",
      "title": "Vendor agreement",
      "kind": "document",
      "status": "draft",
      "pageCount": 4,
      "tags": [
        "vendors"
      ],
      "folderId": null,
      "sourceName": "vendor-agreement.pdf",
      "sourceSize": 184233,
      "createdFromTemplate": null,
      "createdAt": "2026-10-15T09:30:00.000Z",
      "updatedAt": "2026-10-15T09:30:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}

Retrieve a document

GET/v1/documents/{id}/

Metadata only. Use Export to get the PDF.

Scope documents:read· Returns 200

ParameterInDescription
id*pathResource id
curl "https://api.pdfbetter.com/v1/documents/Xq3T9vLmA2pR7sKd/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "document",
  "id": "Xq3T9vLmA2pR7sKd",
  "title": "Vendor agreement",
  "kind": "document",
  "status": "draft",
  "pageCount": 4,
  "tags": [
    "vendors"
  ],
  "folderId": null,
  "sourceName": "vendor-agreement.pdf",
  "sourceSize": 184233,
  "createdFromTemplate": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "updatedAt": "2026-10-15T09:30:00.000Z"
}

Update a document

PATCH/v1/documents/{id}/

Rename it or replace its tags.

Scope documents:write· Returns 200

ParameterInDescription
id*pathResource id
Request body example
{
  "title": "Vendor agreement — Acme",
  "tags": [
    "vendors",
    "2026"
  ]
}
curl -X PATCH "https://api.pdfbetter.com/v1/documents/Xq3T9vLmA2pR7sKd/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Vendor agreement — Acme",
    "tags": [
      "vendors",
      "2026"
    ]
  }'

Response 200

{
  "object": "document",
  "id": "Xq3T9vLmA2pR7sKd",
  "title": "Vendor agreement — Acme",
  "kind": "document",
  "status": "draft",
  "pageCount": 4,
  "tags": [
    "vendors",
    "2026"
  ],
  "folderId": null,
  "sourceName": "vendor-agreement.pdf",
  "sourceSize": 184233,
  "createdFromTemplate": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "updatedAt": "2026-10-15T09:30:00.000Z"
}

Delete a document

DELETE/v1/documents/{id}/

Removes it from your account immediately; stored files are purged within 30 days. Owner only.

Scope documents:write· Returns 200

ParameterInDescription
id*pathResource id
curl -X DELETE "https://api.pdfbetter.com/v1/documents/Xq3T9vLmA2pR7sKd/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "document",
  "id": "Xq3T9vLmA2pR7sKd",
  "deleted": true
}

List fillable fields

GET/v1/documents/{id}/fields/

Fields added in PDFBetter (source: model) and AcroForm fields already in the PDF (source: pdf), with types, options and current values. revision can be passed back as expectedRevision when filling.

Scope documents:read· Returns 200

ParameterInDescription
id*pathResource id
curl "https://api.pdfbetter.com/v1/documents/Xq3T9vLmA2pR7sKd/fields/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "field_list",
  "documentId": "Xq3T9vLmA2pR7sKd",
  "revision": 3,
  "data": [
    {
      "name": "company_name",
      "type": "text",
      "source": "model",
      "label": "Company name",
      "required": true,
      "readOnly": false,
      "options": null,
      "value": "Acme Ltd",
      "page": 1,
      "multiline": false,
      "maxLength": 120
    },
    {
      "name": "plan",
      "type": "radio",
      "source": "model",
      "label": "plan",
      "required": true,
      "readOnly": false,
      "options": [
        "basic",
        "pro"
      ],
      "value": "pro",
      "page": 1,
      "multiline": null,
      "maxLength": null
    },
    {
      "name": "topmostSubform[0].Page1[0].f1_01[0]",
      "type": "text",
      "source": "pdf",
      "label": null,
      "required": false,
      "readOnly": false,
      "options": null,
      "value": "",
      "page": 1,
      "multiline": false,
      "maxLength": null
    }
  ]
}

Fill fields

PUT/v1/documents/{id}/fields/

Sets values by field name. Values are validated like in the editor (types, options, formats, patterns; formulas recalculated). If anything is invalid you get 422 invalid_fields with a message per field and nothing is saved. complete: true also requires every required field.

Scope documents:write· Returns 200

Signature and button fields in the PDF can't be filled through the API — send a signature request instead.

ParameterInDescription
id*pathResource id
Request body example
{
  "values": {
    "company_name": "Acme Ltd",
    "plan": "pro",
    "topmostSubform[0].Page1[0].f1_01[0]": "Acme Ltd"
  },
  "complete": false
}
curl -X PUT "https://api.pdfbetter.com/v1/documents/Xq3T9vLmA2pR7sKd/fields/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "values": {
      "company_name": "Acme Ltd",
      "plan": "pro",
      "topmostSubform[0].Page1[0].f1_01[0]": "Acme Ltd"
    },
    "complete": false
  }'

Response 200

{
  "object": "field_list",
  "documentId": "Xq3T9vLmA2pR7sKd",
  "revision": 4,
  "data": [
    {
      "name": "company_name",
      "type": "text",
      "source": "model",
      "label": "Company name",
      "required": true,
      "readOnly": false,
      "options": null,
      "value": "Acme Ltd",
      "page": 1,
      "multiline": false,
      "maxLength": 120
    },
    {
      "name": "plan",
      "type": "radio",
      "source": "model",
      "label": "plan",
      "required": true,
      "readOnly": false,
      "options": [
        "basic",
        "pro"
      ],
      "value": "pro",
      "page": 1,
      "multiline": null,
      "maxLength": null
    },
    {
      "name": "topmostSubform[0].Page1[0].f1_01[0]",
      "type": "text",
      "source": "pdf",
      "label": null,
      "required": false,
      "readOnly": false,
      "options": null,
      "value": "",
      "page": 1,
      "multiline": false,
      "maxLength": null
    }
  ],
  "updated": [
    "company_name",
    "plan",
    "topmostSubform[0].Page1[0].f1_01[0]"
  ]
}

Export a PDF

POST/v1/documents/{id}/export/

Renders the current version and returns a download link valid for 5 minutes. Counts against your plan exactly like a download in the app: the first export of new content uses one export; re-exporting unchanged content is free.

Scope documents:write· Returns 201

Idempotency-Key is ignored: the response holds a short-lived URL, so a replay would hand back an expired link. Retrying is safe — re-exporting unchanged content is free.

ParameterInDescription
id*pathResource id
Request body example
{
  "flatten": true
}
curl -X POST "https://api.pdfbetter.com/v1/documents/Xq3T9vLmA2pR7sKd/export/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "flatten": true
  }'

Response 201

{
  "object": "export",
  "id": "Ex4mP1eQ7rT2yU9i",
  "url": "https://files.pdfbetter.com/…signed-url…",
  "fileName": "Vendor agreement.pdf",
  "size": 201554,
  "expiresAt": "2026-10-15T09:35:00.000Z"
}

Reference

Signature requests

Send documents for e-signature and track every step.

Create and send a signature request

POST/v1/signature-requests/

Locks the current version of the document and emails recipients (in order for sequential routing). Pass full fields (with assignee = recipient key) or useDocumentFields: true with assignees by field name. send: false creates a draft. Uses one signature request from your plan when sent.

Scope signatures:write· Accepts Idempotency-Key· Returns 201

Request body example
{
  "documentId": "Xq3T9vLmA2pR7sKd",
  "message": "Please sign by Friday.",
  "recipients": [
    {
      "key": "client",
      "name": "Dana Smith",
      "email": "dana@example.com"
    }
  ],
  "useDocumentFields": true,
  "assignees": {
    "client_signature": "client",
    "signed_on": "client"
  },
  "reminderEveryDays": 3,
  "expiresInDays": 30
}
curl -X POST "https://api.pdfbetter.com/v1/signature-requests/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "documentId": "Xq3T9vLmA2pR7sKd",
    "message": "Please sign by Friday.",
    "recipients": [
      {
        "key": "client",
        "name": "Dana Smith",
        "email": "dana@example.com"
      }
    ],
    "useDocumentFields": true,
    "assignees": {
      "client_signature": "client",
      "signed_on": "client"
    },
    "reminderEveryDays": 3,
    "expiresInDays": 30
  }'

Response 201

{
  "object": "signature_request",
  "id": "Vb8nQ2wE5tYu1IoP",
  "documentId": "Xq3T9vLmA2pR7sKd",
  "title": "Vendor agreement",
  "message": "Please sign by Friday.",
  "status": "sent",
  "routing": "sequential",
  "reminderEveryDays": 3,
  "expiresAt": "2026-11-14T09:30:00.000Z",
  "sentAt": "2026-10-15T09:30:00.000Z",
  "completedAt": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "fieldCount": 2,
  "hasCompletedFile": false,
  "hasCertificate": false,
  "recipients": [
    {
      "id": "Lm4kJ7hG2fD9sA3q",
      "name": "Dana Smith",
      "email": "dana@example.com",
      "role": "signer",
      "order": 1,
      "status": "sent",
      "authMethod": "email_link",
      "sentAt": "2026-10-15T09:30:00.000Z",
      "viewedAt": null,
      "signedAt": null,
      "declinedAt": null,
      "declineReason": null
    }
  ]
}

List signature requests

GET/v1/signature-requests/

Newest first.

Scope signatures:read· Returns 200

ParameterInDescription
limitqueryItems per page, 1–100 (default 25)
cursorquerynextCursor from the previous page
statusqueryFilter by status (draft, sent, completed, declined, canceled, expired)
curl "https://api.pdfbetter.com/v1/signature-requests/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "list",
  "data": [
    {
      "object": "signature_request",
      "id": "Vb8nQ2wE5tYu1IoP",
      "documentId": "Xq3T9vLmA2pR7sKd",
      "title": "Vendor agreement",
      "message": "Please sign by Friday.",
      "status": "sent",
      "routing": "sequential",
      "reminderEveryDays": 3,
      "expiresAt": "2026-11-14T09:30:00.000Z",
      "sentAt": "2026-10-15T09:30:00.000Z",
      "completedAt": null,
      "createdAt": "2026-10-15T09:30:00.000Z",
      "fieldCount": 2,
      "hasCompletedFile": false,
      "hasCertificate": false,
      "recipients": [
        {
          "id": "Lm4kJ7hG2fD9sA3q",
          "name": "Dana Smith",
          "email": "dana@example.com",
          "role": "signer",
          "order": 1,
          "status": "sent",
          "authMethod": "email_link",
          "sentAt": "2026-10-15T09:30:00.000Z",
          "viewedAt": null,
          "signedAt": null,
          "declinedAt": null,
          "declineReason": null
        }
      ]
    }
  ],
  "hasMore": false,
  "nextCursor": null
}

Retrieve a signature request

GET/v1/signature-requests/{id}/

Status, recipients and the audit timeline (IP addresses stay in the certificate).

Scope signatures:read· Returns 200

ParameterInDescription
id*pathResource id
curl "https://api.pdfbetter.com/v1/signature-requests/Vb8nQ2wE5tYu1IoP/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "signature_request",
  "id": "Vb8nQ2wE5tYu1IoP",
  "documentId": "Xq3T9vLmA2pR7sKd",
  "title": "Vendor agreement",
  "message": "Please sign by Friday.",
  "status": "sent",
  "routing": "sequential",
  "reminderEveryDays": 3,
  "expiresAt": "2026-11-14T09:30:00.000Z",
  "sentAt": "2026-10-15T09:30:00.000Z",
  "completedAt": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "fieldCount": 2,
  "hasCompletedFile": false,
  "hasCertificate": false,
  "recipients": [
    {
      "id": "Lm4kJ7hG2fD9sA3q",
      "name": "Dana Smith",
      "email": "dana@example.com",
      "role": "signer",
      "order": 1,
      "status": "sent",
      "authMethod": "email_link",
      "sentAt": "2026-10-15T09:30:00.000Z",
      "viewedAt": null,
      "signedAt": null,
      "declinedAt": null,
      "declineReason": null
    }
  ],
  "timeline": [
    {
      "seq": 1,
      "event": "created",
      "actorType": "user",
      "actorEmail": "you@example.com",
      "createdAt": "2026-10-15T09:30:00.000Z",
      "data": {
        "recipientCount": 1,
        "fieldCount": 2,
        "routing": "sequential"
      }
    },
    {
      "seq": 2,
      "event": "sent",
      "actorType": "user",
      "actorEmail": "you@example.com",
      "createdAt": "2026-10-15T09:30:00.000Z",
      "data": {
        "pageCount": 4
      }
    },
    {
      "seq": 3,
      "event": "delivered",
      "actorType": "system",
      "actorEmail": null,
      "createdAt": "2026-10-15T09:30:00.000Z",
      "data": {
        "recipientId": "Lm4kJ7hG2fD9sA3q",
        "recipientName": "Dana Smith",
        "kind": "invite"
      }
    }
  ]
}

Send a reminder

POST/v1/signature-requests/{id}/remind/

Emails everyone whose turn it is, or one recipientId. People invited or reminded in the last hour are skipped.

Scope signatures:write· Accepts Idempotency-Key· Returns 200

ParameterInDescription
id*pathResource id
curl -X POST "https://api.pdfbetter.com/v1/signature-requests/Vb8nQ2wE5tYu1IoP/remind/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"

Response 200

{
  "reminded": 1,
  "signatureRequest": {
    "object": "signature_request",
    "id": "Vb8nQ2wE5tYu1IoP",
    "documentId": "Xq3T9vLmA2pR7sKd",
    "title": "Vendor agreement",
    "message": "Please sign by Friday.",
    "status": "sent",
    "routing": "sequential",
    "reminderEveryDays": 3,
    "expiresAt": "2026-11-14T09:30:00.000Z",
    "sentAt": "2026-10-15T09:30:00.000Z",
    "completedAt": null,
    "createdAt": "2026-10-15T09:30:00.000Z",
    "fieldCount": 2,
    "hasCompletedFile": false,
    "hasCertificate": false,
    "recipients": [
      {
        "id": "Lm4kJ7hG2fD9sA3q",
        "name": "Dana Smith",
        "email": "dana@example.com",
        "role": "signer",
        "order": 1,
        "status": "sent",
        "authMethod": "email_link",
        "sentAt": "2026-10-15T09:30:00.000Z",
        "viewedAt": null,
        "signedAt": null,
        "declinedAt": null,
        "declineReason": null
      }
    ]
  }
}

Cancel a signature request

POST/v1/signature-requests/{id}/cancel/

Closes signing links and tells invited recipients. Canceling twice is harmless.

Scope signatures:write· Accepts Idempotency-Key· Returns 200

ParameterInDescription
id*pathResource id
Request body example
{
  "reason": "Terms changed"
}
curl -X POST "https://api.pdfbetter.com/v1/signature-requests/Vb8nQ2wE5tYu1IoP/cancel/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Terms changed"
  }'

Response 200

{
  "object": "signature_request",
  "id": "Vb8nQ2wE5tYu1IoP",
  "documentId": "Xq3T9vLmA2pR7sKd",
  "title": "Vendor agreement",
  "message": "Please sign by Friday.",
  "status": "canceled",
  "routing": "sequential",
  "reminderEveryDays": 3,
  "expiresAt": "2026-11-14T09:30:00.000Z",
  "sentAt": "2026-10-15T09:30:00.000Z",
  "completedAt": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "fieldCount": 2,
  "hasCompletedFile": false,
  "hasCertificate": false,
  "recipients": [
    {
      "id": "Lm4kJ7hG2fD9sA3q",
      "name": "Dana Smith",
      "email": "dana@example.com",
      "role": "signer",
      "order": 1,
      "status": "sent",
      "authMethod": "email_link",
      "sentAt": "2026-10-15T09:30:00.000Z",
      "viewedAt": null,
      "signedAt": null,
      "declinedAt": null,
      "declineReason": null
    }
  ]
}

Download the signed PDF or certificate

GET/v1/signature-requests/{id}/files/

A link valid for 5 minutes. completed and certificate are available once the request is completed; original is the locked PDF as sent.

Scope signatures:read· Returns 200

ParameterInDescription
id*pathResource id
what*querycompleted, certificate or original (completed, certificate, original)
curl "https://api.pdfbetter.com/v1/signature-requests/Vb8nQ2wE5tYu1IoP/files/?what=completed" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "file_link",
  "url": "https://files.pdfbetter.com/…signed-url…",
  "fileName": "Vendor agreement (signed).pdf",
  "expiresAt": "2026-10-15T09:35:00.000Z"
}

Reference

Forms

Read published forms and their responses.

List forms

GET/v1/forms/

Your published forms (up to 200), most recently updated first.

Scope forms:read· Returns 200

curl "https://api.pdfbetter.com/v1/forms/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "list",
  "data": [
    {
      "object": "form",
      "id": "Fo7mR2tQ9wZp4LxN",
      "documentId": "Xq3T9vLmA2pR7sKd",
      "title": "Event registration",
      "description": null,
      "status": "live",
      "edition": 2,
      "responseCount": 41,
      "fieldCount": 9,
      "url": "https://pdfbetter.com/f/k3m9q2w7x5zp/",
      "createdAt": "2026-10-15T09:30:00.000Z",
      "updatedAt": "2026-10-15T09:30:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}

Retrieve a form

GET/v1/forms/{id}/

One published form.

Scope forms:read· Returns 200

ParameterInDescription
id*pathResource id
curl "https://api.pdfbetter.com/v1/forms/Fo7mR2tQ9wZp4LxN/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "form",
  "id": "Fo7mR2tQ9wZp4LxN",
  "documentId": "Xq3T9vLmA2pR7sKd",
  "title": "Event registration",
  "description": null,
  "status": "live",
  "edition": 2,
  "responseCount": 41,
  "fieldCount": 9,
  "url": "https://pdfbetter.com/f/k3m9q2w7x5zp/",
  "createdAt": "2026-10-15T09:30:00.000Z",
  "updatedAt": "2026-10-15T09:30:00.000Z"
}

Update a form

PATCH/v1/forms/{id}/

Pause, close or reopen it (status), or change its title/description. Reopening checks your plan's live-form allowance.

Scope forms:write· Returns 200

ParameterInDescription
id*pathResource id
Request body example
{
  "status": "closed"
}
curl -X PATCH "https://api.pdfbetter.com/v1/forms/Fo7mR2tQ9wZp4LxN/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "closed"
  }'

Response 200

{
  "object": "form",
  "id": "Fo7mR2tQ9wZp4LxN",
  "documentId": "Xq3T9vLmA2pR7sKd",
  "title": "Event registration",
  "description": null,
  "status": "closed",
  "edition": 2,
  "responseCount": 41,
  "fieldCount": 9,
  "url": "https://pdfbetter.com/f/k3m9q2w7x5zp/",
  "createdAt": "2026-10-15T09:30:00.000Z",
  "updatedAt": "2026-10-15T09:30:00.000Z"
}

List responses

GET/v1/forms/{id}/responses/

Newest first, with answers keyed by field name.

Scope forms:read· Returns 200

ParameterInDescription
id*pathResource id
limitqueryItems per page, 1–100 (default 25)
cursorquerynextCursor from the previous page
curl "https://api.pdfbetter.com/v1/forms/Fo7mR2tQ9wZp4LxN/responses/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "list",
  "data": [
    {
      "object": "form_response",
      "id": "Rs5tY8uI1oP3aS6d",
      "formId": "Fo7mR2tQ9wZp4LxN",
      "edition": 2,
      "respondentEmail": "sam@example.com",
      "values": {
        "full_name": "Sam Lee",
        "attending": true,
        "sessions": "workshop"
      },
      "createdAt": "2026-10-15T09:30:00.000Z",
      "pdf": null
    }
  ],
  "hasMore": false,
  "nextCursor": null
}

Retrieve a response

GET/v1/forms/{id}/responses/{responseId}/

Answers plus a link to the filled PDF (valid 5 minutes).

Scope forms:read· Returns 200

ParameterInDescription
id*pathResource id
responseId*pathResponse id
curl "https://api.pdfbetter.com/v1/forms/Fo7mR2tQ9wZp4LxN/responses/Rs5tY8uI1oP3aS6d/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "form_response",
  "id": "Rs5tY8uI1oP3aS6d",
  "formId": "Fo7mR2tQ9wZp4LxN",
  "edition": 2,
  "respondentEmail": "sam@example.com",
  "values": {
    "full_name": "Sam Lee",
    "attending": true,
    "sessions": "workshop"
  },
  "createdAt": "2026-10-15T09:30:00.000Z",
  "pdf": {
    "object": "file_link",
    "url": "https://files.pdfbetter.com/…signed-url…",
    "fileName": "Event registration 2026-10-15 Rs5tY8.pdf",
    "expiresAt": "2026-10-15T09:35:00.000Z"
  }
}

Reference

Templates

Your reusable templates and the PDFBetter template library.

List templates

GET/v1/templates/

Your reusable templates (type: user) followed by the PDFBetter library (type: library).

Scope templates:read· Returns 200

ParameterInDescription
typequerylibrary or user (library, user)
curl "https://api.pdfbetter.com/v1/templates/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "list",
  "data": [
    {
      "object": "template",
      "id": "invoice",
      "type": "library",
      "name": "Invoice",
      "description": "A clean, fillable invoice with bill-to and ship-to, 12 line items, discount, sales tax and balance due.",
      "category": "invoices",
      "pageCount": null,
      "createdAt": null
    }
  ],
  "hasMore": false,
  "nextCursor": null
}

Create a document from a template

POST/v1/templates/{slug}/documents/

{slug} is a library slug or one of your template ids. The template itself is never modified.

Scope documents:write· Accepts Idempotency-Key· Returns 201

ParameterInDescription
slug*pathLibrary template slug or your template id
Request body example
{
  "title": "Invoice #1042"
}
curl -X POST "https://api.pdfbetter.com/v1/templates/invoice/documents/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Invoice #1042"
  }'

Response 201

{
  "object": "document",
  "id": "Xq3T9vLmA2pR7sKd",
  "title": "Invoice #1042",
  "kind": "document",
  "status": "draft",
  "pageCount": 4,
  "tags": [
    "vendors"
  ],
  "folderId": null,
  "sourceName": "vendor-agreement.pdf",
  "sourceSize": 184233,
  "createdFromTemplate": "invoice",
  "createdAt": "2026-10-15T09:30:00.000Z",
  "updatedAt": "2026-10-15T09:30:00.000Z"
}

Reference

Bulk fill

Fill one document per spreadsheet row.

Start a bulk fill

POST/v1/documents/{id}/bulk-fill/

One filled PDF per row (≤ 5,000 rows), zipped in parts of 500. mapping is field name → column name. Returns a job (202); poll Retrieve a job or listen for bulk_job.completed. dryRun: true validates rows and previews file names without using quota.

Scope documents:write· Accepts Idempotency-Key· Returns 202

ParameterInDescription
id*pathResource id
Request body example
{
  "rows": [
    {
      "Name": "Acme Ltd",
      "Email": "ap@acme.example"
    }
  ],
  "mapping": {
    "company_name": "Name",
    "email": "Email"
  },
  "fileNameTemplate": "invoice-{Name}",
  "flatten": true
}
curl -X POST "https://api.pdfbetter.com/v1/documents/Xq3T9vLmA2pR7sKd/bulk-fill/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      {
        "Name": "Acme Ltd",
        "Email": "ap@acme.example"
      }
    ],
    "mapping": {
      "company_name": "Name",
      "email": "Email"
    },
    "fileNameTemplate": "invoice-{Name}",
    "flatten": true
  }'

Response 202

{
  "object": "job",
  "id": "Jb2nM5qW8eR1tY4u",
  "type": "bulk_fill",
  "documentId": "Xq3T9vLmA2pR7sKd",
  "status": "queued",
  "rowCount": 120,
  "doneCount": 0,
  "succeededCount": 0,
  "failedCount": 0,
  "progress": 0,
  "failedRows": [],
  "error": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "downloads": []
}

Retrieve a job

GET/v1/jobs/{id}/

Progress, failed rows and download links (valid 15 minutes) for each finished zip part.

Scope documents:read· Returns 200

ParameterInDescription
id*pathResource id
curl "https://api.pdfbetter.com/v1/jobs/Jb2nM5qW8eR1tY4u/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "job",
  "id": "Jb2nM5qW8eR1tY4u",
  "type": "bulk_fill",
  "documentId": "Xq3T9vLmA2pR7sKd",
  "status": "running",
  "rowCount": 120,
  "doneCount": 45,
  "succeededCount": 44,
  "failedCount": 1,
  "progress": 38,
  "failedRows": [
    {
      "row": 17,
      "error": "email: Enter a valid email address."
    }
  ],
  "error": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "downloads": []
}

Reference

Webhook endpoints

Receive signed event notifications.

Create a webhook endpoint

POST/v1/webhook-endpoints/

https only; the host must resolve to a public address. The signing secret is returned in this response only. Up to 10 endpoints.

Scope webhooks:write· Returns 201

Idempotency-Key is ignored here so the secret is never stored for replay.

Request body example
{
  "url": "https://example.com/webhooks/pdfbetter",
  "events": [
    "signature_request.completed",
    "form.response_created"
  ],
  "description": "Production"
}
curl -X POST "https://api.pdfbetter.com/v1/webhook-endpoints/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/pdfbetter",
    "events": [
      "signature_request.completed",
      "form.response_created"
    ],
    "description": "Production"
  }'

Response 201

{
  "object": "webhook_endpoint",
  "id": "We9pL3kJ6hG1fD4s",
  "url": "https://example.com/webhooks/pdfbetter",
  "description": "Production",
  "events": [
    "signature_request.completed",
    "form.response_created"
  ],
  "status": "enabled",
  "disabledReason": null,
  "failingSince": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "updatedAt": "2026-10-15T09:30:00.000Z",
  "secret": "whsec_…shown once…"
}

List webhook endpoints

GET/v1/webhook-endpoints/

Newest first.

Scope webhooks:write· Returns 200

ParameterInDescription
limitqueryItems per page, 1–100 (default 25)
cursorquerynextCursor from the previous page
curl "https://api.pdfbetter.com/v1/webhook-endpoints/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "list",
  "data": [
    {
      "object": "webhook_endpoint",
      "id": "We9pL3kJ6hG1fD4s",
      "url": "https://example.com/webhooks/pdfbetter",
      "description": "Production",
      "events": [
        "signature_request.completed",
        "form.response_created"
      ],
      "status": "enabled",
      "disabledReason": null,
      "failingSince": null,
      "createdAt": "2026-10-15T09:30:00.000Z",
      "updatedAt": "2026-10-15T09:30:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}

Retrieve a webhook endpoint

GET/v1/webhook-endpoints/{id}/

Without the secret.

Scope webhooks:write· Returns 200

ParameterInDescription
id*pathResource id
curl "https://api.pdfbetter.com/v1/webhook-endpoints/We9pL3kJ6hG1fD4s/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "webhook_endpoint",
  "id": "We9pL3kJ6hG1fD4s",
  "url": "https://example.com/webhooks/pdfbetter",
  "description": "Production",
  "events": [
    "signature_request.completed",
    "form.response_created"
  ],
  "status": "enabled",
  "disabledReason": null,
  "failingSince": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "updatedAt": "2026-10-15T09:30:00.000Z"
}

Update a webhook endpoint

PATCH/v1/webhook-endpoints/{id}/

Change the URL, events or description; enabled: true re-enables an endpoint we disabled after repeated failures, enabled: false pauses it.

Scope webhooks:write· Returns 200

ParameterInDescription
id*pathResource id
Request body example
{
  "enabled": true
}
curl -X PATCH "https://api.pdfbetter.com/v1/webhook-endpoints/We9pL3kJ6hG1fD4s/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true
  }'

Response 200

{
  "object": "webhook_endpoint",
  "id": "We9pL3kJ6hG1fD4s",
  "url": "https://example.com/webhooks/pdfbetter",
  "description": "Production",
  "events": [
    "signature_request.completed",
    "form.response_created"
  ],
  "status": "enabled",
  "disabledReason": null,
  "failingSince": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "updatedAt": "2026-10-15T09:30:00.000Z"
}

Delete a webhook endpoint

DELETE/v1/webhook-endpoints/{id}/

Stops deliveries immediately, including queued retries.

Scope webhooks:write· Returns 200

ParameterInDescription
id*pathResource id
curl -X DELETE "https://api.pdfbetter.com/v1/webhook-endpoints/We9pL3kJ6hG1fD4s/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "webhook_endpoint",
  "id": "We9pL3kJ6hG1fD4s",
  "deleted": true
}

Rotate the signing secret

POST/v1/webhook-endpoints/{id}/rotate-secret/

Returns a new secret, effective immediately — deploy it to your receiver right away. The old secret stops verifying.

Scope webhooks:write· Returns 200

ParameterInDescription
id*pathResource id
curl -X POST "https://api.pdfbetter.com/v1/webhook-endpoints/We9pL3kJ6hG1fD4s/rotate-secret/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY"

Response 200

{
  "object": "webhook_endpoint",
  "id": "We9pL3kJ6hG1fD4s",
  "url": "https://example.com/webhooks/pdfbetter",
  "description": "Production",
  "events": [
    "signature_request.completed",
    "form.response_created"
  ],
  "status": "enabled",
  "disabledReason": null,
  "failingSince": null,
  "createdAt": "2026-10-15T09:30:00.000Z",
  "updatedAt": "2026-10-15T09:30:00.000Z",
  "secret": "whsec_…shown once…"
}

Send a test event

POST/v1/webhook-endpoints/{id}/test/

Queues a signed ping event to this endpoint (same signing, retries and timeout as real events).

Scope webhooks:write· Accepts Idempotency-Key· Returns 202

ParameterInDescription
id*pathResource id
curl -X POST "https://api.pdfbetter.com/v1/webhook-endpoints/We9pL3kJ6hG1fD4s/test/" \
  -H "Authorization: Bearer $PDFBETTER_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"

Response 202

{
  "object": "webhook_test",
  "eventId": "evt_1aB2cD3eF4gH5iJ6kL7m",
  "deliveryId": "Dl3vR8yT2uI5oP9a"
}

Events

Webhooks

Add endpoints with POST /v1/webhook-endpoints/ or in Settings → API keys. We POST a JSON event to each enabled endpoint subscribed to its type (or to *):

{
  "id": "evt_4fJ2kL9mN1pQ7rS3tU6v",
  "type": "signature_request.completed",
  "created": 1792143000,
  "data": {
    "signatureRequest": {
      "object": "signature_request",
      "id": "Vb8nQ2wE5tYu1IoP",
      "documentId": "Xq3T9vLmA2pR7sKd",
      "title": "Vendor agreement",
      "message": "Please sign by Friday.",
      "status": "completed",
      "routing": "sequential",
      "reminderEveryDays": 3,
      "expiresAt": "2026-11-14T09:30:00.000Z",
      "sentAt": "2026-10-15T09:30:00.000Z",
      "completedAt": "2026-10-15T09:30:00.000Z",
      "createdAt": "2026-10-15T09:30:00.000Z",
      "fieldCount": 2,
      "hasCompletedFile": false,
      "hasCertificate": false,
      "recipients": [
        {
          "id": "Lm4kJ7hG2fD9sA3q",
          "name": "Dana Smith",
          "email": "dana@example.com",
          "role": "signer",
          "order": 1,
          "status": "signed",
          "authMethod": "email_link",
          "sentAt": "2026-10-15T09:30:00.000Z",
          "viewedAt": null,
          "signedAt": "2026-10-15T09:30:00.000Z",
          "declinedAt": null,
          "declineReason": null
        }
      ]
    },
    "recipient": null
  }
}
EventWhen
signature_request.sentA request was sent; the first recipients were invited.
signature_request.viewedA recipient opened their signing link for the first time (recipient is set).
signature_request.signedA signer signed or an approver approved (recipient is set).
signature_request.declinedA recipient declined; the request is closed.
signature_request.completedEveryone finished. The signed PDF and certificate may take a few seconds — retry …/files/ on 409 not_ready.
signature_request.canceledYou canceled the request.
signature_request.expiredThe request passed its expiry date unsigned.
form.response_createdSomeone submitted one of your forms. Answers aren't in the event — fetch the response by id.
document.exportedA PDF was exported (in the app or through the API).
bulk_job.completedA bulk fill finished (status succeeded or failed). Fetch download links from the job.
workflow.run.awaiting_approvalA workflow run reached a Review step and is waiting for approval (run.step is the review step).
workflow.run.succeededA workflow run finished every step.
workflow.run.failedA workflow run stopped on an error (run.error says why, run.step is the step that failed).
workflow.run.export_readyAn Export step set to deliver by webhook produced its file. export.url is a download link valid for 24 hours.
workflow.run.webhookA run reached a Send webhook step. name is the event name you set on that step; ids only, no document content.

Delivery

  • Headers: PDFBetter-Signature, PDFBetter-Event-Id, PDFBetter-Event-Type, PDFBetter-Delivery-Id, PDFBetter-Delivery-Attempt.
  • Respond with any 2xx within 10 seconds; do slow work after responding. Redirects aren't followed and count as failures.
  • Failed deliveries are retried up to 6 times, after 1 min, 5 min, 30 min, 2 h, 6 h, 24 h (retries run on a scheduler every 15 minutes, so they can be slightly later). The id stays the same across retries — de-duplicate on it.
  • After 3 days of continuous failures the endpoint is disabled and the account owner is emailed. Events that happen while it's disabled aren't queued. Re-enable it with PATCH {"enabled": true}.
  • Endpoints must use https and resolve to a public address; private, loopback and link-local addresses are refused at creation and on every delivery.
  • Delivery order isn't guaranteed. Events carry a snapshot; fetch the resource for its latest state.
  • Send a signed ping event with POST /v1/webhook-endpoints/{id}/test/ to check your receiver.

Verify webhook signatures

Each delivery carries PDFBetter-Signature: t=1792143000,v1=5257a8…. v1 is the hex HMAC-SHA256 of `${t}.${rawBody}` keyed with the endpoint's whole signing secret (including whsec_). Compute it over the raw body, compare in constant time, and reject timestamps more than 5 minutes old to stop replays. Rotating the secret takes effect immediately.

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.PDFBETTER_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;

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

const app = express();
// Use the raw body: re-serialized JSON won't match the signature.
app.post("/webhooks/pdfbetter", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.get("PDFBetter-Signature"), req.body.toString("utf8"))) return res.sendStatus(400);
  const event = JSON.parse(req.body);
  // De-duplicate on event.id — retries reuse it.
  if (event.type === "signature_request.completed") {
    // fetch the signed PDF: GET /v1/signature-requests/{id}/files/?what=completed
  }
  res.sendStatus(204);
});

OpenAPI

A machine-readable OpenAPI 3.1 description — every endpoint, schema, example and webhook payload — is at https://api.pdfbetter.com/v1/openapi.json. Import it into Postman, Insomnia or an SDK generator.

Limitations

  • No sandbox: keys act on live data, and signature requests email real recipients.
  • No OAuth yet — integrations act as the account that owns the key. Team members each need their own key.
  • Multipart uploads are limited to about 4 MB per request; use an upload for bigger files (up to 500 MB).
  • URL imports: https only, up to 50 MB, PDFs only, no redirects, 30-second timeout.
  • Signature and button fields in a PDF can't be filled through the API.
  • Editing page content (text, images, redaction) is only available in the editor for now.

Developer FAQ

Is there a sandbox or test mode?

Not yet. Keys act on your real account: signature requests email real people and exports count like app downloads. Test with your own email address as the recipient, and create documents you delete afterwards.

Can I call the API from a browser or mobile app?

No. Cross-origin browser requests are refused so API keys never end up in front-end code. Call the API from your server and pass results to your app.

Why did my request without a trailing slash get a 308?

Canonical paths end with a slash. Requests without one are redirected with 308, which keeps the method and body; fetch and most HTTP clients follow it automatically, curl needs -L. Include the slash to save a round trip.

What happens to my integration if I downgrade from Business?

Requests start failing with 402 plan_required and webhook events stop being sent. Your keys and endpoints are kept, so everything resumes if you upgrade again.

Do webhooks arrive in order?

Not guaranteed. A retry can arrive after a later event. Use the created timestamp and fetch the current state of the resource when order matters.

Can I fill signature fields through the API?

No — signatures come from the signer. Fill everything else with PUT /v1/documents/{id}/fields/, then send a signature request with useDocumentFields so each signer completes their own fields.

API changelog

— API v1

  • Documents: create from upload, URL or template; list, retrieve, update, delete; list and fill fields; export.
  • Signature requests: create and send, list, retrieve with timeline, remind, cancel, download signed PDF and certificate.
  • Forms and responses, templates, bulk fill jobs.
  • Webhooks for signing, forms, exports and bulk fills, signed with HMAC-SHA256.
  • Idempotency keys, cursor pagination, per-key rate limits and an OpenAPI 3.1 description.
  • API key scope sign_requests:* is now called signatures:*; existing keys keep working.

v1 only gains fields and endpoints; nothing is renamed or removed. Product-wide changes are in the changelog.