HTML to PDF in Bubble.io

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

1. Get a free API key (once)

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.

2. Set up the API Connector

In the Plugins tab, install API Connector (by Bubble), then Add another API:

FieldValue
API NameShotmint
AuthenticationPrivate 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:

FieldValue
Namehtml to pdf
Use asAction
so it shows up in workflows
Data typeJSON
Method and URLPOST https://shotmint.creasework.com/v1/pdf
Body typeJSON

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
}

3. The workflow

On your "Download invoice" button: Plugins → Shotmint - html to pdf, then:

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.

Why :formatted as JSON-safe

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.

Building the HTML

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.

Printing a Bubble page instead

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.

Errors and credits

StatusMeaning and fix
400Bad body or parameter
usually a value without :formatted as JSON-safe
401Missing or wrong key
Key value must be Bearer sm_... with one space
402Out of credits
the body has a top-up link; €5 buys 2,000 PDFs
503Queue 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.

Try it with 25 free renders a month

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

Get a free API key Full API reference

More guides: No-code and CI integrations

All guides: guides · integrations · comparisons