A quote (or estimate) is often the first document your SaaS sends a prospective customer. It needs to look professional, include the right fields, and land in their inbox fast. Building a PDF quote generator from scratch means dealing with layout engines, line-item math, dynamic variable injection, and branded output. Most developers end up hacking together a Puppeteer script or a server-side HTML renderer and spending way more time on it than planned.
This tutorial shows a complete flow: design the quote template once, pass line items and client data, call one API endpoint, and get a production-ready PDF back in seconds.
How a Quote Differs from an Invoice
A quote and an invoice share a lot of structure: header with your company info, client details, a line-item table, and a total. But the differences matter when you are building the template.
| Field | Invoice | Quote |
|---|---|---|
| Document number | INV-2026-0012 | QUO-2026-0012 |
| Date label | Invoice date | Quote date |
| Due date | Payment due by | Validity date (offer expires) |
| Line items | Fixed, delivered | May include optional items |
| Footer | Payment terms | Terms and conditions, call to action |
| Status | Paid / Unpaid | Pending / Accepted / Rejected |
The two fields that change your template the most are the validity date (the prospect needs to know the offer is time-limited) and optional line items (you may want to show add-ons the client can choose).
Designing the Quote Template
A good quote template has five sections:
- Header -- your logo, company name, address, quote number, quote date, validity date
- Client block -- company name, contact name, address, email
- Line-item table -- description, quantity, unit price, subtotal; optional items flagged visually
- Totals block -- subtotal, discount (if any), tax rate, total
- Footer -- payment/delivery terms, acceptance CTA, signature line
Use variables for every dynamic value. Hardcode only what never changes (column headers, label text, design elements).
HTML/Tailwind snippet
Here is a minimal quote template body using Handlebars syntax and Tailwind utility classes. This is what you define once in Transactional.dev's editor.
<div class="p-10 font-sans text-sm text-gray-800">
<!-- Header -->
<div class="flex justify-between items-start mb-10">
<div>
<p class="text-2xl font-bold text-gray-900">{{ company.name }}</p>
<p class="text-gray-500">{{ company.address }}</p>
</div>
<div class="text-right">
<p class="text-xl font-semibold">Quote #{{ quote.number }}</p>
<p class="text-gray-500">Date: {{ quote.date }}</p>
<p class="text-amber-600 font-medium">Valid until: {{ quote.validUntil }}</p>
</div>
</div>
<!-- Client block -->
<div class="mb-8 p-4 bg-gray-50 rounded">
<p class="font-semibold">{{ client.name }}</p>
<p>{{ client.contactName }}</p>
<p class="text-gray-500">{{ client.email }}</p>
</div>
<!-- Line items -->
<table class="w-full mb-6">
<thead class="bg-gray-900 text-white">
<tr>
<th class="p-2 text-left">Description</th>
<th class="p-2 text-right">Qty</th>
<th class="p-2 text-right">Unit price</th>
<th class="p-2 text-right">Subtotal</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr class="{{#if this.optional}}bg-amber-50{{else}}bg-white{{/if}} border-b">
<td class="p-2">
{{ this.description }}
{{#if this.optional}}<span class="ml-2 text-xs text-amber-600">(optional)</span>{{/if}}
</td>
<td class="p-2 text-right">{{ this.qty }}</td>
<td class="p-2 text-right">{{ this.unitPrice }}</td>
<td class="p-2 text-right font-medium">{{ this.subtotal }}</td>
</tr>
{{/each}}
</tbody>
</table>
<!-- Totals -->
<div class="flex justify-end mb-8">
<div class="w-64">
<div class="flex justify-between py-1 border-b">
<span>Subtotal</span><span>{{ totals.subtotal }}</span>
</div>
{{#if totals.discount}}
<div class="flex justify-between py-1 border-b text-green-600">
<span>Discount</span><span>-{{ totals.discount }}</span>
</div>
{{/if}}
<div class="flex justify-between py-1 border-b">
<span>Tax ({{ totals.taxRate }}%)</span><span>{{ totals.tax }}</span>
</div>
<div class="flex justify-between py-2 font-bold text-base">
<span>Total</span><span>{{ totals.total }}</span>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-gray-500 text-xs border-t pt-4">
<p>{{ quote.terms }}</p>
<p class="mt-2 font-medium text-gray-700">To accept this quote, reply to this email or sign below.</p>
</div>
</div>
A few notes on this template:
quote.validUntilis visually highlighted in amber so the prospect cannot miss the deadline.- Optional line items use
this.optionalto conditionally style the row and add an "(optional)" label. You can hide them entirely with{{#unless this.optional}}if you prefer a clean quote without noise. - The discount block uses
{{#if totals.discount}}so it disappears when there is no discount, keeping the PDF clean.
Calling the API
Once the template is saved in your Transactional.dev document, copy its UUID from the editor URL. Then generate the PDF with a single POST call.
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": {
"company": {
"name": "Acme SaaS Inc.",
"address": "42 Market Street, San Francisco, CA 94105"
},
"quote": {
"number": "QUO-2026-0047",
"date": "2026-06-10",
"validUntil": "2026-06-25",
"terms": "This quote is valid for 15 days. Delivery within 5 business days of acceptance."
},
"client": {
"name": "Bright Widgets Ltd.",
"contactName": "Sarah Chen",
"email": "sarah@brightwidgets.example"
},
"items": [
{ "description": "Pro plan (annual)", "qty": 1, "unitPrice": "$1,200.00", "subtotal": "$1,200.00", "optional": false },
{ "description": "Onboarding session (2h)", "qty": 1, "unitPrice": "$300.00", "subtotal": "$300.00", "optional": false },
{ "description": "Priority support add-on", "qty": 1, "unitPrice": "$200.00", "subtotal": "$200.00", "optional": true }
],
"totals": {
"subtotal": "$1,500.00",
"discount": null,
"taxRate": "20",
"tax": "$300.00",
"total": "$1,800.00"
}
}
}'
The API responds with a signed URL:
{
"url": "https://files.transactional.dev/client/42/generated/.../1749400000000.pdf",
"documentId": "1d8e5d56-1a4f-4b62-8c33-2d34a64b2f00"
}
Download the PDF or pass the URL directly to your email provider. The URL is short-lived, so store it or send it immediately.
Auth note: the header is x-api-token, not Authorization: Bearer. The body field is documentId (a UUID), not templateId. These are the two most common mistakes when integrating for the first time.
Sending the Quote by Email
A quote is most useful when it arrives in context. The typical flow looks like this:
- User fills out a quote form in your app (client details, line items, deal terms).
- Your backend calls
POST /v1/generatewith the assembled variables. - You receive the PDF URL in the response.
- You pass that URL to your email provider (SendGrid, Postmark, Resend, etc.) as an attachment or a download link.
- The prospect receives a branded email with the quote PDF attached.
In Node.js with Resend, that looks like:
const { url } = await fetch('https://api.transactional.dev/v1/generate', {
method: 'POST',
headers: {
'x-api-token': process.env.TRANSACTIONAL_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ documentId: QUOTE_DOC_ID, variables }),
}).then(r => r.json());
// Download PDF buffer
const pdfBuffer = await fetch(url).then(r => r.arrayBuffer());
await resend.emails.send({
from: 'quotes@acmesaas.com',
to: client.email,
subject: `Your quote #${quote.number} from Acme SaaS`,
html: `<p>Hi ${client.contactName},</p><p>Please find your quote attached.</p>`,
attachments: [{ filename: `quote-${quote.number}.pdf`, content: Buffer.from(pdfBuffer) }],
});
You can also skip the download step and send the signed URL as a "View quote" button in the email body, letting you track whether the prospect opened it.
Storing the Quote and Tracking Its Status
Generating the PDF is one step; managing its lifecycle is another. Store at minimum:
- The generated PDF URL (or a permanent copy in your own S3 bucket)
- The variables snapshot (so you can regenerate or audit what the client saw)
- The quote status:
pending,accepted,rejected,expired - The validity date (run a cron job to flip
pendingtoexpiredautomatically)
A simple schema:
CREATE TABLE quotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
number TEXT NOT NULL,
client_id UUID REFERENCES clients(id),
pdf_url TEXT,
variables JSONB,
status TEXT DEFAULT 'pending',
valid_until DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
When a prospect accepts, update status = 'accepted' and trigger whatever comes next: create an invoice, start a subscription, kick off an onboarding workflow. The quote becomes the source of truth for that deal.
Tips
- Pre-compute totals server-side. Do not put math logic in the template. Calculate subtotals, tax, and totals in your backend and pass formatted strings to the API. This keeps the template dumb and auditable.
- One document per quote type. If you have different quote formats (SaaS subscription vs. one-time project vs. retainer), create one document per type in Transactional.dev. Do not try to handle all cases with complex conditionals in a single template.
- Store the variables snapshot. Quotes can be disputed months later. Having the exact JSON you passed when generating the PDF means you can always regenerate it identically.
- Add a quote number prefix. Use
QUO-instead ofINV-so quotes and invoices are immediately distinguishable in your database and in your client's email inbox.
Common Mistakes
Sending a quote with no expiry. Without a validity date, there is no urgency. Always set validUntil and communicate it clearly in the email subject line.
Using templateId or Authorization: Bearer in the API call. The correct field is documentId (a UUID) and the correct header is x-api-token. Wrong credentials return a 401; wrong field name returns a 400.
Optional items without a clear visual distinction. If a prospect sees a total that includes optional items, they will be confused. Either flag them visually in the template or produce two separate PDFs (base quote + optional add-ons).
Regenerating the PDF from current data instead of the stored snapshot. If prices change after a quote is sent, regenerating from live data will produce a different document. Always regenerate from the original variables snapshot.
Wrapping Up
A PDF quote generator does not have to be a multi-week project. The core of it is: one well-designed template with the right fields (validity date, optional items, terms), a single API call that injects dynamic data, and a straightforward email delivery step.
If you want to skip building and maintaining the PDF rendering infrastructure entirely, Transactional.dev gives you a template editor, a variables system, and a generation API. You define the template once in HTML and Tailwind, and every quote is a POST request away. The free tier is enough to validate the flow before you commit.
