Odoo HTML to PDF API: Replace wkhtmltopdf With Modern Rendering
Yes, you can replace Odoo's PDF engine with an external rendering API. Odoo generates PDFs by piping QWeb-rendered HTML through wkhtmltopdf inside ir.actions.report. Override that one method in a small custom module, POST the HTML to a modern Odoo HTML to PDF API, and return the bytes. No core patch, no fork. This works on self-hosted and Odoo.sh; it does not work on Odoo Online, which blocks custom modules.
If you have ever shipped an invoice to a customer with a collapsed flexbox header or a table that ignored half its CSS, you already know the problem. wkhtmltopdf renders with a WebKit build from 2012, and it shows. The good news: the swap is smaller than you think, and you keep your existing QWeb templates.
This guide walks the full path. You will see how Odoo's report pipeline actually works, the complete override code, a safer invoice-only variant, and the gotchas that bite people in production, headers, relative asset URLs, and version drift across Odoo 15 to 18.
Key Takeaways
- Odoo renders PDFs by calling_run_wkhtmltopdfinsideir.actions.report; inherit that model and override the method to reroute rendering to an API.
- A modern HTML to PDF API renders with Headless Chrome, so flexbox, CSS grid, and webfonts behave exactly as they do in your browser.
- Usereport_ref == 'account.report_invoice'to convert only invoices and leave every other report on Odoo's default path.
- Reimplement headers and footers yourself; wkhtmltopdf injected them per page natively, and a Chrome-based API uses its own header and footer parameters.
- Custom modules run on self-hosted Odoo and Odoo.sh only. Odoo Online cannot install them.
Why your Odoo PDFs look wrong
The short version: Odoo prints with wkhtmltopdf, and wkhtmltopdf is built on a forked Qt 4.8 WebKit engine that predates modern CSS. Flexbox and CSS grid either render incorrectly or fail silently. Webfonts load late or not at all. Anything you designed in Chrome and then printed through Odoo comes out subtly, or badly, wrong.
This is a rendering-engine problem, not a "your CSS is wrong" problem. The layout works in your browser because your browser is a current build of Chromium. wkhtmltopdf never got those years of layout fixes.
Upstream made it official. The wkhtmltopdf project was archived and is no longer maintained, and Odoo's own fork of the tool was archived read-only in August 2024. The Odoo community has floated replacing it for years, in threads like issue #21255 and issue #20650. There is also a real operational cost: wkhtmltopdf's memory and file-descriptor use climbs steeply on large documents, so a 500-page statement run can exhaust resources on the worker.
Consider Priya, a consultant migrating a client from Odoo 14 to 17 in early 2026. Their branded invoice used a CSS grid header with a logo, a tax summary, and a QR code. In Chrome it was pixel-perfect. Printed through Odoo, the grid collapsed into a single column and the QR code floated off the page. She spent two days rewriting the template with float hacks before realizing the engine, not the markup, was the ceiling.
Fixing rather than replacing? If you would rather patch the existing engine first, our Odoo wkhtmltopdf troubleshooting guide covers the common failures (missing CSS, wrong report.url, Docker font issues) before you commit to swapping engines.How Odoo's report pipeline actually works
Before you override anything, know the one seam you need to intercept. Odoo's PDF path has three stages:
- QWeb renders the template to HTML. A report action of type
ir.actions.reportbinds a QWeb template to a model. Calling it produces an HTML document per record. _render_qweb_pdforchestrates the job. It gathers the HTML bodies, the header, the footer, and the paper format, then hands them off for conversion._run_wkhtmltopdfdoes the actual conversion. This method lives inir_actions_report.py. It shells out to thewkhtmltopdfbinary withsubprocess, feeds it the HTML, and returns the finished PDF as bytes.
That third method is your interception point. It takes the rendered HTML and returns PDF bytes, which is exactly the contract an HTML to PDF API fulfills. Replace what happens inside it, keep the same inputs and outputs, and the rest of Odoo neither knows nor cares.
Here is the method signature in recent versions, so you know what you are overriding:
def _run_wkhtmltopdf(
self,
bodies, # list of HTML strings, one per record
report_ref=False, # e.g. 'account.report_invoice'
header=None, # rendered header HTML
footer=None, # rendered footer HTML
landscape=False,
specific_paperformat_args=None,
set_viewport_size=False,
):
# ...builds a wkhtmltopdf command and returns PDF bytes
Overriding the Odoo PDF engine with an HTML to PDF API
Now the part you came for. Create a small module that inherits ir.actions.report and overrides the render method to POST your HTML to the API instead of shelling out to wkhtmltopdf. This is the core of Odoo custom PDF rendering: intercept the bytes, change how they are produced, return the same shape.
The example below sends the rendered HTML to Transformy, which renders it with Headless Chrome and returns the PDF. Store the API key in ir.config_parameter, never in source.
# transformy_pdf/models/ir_actions_report.py
import requests
from odoo import models
TRANSFORMY_URL = "https://api.transformy.com/v1/pdf/chrome"
class IrActionsReport(models.Model):
_inherit = "ir.actions.report"
def _render_via_transformy(self, bodies, header=None, footer=None, landscape=False):
# Join each record's HTML with a hard page break so a multi-record
# print job (say, 20 invoices at once) stays a single PDF.
page_break = '<div style="page-break-after: always;"></div>'
html = ("\n" + page_break + "\n").join(bodies)
paperformat = self.get_paperformat()
payload = {
"html": html,
"page_size": paperformat.format or "A4",
"landscape": landscape or paperformat.orientation == "Landscape",
"margin": {
"top": "%smm" % (paperformat.margin_top or 10),
"bottom": "%smm" % (paperformat.margin_bottom or 10),
"left": "%smm" % (paperformat.margin_left or 7),
"right": "%smm" % (paperformat.margin_right or 7),
},
"print_background": True,
}
api_key = self.env["ir.config_parameter"].sudo().get_param("transformy.api_key")
response = requests.post(
TRANSFORMY_URL,
json=payload,
headers={"Authorization": "Bearer %s" % api_key},
timeout=60,
)
response.raise_for_status() # a 4xx/5xx returns JSON, never a broken PDF
return response.content # raw PDF bytes, exactly what Odoo expects
A few decisions worth defending. The page_break join keeps batch printing intact; drop it and a 20-invoice run merges into one continuous flow. The timeout=60 matters because a hung render should fail loudly, not stall a worker forever. And raise_for_status() is deliberate: the API returns a JSON error with a real HTTP status on failure, so you get a clean exception instead of a PDF full of error text.
You can test this end to end without spending a cent. Transformy's free tier covers 100 documents a month with no credit card, and test keys render real (watermarked) PDFs for free, enough to prove the swap against your actual invoice template before you wire it into production.
Selective override: convert only invoices via report_ref
Rerouting every report at once is a big blast radius. A safer rollout converts one report, verifies it in the wild, then expands. The report_ref argument tells you which report is rendering, so you can guard on it.
class IrActionsReport(models.Model):
_inherit = "ir.actions.report"
def _run_wkhtmltopdf(self, bodies, report_ref=False, *args, **kwargs):
# Reroute only the customer invoice. Everything else keeps
# Odoo's default engine untouched.
if report_ref == "account.report_invoice":
return self._render_via_transformy(
bodies,
header=kwargs.get("header"),
footer=kwargs.get("footer"),
landscape=kwargs.get("landscape", False),
)
return super()._run_wkhtmltopdf(bodies, report_ref, *args, **kwargs)
The *args, **kwargs passthrough is not laziness. It is what keeps this override working when Odoo changes the method signature between versions (more on that below). You name only the arguments you use and forward the rest to super() verbatim.
This pattern scales cleanly. Start with account.report_invoice, confirm the rendered invoices look right, then add sale.report_saleorder or your custom report refs to the guard as you gain confidence. Low-stakes internal reports can stay on the default path forever if you want.
Gotchas nobody warns you about
The override is the easy part. These four details are where Odoo invoice PDF API integrations actually break.
- Headers and footers must be rebuilt. wkhtmltopdf injected running headers and footers per page natively, with its own page-number placeholders. A Chrome-based API does not read those. Do not pass Odoo's rendered
header/footerHTML straight through and expect page numbers. Instead, rebuild them as the API's own header and footer templates, using its page tokens (Transformy uses{{page}}and{{pages}}) with inline styles, since header and footer fragments render in isolation. - Relative asset URLs will not resolve. Your QWeb HTML references images and styles at paths like
/web/image/...and/web/assets/.... Those are relative to your Odoo host. Once the HTML leaves your server, the API cannot resolve them. Fix it by injecting a<base href="https://your-odoo.example.com">(read it from theweb.base.urlsystem parameter) so relative links become absolute, and make sure those assets are reachable by the API. - Paper format mapping is manual. Odoo stores page size, orientation, and margins on a
report.paperformatrecord. The override above reads them withself.get_paperformat()and maps them to the API's parameters. Check the mapping for custom formats; a report with no paper format falls back to defaults you should set deliberately. - Method signatures drift across versions. This is the big one.
_run_wkhtmltopdfand the surrounding render methods changed between Odoo 15, 16, 17, and 18. Argument order and helper names are not stable. Before you ship, openir_actions_report.pyfor your exact version and confirm the signature. The*args, **kwargspassthrough shown above absorbs most of this, but verify rather than assume.
When Tom, a solo developer running a self-hosted Odoo 17, first tried the swap in June 2026, his invoices rendered beautifully except for one thing: every logo was a broken-image icon. The template referenced /web/image/website/1/logo, a relative URL his own server understood but the rendering API never could. Adding a single <base href> tag pointed at web.base.url fixed all of it in one line.
Where this works, and where it doesn't
Hosting decides whether any of this is even possible, so be clear-eyed before you start.
- Self-hosted Odoo: yes. You control the addons path, so you can install the custom module freely.
- Odoo.sh: yes. Odoo.sh is built for custom modules; push the module to your repository and it deploys with your build. A small number of modules are restricted for technical reasons, but a report override like this is standard.
- Odoo Online (odoo.com SaaS): no. Odoo Online does not allow custom or third-party modules with Python code, full stop. If you need modern PDF rendering, you must be on Odoo.sh or self-hosted. There is no override path on Odoo Online.
The same override pattern, intercept the render call and POST HTML to an API, applies well beyond Odoo. If you work across stacks, our guides on HTML to PDF in Python and HTML to PDF in PHP show the same idea in a plain web framework, and the wkhtmltopdf migration guide maps old flags to modern parameters.
Frequently asked questions
Can I replace wkhtmltopdf in Odoo? Yes. Inherit ir.actions.report in a custom module and override _run_wkhtmltopdf (or route through it) to send the rendered HTML to an external API instead of the wkhtmltopdf binary. Your QWeb templates stay the same; only the rendering step changes.
Does this work on Odoo Online? No. Odoo Online blocks custom and third-party Python modules, so there is no way to install the override. This approach works only on self-hosted Odoo and Odoo.sh, both of which support custom modules.
How do I convert only invoices to PDF through the API? Guard your override on the report reference: if report_ref == 'account.report_invoice'. Rerouting only invoices keeps every other report on Odoo's default engine while you validate the new rendering path.
Why do my Odoo PDFs ignore CSS like flexbox and grid? Because wkhtmltopdf renders with a forked Qt 4.8 WebKit engine from around 2012, before modern CSS layout existed. Flexbox and grid fail or render incorrectly. A Headless Chrome renderer supports them natively, which is the whole reason to swap.
Which Odoo versions does this override support? The approach works across Odoo 15 through 18, but the _run_wkhtmltopdf signature changed between versions. Use a *args, **kwargs passthrough to super() and check ir_actions_report.py for your exact version before shipping.
Do I still need wkhtmltopdf installed after the swap? If you override every report, no rendering calls hit the binary, but leaving it installed avoids surprises from reports you did not reroute. With a selective override, keep it, since non-invoice reports still use it.
Conclusion
Odoo's PDF output looks dated because it is: wkhtmltopdf renders with a 2012-era WebKit engine that modern CSS outgrew years ago, and upstream has stopped maintaining it. You do not have to fork Odoo or rewrite templates to fix it. An Odoo HTML to PDF API integration is a single small module that inherits ir.actions.report, overrides the render call, and POSTs your existing HTML to an engine that renders like Chrome.
Three things to remember as you build:
- Override
_run_wkhtmltopdfand return raw PDF bytes; keep the inputs and outputs identical. - Start with a selective
report_refguard on invoices, then expand once it is proven. - Budget for the gotchas: rebuild headers and footers, make asset URLs absolute, and verify the method signature for your Odoo version.
The fastest way to know if this fixes your invoices is to try it on the template that is broken today. Grab a free Transformy key, render one real invoice with the override above, and compare it side by side with the wkhtmltopdf version. If the CSS finally behaves, you know exactly what to ship.