Two-Tiered Autonomous Self-Healing
To keep your systems fast and reliable, Fork PDF handles spatial layout failures using a powerful two-tiered recovery architecture. This example demonstrates how to combine internal engine patches with external webhook fallbacks to create a fully self-correcting generation loop.
Tier 1: Internal Engine Patches (Instant)
When your spatial validation script detects a minor visual layout issue (like a stranded footer or text overflow), it can return a patch object containing updated template variables. The Fork PDF engine intercepts this patch, instantly merges it into your original data payload, and re-renders the document automatically without requiring a network round-trip to your webhook. The engine will attempt up to 2 internal retries before giving up.
Tier 2: Webhook Fallbacks (Structural)
If a layout requires a fundamental structural change—or if the engine exhausts its internal retries—you can explicitly abort by returning success: false without a patch object. You can include any arbitrary custom keys and data in this return object (e.g., my_custom_flag: "POSTAGE_LIMIT"). The system routes your exact JSON object to your asynchronous Webhook, allowing your backend server to read your custom data and manipulate core configurations that the internal loop cannot touch (such as swapping the entire templateId).
1. The Webhook Server (Node.js)
This script triggers the initial PDF generation using a standard Template ID. If a catastrophic failure occurs—such as our test case detecting that the invoice exceeds 6 physical pages—the webhook intercepts the POSTAGE_LIMIT_EXCEEDED error. It then completely swaps the template ID to a more compact, consolidated summary template and re-triggers the generation, seamlessly preserving the original tracking ID.
const express = require('express');
const { ForkPDFClient } = require('forkpdf');
require('dotenv').config();
const app = express();
app.use(express.json());
const client = new ForkPDFClient(process.env.API_KEY, process.env.PROJECT_ID);
app.post('/webhook', async (req, res) => {
res.status(200).send('OK');
const { id, status, success, testResults, error, rawPayloadUrl, downloadUrl } = req.body;
if (success === true || status === 'completed') {
console.log(`[SUCCESS] PDF Generated | ID: ${id}`);
console.log(`[INFO] Download URL: ${downloadUrl}`);
return;
}
if (status === 'failed') {
const canFix = testResults?.custom_user_defined_error;
if (!canFix) {
console.error(`[ERROR] Unrecoverable failure for ${id}: ${error}`);
return;
}
console.log(`[WARN] Spatial constraints failed (${canFix}). Initiating self-healing...`);
try {
const originalResponse = await fetch(rawPayloadUrl);
const originalPayload = await originalResponse.json();
let templateData = originalPayload.data || {};
let shouldRetry = true;
switch (canFix) {
case "POSTAGE_LIMIT_EXCEEDED":
console.log("[HEAL] Action: 6+ pages detected. Using a more compact template to print and save postage.");
// Swap to the ID of your summary-only template
originalPayload.templateId = 'YOUR_CONSOLIDATED_TEMPLATE_ID';
break;
default:
console.error(`[ERROR] Unknown error code: ${canFix}`);
shouldRetry = false;
}
if (shouldRetry) {
const healedPayload = {
...originalPayload,
data: templateData,
targetPdfId: id
};
const retryResponse = await client.generate(healedPayload);
console.log(`[INFO] Self-healed PDF queued. Preserved Tracking ID: ${retryResponse.id}`);
}
} catch (healError) {
console.error("[ERROR] Failed to execute self-healing loop:", healError.message);
}
}
});
async function triggerInitialGeneration() {
let templateData = {
"pages_count": 2,
"order": { "number": "150305" },
"client": { "name": "Honey Bee Architecture", "phone": "+123-456-7890", "address": "123 Anywhere St." },
"invoice": { "date": "15 March 2025", "taxRateDisplay": "10%", "taxRate": 0.10 },
"items": [
{ "name": "Dining Table", "qty": 1, "price": 500 },
{ "name": "Kitchen Set", "qty": 1, "price": 250 },
{ "name": "Lamp", "qty": 2, "price": 45 }
],
"payment": { "bankName": "Brule Bank", "accountName": "Bluebyruby", "accountNumber": "+123-456-7890", "dueDate": "15 March 2025" },
"company": { "name": "Raincoat, Co.", "email": "hello@reallygreatsite.com", "address": "123 Anywhere St." }
};
console.log("[INFO] Dispatching initial asynchronous generation request...");
const retryResponse = await client.generate({
templateId: '0873d851-f2de-46d7-8b29-6bd2bbc1eb5b',
format: 'A4',
webhook: true,
data: templateData
});
console.log(`[INFO] Request queued. Tracking ID: ${retryResponse.id}`);
}
const PORT = 3000;
app.listen(PORT, () => {
console.log(`[SYSTEM] Local webhook server listening on port ${PORT}`);
triggerInitialGeneration();
});
2. The Smart Template & Test Case
When saving a template in your dashboard, you can pair the HTML markup with a custom spatial validation script. In this test case, we utilize both healing tiers. For minor overlapping elements, we return a patch to instantly toggle CSS utility classes inside our Nunjucks template. However, if we detect the document has physically spanned across 6 pages, we return success: false along with a completely custom key (which we named custom_user_defined_error for this example) to explicitly instruct our webhook to execute a Tier-2 template swap.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Expose variables to the spatial testing sandbox -->
<meta id="pdf-meta" data-expected-pages="{{ pages_count | default(1) }}">
<title>Invoice Template</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary-color: #0f172a;
--text-main: #334155;
--text-muted: #64748b;
--border-color: #e2e8f0;
--bg-light: #f8fafc;
}
* { box-sizing: border-box; }
body {
font-family: 'Inter', sans-serif;
background-color: #ffffff;
margin: 0;
padding: 0;
color: var(--text-main);
}
.invoice-container {
max-width: 800px;
margin: 0 auto;
padding: 40px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid var(--border-color);
padding-bottom: 24px;
margin-bottom: 32px;
}
.order-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
}
.order-number {
font-size: 18px;
font-weight: 600;
color: var(--primary-color);
}
.invoice-title {
font-size: 36px;
font-weight: 800;
color: var(--primary-color);
margin: 0;
line-height: 1;
}
.details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 40px;
font-size: 13px;
line-height: 1.6;
}
.invoice-to {
display: flex;
flex-direction: column;
gap: 2px;
}
.section-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
display: block;
}
.client-name {
/* Heal-loop trigger for TEXT_OVERFLOW */
font-size: {% if shrink_header_font %}11px{% else %}16px{% endif %};
line-height: {% if shrink_header_font %}1.2{% else %}1.6{% endif %};
font-weight: 700;
color: var(--primary-color);
margin-bottom: 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 24px;
table-layout: fixed;
}
th {
text-align: left;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
padding-bottom: 12px;
border-bottom: 2px solid var(--border-color);
}
th.text-center, td.text-center { text-align: center; }
th.text-right, td.text-right { text-align: right; }
td {
/* Heal-loop trigger for OVERLAP_SUMMARY_FOOTER */
padding: {% if compact_table_padding %}8px 0{% else %}16px 0{% endif %};
font-size: 13px;
border-bottom: 1px solid var(--border-color);
word-wrap: break-word;
}
tr { page-break-inside: avoid; }
.summary-container {
display: flex;
justify-content: flex-end;
margin-bottom: 40px;
page-break-inside: avoid;
}
.summary {
width: 280px;
background-color: var(--bg-light);
padding: 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 13px;
}
.summary-row.total {
border-top: 2px solid var(--border-color);
margin-top: 12px;
padding-top: 16px;
font-size: 16px;
font-weight: 800;
color: var(--primary-color);
}
.footer-wrapper {
margin-top: auto;
padding-top: 32px;
border-top: 1px solid var(--border-color);
}
.thank-you {
font-size: 20px;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 20px;
}
.footer-info {
display: grid;
grid-template-columns: 1fr 1fr;
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
}
.footer-info strong {
color: var(--primary-color);
display: block;
margin-bottom: 4px;
}
.company-info { text-align: right; }
@media print {
@page { size: A4 portrait; margin: 10mm; }
body { padding: 0; background: #fff; }
.invoice-container {
padding: 0;
position: relative;
min-height: calc({{ pages_count | default(1) }} * 277mm) !important;
}
.footer-wrapper {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="invoice-container">
<div class="header">
<div class="order-info">
<span class="order-label">Order Number</span>
<span class="order-number">#{{ order.number }}</span>
</div>
<h1 class="invoice-title">INVOICE</h1>
</div>
<div class="details">
<div class="invoice-to">
<span class="section-label">Invoice To</span>
<span class="client-name">{{ client.name }}</span>
<span>{{ client.phone }}</span>
<span>{{ client.address }}</span>
</div>
<div class="invoice-date">
<span class="section-label">Invoice Date</span>
<strong style="color: var(--primary-color)">{{ invoice.date }}</strong>
</div>
</div>
<table id="invoice-table">
<thead>
<tr>
<th style="width: 45%;">Item</th>
<th class="text-center" style="width: 15%;">Qty</th>
<th class="text-center" style="width: 20%;">Unit Price</th>
<th class="text-right" style="width: 20%;">Total</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr {% if loop.last and force_summary_break %}style="page-break-before: always;"{% endif %}>
<td style="font-weight: 500; color: var(--primary-color);">{{ item.name }}</td>
<td class="text-center qty">{{ item.qty }}</td>
<td class="text-center price">{{ item.price }}</td>
<td class="text-right row-total">$0.00</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="summary-container">
<div class="summary">
<div class="summary-row">
<span>Subtotal</span>
<strong id="subtotal-display">$0.00</strong>
</div>
<div class="summary-row">
<span>Tax ({{ invoice.taxRateDisplay | default('10%') }})</span>
<span id="tax-display">$0.00</span>
</div>
<div class="summary-row total">
<span>Total Due</span>
<span id="total-display">$0.00</span>
</div>
</div>
</div>
<div class="footer-wrapper">
<div class="thank-you">Thank you for your business!</div>
<div class="footer-info">
<div class="payment-info">
<strong>Payment Instructions</strong>
Bank: {{ payment.bankName }}<br>
Account: {{ payment.accountName }}<br>
Account No: {{ payment.accountNumber }}<br>
Due By: {{ payment.dueDate }}
</div>
<div class="company-info">
<strong>{{ company.name }}</strong>
{{ company.email }}<br>
{{ company.address }}
</div>
</div>
</div>
</div>
<script>
function calculateInvoice() {
const rows = document.querySelectorAll('#invoice-table tbody tr');
let subtotal = 0;
rows.forEach(row => {
const qtyText = row.querySelector('.qty').innerText.replace(/[^0-9.-]+/g, "");
const priceText = row.querySelector('.price').innerText.replace(/[^0-9.-]+/g, "");
const qty = parseFloat(qtyText) || 0;
const price = parseFloat(priceText) || 0;
const rowTotal = qty * price;
subtotal += rowTotal;
row.querySelector('.row-total').innerText = '$' + rowTotal.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2});
});
const taxRate = {{ invoice.taxRate | default(0.10) }};
const taxAmount = subtotal * taxRate;
const total = subtotal + taxAmount;
document.getElementById('subtotal-display').innerText = '$' + subtotal.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2});
document.getElementById('tax-display').innerText = '$' + taxAmount.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2});
document.getElementById('total-display').innerText = '$' + total.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
calculateInvoice();
</script>
</body>
</html>