Invalid Date

Still manually exporting your spreadsheet to PDF every month, then attaching it to emails one by one?

Google Apps Script (GAS) combined with the FUNBREW PDF API lets you generate professionally designed PDFs directly from your spreadsheet data — and automatically save them to Google Drive or send them via Gmail. No manual steps required.

This guide walks you through the complete setup: creating a GAS project, calling the API with UrlFetchApp, building HTML templates from spreadsheet data, saving PDFs to Drive, sending them by email, and scheduling automatic monthly runs. We finish with a complete end-to-end monthly invoice automation example.

For the basics of the PDF API itself, see the Quickstart Guide.

Why Not Just Use Google's Built-in PDF Export?

Google Sheets has a built-in PDF export, but it has real limitations:

  • Layout is locked to the grid: You can't make it look like a proper invoice or report
  • Limited design control: No custom fonts, brand colors, or layouts
  • Hard to automate: Programmatic control of page settings is cumbersome
  • Batch processing is painful: Generating multiple PDFs from multiple sheets requires complex workarounds

What You Get with FUNBREW PDF API + GAS

  • Design with HTML and CSS: Full control over layout, fonts, and branding
  • Direct API calls from GAS: UrlFetchApp makes HTTP requests simple
  • Data flows from sheet to PDF: Read cell values, build an HTML template, generate the PDF
  • Native Google integration: Save to Drive, send via Gmail — all in one script
  • Full automation: Time-based triggers run your script on schedule, with zero intervention

Use Cases

Use Case Spreadsheet Role PDF Trigger
Monthly invoices Customer data, line items Monthly scheduled trigger
Quotes/estimates Product list, quantities, prices On sheet update
Monthly reports Sales, KPI aggregation 1st of each month
Inventory reports Stock counts, reorder points Condition-based
Payroll summaries Hours, overtime data Monthly close

Phase 1: Project Setup

Open the Script Editor

In Google Sheets, go to Extensions → Apps Script. A new script editor tab opens.

Store Your API Key Securely

Never hardcode the API key in your script. Use GAS Script Properties instead.

// Run this once to store your API key
function setApiKey() {
  const scriptProperties = PropertiesService.getScriptProperties();
  scriptProperties.setProperty('FUNBREW_API_KEY', 'sk-your-api-key-here');
  Logger.log('API key saved.');
}

// Use this anywhere in your scripts
function getApiKey() {
  return PropertiesService.getScriptProperties().getProperty('FUNBREW_API_KEY');
}

Run setApiKey() once manually to register the key. After that, every function can call getApiKey() safely. For more on API key security, see the PDF API Security Guide.

Core API Call Function

/**
 * Calls the FUNBREW PDF API and returns the generated PDF as a Blob.
 * @param {string} html - The HTML content to convert to PDF
 * @param {Object} options - Optional settings (format, margin, engine)
 * @returns {Blob} - The generated PDF blob
 */
function generatePdf(html, options = {}) {
  const apiKey = getApiKey();
  const apiUrl = 'https://pdf.funbrew.cloud/api/pdf/from-html';

  const payload = {
    html: html,
    engine: options.engine || 'quality',
    format: options.format || 'A4',
    margin: options.margin || { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' },
  };

  const requestOptions = {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + apiKey,
      'Content-Type': 'application/json',
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  };

  const response = UrlFetchApp.fetch(apiUrl, requestOptions);

  if (response.getResponseCode() !== 200) {
    throw new Error('PDF generation failed: ' + response.getResponseCode() + ' - ' + response.getContentText());
  }

  return response.getBlob().setName('output.pdf');
}

Phase 2: Read Data from the Spreadsheet

Fetch Invoice Data from Sheets

/**
 * Reads invoice data from the active spreadsheet.
 * @returns {Object} - Invoice data object
 */
function getInvoiceData() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('Invoice Data');

  const invoiceData = {
    invoiceNumber: sheet.getRange('B1').getValue(),
    issueDate: Utilities.formatDate(sheet.getRange('B2').getValue(), 'America/New_York', 'MM/dd/yyyy'),
    dueDate: Utilities.formatDate(sheet.getRange('B3').getValue(), 'America/New_York', 'MM/dd/yyyy'),
    customerName: sheet.getRange('B4').getValue(),
    customerEmail: sheet.getRange('B5').getValue(),
  };

  // Line items from a separate sheet (columns A–D, starting row 2)
  const itemsSheet = ss.getSheetByName('Line Items');
  const lastRow = itemsSheet.getLastRow();
  const items = [];

  for (let row = 2; row <= lastRow; row++) {
    const name = itemsSheet.getRange(row, 1).getValue();
    if (!name) break;

    items.push({
      name: name,
      quantity: itemsSheet.getRange(row, 2).getValue(),
      unitPrice: itemsSheet.getRange(row, 3).getValue(),
      subtotal: itemsSheet.getRange(row, 4).getValue(),
    });
  }

  // Summary totals from a third sheet
  const summarySheet = ss.getSheetByName('Summary');
  invoiceData.subtotal = summarySheet.getRange('B1').getValue();
  invoiceData.tax = summarySheet.getRange('B2').getValue();
  invoiceData.total = summarySheet.getRange('B3').getValue();
  invoiceData.items = items;

  return invoiceData;
}

Formatting Helpers

/**
 * Formats a number as a dollar amount with commas.
 */
function formatCurrency(value) {
  return '$' + Number(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}

/**
 * Escapes HTML special characters to prevent XSS in templates.
 */
function escapeHtml(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

Phase 3: Build the HTML Template

Invoice HTML Builder

/**
 * Generates invoice HTML from spreadsheet data.
 * @param {Object} data - Data object from getInvoiceData()
 * @returns {string} - HTML string ready for PDF conversion
 */
function buildInvoiceHtml(data) {
  const lineItemsHtml = data.items.map(item => `
    <tr>
      <td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb;">${escapeHtml(item.name)}</td>
      <td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${item.quantity}</td>
      <td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${formatCurrency(item.unitPrice)}</td>
      <td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${formatCurrency(item.subtotal)}</td>
    </tr>
  `).join('');

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      color: #1f2937;
      max-width: 800px;
      margin: 0 auto;
      padding: 40px;
    }
    .header {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      margin-bottom: 40px;
    }
    .invoice-title {
      font-size: 32px;
      font-weight: 700;
      color: #111827;
      margin: 0 0 8px;
    }
    .meta-label { color: #6b7280; font-size: 13px; }
    .company-info { text-align: right; font-size: 13px; color: #374151; }
    .customer-section {
      font-size: 18px;
      font-weight: 600;
      margin-bottom: 32px;
      padding-bottom: 16px;
      border-bottom: 2px solid #e5e7eb;
    }
    table { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
    thead tr { background: #f8fafc; }
    th {
      padding: 12px;
      text-align: left;
      font-size: 13px;
      font-weight: 600;
      color: #374151;
      border-bottom: 2px solid #e5e7eb;
    }
    th:not(:first-child) { text-align: right; }
    .total-section { text-align: right; margin-bottom: 40px; }
    .total-row {
      display: flex;
      justify-content: flex-end;
      gap: 32px;
      margin-bottom: 6px;
      font-size: 14px;
    }
    .total-label { color: #6b7280; min-width: 80px; text-align: right; }
    .grand-total {
      font-size: 22px;
      font-weight: 700;
      margin-top: 16px;
      padding-top: 16px;
      border-top: 2px solid #111827;
    }
    .footer {
      border-top: 1px solid #e5e7eb;
      padding-top: 20px;
      font-size: 12px;
      color: #9ca3af;
    }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <h1 class="invoice-title">Invoice</h1>
      <p class="meta-label">Invoice #: ${escapeHtml(String(data.invoiceNumber))}</p>
      <p class="meta-label">Date: ${escapeHtml(data.issueDate)}</p>
    </div>
    <div class="company-info">
      <p style="font-weight: 700; font-size: 15px;">Acme Corporation</p>
      <p>123 Main Street, New York, NY 10001</p>
      <p>billing@acme.example.com</p>
    </div>
  </div>

  <div class="customer-section">
    Bill To: ${escapeHtml(data.customerName)}
  </div>

  <table>
    <thead>
      <tr>
        <th style="width: 45%;">Description</th>
        <th style="width: 15%;">Qty</th>
        <th style="width: 20%;">Unit Price</th>
        <th style="width: 20%;">Amount</th>
      </tr>
    </thead>
    <tbody>
      ${lineItemsHtml}
    </tbody>
  </table>

  <div class="total-section">
    <div class="total-row">
      <span class="total-label">Subtotal</span>
      <span>${formatCurrency(data.subtotal)}</span>
    </div>
    <div class="total-row">
      <span class="total-label">Tax (10%)</span>
      <span>${formatCurrency(data.tax)}</span>
    </div>
    <div class="grand-total">
      Total Due: ${formatCurrency(data.total)}
    </div>
  </div>

  <div class="footer">
    <p>Payment due: ${escapeHtml(data.dueDate)}</p>
    <p>Please include the invoice number in your payment reference.</p>
    <p>Thank you for your business.</p>
  </div>
</body>
</html>`;
}

Phase 4: Save the PDF to Google Drive

/**
 * Generates an invoice PDF and saves it to a Google Drive folder.
 * @returns {GoogleAppsScript.Drive.File} - The saved file
 */
function generateAndSavePdf() {
  const data = getInvoiceData();
  const html = buildInvoiceHtml(data);

  Logger.log('Generating PDF: ' + data.invoiceNumber);
  const pdfBlob = generatePdf(html, {
    engine: 'quality',
    format: 'A4',
    margin: { top: '15mm', right: '15mm', bottom: '15mm', left: '15mm' },
  });

  const fileName = 'Invoice_' + data.invoiceNumber + '_' + data.issueDate.replace(/\//g, '-') + '.pdf';
  pdfBlob.setName(fileName);

  // Get or create the destination folder
  const folderName = 'Invoice PDFs';
  let folder;
  const folders = DriveApp.getFoldersByName(folderName);
  folder = folders.hasNext() ? folders.next() : DriveApp.createFolder(folderName);

  const file = folder.createFile(pdfBlob);
  Logger.log('Saved: ' + file.getUrl());

  return file;
}

Run generateAndSavePdf() from the script editor. On first run, Google will ask for Drive permission — click Allow. You should see an "Invoice PDFs" folder appear in My Drive with your PDF inside.

Phase 5: Send the PDF by Email

/**
 * Generates the invoice PDF and emails it to the customer.
 */
function generateAndSendInvoice() {
  const data = getInvoiceData();
  const html = buildInvoiceHtml(data);
  const pdfBlob = generatePdf(html, { engine: 'quality', format: 'A4' });
  pdfBlob.setName('Invoice_' + data.invoiceNumber + '.pdf');

  const subject = 'Invoice ' + data.invoiceNumber + ' — Due ' + data.dueDate;
  const body = `Hi ${data.customerName},

Please find your invoice attached for the services provided.

Amount due: ${formatCurrency(data.total)}
Payment due: ${data.dueDate}

If you have any questions, please don't hesitate to reach out.

Best regards,
Acme Corporation Billing Team`;

  GmailApp.sendEmail(
    data.customerEmail,
    subject,
    body,
    {
      attachments: [pdfBlob],
      name: 'Acme Corporation',
      cc: 'billing@acme.example.com',
    }
  );

  Logger.log('Sent to: ' + data.customerEmail);

  // Also save to Drive
  const folders = DriveApp.getFoldersByName('Invoice PDFs');
  const folder = folders.hasNext() ? folders.next() : DriveApp.createFolder('Invoice PDFs');
  folder.createFile(pdfBlob);
}

Phase 6: Batch Send to Multiple Customers

For month-end runs across an entire customer list, loop through the sheet:

/**
 * Sends invoices to all customers in the "Customer List" sheet.
 */
function sendInvoicesToAllCustomers() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('Customer List');
  const lastRow = sheet.getLastRow();

  const results = { success: [], failed: [] };

  for (let row = 2; row <= lastRow; row++) {
    const status = sheet.getRange(row, 7).getValue();
    if (status === 'Sent') continue; // Skip already-processed rows

    const customerData = {
      invoiceNumber: sheet.getRange(row, 1).getValue(),
      customerName: sheet.getRange(row, 2).getValue(),
      customerEmail: sheet.getRange(row, 3).getValue(),
      total: sheet.getRange(row, 4).getValue(),
      tax: sheet.getRange(row, 5).getValue(),
      subtotal: sheet.getRange(row, 6).getValue(),
      issueDate: Utilities.formatDate(new Date(), 'America/New_York', 'MM/dd/yyyy'),
      dueDate: Utilities.formatDate(
        new Date(new Date().getFullYear(), new Date().getMonth() + 1, 25),
        'America/New_York',
        'MM/dd/yyyy'
      ),
      items: [],
    };

    if (!customerData.customerName || !customerData.customerEmail) continue;

    try {
      const html = buildInvoiceHtml(customerData);
      const pdfBlob = generatePdf(html, { engine: 'quality', format: 'A4' });
      pdfBlob.setName('Invoice_' + customerData.invoiceNumber + '.pdf');

      GmailApp.sendEmail(
        customerData.customerEmail,
        'Invoice ' + customerData.invoiceNumber,
        'Please see the attached invoice. Amount due: ' + formatCurrency(customerData.total),
        { attachments: [pdfBlob] }
      );

      sheet.getRange(row, 7).setValue('Sent');
      sheet.getRange(row, 8).setValue(new Date());
      results.success.push(customerData.invoiceNumber);

      Utilities.sleep(500); // Stay within API rate limits

    } catch (error) {
      Logger.log('Error (row ' + row + '): ' + error.message);
      sheet.getRange(row, 7).setValue('Error: ' + error.message);
      results.failed.push({ id: customerData.invoiceNumber, error: error.message });
    }
  }

  Logger.log('Done — Success: ' + results.success.length + ', Failed: ' + results.failed.length);

  if (results.failed.length > 0) {
    MailApp.sendEmail(
      'admin@acme.example.com',
      'Invoice job errors',
      JSON.stringify(results.failed, null, 2)
    );
  }
}

Phase 7: Scheduled Triggers for Fully Automated Runs

Set a Monthly Trigger in the UI

  1. Click the clock icon (Triggers) in the left sidebar of the script editor
  2. Click Add Trigger
  3. Function to run: sendInvoicesToAllCustomers
  4. Event source: Time-driven
  5. Type of time-based trigger: Month timer
  6. Day of month: last day of month
  7. Time of day: 9am–10am

Set a Trigger Programmatically

/**
 * Creates a monthly trigger on the 27th at 9am.
 * Run this once manually to install the trigger.
 */
function createMonthlyTrigger() {
  // Remove existing triggers for this function to prevent duplicates
  ScriptApp.getProjectTriggers()
    .filter(t => t.getHandlerFunction() === 'sendInvoicesToAllCustomers')
    .forEach(t => ScriptApp.deleteTrigger(t));

  ScriptApp.newTrigger('sendInvoicesToAllCustomers')
    .timeBased()
    .onMonthDay(27)
    .atHour(9)
    .create();

  Logger.log('Monthly trigger created: 27th of each month at 9am');
}

/**
 * Lists all installed triggers for debugging.
 */
function listTriggers() {
  ScriptApp.getProjectTriggers().forEach(trigger => {
    Logger.log(
      trigger.getHandlerFunction() + ' | ' +
      trigger.getEventType() + ' | ' +
      trigger.getTriggerSource()
    );
  });
}

Trigger on Spreadsheet Edit

Trigger PDF generation when a specific cell changes — for example, when someone marks a quote as "Approved":

/**
 * Fires automatically when a cell is edited.
 * Generates and sends a PDF when B8 in "Quote Data" is set to "Approved".
 */
function onEdit(e) {
  const sheet = e.source.getActiveSheet();
  const range = e.range;

  if (sheet.getName() === 'Quote Data' && range.getA1Notation() === 'B8') {
    if (e.value === 'Approved') {
      try {
        generateAndSendInvoice();
        SpreadsheetApp.getActiveSpreadsheet().toast('PDF sent successfully.', 'Done', 5);
      } catch (error) {
        SpreadsheetApp.getActiveSpreadsheet().toast('Error: ' + error.message, 'Error', 10);
      }
    }
  }
}

Phase 8: Using the Template API for Cleaner Code

Instead of building HTML strings in your script, you can pre-register templates in the FUNBREW PDF dashboard and call the template API from GAS. This separates design from code.

/**
 * Generates a PDF using a pre-registered template named "invoice-en".
 * @param {Object} data - Invoice data object
 * @returns {string} - Download URL of the generated PDF
 */
function generatePdfFromTemplate(data) {
  const apiKey = getApiKey();
  const apiUrl = 'https://pdf.funbrew.cloud/api/pdf/generate-from-template';

  const payload = {
    template: 'invoice-en',
    variables: {
      invoice_number: String(data.invoiceNumber),
      issue_date: data.issueDate,
      due_date: data.dueDate,
      customer_name: data.customerName,
      subtotal: formatCurrency(data.subtotal),
      tax: formatCurrency(data.tax),
      total: formatCurrency(data.total),
      line_items: data.items.map(item => ({
        name: item.name,
        quantity: item.quantity,
        unit_price: formatCurrency(item.unitPrice),
        subtotal: formatCurrency(item.subtotal),
      })),
    },
    options: { engine: 'quality', format: 'A4' },
  };

  const response = UrlFetchApp.fetch(apiUrl, {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + apiKey,
      'Content-Type': 'application/json',
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });

  if (response.getResponseCode() !== 200) {
    throw new Error('Template API error: ' + response.getContentText());
  }

  return JSON.parse(response.getContentText()).data.download_url;
}

For creating and managing templates, see the PDF Template Engine Guide.

Complete Example: Monthly Invoice Automation

Here's a production-ready monthly invoice job combining everything above.

Spreadsheet Structure

Invoice Master (Spreadsheet)
├── Customer List (Sheet)
│   ├── Column A: Invoice number
│   ├── Column B: Customer name
│   ├── Column C: Email address
│   ├── Column D: Monthly fee
│   ├── Column E: Tax amount
│   ├── Column F: Total
│   ├── Column G: Send status
│   └── Column H: Sent timestamp
├── Settings (Sheet)
│   ├── B1: Payment due date
│   └── B2: Sender name
└── Log (Sheet)
    └── Execution history

Main Script

/**
 * Monthly invoice automation job.
 * Automatically runs on the 27th of each month at 9am via trigger.
 */
function monthlyInvoiceJob() {
  const startTime = new Date();
  Logger.log('=== Monthly Invoice Job Started: ' + startTime + ' ===');

  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const customersSheet = ss.getSheetByName('Customer List');
    const settingSheet = ss.getSheetByName('Settings');
    const logSheet = ss.getSheetByName('Log');

    const dueDate = Utilities.formatDate(
      settingSheet.getRange('B1').getValue(),
      'America/New_York',
      'MM/dd/yyyy'
    );
    const issueDate = Utilities.formatDate(new Date(), 'America/New_York', 'MM/dd/yyyy');
    const currentMonth = Utilities.formatDate(new Date(), 'America/New_York', 'MMMM yyyy');

    const lastRow = customersSheet.getLastRow();
    const results = { success: 0, skip: 0, error: 0 };

    for (let row = 2; row <= lastRow; row++) {
      const customerName = customersSheet.getRange(row, 2).getValue();
      const customerEmail = customersSheet.getRange(row, 3).getValue();
      const monthlyFee = customersSheet.getRange(row, 4).getValue();
      const status = customersSheet.getRange(row, 7).getValue();

      if (!customerName || !customerEmail) continue;
      if (status === 'Sent') { results.skip++; continue; }

      const invoiceNumber = 'INV-' +
        Utilities.formatDate(new Date(), 'America/New_York', 'yyyyMM') + '-' +
        String(row - 1).padStart(3, '0');

      const tax = Math.round(monthlyFee * 0.1 * 100) / 100;
      const total = monthlyFee + tax;

      const customerData = {
        invoiceNumber: invoiceNumber,
        issueDate: issueDate,
        dueDate: dueDate,
        customerName: customerName,
        customerEmail: customerEmail,
        subtotal: monthlyFee,
        tax: tax,
        total: total,
        items: [
          {
            name: 'Service fee — ' + currentMonth,
            quantity: 1,
            unitPrice: monthlyFee,
            subtotal: monthlyFee,
          }
        ],
      };

      try {
        const html = buildInvoiceHtml(customerData);
        const pdfBlob = generatePdf(html, { engine: 'quality', format: 'A4' });
        pdfBlob.setName('Invoice_' + invoiceNumber + '.pdf');

        // Save to Drive
        const monthFolder = Utilities.formatDate(new Date(), 'America/New_York', 'yyyy-MM');
        const folders = DriveApp.getFoldersByName('Invoice PDFs/' + monthFolder);
        const folder = folders.hasNext()
          ? folders.next()
          : DriveApp.createFolder('Invoice PDFs/' + monthFolder);
        folder.createFile(pdfBlob);

        // Send email
        GmailApp.sendEmail(
          customerEmail,
          'Invoice ' + invoiceNumber + ' — ' + currentMonth,
          'Hi ' + customerName + ',\n\nPlease find your invoice for ' + currentMonth + ' attached.\n\nAmount due: ' + formatCurrency(total) + '\nDue date: ' + dueDate + '\n\nThank you,\nAcme Corporation',
          { attachments: [pdfBlob], name: 'Acme Corporation Billing' }
        );

        // Mark as sent
        customersSheet.getRange(row, 1).setValue(invoiceNumber);
        customersSheet.getRange(row, 7).setValue('Sent');
        customersSheet.getRange(row, 8).setValue(new Date());
        results.success++;

        Utilities.sleep(300);

      } catch (rowError) {
        Logger.log('Row ' + row + ' error: ' + rowError.message);
        customersSheet.getRange(row, 7).setValue('Error');
        results.error++;
      }
    }

    // Write to log sheet
    const duration = Math.round((new Date() - startTime) / 1000);
    logSheet.appendRow([
      startTime,
      'Monthly Invoice Job',
      'Completed',
      'Success: ' + results.success + ', Skipped: ' + results.skip + ', Errors: ' + results.error,
      duration + 's',
    ]);

    Logger.log('=== Done: ' + results.success + ' sent, ' + results.skip + ' skipped, ' + results.error + ' errors ===');

    if (results.error > 0) {
      MailApp.sendEmail(
        'admin@acme.example.com',
        '[Action required] Monthly invoice job errors',
        results.error + ' invoices failed. Please check the spreadsheet status column.'
      );
    }

  } catch (fatalError) {
    Logger.log('Fatal error: ' + fatalError.message);
    MailApp.sendEmail('admin@acme.example.com', '[URGENT] Monthly invoice job failed', fatalError.message);
  }
}

Error Handling and Debugging

Common Errors

"Authorization failed"

function checkApiKey() {
  const apiKey = getApiKey();
  if (!apiKey) {
    throw new Error('API key not set. Run setApiKey() first.');
  }
  Logger.log('Key prefix: ' + apiKey.substring(0, 8) + '...');
}

"Quota exceeded" (Gmail send limit)

GAS limits Gmail sends to ~100/day for free accounts and ~1,500/day for Google Workspace. For large batches, split across multiple runs:

const MAX_PER_RUN = 50;
let count = 0;

for (let row = 2; row <= lastRow; row++) {
  if (count >= MAX_PER_RUN) {
    Logger.log('Limit reached. Resume on next run.');
    break;
  }
  // ... process row ...
  count++;
}

Execution timeout (6-minute GAS limit)

For large batches, add a timeout guard:

function generateWithTimeout() {
  const startTime = new Date();
  const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

  for (let row = 2; row <= lastRow; row++) {
    if (new Date() - startTime > TIMEOUT_MS) {
      Logger.log('Stopping early to avoid timeout. Will resume next run.');
      break;
    }
    // ... process row ...
  }
}

For more error handling patterns, see the PDF API Error Handling Guide.

Production Checklist

Before going live with your automated invoice system:

  • API key stored in Script Properties (not hardcoded)
  • Tested with a staging email address using dry-run mode
  • Google Drive destination folder confirmed accessible
  • Error notification email address configured
  • Trigger set to run during business hours
  • GmailApp send quota reviewed
  • Idempotency confirmed: "Sent" status check prevents double-sending

Dry Run Mode

Always test before your first live run:

function dryRunInvoiceJob() {
  const DRY_RUN = true;
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Customer List');

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const name = sheet.getRange(row, 2).getValue();
    const email = sheet.getRange(row, 3).getValue();
    const total = sheet.getRange(row, 6).getValue();

    if (!name || !email) continue;

    Logger.log('[DRY RUN] Would send to: ' + name + ' <' + email + '> — ' + formatCurrency(total));

    if (!DRY_RUN) {
      // actual send code here
    }
  }
  Logger.log('[DRY RUN] Complete. Nothing was actually sent.');
}

For more on production stability, see the PDF API Production Guide.

Summary

Google Apps Script and FUNBREW PDF API together give you a powerful, fully automated document pipeline — all without leaving the Google ecosystem.

  1. No extra tools to buy: GAS is free with your Google account
  2. HTML/CSS gives full design freedom: Your PDFs look like real invoices, not spreadsheet exports
  3. Drive + Gmail in one script: Store and deliver in the same automated flow
  4. Triggers make it hands-free: Configure once, run forever

Get your API key with the free plan (30 PDFs/month) and start testing in the Playground. You can paste any HTML and see the PDF output instantly while you develop your template.

Related Links

Powered by FUNBREW PDF