DEV Community

SolutionsCraft
SolutionsCraft

Posted on Originally published at solutionscraft.com on

n8n Invoice PDF Generation, No Duplicates

The n8n invoice PDF generation demo everyone builds first is the same five minutes: a trigger, a template, an email. It works great in the editor, right up until a flaky connection makes your order system retry the same webhook, and your customer gets the same invoice twice, or a PDF service hiccups for one request and the email step just... doesn't send, with nothing telling you it didn't. The happy path was never the hard part here. Making it survive a retry, a slow dependency, or a bad payload without either duplicating or silently vanishing is.

This walkthrough builds that version: webhook in, validated and deduplicated, PDF rendered by a self-hosted converter, emailed with the PDF attached, and every outcome (sent or failed) logged somewhere you'll actually see it.

Before you start

You'll need an n8n instance (self-hosted works best for this one, since Step 4 runs a small companion container alongside it, though a free n8n Cloud trial will still run everything except that container), SMTP credentials for the Send Email node, and a Google Sheet to act as the invoice ledger (columns: idempotencyKey, orderId, customerEmail, total, status, errorMessage, updatedAt).

Disclosure: the n8n Cloud link above is an affiliate link — if you sign up through it, we may earn a commission at no extra cost to you. See our affiliate disclosure for details.

Step 1: Validate the payload and generate an idempotency key

Add a Webhook node (POST, path /invoice) as the trigger, then a Code node right after it. This is the only node in the workflow doing real logic, so it's worth getting right: it checks the incoming payload has everything an invoice needs, totals the line items in integer cents rather than raw floats (the standard defensive habit in anything handling money, even though it rarely bites at normal 2-decimal invoice amounts), and derives a stable idempotencyKey by hashing the order ID. That key is what Step 2 checks before generating anything.

const crypto = require("crypto");

function toCents(amount) {
  return Math.round(Number(amount) * 100);
}

const payload = $input.first().json.body;
const errors = [];

if (!payload.orderId) errors.push("missing orderId");
if (!payload.customerEmail) errors.push("missing customerEmail");
if (!Array.isArray(payload.lineItems) || payload.lineItems.length === 0) {
  errors.push("lineItems must be a non-empty array");
} else {
  payload.lineItems.forEach((item, i) => {
    if (!item.description) errors.push(`lineItems[${i}] missing description`);
    if (!(Number(item.quantity) > 0)) errors.push(`lineItems[${i}] quantity must be > 0`);
    if (!(Number(item.unitPrice) >= 0)) errors.push(`lineItems[${i}] unitPrice must be >= 0`);
  });
}

if (errors.length > 0) {
  return [{ json: { ok: false, errors } }];
}

let totalCents = 0;
const lineItems = payload.lineItems.map((item) => {
  const lineCents = toCents(item.unitPrice) * Number(item.quantity);
  totalCents += lineCents;
  return { ...item, lineTotal: (lineCents / 100).toFixed(2) };
});

const idempotencyKey = crypto
  .createHash("sha256")
  .update(String(payload.orderId))
  .digest("hex")
  .slice(0, 16);

return [{
  json: {
    ok: true,
    idempotencyKey,
    orderId: payload.orderId,
    customerEmail: payload.customerEmail,
    lineItems,
    total: (totalCents / 100).toFixed(2),
  },
}];

Enter fullscreen mode Exit fullscreen mode

I wrote this as a plain Node script first and ran it against a normal multi-line invoice, a repeat of the same order ID (confirming it hashes to the same key both times), a payload missing required fields, and a line item with a zero quantity and a negative price. All four cases returned exactly what they should: correct totals, a matching key on the repeat, and the right error list on the two bad payloads. The version above is a direct port of that logic into n8n's expression context ($input.first().json.body instead of a function argument), not a rewrite, though the live n8n execution itself isn't something this sandbox can run.

Blip: require("crypto") works out of the box on n8n Cloud. Self-hosted, it doesn't, until you set NODE_FUNCTION_ALLOW_BUILTIN=crypto (or =* for every built-in module) on the instance. Find that out before your first real invoice, not during it.

Add an IF node after the Code node checking {{ $json.ok }} is true. Wire the false branch to a Respond to Webhook node set to a 400 status with {{ $json.errors }} as the body, so a malformed order fails loudly and immediately instead of limping further into the workflow.

Step 2: Check the ledger before you generate anything

This is the step that actually prevents the double-send. Add a Google Sheets node set to Get Row(s), pointed at your ledger sheet, filtered on idempotencyKey equal to {{ $json.idempotencyKey }}. Follow it with an IF node checking whether any row came back.

If a row exists, this exact order has already been processed, so wire that branch straight to a Respond to Webhook node (200, body like {"status": "already processed"}) and stop there, no PDF, no second email. If nothing came back, continue to Step 3. This is what makes a retried webhook safe: the order system doesn't know or care that it sent the same request twice, but your workflow does.

Step 3: Build the invoice HTML

Add another Code node that turns the validated data from Step 1 into a plain HTML string. Keep this simple and self-contained, since it's the input to the PDF conversion in the next step:

const invoice = $input.first().json;

const rows = invoice.lineItems
  .map(
    (item) => `
    <tr>
      <td>${item.description}</td>
      <td style="text-align:right">${item.quantity}</td>
      <td style="text-align:right">$${Number(item.unitPrice).toFixed(2)}</td>
      <td style="text-align:right">$${item.lineTotal}</td>
    </tr>`
  )
  .join("");

const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8" /><style>
body { font-family: sans-serif; padding: 32px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
th, td { padding: 8px; border-bottom: 1px solid #ddd; }
th { text-align: left; }
.total { text-align: right; font-weight: bold; margin-top: 16px; }
</style></head>
<body>
  <h1>Invoice ${invoice.orderId}</h1>
  <p>Billed to: ${invoice.customerEmail}</p>
  <table>
    <thead><tr><th>Description</th><th>Qty</th><th>Unit price</th><th>Line total</th></tr></thead>
    <tbody>${rows}</tbody>
  </table>
  <p class="total">Total: $${invoice.total}</p>
</body></html>`;

return [{ json: { ...invoice, html } }];

Enter fullscreen mode Exit fullscreen mode

I ran this one too, against the same three-line-item invoice from Step 1's test, and checked the output has exactly one <tr> per line item plus the correct total string rendered in the markup. Both checks passed.

Step 4: n8n invoice PDF generation with Gotenberg

Now turn that HTML string into an actual binary PDF file. Add a Convert to File node, operation Text File , Text Input Field set to html, and File Name set to index.html.

Blip: The Convert to File node also has an operation literally called "HTML." Tempting, but it builds an HTML table out of your JSON, it doesn't wrap an existing HTML string into a file. "Text File" with the name forced to index.html is the one that does what you actually want here.

For the conversion itself, run Gotenberg as a small companion container next to n8n, it's an open-source, self-hosted HTML-to-PDF service with no API key or account needed:

docker run -d --name gotenberg -p 3000:3000 gotenberg/gotenberg:8

Enter fullscreen mode Exit fullscreen mode

Then add an HTTP Request node: POST http://gotenberg:3000/forms/chromium/convert/html (use the container name instead of localhost if n8n and Gotenberg share a Docker network), Body Content Type Multipart Form-Data , with one form field named files set to send the binary data from the Convert to File node. Gotenberg specifically requires the HTML file be named index.html, which is exactly what Step 4's File Name setting sets it to.

On this node's Settings tab, turn on Retry On Fail (3 tries, ~2000ms between them), a one-off timeout on the PDF service shouldn't kill the whole invoice. Set On Error to Continue (using error output) rather than the default Stop Workflow , so a conversion that keeps failing routes to a separate branch instead of just halting silently. Wire that error output to Step 6's failure logging.

I don't have a working Docker daemon in this sandbox to run the container end-to-end, so the request shape above is cross-checked against Gotenberg's own documented route and example curl command, not executed here. The Code node logic feeding it, and the HTML it produces, was executed and verified as described in Steps 1 and 3.

Step 5: Send it, and don't let a failure disappear

Add a Send Email node using an SMTP credential, attaching the PDF from the Gotenberg response as a binary attachment. Configure the same Retry On Fail (3 tries, ~2000ms) and On Error: Continue (using error output) settings as Step 4, and wire its error output to the same failure branch. An SMTP server that's briefly unreachable is common enough that it deserves a retry before you give up on it, and a permanent failure deserves a row in the ledger, not a workflow that just stops.

Step 6: Log the outcome, either way

On the success path (PDF converted and emailed), add a Google Sheets node set to Append or Update Row , matching on idempotencyKey, writing orderId, customerEmail, total, status: "sent", and a timestamp. Follow it with a Respond to Webhook node returning 200.

On the failure branch from either Step 4 or Step 5's error output, add the same Append or Update Row setup, but with status: "failed" and the error message from {{ $json.error.message }}, then a Respond to Webhook node returning 502 so the caller's own retry logic knows this attempt genuinely didn't succeed, distinct from the 200 "already processed" response in Step 2.

That per-node error handling covers the PDF and email calls specifically, but it can't catch everything, a malformed webhook body that crashes the Step 1 Code node before it ever returns, for instance. For that, build a second, tiny workflow with just an Error Trigger node, then in this workflow's own Options > Settings > Error workflow , select it. Whatever slips past the branches above still lands somewhere you'll see it, with the failed execution's own link attached.

What this doesn't solve

The dedup check in Step 2 has a real race condition: if the exact same webhook fires twice within milliseconds of each other, both executions can read "no matching row" before either one finishes writing its own, and you'd still get a duplicate. This setup closes the common case (a retry seconds or minutes later, which is what actually happens with flaky connections and queued webhooks), not a genuine simultaneous double-fire, that needs a proper database with a unique constraint on idempotencyKey, which a spreadsheet can't enforce. An SMTP server accepting the message for delivery also isn't the same thing as it reaching an inbox, a hard bounce still needs its own handling (typically a webhook from your email provider) if you want to know about it here rather than in your provider's dashboard. And none of this retries automatically if the Gotenberg container itself is down for an extended stretch, that shows up as a string of failed rows in the ledger, worth a glance rather than something this workflow tries to page you about on its own.

Where to start

Build Steps 1 and 2 first and send the workflow a handful of test payloads, including the same orderId twice, before you wire up anything that touches a real inbox. Once the ledger is correctly catching duplicates and rejecting bad payloads, add the PDF and email steps, and test with your own address a few times before pointing it at an actual customer. The failure branch is the one part of this worth deliberately triggering yourself, stop the Gotenberg container for a minute and send a test invoice, and confirm a failed row shows up rather than nothing at all.

Top comments (0)