Papely
Developer reference

Papely API

Generate personalized PDF documents programmatically. Upload an image, submit a template plus your data, then poll for completion and download the finished PDF — all server-to-server, with no browser or manual step required.

Base URL

https://papely.app/api/public/v1
  • GET /templates — list templates
  • GET /templates/:id — read one template
  • POST /images — upload an image
  • POST /generations — start a generation
  • POST /generations/batch — generate in batch
  • GET /generations/:id — check status
  • GET /generations/:id/document — download the document
  • GET /generations/:id/emails — check email delivery

Overview

The Papely API is REST over HTTPS. Requests and responses are JSON (image upload is multipart). Generation is asynchronous: you submit a request, receive an accepted handle immediately, then poll until the document is ready and download it. A typical integration follows four steps.

  1. Upload any images you need and keep the img_<id> references they return.
  2. Submit a generation with the template id and your field data. You get back a generationId.
  3. Poll the status endpoint until the generation is completed (or failed).
  4. Download the finished document from the document endpoint (a single PDF, or a ZIP for a batch).

Everything is scoped to the workspace your credential is bound to. A credential from one workspace can never see or act on another workspace resources.

Authentication

Every request authenticates with an API credential presented as a bearer token in the Authorization header:

Authorization: Bearer papely_sk_your_secret_here

A workspace owner creates credentials in the workspace Settings, under the API credentials section. Creating one shows the full secret (it always starts with papely_sk_) exactly once — copy it then, because it is never shown again. Only a short non-secret prefix is stored for display, so a lost secret cannot be recovered; create a new credential instead.

Key properties to design around:

  • Bound to one workspace. A credential acts on, and is billed to, exactly the workspace it was created in.
  • Owner-managed. Only the workspace owner can create, list, or revoke credentials.
  • Revocable. Revoking a credential immediately stops it from authenticating. Store secrets securely (for example as an environment variable or secret manager entry) and never commit them to source control.

A missing, malformed, unknown, or revoked credential all return the same 401 UNAUTHENTICATED, so a rejection never reveals which secret exists.

Discovering templates

GET/templates
GET/templates/:id

Discover what to send without opening the app. These two read-only endpoints, both scoped to your workspace, return the same identifiers you pass in a generate request.

List every template in your workspace. The response is a bare array, each entry the template id and its name:

curl https://papely.app/api/public/v1/templates \
  -H "Authorization: Bearer $PAPELY_SECRET"
200 OK

[
  { "id": "c65f3e23-ed50-47fd-822e-7e7a18b56b8a", "name": "Factura" },
  { "id": "66725d21-d483-4add-b80a-1174802ae0a9", "name": "Contrato" }
]

Read one template to get its fields. Each field has a key (the key you use in the data object), a type, and a label; a table field additionally lists its columns as key and label pairs:

curl https://papely.app/api/public/v1/templates/c65f3e23-ed50-47fd-822e-7e7a18b56b8a \
  -H "Authorization: Bearer $PAPELY_SECRET"
200 OK

{
  "id": "c65f3e23-ed50-47fd-822e-7e7a18b56b8a",
  "name": "Factura",
  "fields": [
    { "key": "title",     "type": "text",  "label": "title" },
    { "key": "issued_on", "type": "date",  "label": "issued_on" },
    { "key": "logo",      "type": "image", "label": "logo" },
    { "key": "line_items", "type": "table", "label": "line_items",
      "columns": [
        { "key": "description", "label": "Description" },
        { "key": "amount",      "label": "Amount" }
      ] },
    { "key": "name",  "type": "text", "label": "name",  "system": true },
    { "key": "email", "type": "text", "label": "email", "system": true }
  ]
}

The response ends with two system columns, name and email, flagged system: true. Unlike the declared fields, they are optional, and you may include them in the data object of a generate request: the email address you put there is the delivery recipient, and both become the {{name}} and {{email}} interpolation variables in the email subject and message. Because they are optional system columns, including either one in data is never rejected as unknown_field. Authored data fields never carry this flag.

An unknown template, or one owned by another workspace, returns a non-disclosing 404 TEMPLATE_NOT_FOUND — identical to a template that does not exist, so a caller can never probe for another workspace ids.

Finding your identifiers

To generate a document you need three kinds of identifier from the workspace UI. Open the template in Papely and use its API details dialog, which lists every value with a copy control:

  • the template id — passed as templateId;
  • each field key and its type — the keys of your data object;
  • for a table field, its columns in order — each cell in a table row maps to a column by position.

A field key is the field name defined on the template. Renaming a field in the template changes its API key. If you rename a field, update the corresponding key in your requests — the old key is rejected as unknown, and the new one silently falls back to blank/default until you send it. Renaming a column heading does not change the table shape — cells map to columns by position, so only reordering or adding/removing columns shifts them.

You can also fetch all of these programmatically: GET /templates lists your templates and GET /templates/:id returns one template field keys, types and table column keys — the same values the dialog shows (see discovering templates).

Supplying field data

The data object in a generate request is keyed by field key, one entry per template field. Send what you have:

  • Every declared field is optional. An omitted field (or an empty string in a scalar field) renders blank, or with the template default where one exists.
  • No extra keys. A key that is not a declared field is rejected with reason unknown_field rather than silently ignored.
  • All scalar values are JSON strings. Text, date, qr values and every table cell are strings (a table cell may also be null, meaning blank) — sending a number (for example 1200 instead of "1200") is rejected with reason invalid_value.

The name and email entries flagged system: true in the template detail are optional system columns you may include in data — the delivery recipient and the interpolation variables — so they are never counted as unknown_field.

The five field types accept:

TypeValueExample
textA JSON string (an empty string is allowed and renders blank)."Invoice #1024"
dateA JSON string holding a date. ISO format (YYYY-MM-DD) is recommended and is read as a plain calendar date. You send the raw value; the template decides how it is formatted when the PDF is rendered."2026-05-25"
qrA non-empty JSON string. Its contents are encoded into the QR code."https://papely.app/verify/abc"
imageAn image reference (img_<id>) returned by POST /images. The data object supplies images by reference only — never as a file path or inline bytes. To ingest an image from a URL, use the upload-by-URL variant of POST /images first. The reference must belong to the same workspace as your credential."img_5bba02c7-7465-47b7-a381-3696f6033a83"
tableAn array of rows. Each row is itself an array of cells — one cell per table column, in the column order from GET /templates/:id. Each cell is a string, or null for a blank cell (equivalent to ""). Every row must have exactly one cell per column; the array may be empty, and the number of rows can differ from one document to the next.[["Consulting", "€1,200.00"]]

Table cell values are literal text: they are printed verbatim and are not template expressions, so a value like {{token}} appears as-is.

Uploading images

POST/images

Send the image as multipart form-data in a part named file. PNG and JPEG are accepted (the format is detected from the file bytes, not its name). The size limit is your plan max image size — 10 MB by default. Uploading an image consumes your workspace storage quota.

curl -X POST https://papely.app/api/public/v1/images \
  -H "Authorization: Bearer $PAPELY_SECRET" \
  -F "[email protected]"

Alternatively, send a JSON body with a url field and Papely downloads the image for you — handy from no-code tools where multipart is awkward. The URL must be a public https:// URL; private addresses and internal hosts are refused. Up to 3 redirects are followed and the download times out after 10 seconds. The image is fetched once, at upload time: the returned reference is an immutable copy, so later changes at that URL never affect the documents you generate.

curl -X POST https://papely.app/api/public/v1/images \
  -H "Authorization: Bearer $PAPELY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/logo.png" }'

A successful upload returns the opaque reference to use as an image field value:

201 Created

{
  "reference": "img_5bba02c7-7465-47b7-a381-3696f6033a83"
}

The reference is only valid within the workspace that uploaded it. Rejections leave no stored bytes: a non-image is 415 UNSUPPORTED_IMAGE_FORMAT, an oversize image is 413 IMAGE_TOO_LARGE, and an exhausted quota is 402 STORAGE_QUOTA_EXCEEDED.

For the URL variant, an unusable url (malformed, not https, or pointing at a disallowed host) is 422 IMAGE_URL_INVALID and a failed download (a non-200 answer, a timeout, too many redirects) is 422 IMAGE_FETCH_FAILED.

Generating a document

POST/generations

Submit a JSON body with the templateId and the data object. Optionally include an Idempotency-Key header (see rate limits and safe retries). The body is capped at 5 MB; a larger body is rejected with 413 PAYLOAD_TOO_LARGE before any work is done.

curl -X POST https://papely.app/api/public/v1/generations \
  -H "Authorization: Bearer $PAPELY_SECRET" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8f2a1c90-4d3e-4a1b-9c77-2b6e5f0a1d34" \
  -d '{
    "templateId": "YOUR_TEMPLATE_ID",
    "data": {
      "title": "Invoice #1024",
      "issued_on": "2026-05-25",
      "verify_url": "https://papely.app/verify/abc",
      "logo": "img_5bba02c7-7465-47b7-a381-3696f6033a83",
      "line_items": [
        ["Consulting", "€1,200.00"],
        ["Hosting",    "€49.00"]
      ]
    }
  }'

A request is accepted — not completed — with a 202 and the id you poll and download by:

202 Accepted

{
  "generationId": "bd54d799-39c8-4577-a151-6a3c6afd9a39"
}

The field keys and the template id above are examples — copy your own from the API details dialog. Ids are opaque; pass them back verbatim. Rejections at this step never count against your quota: an unknown or unowned template is 404 TEMPLATE_NOT_FOUND, and a data-contract violation is 422 INVALID_DATA with a details array naming each offending field.

Optional email delivery. Add a top-level email object to have Papely email the finished document to a recipient; omit it and nothing is sent (the default). This block is only the message template: subject (1–255 characters), message (1–5000 characters) and an optional buttonText (1–100 characters). The recipient is not part of this block — it comes from your data object: data.email is the destination address — a document without one is generated but simply not emailed — and data.name is an optional display name.

The subject and message interpolate {{variables}} per recipient. The allowed variables are name, email, and any template field of type text; an unknown variable is rejected with 422 INVALID_DATA.

Delivery is asynchronous: the API accepts (202) and enqueues, then a worker sends one email per recipient once the generation completes. The recipient gets a tracked download link that needs no API credential to open, and the document stays retrievable through GET /generations/:id/document as usual, whether or not email is enabled. The sender is fixed ([email protected]); the reply-to is resolved from the verified reply-to address configured for your workspace and is never accepted from the client. Emails are currently English only.

A failed email send never fails the generation and never consumes or refunds quota or packs — the PDF stays available. Recipients are never validated at request time: a document with a blank data.email is generated but skipped by delivery, and a malformed address surfaces as a failed delivery on the emails endpoint. Template problems are validation errors that return 422 INVALID_DATA (never 500) with a details array; an unknown variable is { "field": "email", "reason": "unknown_variable:<name>" }.

curl -X POST https://papely.app/api/public/v1/generations \
  -H "Authorization: Bearer $PAPELY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "YOUR_TEMPLATE_ID",
    "data": {
      "title": "Invoice #1024",
      "issued_on": "2026-05-25",
      "verify_url": "https://papely.app/verify/abc",
      "logo": "img_5bba02c7-7465-47b7-a381-3696f6033a83",
      "line_items": [
        ["Consulting", "€1,200.00"]
      ],
      "email": "[email protected]",
      "name": "Ada Lovelace"
    },
    "email": {
      "subject": "Your document {{title}}",
      "message": "Hi {{name}}, your invoice is ready.",
      "buttonText": "Download"
    }
  }'

Batch generation

POST/generations/batch

Generate many documents in one request. Submit a JSON body with one templateId and a documents array — 1 to 100 entries. Each entry is a plain data object: its declared fields keyed by name, plus the optional system columns email and name. The whole batch becomes one generation.

curl -X POST https://papely.app/api/public/v1/generations/batch \
  -H "Authorization: Bearer $PAPELY_SECRET" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6d1f0b2a-8c3e-4f5a-9b7c-1e2d3f4a5b6c" \
  -d '{
    "templateId": "YOUR_TEMPLATE_ID",
    "documents": [
      { "title": "Invoice #1024", "issued_on": "2026-05-25", "verify_url": "https://papely.app/verify/abc", "logo": "img_5bba02c7-7465-47b7-a381-3696f6033a83", "line_items": [ ["Consulting", "€1,200.00"] ] },
      { "title": "Invoice #1025", "issued_on": "2026-05-26", "verify_url": "https://papely.app/verify/def", "logo": "img_5bba02c7-7465-47b7-a381-3696f6033a83", "line_items": [ ["Hosting", "€49.00"] ] }
    ]
  }'

The batch body cap is higher than the single generate — 25 MB — to fit up to 100 documents; a larger body is rejected with 413 PAYLOAD_TOO_LARGE before any work is done. A batch of 0, or of more than 100 documents, is rejected with 422 INVALID_DATA.

The request is accepted — not completed — with a 202 and a single generationId — one generation job holding every document:

202 Accepted

{
  "generationId": "5f07913d-4566-4b53-9ac8-bf5025472cbf"
}

Each document counts as one generation (a batch of N counts as N) against the same monthly allowance, and is charged all-or-nothing: if the whole batch does not fit the remaining quota, none of it is charged and the request returns 402 GENERATION_QUOTA_EXCEEDED. A single bad document rejects the whole batch with 422 INVALID_DATA, its details array naming the offending document by index (for example documents[3].amount).

Poll and retrieve through the same status and document endpoints as a single generation. GET /generations/:id/document returns a ZIP archive of the PDFs when the batch has two or more documents, and a single PDF when it has exactly one.

Optional email delivery. Add a top-level email object to email every document. In a batch it holds only the shared message template — subject, message and an optional buttonText — and carries no recipient of its own. Each recipient lives inside its document, as the optional system columns email and name. A document without an email value is generated but not emailed, and a malformed address surfaces as a failed delivery on the emails endpoint rather than an error. The same interpolation, asynchronous delivery, fixed sender and English-only rules as a single generation apply.

curl -X POST https://papely.app/api/public/v1/generations/batch \
  -H "Authorization: Bearer $PAPELY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "YOUR_TEMPLATE_ID",
    "email": {
      "subject": "Your document {{title}}",
      "message": "Hi {{name}}, your invoice is ready.",
      "buttonText": "Download"
    },
    "documents": [
      { "title": "Invoice #1024", "issued_on": "2026-05-25", "verify_url": "https://papely.app/verify/abc", "logo": "img_5bba02c7-7465-47b7-a381-3696f6033a83", "line_items": [ ["Consulting", "€1,200.00"] ], "email": "[email protected]", "name": "Ada Lovelace" },
      { "title": "Invoice #1025", "issued_on": "2026-05-26", "verify_url": "https://papely.app/verify/def", "logo": "img_5bba02c7-7465-47b7-a381-3696f6033a83", "line_items": [ ["Hosting", "€49.00"] ], "email": "[email protected]", "name": "Grace Hopper" }
    ]
  }'

Checking status

GET/generations/:id

Poll this endpoint with the generationId until the generation finishes. The status is one of pending, processing, completed, or failed. An error message is present only when the status is failed.

curl https://papely.app/api/public/v1/generations/bd54d799-39c8-4577-a151-6a3c6afd9a39 \
  -H "Authorization: Bearer $PAPELY_SECRET"
200 OK

{
  "id": "bd54d799-39c8-4577-a151-6a3c6afd9a39",
  "status": "completed"
}

A failed generation looks like this — read error for the reason, and start a fresh generation to retry (with a new idempotency key):

200 OK

{
  "id": "bd54d799-39c8-4577-a151-6a3c6afd9a39",
  "status": "failed",
  "error": "Rendering failed"
}

A generation id that is unknown or belongs to another workspace returns 404 GENERATION_NOT_FOUND, indistinguishable from one you may not see.

Retrieving the PDF

GET/generations/:id/document

Once the status is completed, this endpoint returns the finished document — a single PDF for a one-document generation, or a ZIP archive of the PDFs for a batch. It responds with a 302 redirect to a freshly signed, short-lived download URL, so follow the redirect to fetch the bytes. Each call mints a new link; request the endpoint again whenever you need a fresh one.

curl -L -o invoice.pdf \
  https://papely.app/api/public/v1/generations/bd54d799-39c8-4577-a151-6a3c6afd9a39/document \
  -H "Authorization: Bearer $PAPELY_SECRET"

The document is retrievable through any credential bound to the workspace it belongs to. A credential is scoped to its whole workspace — not only the generations it created — so it can also read documents produced by other credentials or through the app. Any other case — unknown id, another workspace, or a generation that is not yet completed — returns the same non-disclosing 404 GENERATION_NOT_FOUND.

Checking email delivery

GET/generations/:id/emails

When a generation opted into email delivery, poll this endpoint to track each message. It returns emailEnabled — whether email was requested — and an emails array with one entry per recipient (the recipient email and name you supplied, plus per-message timestamps). Deliveries are created as the worker sends them, so a generation still processing may return an empty array.

curl https://papely.app/api/public/v1/generations/bd54d799-39c8-4577-a151-6a3c6afd9a39/emails \
  -H "Authorization: Bearer $PAPELY_SECRET"
200 OK

{
  "id": "bd54d799-39c8-4577-a151-6a3c6afd9a39",
  "emailEnabled": true,
  "emails": [
    {
      "email": "[email protected]",
      "name": "Ada",
      "status": "delivered",
      "sentAt": "2026-05-25T10:00:00.000Z",
      "deliveredAt": "2026-05-25T10:05:00.000Z",
      "downloadedAt": "2026-05-25T12:00:00.000Z",
      "error": null
    }
  ]
}

Read the per-recipient status — one of pending, sent, delivered, bounced or failed — for the outcome. Downloaded is a derived signal: a non-null downloadedAt means the recipient opened the tracked download link and never changes the status. A failed send is reflected here, never in the generation, and never moves quota.

A generation id that is unknown or belongs to another workspace returns 404 GENERATION_NOT_FOUND, indistinguishable from one you may not see.

Counting and quota

Each generated document counts as one generation against your workspace monthly generation allowance — the same counter the manual UI increments. API and manual generations draw from one shared monthly total.

  • The monthly plan allowance is consumed first. Once it is exhausted, purchased generation packs are drawn down.
  • Only accepted generations count. A request rejected for validation, quota, authentication, or any other reason is never charged.
  • An idempotent replay (same key and identical body) resolves to the original generation and is charged at most once.
  • There is no test or preview mode: every accepted generation counts and produces a real document.

When the allowance and packs are both exhausted, a generate request returns 402 GENERATION_QUOTA_EXCEEDED.

Rate limits and safe retries

Rate limits are enforced per workspace over a one-minute window (these are defaults and may be tuned):

  • POST /generations — 10 requests per minute.
  • POST /images — 20 requests per minute.
  • GET /generations/:id, GET /generations/:id/document, and GET /generations/:id/emails — 60 requests per minute (they share one budget).

Exceeding a tier returns 429 RATE_LIMITED with a Retry-After header in seconds — wait that long, then retry. Polling status and downloading the document at a normal cadence (around every 2–3 seconds) stays comfortably within the shared 60-per-minute budget, so a poll-to-completion loop is never throttled; leave headroom rather than polling both endpoints as fast as possible.

Idempotency. Supply an Idempotency-Key header on POST /generations to make a retry safe after a network error or timeout. A key is bound to the generation it first created:

  • Replaying the same key with a byte-identical body returns the original generationId and does not create or charge a second generation — poll it for status.
  • Reusing the same key with a different body is a conflict: 409 IDEMPOTENCY_KEY_CONFLICT.
  • To start a new generation — including re-generating after one ended in failed — use a new key.

A good key is a UUID generated per logical generation attempt and reused only for retries of that same attempt.

Error responses

Every error uses the same JSON envelope:

{
  "error": {
    "code": "INVALID_DATA",
    "message": "Human-readable summary",
    "details": [
      { "field": "tittle", "reason": "unknown_field" }
    ]
  },
  "requestId": "a1b2c3d4-..."
}

code is a stable, machine-readable string — integrate against it, not the human message or the HTTP status alone. details is present only for INVALID_DATA and lists every offending field with a reason: unknown_field, invalid_value, or unowned_image (a well-formed image reference that the workspace does not own). requestId is a correlation id worth logging and quoting in support requests.

CodeHTTPWhen
UNAUTHENTICATED401The Authorization header is missing or malformed, or the secret is unknown or revoked. All of these are indistinguishable by design.
INSUFFICIENT_PERMISSION403The action requires the workspace owner.
TEMPLATE_NOT_FOUND404templateId is unknown or is not owned by the workspace your credential is bound to.
GENERATION_NOT_FOUND404The generation id is unknown, belongs to another workspace, or is not yet retrievable — all indistinguishable.
INVALID_DATA422The data object breaks the field contract. The details array names each offending field and why.
MALFORMED_JSON400The request body is not valid JSON.
GENERATION_QUOTA_EXCEEDED402The workspace has no monthly generation allowance left and no generation packs.
STORAGE_QUOTA_EXCEEDED402Storing the uploaded image would exceed the workspace storage quota.
UNSUPPORTED_IMAGE_FORMAT415The uploaded file is not a PNG or JPEG (detected from its bytes, not its filename).
IMAGE_TOO_LARGE413The uploaded image is larger than the plan max image size (10 MB by default).
IMAGE_URL_INVALID422The upload-by-URL url is unusable: malformed, not https, or pointing at a disallowed address. All causes are indistinguishable by design.
IMAGE_FETCH_FAILED422Downloading the image from the url failed: a non-200 answer, a timeout, or too many redirects.
PAYLOAD_TOO_LARGE413The generate JSON body exceeds the 5 MB cap.
RATE_LIMITED429A per-workspace rate-limit tier was exceeded. Wait for the Retry-After header, then retry.
IDEMPOTENCY_KEY_CONFLICT409An Idempotency-Key was reused with a different request body.
SERVICE_UNAVAILABLE503The API is temporarily in maintenance. Wait for the Retry-After header, then retry.
INTERNAL_ERROR500An unexpected server error. Safe to retry with a fresh idempotency key.

Some classes deliberately share an HTTP status but stay distinguishable by code. In particular, the two 402 classes — GENERATION_QUOTA_EXCEEDED (out of generations) versus STORAGE_QUOTA_EXCEEDED (out of image storage) — tell you which quota to address. Likewise the two 413 classes (IMAGE_TOO_LARGE versus PAYLOAD_TOO_LARGE) and the two 404 classes (TEMPLATE_NOT_FOUND versus GENERATION_NOT_FOUND) are separated by code. Always branch on code.

End-to-end example

A complete sequence that uploads an image, generates a document, polls to completion, and downloads the PDF. Set $PAPELY_SECRET to your credential secret first.

1 · Upload the image

curl -X POST https://papely.app/api/public/v1/images \
  -H "Authorization: Bearer $PAPELY_SECRET" \
  -F "[email protected]"

# -> 201 { "reference": "img_5bba02c7-7465-47b7-a381-3696f6033a83" }

2 · Start the generation

curl -X POST https://papely.app/api/public/v1/generations \
  -H "Authorization: Bearer $PAPELY_SECRET" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8f2a1c90-4d3e-4a1b-9c77-2b6e5f0a1d34" \
  -d '{
    "templateId": "YOUR_TEMPLATE_ID",
    "data": {
      "title": "Invoice #1024",
      "issued_on": "2026-05-25",
      "verify_url": "https://papely.app/verify/abc",
      "logo": "img_5bba02c7-7465-47b7-a381-3696f6033a83",
      "line_items": [
        ["Consulting", "€1,200.00"]
      ]
    }
  }'

# -> 202 { "generationId": "bd54d799-39c8-4577-a151-6a3c6afd9a39" }

3 · Poll until completed

# Repeat every ~2-3s until status is "completed" (or "failed").
curl https://papely.app/api/public/v1/generations/bd54d799-39c8-4577-a151-6a3c6afd9a39 \
  -H "Authorization: Bearer $PAPELY_SECRET"

# -> 200 { "id": "bd54d799-39c8-4577-a151-6a3c6afd9a39", "status": "completed" }

4 · Download the PDF

curl -L -o invoice.pdf \
  https://papely.app/api/public/v1/generations/bd54d799-39c8-4577-a151-6a3c6afd9a39/document \
  -H "Authorization: Bearer $PAPELY_SECRET"
Papely API — generate personalized PDFs programmatically