chart() now draws scatter plots with type "scatter" — a point per row on two numeric value axes, with optional x-min/x-max bounds and generated alt text.
The chart() builder now supports type: "scatter". Give x a numeric row field and y one or more value fields, and every row becomes a filled point on two value axes — price vs volume, response time vs load, score vs hours. It is the first chart type whose x axis is a value axis rather than a category list.
chart({
type: "scatter",
data: "{{samples}}",
x: "load",
y: { key: "ms", label: "Response time" },
width: 480,
height: 220,
"x-min": 0,
"x-max": 100,
});
Both axes auto-fit the plotted points, so a scatter plot never gets the forced zero that bars use. x-min / x-max bound the x axis the same way y-min / y-max bound the y axis — and they are a build-time error on chart types whose x axis is categorical. Multiple y series share the same x field and are drawn in their palette colours.
Rows whose x or y is missing or non-numeric are skipped with an invalid-chart-data warning, never plotted at zero. The generated alt text lists (x, y) pairs per series, capped at 60 points per series, so screen readers get the same relationship the chart shows.
Available through POST /api/v1/preview and POST /api/v1/render. Full details in pdf-template-author.md §Charts.
chart() can now draw bars and a line in the same chart, with an optional right-hand axis for the rate that needs its own scale — revenue bars plus a margin-percent line.
The most common business-report chart is a combo: bars for an amount, a line for a rate on its own scale. chart() now supports both. Give a y series type: "line" and axis: "right" and it draws as a line measured against a right-hand value axis, while the other series stay bars on the left.
chart({
type: "bar",
data: "{{monthly}}",
x: "month",
y: [
{ key: "revenue", label: "Revenue" },
{ key: "margin", label: "Margin %", type: "line", axis: "right" },
],
"y2-title": "Margin %",
"y2-min": 0,
"y2-max": 40,
width: 480,
height: 220,
});
Bar series always draw first — grouped, or stacked with stacked: true — and lines draw on top. The right axis appears only when a series asks for it, computes its own nice ticks from those series' values, and is titled and bounded by the new y2-title, y2-min and y2-max options. The legend and generated alt text mark right-axis series with "(right axis)", and the combined chart reads "Combined bar and line chart" for screen readers. Setting any y2-* option without a right-axis series is an error, as is a per-series type or axis on a pie chart, and on a horizontal bar chart (which draws bars only).
The stacked per-category total in the alt text now counts bar series only, since a line on the right axis is a different scale. Full option list: pdf-template-author.md §Charts.
chart() gains horizontal: true, drawing bars left-to-right with categories down the left axis — the form to use for ranked lists and long category names.
chart() can now draw horizontal bars: pass horizontal: true on a type: "bar" chart and the bars run left-to-right, with the categories down the left axis in your data's order (first row at the top). It is the form to use for ranked lists and long category names — top customers, spend by department, survey answers — which vertical bars have to thin out or truncate.
chart({
type: "bar",
data: "{{spend}}",
x: "department",
y: "amount",
horizontal: true,
width: 480,
height: 220,
});
The option names keep their meaning rather than their position: x-title still labels the category axis (drawn down the left side) and y-title the value axis (under the numbers), and y-min / y-max still bound the value axis. Category labels are right-aligned against the axis and truncated when they need more than 35% of the chart width, and a chart with so many categories that the bars would be thinner than 2pt — or that no row is tall enough for its label — draws the "Chart too small" placeholder with a warning instead of unreadable slivers.
It combines with stacked: true for horizontal stacked bars, and generated alt text is prefixed "Horizontal bar chart" / "Horizontal stacked bar chart" so screen-reader users hear the orientation. Full option list: pdf-template-author.md §Charts.
chart() gains stacked: true to stack series within each category, with negative values stacking below zero, an axis fitted to the stack totals, and per-category totals in the generated alt text.
chart() can now draw stacked bars: pass stacked: true on a type: "bar" chart and the series stack within each category instead of sitting side by side. It is the usual way to show a total broken down by part — revenue by product per month, usage by category per day.
chart({
type: "bar",
data: "{{revenue}}",
x: "month",
y: [
{ key: "widgets", label: "Widgets" },
{ key: "gadgets", label: "Gadgets" },
],
stacked: true,
width: 480,
height: 220,
});
The first y series sits at the bottom of each stack. Positive values stack upward from zero and negative values downward, so a category that loses money on one product still reads correctly, and the value axis fits the stacked totals rather than the biggest single value. Generated alt text lists the series values and the per-category total, e.g. "Jan: Widgets 3,000, Gadgets 2,000 (total 5,000)".
stacked applies to bar charts only — line and pie reject it, and the render returns an invalid-chart-data warning if a raw document sets it there. Full option list: pdf-template-author.md §Charts.
Decision branches now leave a flowchart's diamond from separate points, merge nodes sit centred between their parents, and every Mermaid diagram is centred in the page column.
Mermaid flowcharts got a visual pass in Markdown PDFs (and anywhere Markdown is rendered — the API, CLI and web app). Decision diamonds are the visible change: the "Yes" and "No" branches used to leave from the same spot underneath the diamond on a short shared bar, which read as a fork rather than two distinct exits. Each branch now leaves from the vertex its target points at — the left or right point when the target sits to that side of the diamond's centre, the forward point only when it is centred — and each vertex carries at most one edge, so the branches fan out to opposite sides instead of stacking up under the diamond.
Nodes with more than one incoming edge are placed centred between their parents now. In the classic Start → Decision → Process A / Process B → End shape, End sits midway under the two processes and both edges into it run short and direct, instead of one looping all the way across the diagram.
Mermaid figures are also centred horizontally in the content column when they are narrower than it. Diagrams that already fill the column are unaffected, and nothing is scaled up — a small diagram stays its natural size, just centred rather than hugging the left margin.
Alongside the decision and placement work, flowchart labels sit properly centred on the text baseline, edges use sharp orthogonal corners, edge labels sit on their routed line over a small knock-out so the line does not strike through them, and nodes use a higher-contrast neutral palette that stays readable in greyscale print. Sequence, class, state, ER, C4, requirement, pie and the other diagram types keep their existing look — their content is unchanged, and they pick up the centring too.
Re-render any document with a ```mermaid fence to see the new output.
A base64-inlined PNG used to eat the whole 200KB markdown budget. Inline images are now excluded from the text cap and bounded by the same rules as remote images — 5MB each, 20 per document — with a 15MB request body cap.
POST /api/v1/md and POST /api/v1/markdown used to count every character of your markdown, including data: image payloads. Inlining a single 150KB PNG as base64 is roughly 200KB of text, so one local image could consume the entire input budget.
The 200KB cap now measures the text only: each inline data: image URL is excluded from the count. Inline images are bounded by the same rules as remote images — 5MB decoded per image and 20 images per document — and the whole request body is capped at 15MB.
Nothing changes for text-only documents, and remote images are untouched. The /md/validate pre-flight, the MCP render_markdown and validate_markdown tools, the web editor and the lint page all apply the same split, as does the anonymous /md path (still capped at 20 pages and the same per-IP rate limits).
A monthly KPI report, a SaaS usage statement and a certificate with a vector seal join the template library — the first library templates to use chart() and vector().
Three new templates are live in the curated library. They are the first library entries to use the chart() and vector() builders, so every visual is drawn as native PDF vector graphics — sharp at any zoom, no images to host.
- Monthly KPI Report (
report-kpi-monthly) — a one-page A4 report with a four-tile stat strip (revenue, gross margin, active customers, churn, each with a vs-last-month delta), a twelve-month revenue line chart, a grouped bar chart of revenue by product line, a top-customers table with totals, and a short commentary panel.
- SaaS Usage Statement (
statement-usage) — an invoice-style statement with vendor and account header, billing period, a daily-usage bar chart covering the period, a line-item charges table with totals aligned to the same columns, payment details and a total-due card.
- Certificate with Vector Seal (
certificate-seal) — a landscape A4 certificate of completion with a gold double border and a circular rosette seal drawn as vector artwork, presentation typography, and two signature lines.
All three charts read their rows from the render's data via {{path}} references, so the same template draws this month's numbers on every call. Each chart is tagged as a figure and gets generated alt text from its values; the seal carries an authored description.
Use them like any library template — pass the template id to /api/v1/render, open them from the template library in the app, or copy the DSL source as a starting point. "View source" on each opens the same DSL the library runs.
New chart() builder draws bar, line and pie charts from your render data as native PDF vector graphics, with alt text generated from the values.
Templates can now draw charts. The new chart() DSL builder takes rows from your data and renders a bar, line or pie chart as native PDF vector graphics. It stays sharp at any zoom and needs no image hosting. Until now, charts needed Markdown with a Mermaid block or hand-drawn vector() shapes.
chart({
type: "bar", // "bar" | "line" | "pie"
data: "{{monthly}}",
x: "month",
y: [
{ key: "revenue", label: "Revenue" },
{ key: "cost", label: "Cost" },
],
width: 480,
height: 220,
title: "Revenue vs cost",
});
data is a {{template}} reference, so the chart is drawn from each render's data: the same template produces this month's chart on every call to /render. Several y fields give grouped bars or several lines, with a legend. Values can be numbers or formatted strings such as "$1,200", and a missing value leaves a gap instead of failing the render.
Every chart is tagged as a figure for PDF/UA. If you don't pass alt, the alt text is generated from the data: chart type, title, series and every plotted value. A screen reader then reads out the numbers the chart shows.
If the data path doesn't resolve, the chart shows "No data" and /preview returns a template warning that names the chart. Full option list: pdf-template-author.md §Charts.
New Mermaid diagram types you can embed in Markdown → PDF.
Markdown documents (POST /api/v1/md, the CLI, the blog) keep gaining Mermaid
diagram types, each rendered as native PDF vector graphics — sharp at any zoom:
- Timeline — chronological lanes of time periods with stacked event cards, optional section bands, and a cycled colour palette. Ideal for roadmaps, project histories, and release recaps.
- Quadrant chart — a 2×2 matrix split by midlines into four tinted cells with quadrant labels and named points plotted in unit-square coordinates. Common in effort/impact and risk/reward prioritisation decks.
- Sankey diagram (
sankey-beta) — weighted flow diagrams that show how quantities move between nodes across layers. Ideal for budget breakdowns, energy flows, and resource allocation: each ribbon's thickness is proportional to the flow value, and nodes are automatically layered by longest-path depth.
- Kanban board (
kanban) — columns of task cards parsed from indentation, with word-wrapped labels, @{ assigned, ticket, priority } card metadata, and priority corner tags. Handy for sprint boards, launch checklists, and status snapshots.
- Requirement diagram (
requirementDiagram) — SysML-style requirement and element boxes (requirement, functionalRequirement, performanceRequirement, interfaceRequirement, physicalRequirement, designConstraint, element) connected by typed relations (contains, copies, derives, satisfies, verifies, refines, traces). Handy for traceability views in specs and design docs.
- Treemap (
treemap-beta) — nested rectangles sized by value for part-of-whole views of hierarchical data. Ideal for budget breakdowns, disk usage, and portfolio compositions: cell areas are proportional to values, branches sum bottom-up, and labels degrade gracefully on tiny cells.
- Mindmap (
mindmap) — indentation-defined hierarchies rendered as a branch-coloured node-link tree, with square, rounded, circle, and hexagon node shapes (bang and cloud nodes are drawn as approximations: a double ring and a pill). Handy for brainstorms, site maps, and plan breakdowns.
- C4 architecture diagrams (
C4Context / C4Container) — Person/System/Container/Component cards grouped in labelled boundaries and joined by labelled relationships, for system-context and container overviews in design docs.
- User journey (
journey) — sectioned tasks with 1–5 satisfaction scores plotted on a mood-coloured lane, with per-actor colour dots and a legend. Handy for day-in-the-life maps, onboarding walkthroughs, and experience reviews.
- Radar chart (
radar-beta) — spider charts that plot multiple series against shared radial axes. Ideal for skill assessments, product comparisons, and performance reviews: each series is a closed polygon in its own colour with a legend, over concentric circle or polygon graticules with an explicit or data-derived scale.
- Block diagram (
block-beta) — author-positioned grids for system overviews where automatic layout would move shapes somewhere unwanted. Blocks flow left-to-right across a fixed column count with wrapping, id:N column spans, space gaps, nestable block:label ... end sub-grids, and arrows between blocks. Ideal for software architectures, network layouts, and process flows.
Wallet-paid /enhance calls now validate the document before the USDC payment settles — already-compliant, unenhanceable, and previously-failed PDFs get a free 400 instead of a paid failure.
Wallet-paid (x402) calls to POST /api/v1/enhance now run the same
validate-first checks before the USDC payment settles that API-key
callers have always had after authenticating.
Blocked documents are now free. If your PDF already meets PDF/A-2A +
PDF/UA-1, can't be fixed by either pipeline, requests a mode that can't
fix it, or was already determined unenhanceable by the current pipeline
version, you get a 400 (or a 200 already-compliant) with the reason
— and no payment is taken. Previously a wallet caller paid first and
learned this after settlement, relying on the automatic refund.
Retry loops can't bill you repeatedly. A document hash we already
failed terminally still fails fast and free on resubmission, across all
wallets, until the enhance pipeline itself gets a meaningful upgrade —
at which point every hash gets one fresh billable shot automatically.
Nothing changes for credit-billed (API key) callers: same decisions,
same shapes, now applied uniformly before queueing. The 10-minute
automatic refund sweep still backstops anything that fails after a
payment settles. See the API reference for the full
POST /api/v1/enhance contract.
Claim the wallets you pay with via a personal_sign signature and their USDC renders show up alongside your credit renders in usage history.
If you pay per-render from an x402 wallet and also hold credits, your history used to live in two places. The new linked wallets page closes that gap: paste an address, sign a challenge message with your wallet, and the wallet is claimed to your account. Linking only proves you control the address — it never authorises a payment.
Usage history now merges both ledgers newest-first, with a Credit / USDC badge on every row and a USDC-spent total across the rows shown. Unlinking keeps history (re-link anytime to restore the same view), and a wallet can only ever be linked to one account.
Programmatic callers get four endpoints under /api/v1/wallet/link: POST /challenge to mint a 10-minute single-use challenge, POST /verify with { address, signature } to claim, GET to list, and DELETE /:addr to unlink. GET /api/v1/usage returns the merged ledger with the same source badge per item. All five need the same session or API key as every other /api/v1/* endpoint.
A new migration converter takes pasted react-pdf JSX and returns validated makesPDF builder DSL with a live PDF preview.
The react-pdf comparison argued the "why switch" — the new migration converter is the "how switch". Paste react-pdf JSX, get back builder DSL (doc(page(col(...))) with your styles carried across as kebab-case properties and dynamic values as {{mustache}} bindings), then render a live preview through /api/v1/preview without leaving the page.
Every conversion is executed and catalog-validated before it is shown: output with zero catalog errors is returned as usable DSL, anything else gets exactly one automatic correction pass, and a snippet that still fails is reported as an error rather than displayed. The 50KB-per-paste cap and 20-conversions-per-hour per-IP limit are in the response contract, and the full DSL reference lives in pdf-template-author.md.
Programmatic callers can hit POST /api/v1/ai/migrate directly with { from: "react-pdf", source } — it needs the same session or API key as every other /api/v1/* endpoint and returns { dsl, sampleData, warnings, retried, signupUrl }. The ?from= parameter is ready for future sources; react-pdf is the only converter today.
Starter now has an annual option - $100 a year, the price of 10 months, with the same 1,500 credits every month.
Starter can now be paid yearly. On the pricing page, switch the Starter card from Monthly to Annual and you pay $100 for the year instead of $120 - two months free. If you're already on monthly Starter, open Manage subscription in Settings -> Billing to switch.
Nothing else about the plan changes. You still get 1,500 credits every month, unbranded PDFs and priority support, and Growth credits still stack on top when you need more.
The pricing page also has a new section for organisations. Every listed price can be bought online without talking to anyone. If you want volume discounts or custom terms, email us and we reply within 2 business days.
Organisations can now prepay makesPDF credits by invoice with a PO number, without talking to anyone.
If your organisation pays suppliers by invoice, you no longer need a card. In Settings -> Billing, enter your organisation's name, a billing email, an optional PO number and an amount from $100 to $25,000. We email the invoice straight away, due in 30 days, and you pay it online.
Credits are added to your account when the invoice is paid, at the same rate as Growth credits: 125 credits per dollar. They never expire.
Holding Growth credits now also removes the makespdf.com link from your PDFs, including on the Free and Hobbyist plans, for as long as you have credits in your balance.
Agents connected over MCP can now render markdown to PDF directly, instead of validating it in MCP and dropping out to curl to produce the file.
When render_template arrived, we said the markdown endpoint would stay REST-only — different input shape, separate decision. That decision never got made, and the gap it left was the same one render_template had been added to fix: an agent connected over MCP could check a markdown document with validate_markdown, then had to leave MCP entirely and run a curl /api/v1/md to get the actual PDF.
What's new
render_markdown is now an MCP tool. Pass GitHub-flavored markdown, get back a real PDF as a base64 resource on the response, PDF/A-2A and PDF/UA-1 dual-compliant like every other render.
It takes the same options as POST /api/v1/md — pageSize, fontFamily, fontSize, margins, theme, splitMode, and the document metadata fields (title, author, subject, keywords). Anything you leave out falls back to YAML frontmatter in the markdown itself, then to the defaults.
Billing matches the REST endpoint exactly: 1 credit per 10 pages, free-plan output watermarked, paid plans clean, and the render lands in your /settings/renders history. The cost comes back in the response metadata — creditsDeducted, creditsRemaining, pageCount, artifactId — so an agent can tell you what it just spent.
The recommended loop
validate_markdown — free, catches heading-hierarchy and alt-text problems.
- Fix whatever it reports.
render_markdown — produces the deliverable.
There's no free markdown preview step, and that's deliberate rather than an omission. The DSL path has render_dsl_preview because templates need layout iteration; markdown doesn't. validate_markdown is the free pre-flight, and it catches the accessibility problems that are actually worth catching before you pay to render.
Nothing to reconfigure
Existing MCP clients pick the tool up the next time they list tools. The full table is at /docs/mcp.
Every rendering endpoint now accepts author, subject and keywords, and writes them to both the PDF Info dictionary and the XMP packet. Accessibility checkers were flagging their absence.
Until now the only document metadata you could set was the title. Accessibility checkers treat that as a finding: a PDF with no author, subject or keywords gives a screen reader and a document index nothing to work with beyond the filename, and it comes back as a WCAG 2.4.2 warning on a report that is otherwise clean.
All three fields are now accepted everywhere a PDF gets made. On POST /api/v1/md they are options.author, options.subject and options.keywords, or author: / subject: / keywords: in the Markdown's YAML frontmatter, with the same precedence as every other option: an explicit body option beats frontmatter. On /api/v1/preview and /api/v1/render they sit alongside options.title. In the builder DSL they are options on doc(), so they can carry {{variables}} and come from your render data:
doc({
size: "A4",
title: "Invoice {{invoice.number}}",
author: "{{company.name}}",
subject: "Invoice {{invoice.number}} for {{customer.name}}",
keywords: ["invoice", "{{company.name}}"],
}, ...)
keywords takes either an array of strings or one comma-separated string. Each value is written to the Info dictionary (/Author, /Subject, /Keywords) and mirrored into the XMP packet (dc:creator, dc:description, pdf:Keywords), which is what a checker actually reads.
POST /api/v1/enhance accepts the same three options, and the remediation path improved whether or not you pass them. It rebuilds the XMP packet from scratch, and it was dropping whatever the source document carried: a PDF that arrived with an author came back with an empty one. It now carries the source values forward, and anything you pass overrides them.
Values you leave unset are omitted rather than written empty, on both sides, because PDF/A requires the Info dictionary and the XMP packet to agree.
The Markdown to PDF page on the site now works signed out, on the same free path the API and the editor extensions have used since May. Paste, convert, done.
POST /api/v1/md has accepted unauthenticated requests since May, and the VS Code and Obsidian extensions have shipped on that path from day one. The page on this site was the odd one out: it redirected you to a sign-in form before you could even see it.
It doesn't any more. Open Markdown to PDF, paste your Markdown or drop a .md file, pick a page size and theme, and convert. Same terms as the API and the extensions: up to 20 pages per document, a small watermark, per-IP rate limits, and nothing kept afterwards.
The output is unchanged, because it was never a different pipeline. Real heading structure, alt text on figures, table headers and reading order, dual-compliant by construction (PDF/A-2A + PDF/UA-1). Tables, syntax-highlighted code blocks, task lists, Mermaid diagrams, LaTeX math and GFM alerts all come through.
Create a free account and the 20-page cap comes off, your renders are listed for re-download under Settings, Renders for 7 days, and you can insert images from your asset library. The watermark stays on Free and Hobbyist and comes off on Starter and above; pricing has the full breakdown.
Export any Obsidian note to an accessible, archival-quality PDF from the ribbon icon or the command palette. Available now in the Obsidian community plugin directory, no account required.
The makesPDF plugin is live in the Obsidian community plugin directory. Open Settings, Community plugins, Browse, search for "MakesPDF", and install. Then export the current note from the ribbon icon or the command palette ("Export current note to PDF").
Obsidian's own export prints the rendered view. This goes through POST /api/v1/md instead, so you get the same tagged output as every other entry point: real heading structure, alt text on figures, table headers, and reading order, dual-compliant by construction (PDF/A-2A + PDF/UA-1).
Obsidian-specific syntax is translated to GitHub Flavored Markdown on the way out, so notes written the way you actually write them come through intact:
- Wikilinks (
[[Page]], [[Page|Alias]]) resolve to their display text.
- Transcluded notes (
![[Note]], ![[Note#Heading]]) are inlined, up to three levels deep, with cycle detection.
- Callouts (
> [!tip], > [!danger]) map to the closest GFM alert, keeping their custom titles.
- Highlights,
%%comments%%, #tags, block references, and sized image embeds (![[diagram.png|600]]) are each handled.
Local images are read from your vault and embedded in the PDF, so attachments do not need to be published anywhere first.
No account is required: exports up to 20 pages run on the free anonymous path. Add an API key from Settings, API Keys for higher limits, larger documents, and saved renders. PDFs are written back into your vault, next to the note or into a folder you choose.
If an x402 wallet payment settles and we fail to deliver your PDF, the full USDC amount is now refunded to your wallet automatically. Validation errors are caught before payment, so a bad request never charges you at all.
Two fairness fixes for wallet-paid (x402) API calls landed this week.
Bad requests are now free. Your request body is parsed and validated
before the USDC payment settles. A malformed body gets a 400 with a
hint, an example of the expected shape, and links to the docs - and
no payment is taken. Fix the request and retry at no cost. Previously a
request that failed validation could still settle the payment.
Failed renders refund themselves. If a payment does settle and we
then fail to deliver your PDF - a template error, a crash on our side,
anything - the full settled amount is refunded to the paying wallet in
USDC on the same network, automatically, usually within minutes. No
support ticket needed. The refund transaction hash is recorded, so if
you ever believe a refund is missing, contact support with your original
payment's transaction hash.
This applies to every x402-priced endpoint: /api/v1/md,
/api/v1/render, and /api/v1/enhance. Credit-billed (API key)
callers are unaffected - failed renders have always deducted zero
credits on that path.
Save a card, set a low-water threshold and a refill amount, and let makesPDF top up your credits automatically.
Running an automated pipeline on API credits meant watching your balance and
manually buying more before it hit zero. Now you don't have to. Once you've
bought Growth credits (which saves your card), you can turn on auto top-up
from Settings → Billing.
Set two things: a threshold (top up when your balance falls below this many
credits) and a refill amount (how many credits to add each time — any whole
dollar amount in the Growth range, at the same 125 credits per dollar as manual
top-ups). When a render drops you below the threshold, we charge your saved card
off-session and add the credits — no interruption, no manual step.
For teams that want a guardrail, set an optional monthly spend cap. Auto
top-up pauses for the rest of the calendar month once the cap is reached (your
existing credits keep working) and we email you so nothing happens silently. It
resumes automatically next month, or as soon as you raise the cap.
If a charge is ever declined, we email you right away and keep your card details
out of it — and after a few consecutive failures we pause auto top-up so a dead
card doesn't get retried forever. Fix the card, flip it back on, and you're set.
Markdown documents can now embed Mermaid xychart-beta diagrams — line and bar charts with titled axes — rendered as crisp native PDF vector graphics like every other diagram type.
xychart-beta is now supported wherever Mermaid is — POST /api/v1/md, the CLI, and the blog renderer. It's the ninth Mermaid diagram type alongside flowchart, sequence, class, state, gantt, pie, ER, and gitgraph.
Drop a fenced ```mermaid block using the xychart syntax and it renders as native PDF vector graphics — no rasterization, sharp at any zoom:
xychart-beta
title "Quarterly revenue vs target"
x-axis "Quarter" [Q1, Q2, Q3, Q4]
y-axis "USD (k)" 0 --> 120
bar [40, 70, 95, 110]
line [50, 65, 90, 100]
You get a titled chart with auto-spaced y-axis ticks and gridlines, a rotated y-axis label, category labels along the x-axis, grouped bars, and overlaid line series with point markers. Multiple line and bar series can be combined in one chart. The y-range comes from the y-axis bounds you give, or is derived from the data when you omit them.
The categorical x-axis (a [...] list of labels) is the common case for time-series and comparison charts; an axis title is optional. Charts render vertically — the horizontal keyword is accepted but draws vertically for now.
A new endpoint that walks an existing PDF's StructTreeRoot and gives you back the full tag tree — as JSON for agents and tooling, or as a human-readable outline PDF. Every enhance run now leaves one too.
/api/v1/pdf/validate answers "is this PDF compliant?".
POST /api/v1/pdf/tags answers a different question: "what is
the structure?" Useful even when nothing is failing — you can finally
see what tags a PDF actually carries, what alt-text it ships with, and
which figures are missing alt entirely.
Drop a PDF on /tags for the interactive form, or call the API
directly.
By default the endpoint returns the tag tree as JSON. The nested
tree field mirrors the PDF's StructTreeRoot — each node has a
role (H1, P, Figure, Table, …), a 1-based page, the mcids
attached, and the four accessibility text properties screen readers
care about (alt, actualText, lang, title). Role-mapped custom
tags resolve through the document's RoleMap so authoring-specific
names collapse to their standard parents. The summary block surfaces
elementCount, figureCount, figuresMissingAlt, and a roleCounts
histogram — enough to spot a problem at a glance.
curl -X POST https://makespdf.com/api/v1/pdf/tags \
-H "Authorization: Bearer $MAKESPDF_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"pdfBase64\":\"$(base64 -i sample.pdf)\"}"
Pass Accept: application/pdf instead and you get back a rendered
indented-outline PDF of the same tree — the same structure data, but
shaped for skimming on screen or sending to a reviewer. Untagged PDFs
return tagged: false, which is a cheap pre-flight before
/enhance.
Enhance now emits one too. Every successful in-place enhance
attaches a tag-report artifact alongside the existing
alt-text-report, so you can pull the full structure record of the
remediated PDF without re-uploading. List artifacts the usual way:
curl -H "Authorization: Bearer $MAKESPDF_API_KEY" \
"https://makespdf.com/api/v1/artifacts?kind=tag-report"
The endpoint is free, capped at 25 MB and 200 requests/hour, and
requires a Bearer token or session — see the
API docs for the full contract.
Pay for a PDF/UA-1 + PDF/A-2A remediation job with a USDC wallet — no API key, no account. The 202 envelope hands you an opaque pollToken to poll the job and download the artifact when it's ready.
POST /api/v1/enhance now accepts x402 settlement as authentication.
Send the PAYMENT-SIGNATURE header, settle USDC on Base (Sepolia in
test, mainnet in prod), and the producer returns a 202 carrying a
freshly-minted pollToken:
{
"jobId": "...",
"status": "queued",
"statusUrl": "/api/v1/enhance/jobs/...",
"estimatedSeconds": 240,
"pollToken": "Rh3...n_w"
}
Re-present that token as Authorization: Bearer <pollToken> on the
follow-up calls — GET /api/v1/enhance/jobs/:id, POST .../cancel, and
GET /api/v1/artifacts/:id/{pdf|source}. The server hashes the
presented token and matches against the job row's stored
pollTokenHash. Mismatch returns the same 404 shape as an authed
owner-mismatch, so jobIds can't be probed.
The token is bearer-equivalent for that one job's lifetime — agentic
callers persist it, humans wouldn't drive this path directly. Replaying
the same PAYMENT-SIGNATURE within the 5-minute idempotency cache
window returns the cached 202 verbatim, including the original
pollToken; this is the supported retry path for connection drops
between settlement and 202.
Why this matters: x402 itself is stateless request/response, but the
spec doesn't preclude polling — a 202 with { jobId, pollToken } is
fully conformant. /enhance takes minutes (vision per page, multiple
LLM calls, in-place tagging), well past the 30s Workers CPU budget, so
synchronous fulfilment was never on the table. Wallet-authenticated
polling closes the loop and unblocks the route's listing in the CDP
Bazaar discovery index.
Listing is the one operation wallet callers can't do — GET /api/v1/enhance/jobs stays user-scoped. You know your jobIds because
we returned them.
One caveat for SDK authors. Idempotency-Key replays (same key, same
wallet, second submission) return the existing jobId but with
pollToken: null — the original raw token isn't recoverable from its
stored hash. Use the signature replay path (the same
PAYMENT-SIGNATURE within the 5-minute cache window) when you need the
original pollToken back; use Idempotency-Key only when you've held
onto the first pollToken yourself.
POST /api/v1/md now accepts unauthenticated requests for short documents — render Markdown to PDF without signing up. Built for the VS Code plugin funnel, open to any caller.
You can now call POST /api/v1/md without an API key. Send Markdown, get a styled PDF back — no signup, no Bearer token, nothing to configure. It's the same deterministic pipeline as the authed call (PDF/A-2A + PDF/UA-1, embedded fonts, tagged structure tree), routed through stricter per-IP limits.
The numbers, per IP address:
- 60 requests per hour
- 200 requests per day
- 20 pages per render (input is also capped at 200 KB of Markdown, same as the authed path)
Hit any of those and the response carries a tip field nudging sign-up. Successful anonymous responses include an X-MakesPDF-Tip header pointing at the higher-limit story; the VS Code plugin surfaces it as a one-time toast.
Every other /api/v1/* endpoint stays authed — /render, /preview, /templates, /enhance, the lot. This carve-out is scoped to /md because the cost ceiling is bounded (no AI call, about a second of pure computation) and the abuse surface is small. Sign up when you want higher limits, saved renders, custom fonts, stored templates, or any of the billed endpoints.
The headline use case is the VS Code plugin — install, open a .md file, run "Convert Markdown to PDF", get a PDF. No settings page detour. Same shape works from curl, a shell script, or any HTTP client:
curl -X POST https://makespdf.com/api/v1/md \
-H "Content-Type: application/json" \
-d '{"markdown": "# Hello\n\nSome **bold** text."}' \
-o output.pdf
A stale or invalid Bearer token still returns 401 — we never silently downgrade an auth attempt to anonymous, so paying callers see a clear "your key is broken" signal instead of a quiet rate-limit cliff.
New barcode() builder generates scannable Code 39 and Code 128 barcodes as native PDF vector graphics — no font, no rasterisation, dynamic values via {{template}} placeholders.
Templates can now render real, scannable barcodes. The new barcode() DSL builder produces Code 39 or Code 128 symbologies as native PDF vector graphics — no font to embed, no image to fetch, no rasterisation.
barcode("{{trackingNumber}}", {
format: "code128", // "code128" (default) or "code39"
width: 240,
height: 56,
"show-text": true,
alt: "Tracking {{trackingNumber}}",
});
The value field accepts {{template}} placeholders and resolves at render time, so the same template renders a different barcode for every payload. Code 128 (default) is the dense general-purpose choice and accepts ASCII 32–127. Code 39 is the older alphanumeric format that's still common in legacy logistics workflows.
Module width is derived from the requested image width and the encoded length so the bars always fill the box minus the standard quiet zones. Pick a width that yields ≥ 0.5pt per module for reliable consumer-scanner reads.
The two shipping label templates in the library (Compact 4×6 and Standard A5) have been migrated to use real barcodes.
Skill: see pdf-template-author.md §Barcodes.
New qr() builder generates scannable QR codes — versions 1–40, all four ECC levels, auto-mode selection, dynamic values via {{template}} placeholders, no font, no rasterisation.
Templates can now render QR codes alongside the Code 39 / Code 128 barcodes we shipped earlier today. The new qr() DSL builder produces QR codes as native PDF vector graphics — no font to embed, no image to fetch, no rasterisation.
qr("https://pay.example.com/inv/{{invoice.number}}", {
ecc: "M", // "L" | "M" (default) | "Q" | "H"
size: 120, // total width/height in pt (square)
alt: "Scan to pay invoice {{invoice.number}}",
});
The value accepts {{template}} placeholders and resolves at render time, so the same template renders a different code for every payload. The encoder auto-selects the smallest QR version that fits (21×21 up to 177×177 modules), picks the most efficient encoding mode (numeric, alphanumeric, or UTF-8 byte), and chooses the lowest-penalty mask per the ISO/IEC 18004 spec.
Pick the error-correction level for the conditions the code will live in: L for clean on-screen URLs, M (default) for everyday print, Q for receipts and dim lighting, H for thermal labels and anywhere damage is likely. Higher ECC means a larger symbol for the same payload — aim for a module width of at least 1pt so phone cameras can resolve it reliably.
The three invoice templates in the library (Classic, Modern, Compact) now include a "scan to pay" QR next to their payment-instructions block. Skill: see pdf-template-author.md §QR Codes.
Every time the assistant changes the DSL, the template is saved (or updated) automatically with a one-sentence description of what changed. Each save is recorded as its own revision, and the new per-template page has a History tab that lists them all in order.
The /ai/chat authoring loop used to be ephemeral: if you closed the tab between turns you lost any in-progress template. That's gone. Every successful DSL iteration now writes to your /templates list automatically — first iteration creates a new row, subsequent iterations update it in place. Your render-data textarea rides along on every save, so the template and the data you've been iterating against stay in sync.
Each save also captures a one-sentence description of what changed in that turn ("Adds a totals row with grid-aligned amount column.", "Switches header from row to column."), and each save is now a proper revision — not just a value that gets blown away on the next turn.
Click into any template (the cards on /templates now open a per-template page at /templates/<id>) and you'll see two tabs in the left column: Chat and History. The History tab lists every save against that template, newest first, with the timestamp and the change description. Linkable too — /templates/<id>?tab=history opens straight to the list.
Re-opening a saved template via Modify Template rehydrates the chat exactly as you left it: same DSL in the workspace, same render data in the panel, same template id under the hood, so the next AI iteration goes straight to update mode. There's no separate "save" or "publish" step — every change is a save, and every save is a revision you can scroll back through.
If you've typed render data between AI turns and try to close the tab, the browser's standard "Leave site?" prompt will still warn you, since data-only edits aren't bundled into a save until the next DSL change ships them through.
Render `templateId: invoice-classic` directly through /api/v1/render — no more fork-then-render. Library and saved templates share one endpoint family.
You can now render any curated library template by ID without first
forking it into your account. POST /api/v1/render with
{"templateId":"invoice-classic","data":{...}} works for every caller
— Bearer-token, session, or x402 wallet — and bills the same way as a
saved template.
Behind the scenes, the curated library and your saved templates now
live in the same templates table, distinguished only by ownership.
That collapses the API surface: /api/v1/library is gone, and
/api/v1/templates covers both. List your own with
?owner=mine, browse the library with ?owner=library, or hit the
default to see everything you can read. GET /api/v1/templates/:id
returns either kind, and PUT / DELETE continue to require ownership
(library rows are read-only — they're seed-managed).
The MCP toolset shrinks to match: list_library and
get_library_template are removed, and list_templates /
get_template cover both surfaces. A new owner filter on
list_templates lets agents narrow to library candidates without a
separate tool call.
If you were following the documented "fork-then-render" recipe in your
client code, you can drop steps 1–3 and call /api/v1/render directly
against the library templateId. Forking is still useful when you want
to customise the DSL — pull, edit, POST /api/v1/templates, render
your copy.
The Use Template button on /templates is now Modify Template — it drops you into /ai/chat with the template already loaded, rendered, and ready to customize.
The button on each card in the Template Library and Your Templates is now Modify Template. Clicking it opens /ai/chat with the chosen template already fetched, pre-rendered into the workspace pane, and primed in the system prompt. You see the starting PDF first, then describe the changes you want — colours, sections, copy, layout — and the assistant edits the DSL and re-renders in place.
When you're happy, save the template. Library seeds become a new row under your account; user-template seeds update the existing row in place. The workspace then shows the API recipe — a templateId plus a copy-pasteable two-step POST /api/v1/render snippet — so the path from "I like this" to "wired into my backend" is one screen.
The form-fill detail page at /templates/<id> is gone. The library exists to launch you into API integration, not to compete with consumer "fill in fields, download a PDF" tools — modifying a template through chat is faster and lands you with the artifact you actually need.
/enhance lifts the in-place-only restriction. mode=regenerate rebuilds a clean DSL source you can re-render forever; mode=both ships the tagged original and the regenerated source side-by-side, billed once.
POST /api/v1/enhance no longer rejects non-in-place modes. The
async pipeline supports all three modes end-to-end:
in-place — tag the original PDF byte-for-byte. Preserves every
pixel — same fonts, layout, signatures of authenticity — with the
PDF/UA-1 tag tree layered on top.
regenerate — rebuild from a clean DSL source we hand back. You
get an editable template (visible in /ai/chat or /settings/renders)
that you can re-render with new data forever. No more paying the
remediation tax every time the document changes.
both — run both. The job row now links two artifacts: the
in-place tagged PDF (primary) and the regenerated DSL render
(secondary). Each is downloadable on /settings/renders and through
the GET /api/v1/artifacts/:id/pdf endpoint. Billed once on the union
of pages.
The /enhance UI has a re-enabled output-mode dropdown plus a "Switch
to regenerate" button on the in-place-impossible 400 response — the same
pre-flight that rejected an in-place submission for a font-program
issue now hands you a one-click resubmit with mode: "regenerate" and
the same upload.
Pricing is unchanged: 10 credits per page on success, the first page is
a free preview, partial outcomes are not billed. mode: "both" is the
same per-page rate as either single mode — you don't pay twice for two
artifacts.
The job status response now carries secondaryArtifactId and
secondaryDownloadUrl for mode: "both" runs. Single-mode jobs leave
those null. See the API docs for the response shape.
/enhance now runs your PDF through veraPDF before queueing, and refuses upfront when the failing rules require regenerate. /validate surfaces the same recommendation so you can preview the routing decision before spending credits.
Until today, the only way to learn that an /enhance upload couldn't be fixed in-place was to wait the full pipeline run, get a compliance-gate-failed at the end, and then re-submit with mode: "regenerate". That round-trip is gone.
Every POST /api/v1/enhance now runs a pre-flight veraPDF pass first, cached by sha256(bytes). If the failing rules require a font or content rebuild that the in-place pipeline structurally can't do (CIDSet, embedded font program, ActualText for PUA codepoints), the producer returns 400 error="in-place-impossible" with the full blocker list and a ready-to-resubmit regenerateRequest.options payload — no queue work, no credit hold. Already-compliant uploads short-circuit to 200 status="already-compliant". Unsupportable uploads (encrypted, DRM-protected) get 400 error="unsupportable" with a one-line reason.
The companion POST /api/v1/pdf/validate endpoint surfaces the same routing decision under enhanceRecommendation, so callers (and the /validate page in your browser) can preview which mode is needed before submitting.
In the web UI, the /validate page now renders a per-mode eligibility panel below the rule list — naming the rules that block in-place — and /enhance auto-validates on file select so the recommendation lands before you click submit. If you submit on in-place anyway and the producer rejects it, the error panel surfaces the one-line reason without re-rendering the rule list you already saw.
Regenerate mode itself is still on the Phase 3.5 list — until that lands, the "Switch to regenerate" hand-off is informational. The pre-flight is the immediate win: the same call you make today now tells the truth in about a second when the answer is "you'll need a different mode."
Submitting a PDF to /api/v1/enhance now returns 202 with a jobId in under a second. Poll a status URL until the job is ready and we email you when it's done — no more multi-minute HTTP connections that drop on flaky networks.
POST /api/v1/enhance used to hold the HTTP connection open for the
entire enhance pipeline. A typical 50-page civic PDF takes 4–5 minutes,
which is past the point where consumer browsers and corporate proxies
silently drop the socket. We saw it ourselves: the worker kept running,
the user saw nothing, the result was unrecoverable.
The endpoint is now async, queue-backed. Submitting a PDF returns
202 in under a second with a jobId and a statusUrl:
{
"jobId": "f8e1…",
"status": "queued",
"statusUrl": "/api/v1/enhance/jobs/f8e1…",
"estimatedSeconds": 240
}
Poll GET /api/v1/enhance/jobs/:id until status is terminal — start
at a 2-second cadence and back off to 8 seconds after the first 30
seconds. The status response carries phase (received / container
/ in-place / done) and a progress: { current, total } object so
you can show a real progress bar instead of a spinner.
When the job succeeds we email you at the address on your
account — subject Your enhanced PDF is ready — with a direct download
link. The PDF is also surfaced on /settings/renders and via
GET /api/v1/artifacts, so you have three paths back to the artifact
without keeping any state on the client.
A few details worth pinning down:
- Idempotency.
POST /api/v1/enhance accepts an Idempotency-Key
header. Re-submitting the same key returns the existing jobId
instead of starting a duplicate run.
- Cancellation.
POST /api/v1/enhance/jobs/:id/cancel flags a job;
the consumer notices on its next checkpoint. v1 doesn't actually halt
in-flight vision calls, so a job already mid-page will still finish
that page before flipping to cancelled.
- Listing.
GET /api/v1/enhance/jobs?status=…&limit=…&offset=…
paginates your jobs newest-first, owner-scoped.
- Billing unchanged. Still 1 credit per 10 pages on success.
partial (some pages failed reconstruction) and failed deduct zero.
- v1 mode restriction. Only
mode: "in-place" is accepted on the
async path. regenerate and both 400 for now; the artifact-shape
policy for jobs that produce two PDFs is the next thing to land.
The /enhance web UI uses the same polling protocol — close the tab,
come back tomorrow, you'll find the PDF on /settings/renders and an
email in your inbox. No more "did it actually finish?" anxiety on
50-page documents.
/api/v1/enhance now bills 10 credits per page on success (up from 1). Each page drives a Sonnet vision call, and the previous rate didn't cover the cost on plan-credit callers. The /enhance web UI shows the new cost before you submit. x402 wallet callers are unchanged at $0.20 / page in USDC.
POST /api/v1/enhance now bills 10 credits per page on success, up
from the previous 1 credit / page. The rate stays flat across all three
modes (in-place, regenerate, both) — mode: "both" is still
charged once for the in-place page count, with the regenerate render
bundled at no marginal charge.
Why the bump. Each page on /enhance drives a per-page Sonnet 4.6
vision call (~$0.05 of OpenRouter spend per page on average), and the
old 1-credit rate didn't cover that on the cheaper plans. At Starter's
1,500 monthly credits for $10, a credit costs you ~$0.0067 — so the new
10-credits-per-page rate works out to ~$0.067/page in revenue, enough
margin to keep enhance sustainable as a feature rather than a
per-call loss leader.
What this means in practice:
- Free (10 credits/mo) → 1 enhance page / month included.
- Hobbyist (100 credits/mo) → 10 enhance pages / month.
- Starter (1,500 credits/mo) → up to 150 enhance pages / month.
- Growth (2,500 wallet credits / $20 top-up) → 250 enhance pages per
$20 of credits, no expiry.
The first page of any upload is still a free preview, partial outcomes
still don't deduct, pipeline failures still don't deduct, and the web
UI is still unbilled (session-cookie callers run on the house).
x402 wallet callers are unchanged — /enhance still settles at
$0.20 / page in USDC, independent of the credit rate. The 402 challenge
body still parses page count from the upload before quoting, so what's
quoted is what's settled.
The /enhance page now shows the new credits-per-page math before you
submit ("10 credits per page × N pages = M credits") and the
"insufficient credits" prompt links straight to the credit purchase
page with the correct shortfall.
A new options.acceptRasterFallback flag lets /api/v1/enhance keep going when individual pages can't be reconstructed — failed pages embed as tagged Figure rasters of the original instead of flipping the call to a free partial preview. The output is labelled hybrid and bills at the full per-page rate.
With options.acceptRasterFallback: true, pages the regenerate pipeline
can't reconstruct no longer flip the entire upload to a free partial
preview — they're embedded as a tagged <Figure> containing a raster of
the original page, with /Alt populated from the pdftotext slice for
that page.
The default behaviour is unchanged: any page failure still returns
partial: true with no credits deducted. The flag is per-request and
explicit — there's no workspace-wide "always accept fallback" toggle.
When the flag is set and at least one page falls back, the response
shape switches to:
{
"partial": false,
"hybrid": true,
"pdfBase64": "…",
"report": {
"pageCount": 5,
"successfulPages": 3,
"rasterFallbackPages": [2, 4],
// …
},
"source": {
"dslJson": "…",
"markdown": null,
"markdownAvailable": false,
"markdownReason": "hybrid output (raster-fallback pages cannot be expressed as markdown)",
},
}
The mix is disclosed in three places so a downstream consumer can
detect it without reading every page: the hybrid: true envelope flag,
the report.rasterFallbackPages list, a visible cover page at the
front of the PDF listing the fallback page numbers, and a
makespdf:hybrid-output = true entry in the document XMP metadata.
The X-Enhance-Outcome response header is hybrid instead of full.
Hybrid is billed at the full per-page rate (1 credit / page across
the original page count) — the customer opted in and got a usable PDF.
Use it for legacy archive bulk uploads where a hybrid-but-honest output
beats no deliverable; stay on the default partial path when you'd
rather re-submit the broken page than archive a raster of it.
Wallet callers can now pay for /api/v1/enhance in USDC. The 402 challenge declares a per-page price ($0.20 / page) computed from the uploaded PDF, so the amount quoted is always the amount settled.
POST /api/v1/enhance now accepts x402 wallet payments alongside Bearer
tokens and session cookies. This was the last of the billed /api/v1
endpoints still gated to credit callers; all three (/md, /render,
/enhance) now share the same payment surface.
The wallet rate is $0.20 / page in USDC — the same as the credit list
price (1 credit / page at $0.20 / credit). No x402 discount, no x402
premium, so the two paths can be repriced independently later.
Because the price is per-page, the 402 challenge body has to know the
page count before it's built. The route now parses the uploaded PDF
first; the amount declared in the challenge is pageCount × 200000
atomic USDC, and that's exactly what gets settled if the wallet pays.
{
"x402Version": 2,
"error": "Payment required",
"resource": {
"url": "https://makespdf.com/api/v1/enhance",
"description": "Enhance a non-compliant PDF to PDF/A-2A + PDF/UA-1 ($0.20 / page)",
"mimeType": "application/json"
},
"accepts": [
{
"scheme": "exact",
"amount": "1000000",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"payTo": "0x…"
}
]
}
A few wrinkles worth calling out for wallet callers:
- Idempotent retry, with one carve-out. Settled wallet renders are
cached by signature hash for 5 minutes; a duplicate
PAYMENT-SIGNATURE
replays the cached JSON envelope verbatim with X-Idempotent-Replay: 1
and never re-runs the pipeline. The exception is mode: "both", which
packs two base64 PDFs into one envelope and can blow the 25 MB Workers
KV value cap — those responses skip the cache, so a retried mode: "both" request re-pays.
- Refunds. Validation rejections (malformed PDF, admin-only options,
unmatched alt-text overrides) leave the payment in
pending for the
daily refund cron. Render-stage failures and partial-regenerate
outcomes are explicitly marked failed so the cron picks them up
faster.
options.model stays admin-only. Wallet callers don't have a user
row, so they can't be admins; passing options.model returns 403.
Drop in any PDF and run the ISO reference validator for PDF/A and PDF/UA. Free, no sign-in, nothing kept. Powered by veraPDF 1.28.2 in a Cloudflare Container sidecar.
/validate is now live — a hosted wrapper around
veraPDF, the ISO reference validator for PDF/A
and PDF/UA. Drop in any PDF, get back a per-profile pass/fail report
plus the failed rule list, with clauses and test numbers straight from
the spec.
Programmatic callers can hit it directly:
curl -X POST https://makespdf.com/api/v1/pdf/validate \
-H "Content-Type: application/json" \
-d "{\"pdfBase64\":\"$(base64 -i sample.pdf)\",\"flavours\":[\"2a\",\"ua1\"]}"
Default profiles are PDF/A-2A and PDF/UA-1, matching what
/enhance targets. The full PDF/A-1 / 2 / 3 / 4 family plus
PDF/UA-1 is selectable per request.
Privacy. Uploads are held in memory for the length of one
request, validated, and discarded as soon as the report renders.
Nothing lands in R2, no DB rows are written, file content is not
logged.
If a PDF fails compliance, /enhance is the next step —
it'll rebuild the document as a tagged, dual-compliant PDF/A-2A +
PDF/UA-1 and hand back a clean DSL source you can re-render whenever
the document changes.
/api/v1/enhance now bills 1 credit per page on success. The /enhance web UI shows the cost before you submit and links straight to the credit purchase page when your balance is short.
POST /api/v1/enhance now bills 1 credit per page on success.
Previously every call was free for anyone with a session or API key
(rate-limited at 50/hour); the per-page rate flips on so the endpoint
covers its OpenRouter spend.
The rate is flat across all three modes — in-place, regenerate, and
both. mode: "both" is charged once for the in-place page count, with
the regenerate render bundled at no marginal charge.
A few invariants worth calling out:
- Pre-dispatch gate. The page count is read from the uploaded PDF
before the enhance pipeline is dispatched. API-key callers without
enough credits receive
402 { error: "Insufficient credits", required, available } and no OpenRouter call is made.
- Partial paths stay free.
mode: "regenerate" partials (returned
when at least one page fails to reconstruct) deduct zero credits.
- Failures don't deduct. A pipeline failure leaves your balance
untouched; the deduction only happens on the successful response.
- Web UI is unbilled. The
/enhance page in the web app uses your
session, which mirrors how /render treats session callers — only API-key
traffic consumes credits.
The /enhance page now shows the cost before you submit (e.g. "this
will cost 5 credits") and replaces the submit button with a buy-credits
link when your balance is short. x402 wallet callers should keep using
authed credits on /enhance for now — fixed-price wallet payments are
still available on /md and /render.
This is the bottom of the planned $0.20–$0.50/page band. Calibration
follows the competitor pricing snapshot.
The MCP server now exposes a billed render_template tool, so agents can finish the loop and produce a real PDF without dropping out to curl or the CLI.
When the MCP server first shipped, the billed render endpoint was deliberately left out — the worry was that an agent in a loop could quietly burn through credits with no human in the loop. In practice that decision turned the agent flow into a half-loop: validate, preview (watermarked), save… then leave MCP and run a curl /api/v1/render to get the actual deliverable. That's friction in the one step that matters most.
What's new
render_template is now an MCP tool. It mirrors POST /api/v1/render with the { templateId, data } body shape — render a user-owned saved template (created via save_template or forked from the library) and get back a real PDF as a base64 resource on the response.
The cost is explicit, both in the tool description (which calls out "1 credit per 10 pages" up front) and in the JSON metadata block returned alongside the PDF: creditsDeducted, creditsRemaining, pageCount, and the artifact ID. An agent that wants to be a good citizen can read those and tell the human exactly what the render cost before continuing.
How to use it
The recommended loop is now:
- Ask the agent to draft DSL and call
validate_dsl until it's clean.
render_dsl_preview to eyeball the layout — free, watermarked, no credits charged.
save_template once you're confident.
render_template with real data to produce the deliverable.
Output behaviour matches /api/v1/render exactly: free-plan callers get the standard plan watermark, paid plans render clean. The artifact lands in your /settings/renders history just like a REST-driven render.
What's still REST-only
The markdown endpoint (/api/v1/md) stays REST-only for now. It's billed too but the input shape is fundamentally different — markdown body, frontmatter options — and it doesn't share the author → save → render loop with the DSL pipeline. Different question, separate decision.
Setup hasn't changed: existing MCP clients (Claude Desktop, Claude Code, Cursor) pick up the new tool the next time they list tools. See /docs/mcp for the full tool table.
New /compare section with side-by-side breakdowns of makesPDF against the major PDF generation tools. First live page is react-pdf; nine more competitors are in the pipeline.
The new /compare section is live. It's the home for honest, side-by-side breakdowns of how makesPDF stacks up against the major PDF generation tools — react-pdf, Puppeteer, WeasyPrint, wkhtmltopdf, DocRaptor, PDFShift, typst, iText, and pdfmake.
The first live page is react-pdf vs makesPDF: one-line difference, feature matrix, same-input/two-outputs code example, why people switch, and when react-pdf is still the right call. The remaining nine competitors are listed in the "Coming soon" rail and will land in batches over the next few weeks, starting with the highest-dev-trust pairings (Puppeteer, the "LLM + Puppeteer" wrapper pattern, WeasyPrint).
Each page reuses one shared layout, so adding a new comparison is a markdown-only change. If you want a comparison against a tool we haven't listed, the index page is the place to look — or open an issue and we'll prioritise.
Templates can ship real fillable AcroForm widgets — text fields, checkboxes, dropdowns — that stay interactive in Reader and Preview and remain tagged for PDF/UA-1 screen readers. Now documented across the docs, skill file, and homepage.
If you author a template with input(), checkbox(), or select(), the rendered PDF carries real AcroForm widgets — the fields stay interactive in Adobe Reader, Apple Preview, and any modern browser PDF viewer. The pipeline shipped a while back; what's new this update is that it's actually documented and discoverable.
Three builders
input(opts) — /Tx text fields. Single- or multi-line, with maxLength and an accessible tooltip.
checkbox(opts) — /Btn widgets with pre-baked on/off appearance streams. Pass label to render the box + text together.
select(opts) — /Ch combo dropdowns. The appearance stream displays the currently selected option's label.
Field name must be unique across the document, and tooltip is the field's accessible name (/TU) — required for PDF/UA-1 conformance. Pair every form template with tagged: true on the doc(...) so the widgets land inside the structure tree.
Why this matters
Most form-capable PDF APIs don't tag form fields properly for accessibility, and most tagged-PDF APIs don't ship form fields at all. We have both. That combination — fillable + PDF/UA-1 tagged + freshly veraPDF-validated — is exactly the differentiation the compliance audience cares about: gov forms, healthcare consent, fintech KYC.
See it in action
A government-style consent form lives in the makesPDF repo at assets/samples/forms/consent-form-dsl.js, with the rendered output at assets/samples/forms/consent-form-render.pdf. That rendered PDF is also one of the fixtures in our per-release veraPDF gate: 153 PDF/A-2A rules + 106 PDF/UA-1 rules, zero failures.
The full reference for the three builders is now on /docs/api under "Fillable forms," and the skill file at /skills/pdf-template-author.md documents them under the Builder DSL Reference so any agent author can use them on the first try.
Out of scope (for now)
/Sig digital-signature fields, Acrobat-style JavaScript field calculations, and radio-button groups (model with a select instead).
A new /settings/renders page lists every PDF you've rendered in the last 7 days with a one-click download — and the same list is available to API callers at GET /api/v1/artifacts.
If a download missed, a tab closed, or you just want the PDF you rendered ten minutes ago, you no longer have to re-render to get it back.
What's new
/settings/renders lists your recent render artifacts — date, source endpoint (/md, /preview, /render), template name (for stored-template renders), page count, byte size, and how long until the artifact expires. Each row has a Download link that streams the original PDF straight from R2.
The same data is available programmatically at GET /api/v1/artifacts. It accepts kind (filter by source endpoint), templateId (filter to one stored template), and limit / offset. Owner-scoped, paginated, rate-limited at 500 requests / hour. Pair it with the existing GET /api/v1/artifacts/:id/pdf to fetch the bytes.
Why now
We already captured every successful render under an X-Artifact-Id so issue reports could reference it, and the existing /settings/usage page surfaced the metadata for credit auditing. What was missing was the simple "give me the PDF back" affordance — useful for the new "Try it now" button on /ai/chat (which auto-downloads but can race a closing tab), and for API consumers that want to revisit a render without keeping the response body themselves.
Retention
Artifacts are kept for 7 days by default. Filing an issue report against a render extends retention on that artifact to 30 days so we can reproduce the bug. Both numbers are visible on each row.
Agents and scripts can now hit /api/v1/md and /api/v1/render with a signed USDC payment instead of an account. $0.01 per markdown render, $0.02 per DSL render, settled on Base.
Until now, every makesPDF request needed a Bearer token or a session cookie tied to a user account. That's a fine model for a developer paying a monthly bill, but it's friction for an autonomous agent that just wants to render a PDF and move on. As of today, you can pay per call with an x402 signed USDC payment instead.
What changed
Hit /api/v1/md or /api/v1/render with no auth and you'll get a 402 Payment Required advertising the price ($0.01 for markdown, $0.02 for DSL), the asset (USDC on Base mainnet), and the receiver address. Sign an EIP-3009 TransferWithAuthorization for that exact amount, retry with the signed payload in a PAYMENT-SIGNATURE header, and the route verifies + settles via the x402 facilitator before rendering. Idempotency is handled for you — the same signed payload coming back inside 5 minutes serves the cached PDF without re-settling.
The shape
Wire format is x402 V2: CAIP-2 network IDs (eip155:8453 for Base mainnet), atomic-unit amounts ("10000" = $0.01 USDC), extra: { name: "USDC", version: "2" }. Bearer-token and session callers are unaffected — same code path, same billing, same response. The paid path forces watermark-free output and rate-limits per wallet instead of per caller. Render failures mark the payment for refund; settle failures don't bill at all.
Caveats
x402 callers on /api/v1/render must use the inline-DSL body form ({ dsl, data }) — there's no stored-template ownership without a user account. If you want template persistence, you'll need a regular account; if you just want to render once and disappear, x402 is the path.
What's next
Bazaar should auto-discover the endpoints within hours. We'll be adding x402 mentions to /llms.txt, the skill file, and the OpenAPI spec next so AI agents can self-onboard from the docs. Wallet-claiming for humans (link an x402 wallet to a regular account) is on the roadmap.
Settled tx, payment row, and refund worklist all live in our D1 — we monitor render_status = 'failed' daily for manual refunds while volume is low.
When the AI chat renders a preview against pasted JSON, the workspace pane now shows a copy-pasteable two-step API recipe and one-click Save / Try buttons.
The /ai/chat workspace now teaches you the production rendering flow inline. Once the assistant has produced a preview and you've pasted real JSON in the Render data panel, the workspace pane shows a third section under the PDF preview with the exact two API calls your backend would make:
POST /api/v1/templates { dsl, name } to save the template (free).
POST /api/v1/render { templateId, data } to render with your data (1 credit per 10 pages).
The recipe interpolates the model's actual DSL and your actual JSON, with tabs for curl, fetch, and Node so you can paste the snippet straight into your codebase.
A Save template button creates the template under your account; clicking it again after the chat updates the DSL overwrites the same row. Once saved, Try it now — 1 credit runs the billed render path against your data and swaps the watermarked preview for the production PDF in place. The chat is still for authoring; the API is still where production rendering happens.
attr.grid now accepts "auto" and "<n>fr" so columns can size to their data instead of a defensive percentage. Header, body and totals rows of one table coordinate automatically.
attr.grid now accepts two new tokens alongside fixed pt and "<n>%":
"auto" — sizes the column to the widest content in it.
"<n>fr" — shares the surplus left after fixed and auto columns, proportionally. "1fr" and "2fr" split 1:2; pure-fr grids divide the row by fr factor.
Mix freely. ["120pt", "auto", "1fr"] pins the first column, sizes the second to its content, and lets the third absorb whatever's left. Resolution order is fixed → auto → fr; auto cells that overflow the row shrink toward min-content rather than wrapping.
Tables coordinate widths across rows. A table()'s header and every materialized data row resolve their auto/fr columns once across the union of their content, so column edges line up vertically instead of each row sizing itself in isolation.
A new optional row attribute, grid-id, extends that coordination across separate containers — the use case is totals() aligning with the table() above it. table() and totals() now stamp a deterministic grid-id derived from the shared cols array, so totals rows participate in the same group as the table whose columns they should align with. The Amount column lines up whether it's a fixed percentage, an auto sized to data (e.g. a Balance column in a bank statement), or anything in between.
totRow() (the building block behind totals()) was rewritten to span its label across every column except the last, leaving the value in the final column. Labels get all the room they need and never wrap into a narrow numeric column; the value column is what aligns with the table.
Six library templates were tightened to demonstrate the new tokens — invoice-compact, receipt-retail, quote-modern, statement-bank, shipping-label-standard, and report-standard — with defensive percentages replaced by auto/1fr where they were previously hiding worst-case data-width guesses.
The DSL skill file at /skills/pdf-template-author.md has the new tokens documented in the grid section. The /api/v1/preview and /api/v1/render pipelines pick this up automatically — no API changes.
A dedicated pdf-fonts skill documents per-request and account-level font registration, including the BYO path for non-Latin European scripts. Variable fonts now fail fast with a clear error.
The font story is now properly written down.
- New skill file:
skills/pdf-fonts.md — covers the embedded families (Inter, NotoSans, Cousine, NotoSymbols2), per-request fonts: registration, account-level uploads, and the BYO path for non-Latin European scripts (Cyrillic, Greek, Vietnamese, Georgian, Armenian).
- The companion-skills index in the main authoring skill points at it, so agents discover it on the first authoring loop.
- Variable fonts uploaded via
/api/v1/fonts are now rejected with an explicit error rather than silently producing garbled output.
CJK and complex-script shaping (Arabic, Hebrew, Indic, Thai, Khmer) are still tracked separately and don't render correctly yet — see the i18n todo docs.
Omit widths in table() and the builder picks for you — every column sizes to content, and the column with the widest measured content automatically absorbs leftover space.
The table() builder used to require an explicit width on every column. In practice that meant authors guessed defensive percentages ("10%", "15%") for numeric columns and quietly hit the same trap whenever a real currency value was wider than the slot — the cell wrapped to two lines and the visible text dropped to the bottom of the row.
Widths are now optional. Omit them and the builder picks:
table(
[["Description"], ["Qty", undefined, "right"], ["Amount", undefined, "right"]],
"item in items",
[["{{item.desc}}"], ["{{item.qty}}"], ["{{item.amount}}"]]
);
Every column starts as auto (sized to its widest measured content). After measurement, the single column with the largest aggregate content width is promoted to 1fr so it absorbs leftover space — usually whichever column carries the long free-form text. Tie-break is leftmost.
The promotion is purely additive. If you write a width — "1fr", "<n>%", <n>pt, or a number — for any column, the builder leaves your array alone. Existing templates render exactly as before.
A new grid token, auto-stretch, makes the candidate-then-promote behaviour available directly in attr.grid for hand-built rows that don't go through table(). Cell widths in the second table() argument are now optional too — the row's grid slot pins the column width, so per-cell widths were always redundant.
Skill: see pdf-template-author.md §Grid token forms.
The /ai page is now a sortable leaderboard across five scenarios and 16+ providers, with per-row download links to every generated PDF.
The bring-your-own-AI benchmark on /ai grew up.
- 5 scenarios instead of one — invoice, receipt, quote, resume, report.
- 16+ providers, including OpenRouter routes for cross-vendor comparison.
- Sortable leaderboard — sort by latency, tokens, success rate, or scenario.
- Per-row PDF links — every generated artifact is downloadable so you can eyeball the actual output, not just the numeric score.
The benchmark runner is still the same scripts/tools/benchmark-models.ts; results are published as static JSON in the public dir and refreshed on demand.
Upload TTF or OTF fonts once at the account level and reference them by family name from any template — no per-request `fonts:` payload required.
You can now upload custom fonts once at the account level and use them across every template, without re-sending the bytes on every render.
- Upload at
/settings/fonts — TTF or OTF, max 5 MB per variant.
- Variable fonts are rejected with a clear error (we don't support them yet).
- Up to 4 families per account, 6 variants per family.
- Fonts are subsetted on registration and cached in KV.
Once uploaded, just reference the family in your DSL styles:
text({ "font-family": "Brand", "font-weight": "bold" }, "Hello");
The per-request fonts: payload on /preview and /render still works for one-off cases, but accounts that use the same brand fonts everywhere don't have to ship them on each call any more.
This is also the supported path for non-Latin European scripts (Cyrillic, Greek, Vietnamese, Georgian, Armenian) — see the pdf-fonts skill.
Top up between $20 and $500 in a single Growth purchase, and admins can now grant wallet credits directly to any user.
Two billing improvements landed together.
Custom-amount Growth purchase
The Growth plan now accepts any amount between $20 and $500 in a single purchase. Pick a number, hit checkout, credits land in your wallet.
Admin credit grants
Admins can grant wallet credits directly to any user — handy for support cases, pilot programs, and one-off comps without round-tripping through Stripe.
A few smaller polish fixes along the way: plan-card CTA buttons align consistently at the bottom of each card, the checkout spinner clears properly after a Stripe redirect, and the makespdf.com watermark on free renders is now anchored to the page bottom.
blog.makespdf.com is live, served from the same Worker. Under the hood, the Mermaid pipeline was rewritten as @pdf/mermaid-render — 80% smaller SVG, no fake DOM, Workers-native.
Two things shipped together because they wanted each other.
blog.makespdf.com
The blog is now served from a dedicated subdomain with its own routing, while still living inside the same Cloudflare Worker as the rest of the app. Posts are markdown files in apps/web/content/blog/; each one is also available as a download-ready PDF rendered by the same engine that powers the public API.
@pdf/mermaid-render
The Mermaid → SVG pipeline was rewritten end-to-end. The old approach pulled in mermaid + linkedom (76.5 MB installed) and depended on a fake DOM; the new package is a clean parse → layout → render pipeline:
- Vendored jison parsers from Mermaid v11.12.2 produce typed ASTs.
- ELK.js computes graph layout for flowchart / class / state / ER.
- SVG XML is generated directly — no DOM, no shim.
- Single external dependency:
elkjs.
Result: 7.7 MB installed (down from 76.5 MB), SVG output ~80% smaller (3 KB vs 15 KB), deterministic, Workers-compatible. Supports all 8 Mermaid diagram types: flowchart (with subgraphs), sequence, class, state, gantt, pie, ER, gitgraph.
The curated template library (invoices, receipts, quotes, resumes, …) is now browsable over the API. Discover a template, fetch its DSL, fork into a user template, render.
The curated template library — invoices, receipts, quotes, resumes, and friends — is now first-class over the API.
GET /api/v1/library # list / search (category, tags, q)
GET /api/v1/library/:id # fetch a template's full DSL + sampleData
Both endpoints are read-only and rate-limited at 500 requests/hour.
There is intentionally no "render a library template directly" shortcut. The flow is fork-then-render:
GET /api/v1/library?category=invoice&q=classic — discover.
GET /api/v1/library/invoice-classic — fetch DSL.
- Customise locally, then
POST /api/v1/templates to create a user copy.
POST /api/v1/render { templateId, data } as normal.
This keeps ownership and billing attached to a concrete user row.
A first-party Model Context Protocol server at /api/v1/mcp lets agents drive the full authoring loop — preview → save → render — without scraping HTML.
makesPDF now exposes a Model Context Protocol server at /api/v1/mcp. Agents (Claude Desktop, Claude Code, Cursor, anything that speaks MCP) can drive the full authoring loop without scraping HTML or hand-rolling a client.
The server exposes tools for:
- Preview — render inline DSL with sample data, zero credits.
- Save / list / fetch / update / delete templates — the full persistence API.
- Render — produce a real PDF from a saved template.
- Library browsing — discover the curated template library and fetch DSL to fork.
Setup instructions: see /docs/mcp. The server is authed the same way as the rest of /api/v1/* — Bearer API key or session cookie.
Every render is now captured as an artifact (inputs + output) addressable by an X-Artifact-Id header, with user and admin pages to file and triage rendering issues.
Every successful /render and /preview call now captures the input DSL, the input data, and the resulting PDF as a first-class artifact. The response carries an X-Artifact-Id header so callers can refer to a specific render later.
The Done panel after a render in the web UI now exposes:
- A direct download link for the finished PDF.
- A "Report an issue" hook that ties the report to the artifact id — no need to re-upload anything.
Two new pages were added:
/settings/reports — your own submitted reports.
/admin/reports — admin triage view across all users.
A nightly cleanup job sweeps expired render artifacts so storage stays bounded.
The biggest pivot since launch. /api/v1/render now takes a templateId you saved earlier, /preview is the free authoring surface, and AI is no longer part of the core worker.
The biggest pivot since launch. makesPDF is now positioned as the fastest PDF rendering API — no longer an AI product. The AI is yours to bring.
Breaking: /api/v1/render contract changed
- Old:
POST /api/v1/render { type, data } — called Claude/OpenAI to auto-generate a template, then rendered it. Billed.
- New:
POST /api/v1/render { templateId, data } — renders a template you previously saved via POST /api/v1/templates. Billed (1 credit / 10 pages on success; zero on any 4xx/5xx).
There is no migration shim — pre-launch, no external customers to migrate.
New: template persistence API
POST /api/v1/templates — save a DSL template (free; catalog-validated; 512KB DSL cap).
GET /api/v1/templates — list your templates.
GET /api/v1/templates/:id — fetch DSL.
PUT /api/v1/templates/:id — update DSL or name.
DELETE /api/v1/templates/:id — delete.
All endpoints user-scoped; 404 for unknown or non-owned ids (indistinguishable by design).
/api/v1/preview is now the free draft surface
Iterate on DSL with /preview (free, length-preserving filler in data, "PREVIEW — NOT FOR USE" watermark baked in), save with /templates, then render real PDFs with /render.
AI removed from the core worker
CLAUDE_API_KEY is no longer required for deployment. It stays optional for dev tooling only (scan-template.ts, batch-scan.ts, the bakeoff runner). No Anthropic/OpenAI SDK is loaded at request time.
Promo: /api/v1/md free under a page cap
KV-gated. The Markdown-to-PDF route skips the credit deduct when pages ≤ cap and emits an X-Credits-Free-Under header.