Your user just completed a purchase. They expect a receipt in their inbox within seconds. The receipt needs to be a proper PDF, not an HTML email pretending to be a document. Here's how to generate that PDF and attach it to a transactional email using services like Resend or Postmark.
The problem: getting from "payment confirmed" to "PDF in inbox"
Most transactional email services (Resend, Postmark, SendGrid) accept file attachments as base64-encoded strings. That part is straightforward. The hard part is generating the PDF itself.
You need to:
- Receive a webhook or event (payment success, order placed, account created)
- Generate a PDF with dynamic data (customer name, line items, totals)
- Encode that PDF as base64
- Attach it to the email API call
- Send it all within a few seconds so the user doesn't wait
Each step introduces its own failure modes. The PDF generation step is where most teams get stuck.
Why this is painful for developers
The obvious approach is to spin up a headless browser (Puppeteer, Playwright) and print a page to PDF. This works on your laptop. It falls apart in production.
Headless browsers consume 200-500MB of RAM per instance. They crash under load. They add 2-5 seconds of latency per document. On serverless platforms like Vercel or AWS Lambda, they barely fit within the deployment size limit. And you still need to manage Chromium versions, font rendering, and timeout handling.
For a receipt that takes 0.5 seconds to design in HTML, you end up spending days on infrastructure.
A simpler approach: API-based PDF generation
Instead of running your own PDF rendering stack, you can call an API that handles the heavy lifting. The flow becomes:
- Design your receipt template once (HTML + Tailwind CSS)
- When a payment event fires, call the PDF API with the relevant variables
- Download the PDF from the returned URL
- Attach it to your email
Transactional.dev does exactly this. You create a template in their editor using HTML and Tailwind, define your variables (customer name, invoice number, line items), and call a single endpoint to generate the PDF.
Step-by-step implementation
1. Generate the PDF
When your payment webhook fires, call the Transactional.dev API to generate the PDF:
const response = await fetch('https://api.transactional.dev/v1/generate', {
method: 'POST',
headers: {
'x-api-token': process.env.TRANSACTIONAL_API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: '1d8e5d56-1a4f-4b62-8c33-2d34a64b2f00',
variables: {
customer: {
name: payment.customerName,
email: payment.customerEmail,
},
invoice: {
number: payment.invoiceNumber,
date: new Date().toISOString().split('T')[0],
items: payment.lineItems,
total: payment.amount,
},
},
}),
});
const { url } = await response.json();
2. Download the PDF as a buffer
const pdfResponse = await fetch(url);
const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());
3. Send the email with Resend
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: 'billing@yourapp.com',
to: payment.customerEmail,
subject: `Receipt for order ${payment.invoiceNumber}`,
html: '<p>Thanks for your purchase! Your receipt is attached.</p>',
attachments: [
{
filename: `receipt-${payment.invoiceNumber}.pdf`,
content: pdfBuffer.toString('base64'),
},
],
});
Alternative: sending with Postmark
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_API_KEY);
await client.sendEmail({
From: 'billing@yourapp.com',
To: payment.customerEmail,
Subject: `Receipt for order ${payment.invoiceNumber}`,
HtmlBody: '<p>Thanks for your purchase! Your receipt is attached.</p>',
Attachments: [
{
Name: `receipt-${payment.invoiceNumber}.pdf`,
Content: pdfBuffer.toString('base64'),
ContentType: 'application/pdf',
},
],
});
Putting it together: a complete webhook handler
Here's a full Express.js handler that ties everything together:
app.post('/webhooks/payment-success', async (req, res) => {
const payment = req.body;
try {
// 1. Generate PDF
const pdfRes = await fetch('https://api.transactional.dev/v1/generate', {
method: 'POST',
headers: {
'x-api-token': process.env.TRANSACTIONAL_API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: '1d8e5d56-1a4f-4b62-8c33-2d34a64b2f00',
variables: {
customer: { name: payment.customerName, email: payment.customerEmail },
invoice: {
number: payment.invoiceNumber,
items: payment.lineItems,
total: payment.amount,
},
},
}),
});
const { url } = await pdfRes.json();
// 2. Download PDF
const pdfBuffer = Buffer.from(await (await fetch(url)).arrayBuffer());
// 3. Send email with attachment
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: 'billing@yourapp.com',
to: payment.customerEmail,
subject: `Receipt for order ${payment.invoiceNumber}`,
html: '<p>Your receipt is attached. Thanks for your purchase!</p>',
attachments: [
{
filename: `receipt-${payment.invoiceNumber}.pdf`,
content: pdfBuffer.toString('base64'),
},
],
});
res.status(200).json({ ok: true });
} catch (err) {
console.error('Failed to send receipt:', err);
res.status(500).json({ error: 'Receipt generation failed' });
}
});
Using cURL for testing
You can test the PDF generation step independently:
curl -X POST 'https://api.transactional.dev/v1/generate' \
-H 'x-api-token: YOUR_API_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"documentId": "1d8e5d56-1a4f-4b62-8c33-2d34a64b2f00", "variables": {"customer": {"name": "Acme Corp", "email": "ops@acme.example"}, "invoice": {"number": "INV-2026-0142", "total": 1280.50}}}'
This returns a JSON object with a url field pointing to the generated PDF. Download it, verify it looks correct, then wire up the email step.
Common mistakes and tradeoffs
Forgetting to await the PDF download. The generation API returns a URL, not the file itself. You need a second fetch to get the actual bytes before encoding to base64.
Sending the URL instead of the attachment. Some teams skip the attachment and just email a link. This works, but links expire. If the user opens the email a week later, the PDF might be gone. Attaching the file directly is more reliable for receipts and invoices.
Not handling generation failures. The PDF API call can fail (bad template, missing variables, network issues). Always wrap it in a try/catch and have a fallback: queue a retry, log the error, or notify your team.
Oversized attachments. Most email providers cap attachments at 10-25MB. Receipts are typically tiny (under 100KB), but if you're generating reports with images, watch the file size.
Base64 encoding format. Resend expects a raw base64 string. Postmark also expects base64 in the Content field. Don't accidentally double-encode it or wrap it in a data URL.
Conclusion
Generating and attaching a PDF to a transactional email doesn't need to be a multi-sprint project. With an API-based approach, the entire flow fits in a single webhook handler: generate the PDF, download it, attach it, send the email.
Transactional.dev handles the PDF rendering so you can focus on the template design and email logic. Create a free account, design your receipt template, and start sending PDF receipts in minutes instead of weeks.
