API quickstart

Base URL https://api.pressfox.io. JSON in, JSON out. Public ids are prefixed: ws_ workspace, tpl_ template, ver_ template version, rpt_ report, brd_ brand profile, ast_ asset, key_ API key, evt_ webhook event.

On this page

Quickstart

  1. Create a key. In the app, open Settings → API keys and create a test key. It starts with pf_test_, is shown once, and renders watermarked reports that are not metered and are kept 24 hours. Create a live key (pf_live_) when you are ready to send real documents.
  2. Publish a template. Copy a pack or design your own, bind fields to paths in your sample payload, then publish. Publishing creates an immutable version and generates its payload contract. Copy the template key from the Publish dialog, or list templates with GET /api/v1/templates.
  3. Send data. POST /api/v1/reports with the template id and your payload. The response carries the hosted link and the PDF. That is the whole integration.
Request
POST /api/v1/reports
Authorization: Bearer pf_live_7f3a…
Idempotency-Key: visit-48213
Content-Type: application/json

{
  "template": "clinic-visit-summary",
  "payload": { "patient": { "name": "A. Rivera" }, "results": [ { "test": "HbA1c", "value": 6.1 } ] },
  "brand": "tolland",
  "access": { "kind": "password", "password": "correct-horse-battery" }
}
Response when the render finished within the request200
HTTP/1.1 200 OK
Content-Type: application/json

{
  "report_id": "rpt_01j9xk7q4",
  "title": "Clinic visit summary – 2026-10-07",
  "template_id": "tpl_01j9xk2m8",
  "template_key": "clinic-visit-summary",
  "template_version": 4,
  "status": "ready",
  "mode": "live",
  "language": "en",
  "url": "https://app.pressfox.io/report/k7Qm2Zb9pX4nT1vW8sLq0A",
  "pdf_url": "https://app.pressfox.io/report/k7Qm2Zb9pX4nT1vW8sLq0A/pdf",
  "markdown_url": null,
  "pages": 2,
  "units": 1,
  "created_at": "2026-10-07T09:14:02.118402+00:00",
  "ready_at": "2026-10-07T09:14:03.402911+00:00",
  "error": null,
  "message": "Filed. Don't lose the link."
}

Authentication

Send Authorization: Bearer pf_live_… or pf_test_… on every request. A key belongs to one workspace and acts for the whole workspace; it carries no user and no role. Revoke it from the app at any time. Requests without a valid key receive 401 unauthorized. GET /api/v1/whoami returns { workspace_id, workspace, mode, key } for the key you sent, which is a cheap way to check a deployment.

Idempotency. Send an Idempotency-Key header on POST /api/v1/reports: any string of up to 128 characters, unique within your workspace. Use your own record id, such as an invoice or visit number. For 24 hours the same key returns the original status and body. The same key with a different body, or with the other key mode, receives 409 conflict (That Idempotency-Key was already used with a different request). A second request while the first is still running also receives 409 (still in flight). A refused request releases its key, so you can retry with it once the payload is fixed. Keys longer than 128 characters are refused with 422 invalid_value.

Reports

POST /api/v1/reports

Creates a report from a published version of the template and your payload: the version you pin ("template": "clinic-visit-summary@3", see versions and pinning), or the current one when you name none. The payload is validated against that version's contract before anything is queued; a rejected payload never becomes a report. The request then waits up to 2.5 seconds for the render. Returns 200 with the full object when the render finished in time, 202 with status: "queued" or "rendering" when it did not, and 500 with status: "failed" and the reason in error when the render failed within the wait. Payloads over 1 MiB, or estimated at more than 50 pages from the version's sample, go to a separate queue with a longer render deadline (120 seconds rather than 20); they can still answer 200 when they finish quickly.

FieldTypeNotes
templatestring, requiredA template in this workspace, by key (clinic-visit-summary) or by id (tpl_…). The key is set in the app and stays put when the template is renamed; changing it there stops the old key resolving, while the id never changes. Unknown or archived templates receive 404 not_found; a template with no published version receives 422 template_not_published. Add @N to pin version N (clinic-visit-summary@3, tpl_…@3); without it the report uses the current version. An unknown version receives 404 version_not_found, a retired one 410 version_retired, and a malformed suffix (key@, key@0, key@x, key@1@2) 422 invalid_value. template_id, the original name of this field, takes the same values; send one or the other, not both.
template_versionintegerThe version to render, a positive integer: the same as @N on template, for clients that keep the number apart. Sending both with different numbers receives 422 invalid_value on $.template_version; the same number twice is fine. Omit both to use the current version.
payloadany JSON, requiredMust satisfy the version's contract. Up to 8 MiB (8,388,608 bytes) as canonical JSON; larger receives 413 payload_too_large. Images are sent as links: an https URL (a pre-signed one is fine) or asset:<id>; see images.
optionsobjectValues for the switches the template version declares, by name. A key the version does not declare receives 422 unknown_option, and the message lists the names it does declare.
brandstringA brand from the app, by key (tolland) or by id (brd_…): the report renders in its colours, font and logo. Omit it to render with the version's own theme. List the keys with GET /api/v1/brands. An unknown one receives 404 brand_not_found, naming the value. brand_id is the original field and takes the same values; sending both receives 422 invalid_value.
accessobjectkind plus its field; see access policies. Default { "kind": "link" }.
pdf_passwordstringA password on the PDF itself: the file opens only with it. At least 4 characters and at most 127 bytes, or "same_as_link" to reuse the access.password of a password link. Standard and above; below, 402 pdf_password_not_included. Omit it and a template set to take the password from a field of the data uses that field. Never stored with the report and never returned; see a password on the PDF.
languagestringThe report's language: en, es, pt, de or fr. Default: the language the template is written in. Any other needs the template's translations into it, or receives 422 language_not_available listing the ones it has; see languages.
translate_fromstringThe language your payload's free text is written in, when it differs from language: the fields the template marks Translate are machine-translated into the report's language before it renders, and the report says so. Every paid plan, from Starter; see languages.
formatsarray of stringsExtra outputs: ["markdown"] adds a Markdown version of the report (see Markdown output). HTML and PDF are always made; the values are "html", "pdf" and "markdown", and anything else receives 422 unknown_format. Omit it to follow the workspace setting Always include Markdown; [] opts one report out.
titlestringUp to 200 characters, one line. Names the report in the app and in share emails, and names its files: the PDF downloads as <title>.pdf. Omit it to use the template's title pattern, by default the template name and the day (Clinic visit summary – 2026-10-07). Titles are stored unencrypted, unlike the payload: leave personal data out of them.
labelsobjectUp to 10 labels to find the report by later: { "client": "Northwind Shipping", "reference": "SA-2026-014" }. They add to the labels the template sets from your data, and win over them. Stored unencrypted, like the title; see labels.
corrects, correction_reasonstringMake this report a correction of an issued one: corrects is its rpt_… id (replaces is the same field), correction_reason says why (required with it, at most 300 characters). With old_link, reshare and correction_notice; see corrections.
retain_daysintegerKeep this report for this many days (1 to 3,650), then delete it with its page, PDF and links. At most the workspace's report retention, if an owner set one; above it receives 422 retain_days_too_long. Omit it to keep the report as the workspace does: while the subscription is active by default. See how long reports are kept.

Response object, the same on every reports endpoint:

FieldTypeNotes
report_idstringrpt_…
titlestringThe report's title, fixed at creation: yours, or the template's pattern filled from the payload.
labelsobjectThe report's labels, {} when it has none. See labels.
template_id, template_keystringThe template the report was made from; the key as it is now.
template_versionintegerThe version number the report was rendered from, pinned or current at the time. It never changes for a report.
statusstring"queued", "rendering", "ready", "failed" or "deleted"
modestring"live" or "test", from the key
languagestringThe report's language, fixed at creation: yours, or the template's source language.
urlstringThe hosted page, …/report/<token>. Present from creation; the page answers for the report's status until it is ready. The token is random and separate from the id.
pdf_urlstringurl plus /pdf. Present from creation; the file exists once the report is ready. It opens in the browser; add ?download=1 to save it instead. Either way the file is named after the title.
markdown_urlstring or nullurl plus /md: the report as Markdown, when it was asked for (formats or the workspace setting); null otherwise. Same access policy as the PDF; see Markdown output.
pdf_protectedbooleantrue when the PDF opens only with a password. The password is never returned.
pages, unitsintegerPage count and the units it is metered at, ceil(pages / 50). 0 until ready.
created_at, ready_atRFC 3339 or nullUTC with microseconds and offset, as in the samples. ready_at is null until ready.
retain_untilRFC 3339 or nullWhen the report, its links and its PDF end. null: no end while the workspace's subscription is active. See how long reports are kept.
errorstring or nullWhat the render plane refused, when status is "failed". Null otherwise.
messagestringA one-line human message for the log. Not for parsing.
replaces, replaced_bystring or nullThe report this one corrects, and the report that replaced this one once it was issued. See corrections.
replaced_atRFC 3339 or nullWhen this report was replaced.
replace_kind, correction_reasonstring or null"correction" (or "completion", a form completed by its last party) and, for a correction, its reason.
Response when the render is still running202
HTTP/1.1 202 Accepted

{
  "report_id": "rpt_01j9xkb2w",
  "title": "Clinic visit summary – 2026-10-07",
  "template_id": "tpl_01j9xk2m8",
  "template_key": "clinic-visit-summary",
  "template_version": 4,
  "status": "rendering",
  "mode": "live",
  "language": "en",
  "url": "https://app.pressfox.io/report/Qh3Lm8Tz2dR5yB7cN9kE1F",
  "pdf_url": "https://app.pressfox.io/report/Qh3Lm8Tz2dR5yB7cN9kE1F/pdf",
  "markdown_url": null,
  "pages": 0,
  "units": 0,
  "created_at": "2026-10-07T09:20:41.007310+00:00",
  "ready_at": null,
  "error": null,
  "message": "Rendering. Poll this report or wait for the webhook."
}
Response when the render failed within the wait500
HTTP/1.1 500 Internal Server Error

{
  "report_id": "rpt_01j9xkc9f",
  "title": "Clinic visit summary – 2026-10-07",
  "template_id": "tpl_01j9xk2m8",
  "template_key": "clinic-visit-summary",
  "template_version": 4,
  "status": "failed",
  "mode": "live",
  "language": "en",
  "url": "https://app.pressfox.io/report/Vb8Nq1Rw5kJ2hG6mP3xD4S",
  "pdf_url": "https://app.pressfox.io/report/Vb8Nq1Rw5kJ2hG6mP3xD4S/pdf",
  "markdown_url": null,
  "pages": 0,
  "units": 0,
  "created_at": "2026-10-07T09:22:10.551004+00:00",
  "ready_at": null,
  "error": "too-many-pages: document lays out to about 7 pages; the cap is 3",
  "message": "The render failed. Fix what the error names and send it again."
}

A failed report is a report: it has an id, it is listed, and its error names the cause. A render that fails after the request returned is reported by GET, by the report.failed webhook, or by both.

GET /api/v1/reports/{id}

Returns the report object. Poll this after a 202 at a sensible interval, or prefer a webhook. A test key sees test reports only, a live key live ones; the other mode's ids answer 404.

GET /api/v1/reports?template=&status=&cursor=&limit=

Lists the key's mode's reports newest first as { items, next_cursor }. All filters are optional: template (a template key or id, or key@N for the reports made from version N), status (one of the five statuses; without it deleted reports are left out), limit (1 to 200, default 50), cursor, the next_cursor of the previous page, replaced=false to leave out reports a correction replaced, and label.<key>=<value> to keep reports carrying a label (see labels). next_cursor is null on the last page. A cursor this API did not issue receives 422 invalid_value.

GET /api/v1/reports?template=clinic-visit-summary&status=ready&limit=50

{
  "items": [ { "report_id": "rpt_01j9xk7q4", "template_key": "clinic-visit-summary", "template_version": 4, "status": "ready", "mode": "live", "language": "en", "pages": 2, "units": 1, … } ],
  "next_cursor": "MjAyNi0xMC0wN1QwOToxNDowMi4xMTg0MDIrMDA6MDB8MDE5OWI…"
}

DELETE /api/v1/reports/{id}

Marks the report deleted and removes the payload, the HTML, the PDF and the Markdown from storage; the link stops answering. Returns 204, also when the report was already deleted. The row stays, keyless, so GET still answers with status: "deleted". Metered units are not refunded.

Access policies

access.kind decides who can open the hosted link. The policy is set at creation and is not editable through this API; the app can revoke a link, which issues a new token.

KindFieldsPlansThe hosted page
linknoneallAnyone with the link. The default.
passwordpassword, at least 8 charactersStandard and aboveAsks for the password (401) and sets a cookie for 12 hours. Five wrong attempts in 15 minutes close the gate with 429 and a growing wait.
expiryexpires_at, an ISO 8601 time in the futureStarter and aboveAnswers 410 after the time passes.
signednonePro and aboveOpens only with ?exp=<unix seconds>&sig=<hex HMAC-SHA256 over "token.exp"> made with the workspace signing key; otherwise 403. The report body's url and pdf_url already carry a signature good for seven days; POST /api/v1/reports/{id}/links with {"expires_in": seconds} (60 seconds to 30 days) mints fresh ones and answers {url, pdf_url, markdown_url, expires_at}.
privatenonePro and aboveOpens only for a signed-in member of the workspace; everyone else sees 404.

A kind outside this list receives 422 invalid_value on $.access.kind. A missing password or expires_at receives 422 missing_field. expiry on Free, or password on Free or Starter, receives 402 access_policy_not_included; signed or private below Pro receives 402 signed_urls_not_included.

A password on the PDF itself

An access policy decides who can open the hosted link; a PDF password stays with the file wherever it goes, as an attachment, forwarded or downloaded. The PDF is encrypted with AES-256 and opens in every common viewer once the reader types the password. Readers can still print, copy and use a screen reader: the password keeps strangers out, it does not restrict the reader. Standard and above.

  • Per report: send "pdf_password": "N0rthwind-1984", or "same_as_link" with access.kind password to use the link's password for the file too.
  • From the data: in the designer, PDF password names a field of the data and a format: As it is, Digits only (1984-03-07 becomes 19840307) or Last 4 (spaces removed first). The setting is frozen with the version; every report from it needs that field, or receives 422 invalid_value naming it. A pdf_password in the request wins over the setting.

Recipe: statements that open with the client's date of birth. Add client.birth_date to the payload, set PDF password to that field with Digits only, and publish. Tell clients once how to find their password; the hosted page's download button says the PDF needs one, without saying it.

The password is used to encrypt the file and deleted with the payload when the report is ready: Pressfox cannot recover it, and it appears in no answer, log or webhook. A report whose PDF could not be encrypted fails rather than go out unprotected.

Share by email

POST /api/v1/reports/{id}/shares with {"emails": [...], "duration": "24h" | "48h" | "7d" | "30d" | "none", "message"?: "..."} emails a ready report to up to 50 people, one email each, sent from Pressfox on behalf of your workspace. Each person gets their own link to the hosted page and the PDF: /report/<token>?share=<rcp_id>&exp=<unix seconds, 0 for none>&ssig=<hex>, an HMAC-SHA256 with the workspace signing key over "token.share.<rcp_id>.<exp>". It does not work as a signed link or a QR link, and they do not work as it.

A share link opens that one report under every access kind, password and private included: the person was chosen by address. It answers 410 once its time passes, when the report's own expiry passes, and when the share or that recipient is revoked in the app; revoking the report's link or deleting the report closes it too (404). "none" is still capped by the report's own expiry and the plan's retention; the response's expires_at says when the links stop. Addresses are trimmed, lower-cased and de-duplicated; one bad address refuses the whole request with 422 invalid_email, naming it. A workspace shares with at most 25 people a day on Free, 100 on Starter and 500 on Standard and above (429 share_limit with Retry-After). The answer is 201 with the share and its recipients; opens are counted per recipient in the app when a browser shows the page (never for bots, link previews or the mail filters that fetch links as an email arrives), and the email carries no tracking pixel.

Confirmation and reminders (Standard, Pro and Enterprise; 402 share_ack_not_included below). Add "ask_ack": true to ask each person to confirm they received the report: their page shows a Confirm receipt button, which records that the link sent to that address was used to confirm receipt, never that they agree with the report. "remind_after_days" (0, 1, 3 or 7; 3 by default with ask_ack, else 0) reminds people who have not opened it, or with "remind_until": "acknowledged" not confirmed it, at most twice; reminders stop when the link closes or the report is replaced, and count toward the daily limit. Each recipient in the answer carries acknowledged_at, reminders_sent and last_reminded_at.

Languages, translation and summaries

A report is in one of five languages: English (en), Spanish (es), Portuguese (pt), German (de) and French (fr). Three things follow it.

  • The template's text. A template is written in one language (its source_language, English unless the author changed it) and can carry translations into the others, drafted in the designer, reviewed by its author and frozen into each published version. "language": "es" renders the Spanish labels, headings and footers; no model is involved. A language the version has no translations for receives 422 language_not_available, which lists the ones it has; GET /api/v1/templates lists them too. A label without a translation prints in the source language.
  • Formatting and fixed words. Numbers and dates use the language's locale (es-ES, pt-BR, de-DE, fr-FR) unless the template sets its own; the words Pressfox prints by itself (page numbers, the photo appendix, the hosted page's buttons and status pages) are in the report's language.
  • Your text, when you ask. Payload values are printed as you send them. To translate free text written in another language, send "translate_from": "pt": the fields the template's author marked Translate (in the contract, "x-pressfox-translate": true) are machine-translated into the report's language when the render starts. Ids, codes, numbers and dates are never changed, and every number of the original must survive the translation or nothing is used. The PDF and the hosted page carry a notice in the report's language, such as Text fields machine-translated from Portuguese.

Translating text: the rules. translate_from equal to language does nothing. A template that marks no field receives 422 nothing_to_translate; more than 5,000 distinct texts or 400,000 characters to translate, 422 invalid_value on $.payload. It needs a plan with translated report units: without, 402 translated_units_per_month_not_included; past the month's units, 402 translated_units_limit. A report is counted at its estimated units, once, however often its render is retried. A workspace whose industry is health receives 403 ai_off. A translation that cannot be completed reliably fails the report, with error naming the cause and no text from the payload; send it again, or without translate_from to keep the original text.

AI summaries. A template can hold an AI summary widget that reads payload fields its author chose (for example every finding's description) and prints a few sentences about them, labelled Written by AI, in the report's language and after any translation. It is written once when the render starts and frozen with the report. It needs a plan that includes summaries (every paid plan, from Starter); otherwise, in a health workspace, when the fields are empty, or when the model's answer is unusable, the widget prints nothing and the report renders anyway. You send nothing extra for it.

Model calls see only the marked fields of the one report, never images, and nothing is kept: the payload is deleted when the report is ready, as always.

Markdown output

Every report can also be read as Markdown: the same content as its HTML and PDF, in reading order, with headings, tables and labels intact, so people and AI tools (assistants, search, RAG pipelines, agents over MCP) can read it without parsing a PDF. It is built by code from the report's document at render time, with no AI involved: the same report always gives the same bytes. The PDF stays the official document. It is available on every plan and does not change units.

Asking for it. Send "formats": ["markdown"] on POST /api/v1/reports. HTML and PDF are always produced; formats adds outputs and takes "html", "pdf" and "markdown". Any other value receives 422 unknown_format on $.formats[i]. The workspace setting Always include Markdown (Settings → Reports in the app) turns it on for every report; the request field wins when given, so "formats": [] leaves one report without it. A report made without it has markdown_url: null and never gets one later: its inputs are deleted once it is ready.

Request
POST /api/v1/reports
Authorization: Bearer pf_test_7f3a…
Content-Type: application/json

{ "template": "vessel-inspection", "payload": { … }, "formats": ["markdown"] }

Getting it. markdown_url in the report object, the report.ready webhook and POST /links is the hosted file, …/report/<token>/md. It opens under the report's access policy exactly like the PDF (link, password, expiry, signed, private, the PDF's QR link and share links), answers text/markdown; charset=utf-8 and is named after the title (<title>.md); add ?download=1 to save it. For a signed report it carries the same signature as pdf_url. The hosted page's actions menu has Download as Markdown, and downloads are counted in the report's analytics like PDF downloads, on their own counter.

GET /api/v1/reports/{id}/markdown

Returns the text itself to an API key: 200 with text/markdown; charset=utf-8. A report made without a Markdown version receives 404 markdown_not_requested; one still rendering, or one that failed, 409 report_not_ready.

What it looks like
---
title: "Fleet inspection 2025"
template: fleet-inspection-full-year@1
language: en
report_id: rpt_2Qq3i6Fz6JN8pDfBkPyzcu
created_at: 2026-10-09T02:41:07Z
pages: 271
mode: test
url: https://reports.pressfox.io/report/UXO3Hy5NvORcqsAeKJIUcg
---

> TEST report — not valid as a record.

# Fleet inspection report

## MV OCEAN HORIZON · Genoa, Italy · 2025-01-20

- **Date:** 20 Jan 2025
- **Result:** Satisfactory

### Checklist

- [x] Means of escape unobstructed — Pass
- [ ] Main engine and auxiliaries — Fail

| ID | Area | Description | Severity | Status |
|---|---|---|---|---|
| 3.1 | Machinery | Emergency generator failed to take load within 45 seconds. | Observation | Rectified |

*Photo 13 (p. 116): Internal ladders / stairs — Cargo Hold No.2*
  • Front matter (YAML): title, template as key@N, language, report_id, created_at, pages, mode, and the hosted url only when the report's access kind is link. A test report's body starts with > TEST report — not valid as a record.
  • Body, in the printed document's reading order and the report's language (the template's translations and Pressfox's own words, as in the PDF): covers as text; headings by level; text with bold and italic kept; callouts as blockquotes; field grids, key values, parties and totals as - **Label:** value lists; tables as Markdown tables with their totals rows and empty text; checklists as task lists (- [x] … — Pass, - [ ] … — Fail, N/A as a plain item); status, score, meter and KPI cards one line each; lists and timelines as lists; charts as their data in a table and a line naming the chart type, never an image; signatures as **Signed:** name, role, date; AI summaries followed by Written by AI; photos as *Photo N (p. X): caption*, with a link to the hosted image only for link reports, and the photo appendix as a list of captions.
  • Left out: running headers and footers, page numbers, the table of contents, watermarks, logos, QR codes and dividers (page furniture, not content). A labelled barcode keeps its value as text.
  • HTML fallback and escaping. A table whose cells hold line breaks is written as an HTML <table> instead, with every cell escaped. Markdown characters in your values are escaped, pipes in table cells too; nothing in a payload can inject HTML.

The Markdown is stored encrypted next to the HTML and PDF and deleted with them: after 24 hours for test reports, at the end of the plan's retention otherwise, or when the report is deleted.

Labels

Labels say what a report is about, so you can find it again after its data is gone: client: Northwind Shipping, reference: SA-2026-014, type: service agreement. A report keeps at most 10. Keys are lower-case letters, digits and underscores, starting with a letter, at most 32 characters; values are text of at most 200 characters, kept on one line. Labels are never printed on the document, so changing them never changes the report.

They come from three places, the later winning: the template's label bindings (Template settings in the designer, frozen with each version), filled from the payload with the same {path} placeholders as the report title ({customer.name}, or SA-{agreement.reference}; text without placeholders is a constant, and a binding that finds nothing sets no label); the request's labels; and later changes.

Labels on create
POST /api/v1/reports
Authorization: Bearer pf_live_7f3a…
Content-Type: application/json

{
  "template": "service-agreement",
  "payload": { "agreement": { "reference": "SA-2026-014", … }, "customer": { "name": "Northwind Shipping", … }, … },
  "labels": { "owner": "j.alves" }
}

→ "labels": { "client": "Northwind Shipping", "reference": "SA-2026-014", "type": "service agreement", "owner": "j.alves" }

Find reports by label. label.<key>=<value> on GET /api/v1/reports keeps the reports carrying that label, matched exactly. Several keys are ANDed; one value per key. In the app, the Reports page filters by label and its search box finds titles and label values.

Every report for one client
GET /api/v1/reports?label.client=Northwind%20Shipping&replaced=false

{
  "items": [ { "report_id": "rpt_01ja3m9qe", "title": "Service agreement SA-2026-014", "labels": { "client": "Northwind Shipping", "reference": "SA-2026-014", "type": "service agreement" }, "status": "ready", … } ],
  "next_cursor": null
}

PATCH /api/v1/reports/{id}/labels

Sets or removes labels on an existing report: a string sets one, null removes it, labels you leave out stay. Answers the report object. In the app, editors and above change labels from the report's drawer, and each change is in the audit log (keys only, never values).

PATCH /api/v1/reports/rpt_01ja3m9qe/labels
Authorization: Bearer pf_live_7f3a…
Content-Type: application/json

{ "labels": { "status": "signed", "owner": null } }

Labels are stored unencrypted so they can be searched, like the title. Use them for names and references, not for sensitive data such as tax numbers or health details. They are never written to logs. A zero-retention report takes none (422 zero_retention_unsupported). Labels appear in report bodies, webhooks and the workspace export.

Corrections

A report is frozen once made. When one has a mistake, send the corrected data as a new report that corrects it. The original stays as it was, and everyone who opens its link is told a corrected version exists and where.

Request
POST /api/v1/reports
Authorization: Bearer pf_live_7f3a…
Idempotency-Key: SA-2026-014-v2
Content-Type: application/json

{
  "template": "service-agreement",
  "payload": { … the corrected data … },
  "corrects": "rpt_01ja3m9qe",
  "correction_reason": "Pilot phase shortened to three months",
  "old_link": "keep",
  "reshare": true
}
FieldTypeNotes
correctsstringThe rpt_… id of the report this one corrects. replaces is the same field. It must be a live report of your workspace that is ready and was not replaced yet. A test key cannot correct: test reports are not corrected.
correction_reasonstring, required with correctsWhy, in plain words, at most 300 characters: Pilot phase shortened to three months. Shown on the original's page and in the share email, printed on the correction unless correction_notice is off, sent in the report.superseded webhook, and returned by the API. Leave personal data out of it.
old_linkstring"keep" (default): the original's link keeps working, with a banner that links to the newest version. "off": every link to the original (page, PDF, Markdown, share and QR links) answers 410 with that link instead.
resharebooleanSend the correction to everyone the original was shared with by email (shares neither revoked nor expired), for the same duration counted from now. It is sent in the name of whoever made the correction, or your workspace's name for an API key; the original's personal message is not repeated. Default: on when the original was shared. The email reads A corrected version of … and gives the reason.
correction_noticebooleanPrint a line at the top of the correction's first page saying which report it replaces and why. Default on.

Everything else is a normal create: the payload is validated against the version's contract, the template may be a newer version, and the correction starts with the original's labels (the template's bindings and your labels win). The original is replaced only when the correction is ready: a correction that fails replaces nothing. Correct the newest version, not an older one: a report already replaced answers 409 already_replaced naming the current version, one that is not ready yet 409 report_not_ready, and one that another correction is still rendering 409 correction_in_progress naming that correction.

The original, once the correction is ready
GET /api/v1/reports/rpt_01ja3m9qe

{
  "report_id": "rpt_01ja3m9qe",
  "status": "ready",
  "replaces": null,
  "replaced_by": "rpt_01ja7c2kd",
  "replaced_at": "2026-10-14T10:02:11.402118+00:00",
  "replace_kind": null,
  "correction_reason": null,
  …
}

What people see. The original's page keeps its frozen document and adds a banner in the report's language: when it was replaced, the reason, and Open the corrected version. A correction can itself be corrected; every older version points to the newest. The original's PDF cannot change, but its QR code leads to the page, which shows the banner.

Finding current versions. replaced=false on GET /api/v1/reports leaves out every report a correction replaced. Each report body carries replaces, replaced_by, replaced_at, replace_kind and correction_reason.

Webhooks. The correction's report.ready carries replaces. The original gets report.superseded with replaced_by and correction_reason; subscribe to it in Settings → Webhooks.

report.superseded
{
  "type": "report.superseded",
  "id": "evt_01ja7c2p8",
  "created_at": "2026-10-14T10:02:11.611305+00:00",
  "data": {
    "report_id": "rpt_01ja3m9qe",
    "status": "ready",
    "template_key": "service-agreement",
    "labels": { "client": "Northwind Shipping", "reference": "SA-2026-014", "type": "service agreement" },
    "replaced_by": "rpt_01ja7c2kd",
    "correction_reason": "Pilot phase shortened to three months",
    …
  }
}

Units. A correction is a report. One made within 7 days of its original, on the same template, uses no units, up to a tenth of your live reports this calendar month (at least one), in your workspace's time zone; past that it is metered as usual.

Correcting from the app. Your system sends the corrected data itself, so nothing needs keeping. To correct from the dashboard with the original's data, Pro and Enterprise workspaces will be able to turn on Keep report data for corrections (Settings → Reports, 7 or 30 days): each live report's data then stays encrypted for that long after the report is created, instead of being deleted when it is ready. It is not available yet, and it will be off by default. Until then, keep your request body if you may want to correct a report.

Verify a document

From Standard, every live report's PDF gets a verification record when it is ready: its SHA-256 fingerprint, the issuer's name and the time of issue. Anyone holding the PDF can check it at /verify, on app.pressfox.io or on your own report address; the browser computes the fingerprint and the file is never uploaded. Once your workspace has its own report address, each PDF prints a line saying where to check it, on that address. Test reports and zero-retention reports get no record.

The page asks GET /api/verification/check/{sha256}: public, no key, rate limited per address, never cached. It answers { "status": "genuine" | "replaced" | "revoked" | "not_found" }, with issuer and issued_at for a known file, replaced_at once a correction replaced it, and revoked_at with the issuer's reason once it was revoked. A value that is not 64 hexadecimal characters receives 422 invalid_value. On your own report address it answers only for your workspace's documents.

Not found means the file was not issued by Pressfox or was changed after it was issued: annotating or saving a PDF again changes its fingerprint, and a copy signed through your signing provider is a new file (see signing). A record holds no report content and is kept 10 years, until the workspace is deleted. Verification proves a file is unchanged since issue; it is not an electronic signature or seal.

Templates and contracts

GET /api/v1/templates?cursor=&limit=

Lists the workspace's templates most recently updated first, as { items, next_cursor }. Each item is { id, key, name, current_version, source_language, languages }, where current_version is { id, number, kit_version } for the published version reports render from, or null for a template that has never been published. versions lists its published versions newest first (at most 50) as { number, current, retired, created_at }, so you can see which numbers you may pin. source_language is the language that version is written in and languages the ones a report can be created in: the source, then every language it has translations for (see languages); null and [] before the first publish. limit is 1 to 200, default 50. Archived templates are not listed.

GET /api/v1/templates?limit=50

{
  "items": [
    { "id": "tpl_01j9xk2m8", "key": "clinic-visit-summary", "name": "Clinic visit summary", "current_version": { "id": "ver_01j9xk5p2", "number": 4, "kit_version": "1.4.0" }, "source_language": "en", "languages": ["en", "es", "pt"],
      "versions": [
        { "number": 4, "current": true, "retired": false, "created_at": "2026-10-06T15:02:11.204118+00:00" },
        { "number": 3, "current": false, "retired": false, "created_at": "2026-09-21T10:40:52.880315+00:00" },
        { "number": 2, "current": false, "retired": true, "created_at": "2026-09-02T08:12:30.117902+00:00" },
        { "number": 1, "current": false, "retired": true, "created_at": "2026-08-28T13:55:04.661020+00:00" }
      ] },
    { "id": "tpl_01j9xkz0a", "key": "invoice", "name": "Invoice (draft)", "current_version": null, "source_language": null, "languages": [], "versions": [] }
  ],
  "next_cursor": null
}

GET /api/v1/brands

Lists the workspace's brands, oldest first, as { items }. Each item is { id, key, name, is_default }. Send a key as "brand" when creating a report. The default brand is where new templates start in the designer; a report without "brand" keeps the template's own theme. Brands are designed in the app (Free and Standard have one, Pro unlimited); this endpoint is read only.

GET /api/v1/brands
Authorization: Bearer pf_live_…

200 OK
{ "items": [ { "id": "brd_01j9xk3n1", "key": "tolland", "name": "Tolland & Reese", "is_default": true } ] }

GET /api/v1/templates/{key or id}/contract

Returns the contract of the current published version, or of version N when {key or id} ends in @N or the query says ?version=N (both with different numbers is 422 invalid_value on version). The X-Pressfox-Template-Version header names the version you got. schema is the JSON Schema the API validates payloads against, inferred from the version's sample with every path a widget reads marked required; reads lists those paths, dotted, with array indexes collapsed; unread lists sample leaves no widget reads. Generate types from schema, or validate on your side before sending. A template with no published version receives 422 template_not_published; an unknown version 404 version_not_found, a retired one 410 version_retired.

GET /api/v1/templates/clinic-visit-summary/contract          (the current version)
GET /api/v1/templates/clinic-visit-summary@3/contract        (version 3; ?version=3 is the same)

HTTP/1.1 200 OK
X-Pressfox-Template-Version: 4

{
  "schema": {
    "type": "object",
    "additionalProperties": true,
    "required": ["patient", "results"],
    "properties": {
      "patient": { "type": "object", "additionalProperties": true, "required": ["name"], "properties": { "name": { "type": "string" }, "dob": { "type": "string", "format": "date" } } },
      "results": { "type": "array", "items": { "type": "object", "additionalProperties": true, "required": ["test", "value"], "properties": { "test": { "type": "string" }, "value": { "type": "number" } } } }
    }
  },
  "reads": ["patient.name", "results.test", "results.value"],
  "unread": ["patient.dob"]
}

Versions and pinning

Every publish freezes the template into a new, numbered version: 1, 2, 3 and on. A version never changes after it is published: its layout, its contract, its title pattern, its options and its translations stay as they were, and every report records the version it was made from (template_version). Publishing makes the new version the current one.

  • Default: the current version. "template": "clinic-visit-summary" renders the current version, as it always has. When a publish changes the contract, callers that send the old shape start receiving 422s.
  • Pin: key@N. "template": "clinic-visit-summary@3" (or "template_version": 3) renders version 3 whatever is current. Keys cannot contain @, so the reference is unambiguous; it works with ids too (tpl_…@3).
  • Versions run side by side. One system can send @3 while another sends @4 or nothing at all, at the same moment. Each report is validated against, and rendered with, its own version: contract, title pattern, languages, options, images and AI summaries.
  • A contract per version. GET /api/v1/templates/clinic-visit-summary@3/contract gives the schema version 3 validates against; GET /api/v1/templates lists the numbers each template has.
  • Retire. In the app, a version that should no longer be used can be retired. New reports pinned to it receive 410 version_retired, whose message names the current version to move to; reports already made from it keep their links and PDFs. The current version cannot be retired, and a retired version can be unretired.
  • Make current: rollback. In the app, any published version that is not retired can be made current again. Requests without a version render it from then on; nothing is republished and no new number is minted.

Recommendation. Pin in production (key@N), and leave the version out while you build against a test key. When a new version is published, fetch its contract, update your payload, and move the pin when you are ready. The publish dialog in the app shows how the contract changed against the previous version.

A request pinned to version 3
POST /api/v1/reports
Authorization: Bearer pf_live_7f3a…
Content-Type: application/json

{
  "template": "clinic-visit-summary@3",
  "payload": { "patient": { "name": "A. Rivera" }, "results": [ { "test": "HbA1c", "value": 6.1 } ] }
}
An unknown version, and a retired one
HTTP/1.1 404 Not Found

{
  "error": {
    "type": "not_found",
    "code": "version_not_found",
    "message": "Template clinic-visit-summary has no version 7. It has 4, 3, 2, 1. Send one of those, or leave the version out to use the current one (4).",
    "field": "$.template",
    …
  }
}

HTTP/1.1 410 Gone

{
  "error": {
    "type": "gone",
    "code": "version_retired",
    "message": "Version 1 of clinic-visit-summary is retired. Move to clinic-visit-summary@4, the current version, or ask an admin to unretire it.",
    "field": "$.template",
    …
  }
}

Images

Send each image as a link to where it already lives: { "src": "https://your-bucket.s3.eu-west-1.amazonaws.com/visits/48213/hull.jpg?X-Amz-Signature=…", "caption": "Forward hull" }. Private buckets work with pre-signed URLs; a few minutes of validity is enough. Pressfox downloads every image once, when the report starts rendering, and keeps a copy with the report, so the report never depends on the link again: it keeps working after the link expires, and is deleted with the report.

  • Where. Only fields the template uses as images are fetched (in the contract, "description": "asset id"). A URL anywhere else in the payload stays text.
  • What. https only. JPEG, PNG or WebP, decided by the file's bytes rather than its Content-Type. Up to 20 MiB per image, 500 distinct images and 1 GiB in all per report. Up to three redirects are followed; every address is checked, and private, loopback and cloud-metadata addresses are never contacted.
  • When. URLs are checked for shape when you create the report (422 naming the field). The download happens in the render: up to 16 at a time, the same URL fetched once, a busy or failing storage (429, 5xx, timeouts) retried twice. A report with more than 8 images goes to the large queue, so expect 202 and poll or use the webhook.
  • Failures. An image that cannot be fetched fails the report with error naming its path and the reason, e.g. $.inspections[3].photos[1].src could not be fetched: the storage answered 403: the link may have expired or need signing again. The link itself is never written to an error or a log.

The same photo sent in many reports is stored once. Images you reuse everywhere (logos, signatures) can instead be uploaded once as assets and referenced as asset:<id>; both forms can be mixed in one payload.

Assets

POST /api/v1/assets

Uploads an image once for use in many payloads: logos, signatures, letterheads. For photos that already live in your storage, send their URLs instead (images). Two body shapes: JSON { content_type, data, name? } with data base64 (a data:…;base64, prefix is accepted and stripped), or multipart/form-data with a file part and an optional name field. Accepted types: image/png, image/jpeg, image/webp, image/gif, image/svg+xml; the bytes must match the declared type, and an SVG may carry no script or event handler. Ceiling 10 MiB per file. Assets are deduplicated by content hash within the workspace: new bytes answer 201, bytes already stored answer 200 with the existing asset. Stored bytes count against your plan's asset allowance; over it, 402 asset_bytes_limit.

Upload, JSON
POST /api/v1/assets
Authorization: Bearer pf_live_7f3a…
Content-Type: application/json

{ "content_type": "image/jpeg", "data": "<base64>", "name": "hull-forward.jpg" }

HTTP/1.1 201 Created

{
  "asset_id": "asset:ast_01j9xkd8m3",
  "bytes": 184322,
  "content_type": "image/jpeg",
  "name": "hull-forward.jpg",
  "width": 1600,
  "height": 1200,
  "created_at": "2026-10-07T09:10:12.330117+00:00"
}
Upload, multipart
curl https://api.pressfox.io/api/v1/assets \
  -H "Authorization: Bearer pf_live_7f3a…" \
  -F "file=@hull-forward.jpg;type=image/jpeg" \
  -F "name=hull-forward.jpg"

Reference the asset as asset:<id> wherever a string appears in the payload (or in a brand theme). Every reference is resolved before the render; an id that is not in your workspace is left unresolved, and the image block renders its empty placeholder instead; the report still succeeds, so check ids before you send. GET /api/v1/assets/{id} returns the same object as the upload, with or without the asset: prefix on the id.

Use in a payload
{ "template": "vessel-inspection", "payload": { "photos": [ { "image": "asset:ast_01j9xkd8m3", "caption": "Forward hull, port side" } ] } }

Errors

Every error, from every endpoint, uses one envelope and one voice. type is the broad class, code is stable and documented, message is for humans, field is a JSON path into your request when the error is about a field, expected and received appear when there is something to compare, and docs_url links to the catalogue entry. Keys with nothing to say are left out rather than sent as null. Quote request_id when you write to support.

HTTP/1.1 422 Unprocessable Content

{
  "error": {
    "type": "validation_error",
    "code": "wrong_type",
    "message": "$.results[0].value should be a number. You sent a string. I'll be here.",
    "field": "$.results[0].value",
    "expected": "number",
    "received": "string",
    "docs_url": "https://pressfox.io/docs/errors#wrong_type",
    "request_id": "req_01j9xk8d2"
  }
}

Plan limits are a class of their own: type: "limit_exceeded", HTTP 402, and a code that names the limit. field is the limit's name or the request field that tripped it. upgrade_plan names the lowest plan that lifts the refusal ("starter", "standard", "pro" or "enterprise"), and the message names it too; it is left out when no plan would.

HTTP/1.1 402 Payment Required

{
  "error": {
    "type": "limit_exceeded",
    "code": "signed_urls_not_included",
    "message": "Signed and private links are not included in the Standard plan. The Pro plan lifts it.",
    "field": "$.access.kind",
    "upgrade_plan": "pro",
    "docs_url": "https://pressfox.io/docs/errors#signed_urls_not_included",
    "request_id": "req_01j9xk9f1"
  }
}
CodeStatusMeaning
missing_field422A required field is absent.
wrong_type422A field has the wrong JSON type.
empty_array422A list that must have items is empty.
invalid_value422A value fails a format, enum or range rule, or a header or query value is not right.
unknown_option422options names a switch the version does not declare.
template_not_published422The template has no published version.
unsupported_asset_type, asset_too_large422The upload is not an accepted image type, or is over 10 MiB.
invalid_request400The body could not be parsed as JSON, or the request is malformed in a way no other code covers.
unauthorized401Missing or invalid API key.
not_found404No such template, brand, report or asset in this workspace and mode.
version_not_found404The template has no version with the number you pinned.
version_retired410The version you pinned is retired; move to the one the message names.
conflict409Idempotency key reused with a different request, or still in flight.
payload_too_large413The payload is over 8 MiB.
rate_limited429Too many requests this minute from the key or the workspace; wait Retry-After seconds. See rate limits.
live_units_limit, report_storage_limit, *_limit, *_not_included402A plan limit or a plan feature. The code names which, and upgrade_plan the plan that lifts it.

Full wording and what to do for each in the error catalogue. Besides rate_limited, the API answers 429 for sharing over the daily cap (share_limit), and a hosted report page for too many wrong passwords. The app's Playground has its own limits (60 requests a member an hour, 10 runs with AI a workspace a day: playground_ai_daily_limit).

Rate limits

Requests to /api/v1 are counted in one-minute windows that start on the minute, per API key and across all of a workspace's keys. Test and live keys share the workspace's windows; a connected AI app counts as the key of its connection. Requests that fail authentication are not counted, and hosted report pages, PDFs and webhook deliveries are not API requests.

WindowPer API keyPer workspace (all keys)
All requests600 a minute1,200 a minute
Report creation (POST /api/v1/reports)120 a minute240 a minute

A report creation counts in both rows. Every counted response carries three headers for the window closest to its limit: RateLimit-Limit (the window's size), RateLimit-Remaining (requests left in it) and RateLimit-Reset (seconds until it resets). A request over a limit is not counted and is refused with 429 rate_limited and a Retry-After header in seconds; wait that long and send it again, with the same Idempotency-Key if it created a report. Large batches go faster spread over the minute than sent at once. If your volume needs more, write to us.

Webhooks

Paid workspaces register HTTPS endpoints in the app (Settings → Webhooks): 1 on Starter, up to 10 on Standard, as many as you need on Pro and Enterprise; on Free the app answers 402 webhooks_not_included. Each endpoint subscribes to any of these events: report.ready, report.failed, report.superseded (a newer version replaced the report; see corrections), report.completed (a form completed by its last party), report.opened and report.acknowledged (someone a report was shared with by email opened it for the first time, or confirmed they received it; data is { report_id, share_id, recipient_id, email, opened_at | acknowledged_at }, so subscribe to them only if your endpoint may receive those addresses), and batch.completed. Payloads are thin: ids, status and URLs, never report data. Events for reports made with a test key carry "mode": "test", so filter on it if your endpoint should act on live reports only; reports sent from the app's Playground are never delivered. Reports a member creates from a form in the app are real reports and are delivered, with "source": "dashboard" ("api" for reports your integration requested), so an endpoint can leave alone the reports it did not ask for.

FieldNotes
typeThe event, such as "report.ready" or "report.failed"
idThe event id, evt_…, the same value as the webhook-id header. Deduplicate on it; a redelivery carries the same id.
created_atWhen the event was recorded, RFC 3339.
data{ report_id, status, url, pdf_url, markdown_url, template_id, template_key, template_version, mode, labels, source } (markdown_url is null when the report has no Markdown version), plus replaces on a correction and replaced_by and correction_reason on report.superseded. Fetch the report for pages, units and the error text.

Deliveries are signed in the Standard Webhooks format. Three headers: webhook-id, webhook-timestamp (Unix seconds) and webhook-signature, whose value is v1,<base64 HMAC-SHA256>. The key is the endpoint secret shown once at creation, whsec_ followed by base64; strip the prefix and base64-decode it. The signed string is id + . + timestamp + . + the raw body bytes, so verify against the bytes you received, not a re-serialised object. One signature is sent today; there is no secret rotation. Reject timestamps more than five minutes from your clock.

Delivery
POST https://example.com/hooks/pressfox
content-type: application/json
user-agent: Pressfox-Webhooks/1.0
webhook-id: evt_01j9xkf0s7
webhook-timestamp: 1791364443
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"type":"report.ready","id":"evt_01j9xkf0s7","created_at":"2026-10-07T09:14:03.402911+00:00","data":{"report_id":"rpt_01j9xk7q4","status":"ready","url":"https://app.pressfox.io/report/k7Qm2Zb9pX4nT1vW8sLq0A","pdf_url":"https://app.pressfox.io/report/k7Qm2Zb9pX4nT1vW8sLq0A/pdf","markdown_url":null,"template_id":"tpl_01j9xk2m8","template_key":"clinic-visit-summary","template_version":4,"mode":"live","source":"api"}}
Verify (Node)
import { createHmac, timingSafeEqual } from "node:crypto";

// secret: the "whsec_…" value shown once when the endpoint was created.
// headers: lower-cased request headers. rawBody: the body as received, a Buffer, not re-serialised.
export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  if (!/^\d+$/.test(ts ?? "")) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(ts)) > 300) return false;                   // the sender's own tolerance is 5 minutes

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const signed = Buffer.concat([Buffer.from(`${id}.${ts}.`), rawBody]); // "id.timestamp." + body bytes
  const expected = createHmac("sha256", key).update(signed).digest();

  // One "v1,<base64>" value is sent today; the header is still parsed as a space-separated list.
  return (headers["webhook-signature"] ?? "").split(" ").some((part) => {
    const [version, sig] = part.split(",");
    if (version !== "v1" || !sig) return false;
    const given = Buffer.from(sig, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Respond with any 2xx within 10 seconds, measured from connect to the last byte read; redirects are not followed and count as a failure, and the response body is ignored past 4 KB. Failed deliveries are retried at 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours after the previous attempt. An endpoint that has been failing without a single success for 3 days is disabled: pending retries stop and new events are not sent to it. The app shows it as disabled with when and why (and records it in the audit log). Fix the receiver, then choose Enable again on the endpoint (owners and admins): it keeps its URL and signing secret, its failure count starts again from zero, and the 3 days start again from then. Events missed while it was disabled are not sent automatically; the app shows every delivery with its attempts and lets you redeliver one.

Test and live mode

Mode comes from the key. Test keys render documents watermarked TEST and count nothing against your plan. Live keys render clean documents on paid plans (Free carries a PRESSFOX watermark) and meter units: each live report is charged ceil(pages / 50) units once, at render time. Templates, brands and assets are shared between modes; reports are not: a test key lists, reads and deletes test reports only, and a live key live ones. The idempotency store is per workspace, so the same key sent from a test and a live key is a conflict, not a replay.

Test reports are kept 24 hours on every plan, whether they come from a test key or from the app's Playground: an hourly sweep deletes them, and their links, QR codes and shares stop at the same time (the hosted page says Available until). Live reports are kept as the next section says. Test reports from a test key are sent to your webhooks with "mode": "test"; reports sent from the Playground never are.

How long reports are kept

On paid plans a live report has no end while the workspace's subscription is active: its retain_until is null. When the subscription ends (it is cancelled, or the workspace moves to Free), every report without an end gets one 30 days later; subscribing again within those days removes it. Moving between paid plans changes nothing. On Free, live reports are kept 7 days; on Enterprise, as agreed. When a report's time ends, its page, PDF, Markdown, links, QR code and shares stop together, and the report is deleted.

Shorter is your choice. An owner sets the workspace's report retention in the app (Settings → Reports, report_retention_days: 1 to 3,650 days, or empty for "while subscribed"); it applies to reports made after the change. retain_days on POST /api/v1/reports sets one report's, at most the workspace's setting. A report's end can be brought forward in the app, never pushed back.

Report storage has a limit per plan: 1 GB on Free, 10 GB on Starter, 50 GB on Standard, 500 GB on Pro and 2 TB on Enterprise. A live report over it receives 402 report_storage_limit; nothing is deleted to make room, so delete reports you no longer need, add storage on a paid plan, or change plan. Test reports never count toward it.

Use Pressfox from AI agents (MCP)

The Pressfox MCP server lets an AI assistant such as Claude, ChatGPT or any other Model Context Protocol client do what your integration does: find the template, read its contract, create the report and hand back the link. There are two ways to connect, with the same tools: by URL, where you sign in to Pressfox and approve the assistant (no key to paste), or locally, a small Python package that runs on your machine with a workspace key. Either way the assistant can do nothing your approval or your key could not, and no model runs on our side: the assistant is yours.

Connect by URL (OAuth)

The server's URL is https://mcp.pressfox.io/mcp. In Claude (claude.ai, Desktop or mobile): Settings, Connectors, Add custom connector, paste the URL, then Connect. In Claude Code: claude mcp add --transport http pressfox https://mcp.pressfox.io/mcp, then /mcp to sign in. Other assistants that support remote MCP servers with OAuth take the same URL.

A Pressfox page opens. It names the app, the address it sends you back to, and what the app may do. Pick the workspace and the mode, then approve:

  • Test makes watermarked, unmetered reports, the right start. Editors and up can connect in test mode.
  • Live makes real reports that count toward your plan. Only admins and owners can connect in live mode.

The app gets three permissions: read templates and their contracts (templates:read), read reports (reports:read), and create, link and share reports (reports:write). It cannot delete reports, change templates, see other workspaces or manage members, keys or billing. It never sees an API key: it holds short-lived tokens bound to the Pressfox MCP server, which the server checks on every call. To disconnect, open Settings, Security, Connected AI apps in Pressfox; the app stops working at once. If you leave the workspace or lose the role the mode needs, the connection stops too.

For client developers: the server follows the MCP authorization specification (revision 2026-07-28). Protected resource metadata is at https://mcp.pressfox.io/.well-known/oauth-protected-resource/mcp; the authorization server is https://api.pressfox.io (metadata at /.well-known/oauth-authorization-server). It supports Client ID Metadata Documents and dynamic client registration, the authorization code grant with PKCE (S256 only), resource indicators, iss in the authorization response, rotating refresh tokens and token revocation.

Run it locally with a key

Configure the local server with environment variables only: PRESSFOX_API_KEY (required; a test key makes watermarked, unmetered reports, which is the right start), PRESSFOX_API_URL (https://api.pressfox.io) and PRESSFOX_TIMEOUT_S (default 60). The key is never printed or logged, and neither are payloads. It needs uv; pressfox-mcp --check verifies the URL and key and prints the workspace.

Claude Desktop, claude_desktop_config.json
{
  "mcpServers": {
    "pressfox": {
      "command": "uvx",
      "args": ["--from", "/path/to/pressfox-mcp", "pressfox-mcp"],
      "env": {
        "PRESSFOX_API_KEY": "pf_test_…",
        "PRESSFOX_API_URL": "https://api.pressfox.io"
      }
    }
  }
}
Claude Code
claude mcp add pressfox \
  --env PRESSFOX_API_KEY=pf_test_… \
  --env PRESSFOX_API_URL=https://api.pressfox.io \
  -- uvx --from /path/to/pressfox-mcp pressfox-mcp
ToolCallsReturns
list_templates()GET /api/v1/templatesEach template's key, id, name, whether it is published, and its version numbers (current and retired marked).
list_brands()GET /api/v1/brandsEach brand's key, name and whether it is the default; pass a key to create_report as brand.
get_template_contract(template, version?)GET /api/v1/templates/{key}/contractThe version it describes, the required fields, every field's path and type (image fields marked), and an example payload of the right shape.
create_report(template, payload, template_version?, title?, language?, brand?, access?, markdown=true, wait_seconds=10, idempotency_key?)POST /api/v1/reports (asking for Markdown unless markdown=false), then GET while it rendersreport_id, status, url, pdf_url, markdown_url, pages, title, and error when the render failed.
get_report(report_id, include_markdown=false)GET /api/v1/reports/{id}, and …/markdown with include_markdownThe same report view; with include_markdown, the report's Markdown inline (up to 200,000 characters, the link beyond that), so the assistant can answer questions about it.
list_reports(template?, status?, limit=20)GET /api/v1/reportsReports newest first.
share_report(report_id, emails, duration="7d", message?)POST /api/v1/reports/{id}/sharesThe share and each recipient's delivery status. It sends real email, so the agent is told to confirm the addresses first.

template takes a key, an id or key@N to pin a version, as in versions and pinning. Images go inside the payload as https URLs, exactly as in images; there is no upload tool. When the API refuses a payload, the agent reads the same error envelope you would, field path included, fixes that field and sends again. Agents that write integration code rather than call tools can read /llms.txt, which links this guide as Markdown and everything in one file.