# Pressfox API reference (Markdown)

> Plain-Markdown copy of the API guide for AI agents and tools. The HTML page https://pressfox.io/docs is canonical; if the two disagree, it wins. Error wording: https://pressfox.io/docs/errors. Every route and schema: https://pressfox.io/docs/api. Written 2026-10-10.

Pressfox turns JSON into branded reports. You design a template in the browser and publish it; your system POSTs data to the API; Pressfox validates it against the template's contract, renders it, and returns a hosted link and a PDF.

- 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.
- Field names do not change without a version bump.

## Quickstart

1. **Create a key.** In the app, Settings → API keys, 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 key and your payload. The response carries the hosted link and the PDF.

```http
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": "acme",
  "access": { "kind": "password", "password": "correct-horse-battery" }
}
```

```http
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",
  "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: 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`. 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 receive `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.

> **Try it in the Playground.** In the app, open *Playground* (under Integrate), pick a template and version, and send this same request without writing code: the payload is checked as you type with the same messages as this endpoint, and the result shows the hosted page, the PDF and the request as curl, Node or Python. Playground reports are test reports: watermarked *TEST*, free, never sent to webhooks, kept 24 hours. AI translation and summaries are simulated unless switched on (then at most one unit, and 10 runs per workspace per day).

- **200** with the full object when the render finished in time.
- **202** with `status: "queued"` or `"rendering"` when it did not. Poll `GET /api/v1/reports/{id}` or use a webhook.
- **500** with `status: "failed"` and the reason in `error` when the render failed within the wait. This body is a report, not an error envelope.

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.

| Field | Type | Notes |
|---|---|---|
| `template` | string, required | A 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 stops the old key resolving, while the id never changes. Unknown or archived templates: `404 not_found`; a template with no published version: `422 template_not_published`. Add `@N` to pin version N (`clinic-visit-summary@3`, `tpl_…@3`); without it the report uses the current version. Unknown version: `404 version_not_found`; retired: `410 version_retired`; 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_version` | integer | The version to render, a positive integer; the same as `@N` on `template`. Both with different numbers: `422 invalid_value` on `$.template_version`. Omit both for the current version. |
| `payload` | any JSON, required | Must satisfy the version's contract. Up to 8 MiB (8,388,608 bytes) as canonical JSON; larger: `413 payload_too_large`. Images are sent as links: an https URL (a pre-signed one is fine) or `asset:<id>`; see Images. |
| `title` | string | Up 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. |
| `retain_days` | integer | Keep 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: `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. |
| `language` | string | The report's language, one of `en`, `es`, `pt`, `de`, `fr`; default the template's own (source) language. A language the published version has no translations for receives `422 language_not_available`, which lists the ones it has. See Languages. |
| `translate_from` | string | The language the 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. |
| `options` | object | Values for the switches the template version declares, by name. A key the version does not declare: `422 unknown_option`, and the message lists the names it does declare. |
| `brand` | string | A brand from the app, by key (`acme`) 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`. Unknown: `404 brand_not_found`, naming the value. `brand_id` is the original field and takes the same values; sending both is `422 invalid_value`. |
| `access` | object | `kind` plus its field; see Access policies. Default `{ "kind": "link" }`. |
| `pdf_password` | string | A 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 itself. |
| `formats` | array of strings | Extra outputs: `["markdown"]` adds a Markdown version (see Markdown output). HTML and PDF are always made; values are `"html"`, `"pdf"` and `"markdown"`, anything else is `422 unknown_format`. Omit it to follow the workspace setting *Always include Markdown*; `[]` opts out. |

**Report object**, the same on every reports endpoint:

| Field | Type | Notes |
|---|---|---|
| `report_id` | string | `rpt_…` |
| `title` | string | Fixed at creation: yours, or the template's pattern filled from the payload. |
| `template_id`, `template_key` | string | The template the report was made from; the key as it is now. |
| `template_version` | integer | The version number the report was rendered from, pinned or current at the time. Never changes for a report. |
| `status` | string | `"queued"`, `"rendering"`, `"ready"`, `"failed"` or `"deleted"` |
| `mode` | string | `"live"` or `"test"`, from the key |
| `language` | string | The report's language, fixed at creation: yours, or the template's source language. |
| `url` | string | The 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_url` | string | `url` plus `/pdf`. Present from creation; the file exists once the report is ready. Opens in the browser; add `?download=1` to save it. Named after the title. |
| `markdown_url` | string or null | `url` 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_protected` | boolean | `true` when the PDF opens only with a password. The password is never returned. |
| `pages`, `units` | integer | Page count and metered units, `ceil(pages / 50)`. `0` until ready. |
| `created_at`, `ready_at` | RFC 3339 or null | UTC with microseconds and offset. `ready_at` is null until ready. |
| `retain_until` | RFC 3339 or null | When the report, its links and its PDF end. `null`: no end while the workspace's subscription is active. |
| `error` | string or null | What the render plane refused, when `status` is `"failed"`. |
| `message` | string | A one-line human message for the log. Not for parsing. |

```http
HTTP/1.1 202 Accepted

{ "report_id": "rpt_01j9xkb2w", "status": "rendering", "pages": 0, "units": 0, "ready_at": null, "error": null,
  "url": "https://app.pressfox.io/report/Qh3Lm8Tz2dR5yB7cN9kE1F", "pdf_url": "https://app.pressfox.io/report/Qh3Lm8Tz2dR5yB7cN9kE1F/pdf",
  "message": "Rendering. Poll this report or wait for the webhook.", … }
```

```http
HTTP/1.1 500 Internal Server Error

{ "report_id": "rpt_01j9xkc9f", "status": "failed",
  "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 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 optional: `template` (a template id or key, 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) and `cursor`, the `next_cursor` of the previous page. `next_cursor` is null on the last page. A cursor this API did not issue: `422 invalid_value`.

### 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 already deleted. `GET` still answers with `status: "deleted"`. Metered units are not refunded.

### POST /api/v1/reports/{id}/links

For a report whose access kind is `signed`: body `{ "expires_in": seconds }` (60 seconds to 30 days, default 7 days); answers `{ url, pdf_url, markdown_url, expires_at }` with fresh signed links (`markdown_url` is null when the report has no Markdown version).

## Access policies

`access.kind` decides who can open the hosted link. Set at creation; not editable through the API (the app can revoke a link, which issues a new token).

| Kind | Fields | Plans | The hosted page |
|---|---|---|---|
| `link` | none | all | Anyone with the link. The default. |
| `password` | `password`, at least 8 characters | Standard and above | Asks 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. |
| `expiry` | `expires_at`, ISO 8601 time in the future | Starter and above | Answers 410 after the time passes. |
| `signed` | none | Pro and above | Opens 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` mints fresh ones. |
| `private` | none | Pro and above | Opens only for a signed-in member of the workspace; everyone else sees 404. |

A kind outside this list: `422 invalid_value` on `$.access.kind`. Missing `password` or `expires_at`: `422 missing_field`. `expiry` on Free, or `password` on Free or Starter: `402 access_policy_not_included`; `signed` or `private` below Pro: `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. 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.

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. Default duration `7d`; `message` is plain text, up to 1000 characters. 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>"`.

- 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. More than 50: `422 too_many_recipients`. A report that is not ready: `422 report_not_ready`.
- 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 (`id`, `duration`, `expires_at`, `state`, `message`) and its `recipients` (`email`, `status` queued/sent/failed, open counts). Opens are never counted for bots or link previews, and the email carries no tracking pixel.

## Languages, translation and summaries

A report is in one of five languages: English (`en`), Spanish (`es`), Portuguese (`pt`), German (`de`), 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: `422 language_not_available`, listing 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 sent. 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 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: `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: `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. Available on every plan; it 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: `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).

**Getting it.**

- `markdown_url` in the report object (also in the `report.ready` webhook and `POST /links`): the hosted file, `…/report/<token>/md`, under the report's access policy exactly like `/pdf` (link, password, expiry, signed, private, the PDF's QR link and share links). It answers `text/markdown; charset=utf-8`, 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*. Downloads are counted in the report's analytics, like PDF downloads, on their own counter.
- `GET /api/v1/reports/{id}/markdown` with an API key returns the text itself: **200** `text/markdown; charset=utf-8`. A report without a Markdown version: `404 markdown_not_requested`. A report still rendering, or one that failed: `409 report_not_ready`.

**What it looks like.**

```markdown
---
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, in 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 and pipes in table cells too; nothing in a payload can inject HTML.

It 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 it can be found after its data is gone: `client: Northwind Shipping`, `reference: SA-2026-014`. At most 10 per report; 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, so changing them never changes the report. They are stored unencrypted (like the title) so they can be searched: use them for names and references, not sensitive data. A zero-retention report takes none (`422 zero_retention_unsupported`).

Where they come from, the later winning: the template's label bindings (set in the designer's template settings, frozen with each version), filled from the payload with the same `{path}` placeholders as the report title (`{customer.name}`, `SA-{agreement.reference}`; text without placeholders is a constant; a binding that finds nothing sets no label); the request's `labels` object on `POST /api/v1/reports`; later changes.

- Filter: `GET /api/v1/reports?label.client=Northwind%20Shipping` keeps reports carrying that label, matched exactly; several keys are ANDed, one value per key.
- Change: `PATCH /api/v1/reports/{id}/labels` with `{ "labels": { "status": "signed", "owner": null } }`: a string sets, `null` removes, others stay. Answers the report object.
- Labels appear in every report body (`labels`), in webhook `data` and in the workspace export.

## Corrections

A report is frozen once made. To fix one, create a new report that corrects it: `POST /api/v1/reports` with the corrected payload plus:

| Field | Notes |
|---|---|
| `corrects` | The `rpt_…` id of the report it corrects (`replaces` is the same field). A live report of your workspace, ready, not already replaced. Test keys cannot correct. |
| `correction_reason` | Required with `corrects`, at most 300 characters. Shown on the original's page and in the share email. |
| `old_link` | `"keep"` (default): the original's link keeps working with a banner linking to the newest version. `"off"`: every link to the original (page, PDF, Markdown, share and QR links) answers `410` with that link. |
| `reshare` | Boolean. Email the correction to everyone the original was shared with. Default: on when it was shared. |
| `correction_notice` | Boolean, default on: print a line at the top of the correction's first page naming the report it replaces and why. |

The original is replaced only when the correction is ready; a failed correction replaces nothing. The correction starts with the original's labels (template bindings and request `labels` win). Errors: `409 already_replaced` (names the current version; correct that one), `409 correction_in_progress` (another correction of it is still rendering; wait for it, then correct that one), `409 report_not_ready`, `422` for an unknown, test or deleted report. Report bodies carry `replaces`, `replaced_by`, `replaced_at`, `replace_kind` (`"correction"` or `"completion"`) and `correction_reason`; `GET /api/v1/reports?replaced=false` lists current versions only. Webhooks: the correction's `report.ready` carries `replaces`; the original gets `report.superseded` with `replaced_by` and `correction_reason`. A correction within 7 days of its original, on the same template, uses no units, up to 10% of the month's live reports (counted over the last 31 days). Pro and Enterprise workspaces can keep each live report's data encrypted for 7 or 30 days (Settings → Reports, "Keep report data for corrections") to correct from the dashboard; otherwise the API caller sends the corrected data itself.

## 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. 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.

## Signing

Pressfox does not collect signatures. Download the PDF (or `GET /api/v1/reports/{id}/pdf`) and send it with your own signing provider (DocuSign, Yousign or any other); keep the signed copy where your records are. Guide: https://pressfox.io/docs/signing

## 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 never published. `versions` lists its published versions newest first (at most 50) as `{ number, current, retired, created_at }`. `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; null and `[]` before the first publish. `limit` 1 to 200, default 50. Archived templates are not listed.

```json
{
  "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" } ] },
    { "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.

```json
{ "items": [ { "id": "brd_01j9xk3n1", "key": "acme", "name": "Acme", "is_default": true } ] }
```

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

Returns the contract of the current published version, or of version N when the path ends in `@N` (`/api/v1/templates/clinic-visit-summary@3/contract`) or the query says `?version=N` (both with different numbers: `422 invalid_value` on `version`). The `X-Pressfox-Template-Version` header names the version returned. `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. Image fields are strings with `"description": "asset id"`. Generate types from `schema`, or validate before sending. A template with no published version: `422 template_not_published`; an unknown version `404 version_not_found`; a retired one `410 version_retired`.

```json
{
  "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…). A version never changes after it is published: its layout, contract, title pattern, options and 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 before. 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; ids work too (`tpl_…@3`).
- **Versions run side by side.** One system can send `@3` while another sends `@4` or nothing, 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` is version 3's schema; `GET /api/v1/templates` lists each template's version numbers.
- **Retire.** In the app, a version 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; 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 number is minted.

Recommendation: pin in production (`key@N`), leave the version out while building against a test key, and move the pin when you have updated your payload to a new version's contract.

```http
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 } ] } }
```

```http
HTTP/1.1 410 Gone

{ "error": { "type": "gone", "code": "version_retired", "field": "$.template",
  "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.", … } }
```

## 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 keeps working after the link expires; the copy 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 stays text.
- **What.** https only. JPEG, PNG or WebP, decided by the file's bytes. Up to 20 MiB per image, 500 distinct images and 1 GiB in all per report. Up to three redirects; private, loopback and cloud-metadata addresses are never contacted.
- **When.** URLs are checked for shape at create (`422` naming the field). The download happens in the render: up to 16 at a time, the same URL fetched once, 429/5xx/timeouts retried twice. A report with more than 8 images goes to the large queue, so expect `202`.
- **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.

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. Two body shapes: JSON `{ content_type, data, name? }` with `data` base64 (a `data:…;base64,` prefix is accepted), or `multipart/form-data` with a `file` part and optional `name`. Accepted: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/svg+xml`; bytes must match the declared type; an SVG may carry no script or event handler. Up to 10 MiB per file. Deduplicated by content hash: new bytes answer **201**, known bytes **200** with the existing asset. Stored bytes count against the plan's allowance (`402 asset_bytes_limit`).

```http
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" }
```

```sh
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 it as `asset:<id>` wherever a string appears in the payload: `{ "template": "vessel-inspection", "payload": { "photos": [ { "image": "asset:ast_01j9xkd8m3", "caption": "Forward hull, port side" } ] } }`. An id not in your workspace is left unresolved and the image block renders its empty placeholder (the report still succeeds), so check ids before you send. `GET /api/v1/assets/{id}` returns the same object, with or without the `asset:` prefix.

## Errors

Every error uses one envelope. `type` is the broad class (`validation_error`, `invalid_request`, `authentication_error`, `permission_error`, `not_found`, `conflict`, `gone`, `rate_limited`, `limit_exceeded`), `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, `docs_url` links to the catalogue. Keys with nothing to say are left out. Quote `request_id` to support.

```http
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: `type: "limit_exceeded"`, HTTP **402**, a code naming 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.

| Code | Status | Meaning |
|---|---|---|
| `missing_field` | 422 | A required field is absent. |
| `wrong_type` | 422 | A field has the wrong JSON type. |
| `empty_array` | 422 | A list that must have items is empty. |
| `invalid_value` | 422 | A value fails a format, enum or range rule, or a header or query value is not right. Also a malformed `@N` on `template`, or a `template_version` that differs from it. |
| `unknown_option` | 422 | `options` names a switch the version does not declare. |
| `unknown_format` | 422 | `formats` holds a value other than `html`, `pdf` or `markdown`. |
| `too_many_images` | 422 | The payload links more than 500 distinct images. |
| `template_not_published` | 422 | The template has no published version. |
| `language_not_available` | 422 | `language` names one the template has no translations for; the message lists the ones it has. |
| `nothing_to_translate` | 422 | `translate_from` was sent but the template marks no field for translation. |
| `template_contract_invalid` | 422 | The template's contract cannot be compiled; publish it again. |
| `unsupported_asset_type`, `asset_too_large` | 422 | The upload is not an accepted image type, or is over 10 MiB. |
| `not_json` | 400 | The body is not JSON. `POST /api/v1/assets` answers it with 422. |
| `invalid_email`, `too_many_recipients`, `report_not_ready`, `report_expired` | 422 | Share refusals. |
| `markdown_not_requested` | 404 | `GET /api/v1/reports/{id}/markdown` on a report made without a Markdown version. |
| `report_not_ready` | 409 | `GET /api/v1/reports/{id}/markdown` while the report renders, or after it failed. |
| `invalid_request` | 400 | The request is malformed in a way no other code covers. |
| `method_not_allowed` | 405 | The path exists but does not take this method; the `Allow` header lists the ones it takes. |
| `unauthorized` | 401 | Missing or invalid API key. |
| `forbidden` | 403 | Not allowed for this key. |
| `ai_off` | 403 | AI features (here `translate_from`) are off for a workspace whose industry is health. |
| `not_found` | 404 | No such template, brand, report or asset in this workspace and mode. |
| `version_not_found` | 404 | The template has no version with the number you pinned; the message lists the ones it has. |
| `version_retired` | 410 | The version you pinned is retired; the message names the current one to move to. |
| `conflict` | 409 | Idempotency key reused with a different request, or still in flight. |
| `payload_too_large` | 413 | The payload is over 8 MiB. |
| `internal_error` | 500 | Something broke on our side and nothing was changed; send it again, and quote `request_id` if it keeps failing. |
| `storage_unavailable` | 503 | The workspace's storage did not answer; send it again in a few minutes. |
| `rate_limited` | 429 | Too many requests this minute from the key or the workspace; wait `Retry-After` seconds. See Rate limits below. |
| `share_limit` | 429 | Daily share limit; see `Retry-After`. |
| `playground_rate_limited`, `playground_ai_daily_limit` | 429 | The app's Playground only: 60 requests per member per hour; 10 runs with AI per workspace per day (`resets_at`, `Retry-After`). Switch AI off to simulate it, or use the API. |
| `live_units_limit`, `report_storage_limit`, `templates_limit`, `brands_limit`, `asset_bytes_limit` | 402 | A plan limit. `upgrade_plan` names the plan that lifts it. |
| `webhooks_not_included`, `signed_urls_not_included`, `access_policy_not_included`, `pdf_password_not_included`, `custom_domain_not_included`, `tenant_subdomain_not_included`, `translated_units_per_month_not_included`, `addons_not_included` | 402 | A feature the plan does not include. |
| `translated_units_limit` | 402 | The month's translated report units are used up; send without `translate_from`. |

Besides `rate_limited` and `share_limit`, the only other 429 is the password gate on a hosted report page. Full wording and fixes: https://pressfox.io/docs/errors.

### Why a report failed

A report accepted with 202 can still fail while it renders: `status` becomes `"failed"`, a `report.failed` webhook is sent, no units are used, and the reason is in the report's `error`. Most reports carry a sentence there, sometimes starting with a code (`too-many-pages: document lays out to about 7 pages; the cap is 3`). A zero-retention report carries only the code and, when there is one, the path (`image_fetch_failed (at $.photos[2].url)`); its data is deleted at once, so send it again.

| Code | What happened | What to do |
|---|---|---|
| `render_timeout` | The render did not finish in time. | Send it again; if it repeats, fewer or smaller photos, fewer pages. |
| `render_failed` | Failed after its retries for another reason (the only code a zero-retention report records then). | Send it again; if it repeats, contact support with the report id. |
| `pdf_finish_failed` | The PDF's last step (properties, fast-web-view layout) failed; nothing issued. | Send it again; if it repeats, contact support. |
| `pdf_protection_failed` | The PDF could not be protected with `pdf_password`; it is never issued without it. | Send it again; to issue it now, send it without `pdf_password` and protect the link (`access.kind: "password"`). |
| `too-many-pages` | Longer than the plan's page cap for a live report (covers and photo appendix not counted). | Send less data or move up a plan; a test report renders at full length. |
| `body-too-large`, `json-size`, `json-depth`, `json-forbidden-key` | The data and photos are too large, nested too deeply, or hold a key such as `__proto__`. | Fewer or smaller photos; smaller, flatter data. |
| `pdf-too-large` | The finished PDF would be over the limit. | Fewer or smaller photos, or split the report. |
| `image_fetch_failed` | An image linked by URL could not be fetched; the error names its path. | Make the URL public, or upload it as an asset. |
| `images_too_slow` | Fetching the linked images took too long. | Fewer or smaller images, or upload them as assets. |
| `ai_failed` | `translate_from` text could not be translated reliably. | Send it again, or without `translate_from`. |
| `live_units_limit` | The month's units ran out while it rendered. | As for the request refusal above. |
| `cancelled` | Its batch was cancelled before it rendered. | Send it again if you still need it. |

Template faults: `unknown-block`, `invalid-prop`, `invalid-context`, `unknown-party`, `unknown-option`, `invalid-template`, `chrome-block`, `cover-block`, `cover-placement`, `fixed-block`, `expression-parse`, `expression-runtime`, `expression-timeout`, `repeat-depth`, `repeat-limit`, `block-limit` name a fault in the published template version, not in your data. The error names the block or path; fix it in the designer, publish a new version, and send the report again.

## 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; hosted report pages, PDFs and webhook deliveries are not API requests.

| Window | Per API key | Per workspace (all keys) |
|---|---|---|
| All requests | 600 a minute | 1,200 a minute |
| Report creation (`POST /api/v1/reports`) | 120 a minute | 240 a minute |

A report creation counts in both rows. Every counted response carries `RateLimit-Limit` (the window's size), `RateLimit-Remaining` (requests left in it) and `RateLimit-Reset` (seconds until it resets), for the window closest to its limit. 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.

## Webhooks

Paid workspaces register HTTPS endpoints in the app (Settings → Webhooks): 1 on Starter, up to 10 on Standard, as many as needed on Pro and Enterprise; on Free the app answers `402 webhooks_not_included`. Events: `report.ready`, `report.failed`, `report.superseded` (a correction replaced the report), `report.completed` and `batch.completed`. Payloads are thin: ids, status and URLs, never report data. Events for reports made with a test key carry `"mode": "test"` (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.

| Field | Notes |
|---|---|
| `type` | The event, such as `"report.ready"` |
| `id` | Event id `evt_…`, same as the `webhook-id` header. Deduplicate on it; a redelivery carries the same id. |
| `created_at` | When 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`, `correction_reason` on `report.superseded`. Fetch the report for pages, units and the error text. |

Signed in the Standard Webhooks format. Headers: `webhook-id`, `webhook-timestamp` (Unix seconds) and `webhook-signature` = `v1,<base64 HMAC-SHA256>`. The key is the endpoint secret shown once at creation, `whsec_` + base64: strip the prefix and base64-decode it. The signed string is `id` + `.` + `timestamp` + `.` + the raw body bytes; verify against the bytes received, not a re-serialised object. Reject timestamps more than five minutes from your clock.

```http
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","template_id":"tpl_01j9xk2m8","template_key":"clinic-visit-summary","template_version":4,"mode":"live","source":"api"}}
```

```javascript
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;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const signed = Buffer.concat([Buffer.from(`${id}.${ts}.`), rawBody]);
  const expected = createHmac("sha256", key).update(signed).digest();
  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; redirects are not followed and count as a failure. 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 failing without a single success for 3 days is disabled: retries stop and new events are not sent. The app shows when and why. Fix the receiver, then choose Enable again on the endpoint (owners and admins): it keeps its URL and secret, its failure count restarts from zero and the 3 days start again. Events missed while it was disabled are not sent automatically; the app shows every delivery and can 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 Idempotency-Key from a test and a live key is a conflict, not a replay.

**Test reports are kept 24 hours** on every plan, from a test key or from the app's Playground: an hourly sweep deletes them, and their links, QR codes and shares stop with them (the hosted page says *Available until*). Live reports are kept as the next section says.

## 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 (`422 retain_days_too_long` above it). 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 (Claude, ChatGPT or any MCP client) use this API. Two ways, same tools, both in preview (write to hello@pressfox.io to have them turned on):

- **By URL, with OAuth:** add `https://mcp.pressfox.io/mcp` to the assistant (Claude: Add custom connector; Claude Code: `claude mcp add --transport http pressfox https://mcp.pressfox.io/mcp`). The user signs in to Pressfox and approves the app for one workspace and a mode (test: editors and up; live: admins and owners). The app gets `templates:read`, `reports:read` and `reports:write`; it cannot delete reports, change templates or see other workspaces, and never sees an API key. Disconnect in Settings, Security, Connected AI apps. MCP authorization spec revision 2026-07-28: protected resource metadata at `https://mcp.pressfox.io/.well-known/oauth-protected-resource/mcp`, authorization server `https://api.pressfox.io` (Client ID Metadata Documents or dynamic registration, authorization code with PKCE S256, resource indicators, rotating refresh tokens, revocation).
- **Locally, over stdio, with a key:** environment `PRESSFOX_API_KEY` (required), `PRESSFOX_API_URL` (`https://api.pressfox.io`), `PRESSFOX_TIMEOUT_S` (default 60).

Tools: `list_templates()`, `list_brands()`, `get_template_contract(template, version?)`, `create_report(template, payload, template_version?, title?, language?, brand?, access?, markdown=true, wait_seconds=10, idempotency_key?)`, `get_report(report_id, include_markdown=false)`, `list_reports(template?, status?, limit=20)`, `share_report(report_id, emails, duration="7d", message?)`. `template` may be `key@N` to pin a version; `list_templates` shows each template's version numbers. `create_report` asks for a Markdown version by default, and `get_report(…, include_markdown=true)` returns it inline (up to 200,000 characters, with the link beyond that), so the assistant can answer questions about the report. Images go in the payload as https URLs. Setup and client configuration: https://pressfox.io/docs#mcp and https://pressfox.io/llms-full.txt.
