İçeriğe geç

Dökümantasyon

v1 · Last updated 2026-06-05

Hızlı başlangıç

  1. Sign up.
  2. Upload a PDF and place fields in the editor.
  3. Generate an API key on the API keys page (the secret is shown once — copy it).
  4. Render your first PDF:
curl -X POST https://plumapdf.com/api/v1/$WORKSPACE/templates/$TEMPLATE_ID/generate \
  -H "Authorization: Bearer pk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"data":{"name":"Mert"}}' \
  -o filled.pdf

That host is your instance's URL — on the hosted plan it's this site's domain; on a self-hosted install it's wherever you deployed Pluma. $WORKSPACE is the slug from the dashboard URL (e.g. acme in /app/acme/templates). Every endpoint below has a live Try it form — paste your key once and it sticks.

Kimlik doğrulama

Every /api/v1/* request needs a bearer token:

Authorization: Bearer pk_live_…

Keys are tenant-scoped and reach any workspace inside the tenant. Revoke from the dashboard at any time; revocation propagates within ~60s.

Pagination & filters

List endpoints (/templates, /jobs) use cursor pagination:

{ "templates": [...], "nextCursor": "<id>" }

Pass ?cursor=<nextCursor> for the next page. nextCursor: null means end of list. Page size: ?limit=N (default 50, max 100).

Idempotency

Add Idempotency-Key: <uuid> on any POST. Replays within 24 hours return the cached response instead of re-running. Use it on every render so network retries are safe.

Rate limits

Every render endpoint is capped at 60 requests per minute per API key (sustained 1/sec). Requests above the cap return 429 too_many_requests with Retry-After: 60. The cap is per key, not per workspace — issue a separate key per integration or environment if you need parallel headroom.

Every response — success or failure — carries the standard IETF RateLimit headers so you can pace traffic without hitting 429s:

HeaderMeaning
RateLimit-LimitThe window's ceiling (e.g. 60).
RateLimit-RemainingRequests left in the current window.
RateLimit-ResetSeconds until the window resets.
Retry-AfterSent only with a 429. Seconds to wait before retrying.

How to handle 429. Sleep Retry-After seconds, then retry. Don't retry sooner — the limit is shared across instances of the same key, so a faster retry will just 429 again. For bulk traffic, batch into the async /jobs endpoint (one request creates a job that processes many items in the background) instead of firing N parallel /generate calls.

Other endpoints. Auth endpoints (login, password reset, signup) have their own narrower limits to prevent brute force. The general API limit for non-render reads (list templates, get job status) is wider (5/sec per IP); reads almost never hit it.

Uç noktalar

GET/api/v1/:workspace/templates

List templates

Paginated list of templates in a workspace. Filter by active flag, folder, or substring on name.

Parameters

workspacepathWorkspace slug.required
activequerytrue | false
folderqueryExact folder match. Use "null" for unfoldered.
qqueryCase-insensitive substring on name.
limitquery1 – 100 (default 50)
cursorquerynextCursor from the previous response.

Response

{
  "templates": [
    {
      "id": "…",
      "workspaceId": "…",
      "name": "Invoice",
      "fields": [
        "…"
      ],
      "active": true,
      "folder": "invoices",
      "updatedAt": "…"
    }
  ],
  "nextCursor": null
}
GET/api/v1/:workspace/templatesTry it
/api/v1//templates

Paste an API key above to enable the Send button.

GET/api/v1/:workspace/templates/:id

Get one template

Full template metadata including field placements and the linked schema id.

Parameters

workspacepathWorkspace slug.required
idpathTemplate UUID.required

Response

{
  "template": {
    "id": "…",
    "name": "Invoice",
    "fields": [
      "…"
    ],
    "schemaId": "…",
    "active": true
  }
}
GET/api/v1/:workspace/templates/:idTry it
/api/v1//templates/

Paste an API key above to enable the Send button.

POST/api/v1/:workspace/templates/:id/generate

Render one PDF

Synchronous render. Default streams application/pdf back. Optional responseMode='s3' returns a presigned download URL instead.

Parameters

workspacepathWorkspace slug.required
idpathTemplate UUID.required

Response

(application/pdf — binary stream)

// or, with responseMode: "s3"
{ "downloadUrl": "https://…", "expiresAt": "…", "urlExpiresAt": "…" }

filename is optional. If supplied, it sets the response's Content-Disposition; otherwise we fall back to a sanitized version of the template name. Path separators and control characters are stripped; .pdf is appended if missing.

renderer (optional) selects the engine: plumav1 (default; required for QR/barcode, shapes, signatures, custom transforms) or standard (legacy AcroForm-only path).

responseMode (optional, default "inline"): set to "s3" to upload the rendered PDF to the paid renders bucket and return a presigned downloadUrl instead of the bytes. The URL is valid for 10 minutes; the underlying object lives until its expiresAt (controlled by ttlSeconds below).

ttlSeconds (optional) sets how long the stored render survives in the bucket. Capped by your plan: Free 30 min, Advanced 7 d, Pro 30 d, Enterprise 90 d. Default 72 h on paid tiers, 30 min on Free. Values above the plan cap return 400 ttl_exceeds_plan.

password (optional) password-protects the rendered PDF. Requires the template to opt in via apiCanSetPassword; otherwise the field is ignored. If the template has its own default password set in the editor, the request-body password overrides it.

ignoreErrors (optional, default false) controls what happens when a field's custom transform code throws, rejects, fails to compile, or times out:

  • false — any transform failure aborts the render with HTTP 400 and a JSON body { "error": "transform_failed", "fields": [{ "name": "…", "message": "…" }, …] }. You get every offending field at once, not just the first.
  • true — failures are coerced as if the transform had returned null: text fields fall back to the pre-transform value, checkboxes render unchecked, radios render no widget. The PDF still comes back as 200 application/pdf and the count of silently-failed transforms is on the X-Pluma-Transform-Errors response header. Wire this header to your monitoring if you use soft mode in production.
POST/api/v1/:workspace/templates/:id/generateTry it
/api/v1//templates//generate

Paste an API key above to enable the Send button.

POST/api/v1/:workspace/templates/:id/bulk

Bulk render (synchronous)

Render up to 30 items in one request and stream a ZIP back. Each item gets its own PDF inside the archive.

Parameters

workspacepathWorkspace slug.required
idpathTemplate UUID.required

Response

(application/zip — binary stream)

Per-item filename is optional. Items without one default to <template-slug>_0001.pdf, <template-slug>_0002.pdf, … — e.g. a template named Sample template — try it! produces sample-template-try-it_0001.pdf. Names are ASCII-slugified (accents stripped, non-alphanumerics collapsed to dashes); if the template name has no ASCII characters at all, the prefix falls back to item. Duplicate names get a -2, -3, … suffix so unzip never overwrites.

ignoreErrors applies uniformly to every item — per-item overrides are ignored. Default false: the first item whose transform throws aborts the entire request with HTTP 400 and body { "error": "transform_failed", "template", "itemIndex", "fields": [...] }. Siblings already in flight are dropped; no further items are scheduled. With true the renderer coerces failures per item and the response is the usual ZIP; per-item counts surface on the renderer-side log, not on the ZIP response.

POST/api/v1/:workspace/templates/:id/bulkTry it
/api/v1//templates//bulk

Paste an API key above to enable the Send button.

POST/api/v1/:workspace/templates/:id/sign-requests

Send a template out for e-signature

Renders the PDF, stores it in the paid renders bucket, and emails the recipient a /sign/<token> link. When they sign, the PDF is re-rendered with their signature embedded and delivered to your template's deliveryEmail (or your tenant owner, if unset).

Parameters

workspacepathWorkspace slug.required
idpathTemplate UUID. Must contain at least one signature field with kind=request.required

Response

{
  "signatureRequestId": "uuid",
  "recipientEmail": "[email protected]",
  "status": "pending",
  "expiresAt": "2026-05-17T10:00:00.000Z"
}

recipientEmail is the only required field. The address is lowercased before storage; subsequent lookups don't depend on case. The template must contain at least one signature field with kind=request for the recipient's signature to land in the re-rendered PDF.

data follows the same shape as /generate — values keyed by field JSON-path. Image-typed values can reference uploaded field-images by id; expansion happens server-side.

recipientName (optional, max 200 chars) is the recipient's display name. When set, the /sign/<token> page greets them by name ("Hi Jane, please sign…") and the request email opens with "Hi Jane,"; otherwise we fall back to the email localpart and a generic "Hi,". Stored verbatim on the SignatureRequest row so the dashboard and the recipient-copy email reuse the same name.

requesterDisplayName (optional) is the sender name shown in the request email. Defaults to the authenticated user's display name or email.

Lifecycle. The SignatureRequest expires after 7 days (default). Recipient can sign once or decline; either flips the row's status and a second visit to /sign/<token> returns 410. The expiry sweeper marks any still-pending row past expiresAt as expired hourly.

Availability. Hosted Pluma only. Self-hosted installs return 402 not_available_on_self_hosted.

Plan gate. Free is blocked entirely — Free tenants get 402 paid_tier_required. Paid tiers include a monthly quota: Advanced 20, Pro 100, Enterprise 500. Past the quota, each sign-request is billed from your prepaid balance at €0.10 (configurable via SIGN_REQUEST_OVERAGE_CENTS). When the balance is empty AND the quota is exhausted, the endpoint returns 402 sign_request_quota_exceeded with a top-up hint.

Render-quota cost. The initial pre-sign render counts against your /generate render quota (same accounting). The post-sign re-render does NOT — it's triggered by the recipient, not your tenant. One quota-counting render per SignatureRequest.

Sign-request counter. The per-month sign_requests_count only increments after the WHOLE workflow succeeded: render OK, PDF stored, recipient email accepted, row persisted. A render failure, storage failure, or SMTP failure returns the appropriate error without consuming any quota or balance.

Renderer. Always uses plumav1 — the only engine that supports signature placement. The renderer field is not accepted on this endpoint.

Error responses. Failures are returned as JSON with a stable error code and a human-readable detail message. No stack traces. When the renderer surfaces per-field transform errors, they're forwarded under a fields array — same shape /generate uses for transform_failed.

POST/api/v1/:workspace/templates/:id/sign-requestsTry it
/api/v1//templates//sign-requests

Paste an API key above to enable the Send button.

POST/api/v1/:workspace/jobs

Create async render job

Queue a large batch (up to 10,000 items). Returns immediately with a jobId; poll GET /jobs/:id for status + downloadUrl.

Parameters

workspacepathWorkspace slug.required

Response

{
  "jobId": "uuid",
  "status": "pending",
  "itemsTotal": 2
}

Per-item filename follows the same rules as /bulk: optional, defaults to <template-slug>_NNNN.pdf, duplicates get a -N suffix. Legacy alias: POST /api/v1/:workspace/templates/:id/bulk-job (also /bulk-jobs).

ignoreErrors persists on the job row so a stale-job recovery run after a builder restart honours the same contract. Default false; on a transform failure the job's status flips to failed and the error field carries a single sentence naming the template, item, and offending field — e.g. Transform failed for template "Invoice template" on item 4: "Customer name": Cannot read properties of null (reading 'name').

POST/api/v1/:workspace/jobsTry it
/api/v1//jobs

Paste an API key above to enable the Send button.

GET/api/v1/:workspace/jobs

List render jobs

Workspace-scoped job list, newest first.

Parameters

workspacepathWorkspace slug.required
statusquerypending | running | completed | failed | expired
templateIdqueryOnly jobs for this template UUID.
limitquery1 – 100 (default 50)
cursorquerynextCursor from the previous response.

Response

{
  "jobs": [
    {
      "id": "…",
      "status": "completed",
      "itemsTotal": 100,
      "itemsDone": 100
    }
  ],
  "nextCursor": null
}
GET/api/v1/:workspace/jobsTry it
/api/v1//jobs

Paste an API key above to enable the Send button.

GET/api/v1/:workspace/jobs/:id

Get one job

Once status === completed, the response includes a downloadUrl valid for 10 minutes. Once status === expired, the row is preserved but the ZIP is gone (re-create the job to regenerate).

Parameters

workspacepathWorkspace slug.required
idpathJob UUID.required

Response

{
  "id": "…",
  "status": "completed",
  "itemsTotal": 100,
  "itemsDone": 100,
  "downloadUrl": "https://…"
}
GET/api/v1/:workspace/jobs/:idTry it
/api/v1//jobs/

Paste an API key above to enable the Send button.

Webhooks

Register URLs in the Webhooks page to receive events (job completed, render quota crossed, member events, …). Each delivery carries:

  • Pluma-Event — event type (e.g. job.completed)
  • Pluma-Signature: sha256=<hex> — HMAC-SHA256 of the raw body
  • Pluma-Delivery-Id — unique per attempt; safe to dedupe on

Verify the signature (Node)

const crypto = require('crypto');
function verify(rawBody, headerSig, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(headerSig));
}

Verify the signature (Python)

import hmac, hashlib

def verify(raw_body: bytes, header_sig: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_sig)

We retry non-2xx responses with exponential backoff for up to 24 hours, then mark the delivery failed.

Custom transform code

Fields can carry a JS function body that reshapes the resolved value before it's painted onto the PDF. The body runs in a sandboxed goja runtime with two bindings in scope: value (the resolved JSON-path value as a string) and data (the full request payload). Three call shapes are wired in:

  • text / multiline — must return a string. Activated only when the field's format is "custom". Other format presets ignore transformCode even when it's set.
  • checkbox — must return a boolean. true draws the check, false leaves it blank.
  • radio — must return an integer option index (0-based). The renderer draws the widget whose radioIndex matches; other widgets in the group stay blank.

The sandbox is sync-shaped but async-aware: you can use await, but everything you can await is synchronous in practice (no fetch, no setTimeout, no host APIs). Dynamic code generation is disabled — eval, Function, AsyncFunction, GeneratorFunction, and the prototype-chain escape via Function.prototype.constructor are all stripped. Each call is bounded by a 50 ms watchdog.

Returning null / undefined means "no value here":

  • text → fall back to the pre-transform value
  • checkbox → unchecked
  • radio → no widget drawn in the group

If the transform throws, rejects a Promise, fails to compile, or hits the deadline, behaviour depends on the request's ignoreErrors flag:

  • ignoreErrors = false (default) — the render aborts with HTTP 400, body { "error": "transform_failed", "fields": [{ "name": "…", "message": "…" }, …] }. Every offending field is in fields, not just the first.
  • ignoreErrors = true — failures are coerced to the same nullish behaviour above (text → pre-transform value, checkbox → false, radio → blank). The PDF is returned normally; the response carries X-Pluma-Transform-Errors: <N> where N is the count of silently-failed transforms. Wire that header to your observability if you use soft mode in production.

Stack traces are intentionally not part of the response — the operator-facing message ("Cannot read properties of null reading 'age'") is what you need to fix the script. The renderer's own log includes the field name + a short script-hash for cross-referencing failures across renders.

Hata kodları

StatusErrorMeaning
400bad_jsonBody wasn't valid JSON or didn't match the expected shape.
400transform_failedOne or more fields' custom transform code threw / rejected / timed out. Response body lists each failed field. Pass ignoreErrors: true to render anyway.
400ttl_exceeds_planttlSeconds is above your tier's max. Response includes maxAllowedSeconds + suggested upgrade tier.
400invalid_recipient_email/sign-requests rejected the supplied recipient address.
400recipient_email_required/sign-requests body omitted recipientEmail.
400no_signature_placeholder/sign-requests on a template with no request-kind signature field. Add one in the editor — the recipient's signature has nowhere to land otherwise.
401invalid_keyAPI key missing, malformed, or revoked.
402render_quota_exceededWorkspace at or above its monthly render limit. Upgrade to clear.
402paid_tier_required/sign-requests on a Free tenant. Sign-requests are a paid-tier feature.
402sign_request_quota_exceededSign-request monthly quota exhausted and prepaid balance is empty. Response body carries quota, used, overageMillisPerRequest, and a topup_url.
402not_available_on_self_hosted/sign-requests on a self-hosted install. The feature is hosted-only.
502email_send_failed/sign-requests couldn't deliver the recipient email. No counter bump, no balance charge — safe to retry.
502storage_upload_failed/sign-requests couldn't store the pre-sign PDF. No counter bump.
403tenant_suspendedWorkspace is suspended. Contact support.
404not_foundTemplate / job ID doesn't exist or isn't visible to your key.
410expired_or_unknownPublic /api/sign/:token/* endpoint — the SignatureRequest was unknown, already signed/declined, or past its expiresAt. Returned uniformly to prevent token enumeration.
429too_many_requestsRate limit. Honor Retry-After. See Rate limits.
503renderer_busyRenderer fleet at capacity. Sleep Retry-After seconds and retry — no charge, no side effects (the gate fires before any render work starts).
504render_failedRenderer timed out. Retry once; if persistent, the PDF is malformed.

Result retention

Sync renders with responseMode: "inline" (the default for /generate and /bulk) are never stored — streamed back and discarded.

Sync renders with responseMode: "s3" are uploaded to the paid renders bucket and survive for ttlSeconds (capped by your plan: Free 30 min, Advanced 7 d, Pro 30 d, Enterprise 90 d). The sweeper deletes them once their expiresAt passes; the bucket's 90-day lifecycle is a backstop.

Async job ZIPs persist 1 hour, then are deleted. The job row stays so you can see the history. Once the ZIP is gone, status flips to expired.

/sign-requests SignatureRequests live for 7 days while pending. The pre-sign PDF + the signed PDF sit in the paid renders bucket with the same expires=<unix> key shape. Once signed/declined/expired, the row is preserved for audit; the artifacts are swept on the same schedule as any other paid-bucket render.

Yardıma mı ihtiyacın var?

Open a ticket from the in-app Support page, or email [email protected].