Bubble has no built-in PDF action, and many PDF plugins print the page inside the user's browser. The API Connector (Bubble's own free plugin) can call a PDF API from Bubble's server instead: you send HTML built from your database, Chromium prints it to an A4 PDF, and you get back a download link to open, email or save. Our one-page test invoice (19 KB) came back in about 0.1 s.
What was tested: every JSON body and response on this page was sent to the live API and the PDFs were checked with pdfinfo and pdftotext, including the broken body Bubble sends when a value isn't JSON-safe. We did not build this in a Bubble app ourselves; the field names come from Bubble's API Connector documentation (October 2026).
A key gives 25 free renders a month, no signup: click Get a free key and copy it (it starts with sm_). Paid credits are €5 for 2,000 renders and never expire.
In the Plugins tab, install API Connector (by Bubble), then Add another API:
| Field | Value |
|---|---|
| API Name | Shotmint |
| Authentication | Private key in header Key name Authorization (the default), Key value Bearer sm_... |
The key stays on Bubble's server and is never sent to your users' browsers. Then Add another call:
| Field | Value |
|---|---|
| Name | html to pdf |
| Use as | Action so it shows up in workflows |
| Data type | JSON |
| Method and URL | POST https://shotmint.creasework.com/v1/pdf |
| Body type | JSON |
Paste this body. Each <name> becomes a parameter below the body; untick Private on html and filename so workflows can set them:
{
"html": <html>,
"format": "A4",
"margin": "15mm",
"print_background": true,
"response": "url",
"filename": <filename>
}
Note that <html> and <filename> have no quotes around them. The quotes come from the :formatted as JSON-safe operator in the workflow (step 3). "response": "url" makes the API answer with JSON holding a link to the file instead of the raw PDF bytes, which is what a JSON call can store.
For Initialize call, give the parameters test values with the quotes included, e.g. "<h1>Test</h1>" and "test". Bubble sends one real request (1 credit) and learns the response fields:
{
"url": "https://shotmint.creasework.com/f/page-yY38LvDCcxaVzFeOiQK_oA.pdf/INV-1042.pdf",
"content_type": "application/pdf",
"size": 18574,
"expires_at": "2026-10-25T16:01:30.011Z",
"credits_remaining": 19,
"page_status": null
}
On your "Download invoice" button: Plugins → Shotmint - html to pdf, then:
:formatted as JSON-safe.Current page Invoice's number:formatted as JSON-safe. The download is named INV-1042.pdf; .pdf is added for you.Add a second step, Navigation → Open an external website, with destination Result of step 1's url. To email the PDF instead, put that link in a Send email body. Links work for 24 hours. For a "download" button, render on each click so the link is always fresh; it costs one credit per click.
The API Connector pastes parameter values into the body text as they are. If you write "html": "<html>" and the customer is called Acme "Rocket" Corp, or a description has a line break, the quote ends the JSON string early and the body is invalid. This is the exact request Bubble sends in that case, and the API's answer:
{"html": "<h1>Invoice INV-1042</h1><p>Billed to: Acme "Rocket" Corp</p>", "format": "A4", "response": "url"}
400 {"error": "Request body is not valid JSON: Expected ',' or '}' after property value in JSON at position 55 (line 1 column 56)", ...}
:formatted as JSON-safe escapes quotes, backslashes and line breaks and adds the surrounding quotes itself, which is why the body has none. With it, the same customer name printed correctly in our test: Billed to: Acme "Rocket" Corp.
Write the template in the html field with Bubble's dynamic expressions in place of the values, or keep it in an option set or a text field and use :find & replace. Put the styles in a <style> tag at the top; Chromium prints it like a web page:
<style>
body { font-family: sans-serif; margin: 0 }
table { width: 100%; border-collapse: collapse }
td, th { border-bottom: 1px solid #ddd; padding: 8px; text-align: left }
</style>
<h1>Invoice INV-1042</h1>
<p>Billed to: Acme "Rocket" Corp</p>
<table>
<tr><th>Item</th><th>Qty</th><th>Price</th></tr>
<tr><td>Design work</td><td>3</td><td>€450.00</td></tr>
</table>
<p><b>Total: €450.00</b></p>
For line items, use the list of items :format as text, with the content per item set to <tr><td>...</td></tr> and an empty delimiter. Images must be absolute https:// URLs (Bubble's uploaded files start with //, so prefix https:). Extras that go in the body: "landscape": true, "format": "Letter", "page_numbers": true for a "1 / 3" footer, or header_template / footer_template.
To print a public page as it looks, send "url": "https://yourapp.bubbleapps.io/invoice/..." in place of html. Bubble pages build themselves in JavaScript, so add "wait_until": "networkidle". Our test print of bubble.io/pricing (a Bubble app) gave the full 9-page page in 7.5 s. The renderer is not logged in to your app, so pages behind login print the login screen; for those, send the HTML.
| Status | Meaning and fix |
|---|---|
400 | Bad body or parameter usually a value without :formatted as JSON-safe |
401 | Missing or wrong key Key value must be Bearer sm_... with one space |
402 | Out of credits the body has a top-up link; €5 buys 2,000 PDFs |
503 | Queue full during a burst safe to retry |
Failed renders cost nothing. credits_remaining in each response lets a backend workflow warn you before you run out. See also: Make, Zapier, n8n, and the full API docs.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons