Recipes / Google Workspace & Gmail

Capture supplier invoices from Gmail into Google Sheets

Supplier invoices arrive as PDFs in a shared inbox, each vendor with a different layout, and someone re-keys the line items into a spreadsheet every week.

Finance

Updated October 2026

← All recipes
STEP01TriggerNEW INVOICE EMAILARRIVES IN GMAILSTEP02ParsePARSER READS THE PDF,ANY LAYOUTSTEP03WriteLINE ITEMS WRITTEN TO APER-SUPPLIER TAB INSHEETSSTEP04FlagDUPLICATES FLAGGED,DAILY SUMMARY EMAILEDFINANCESTEP 01TriggerNEW INVOICE EMAILARRIVES IN GMAILSTEP 02ParsePARSER READS THEPDF, ANY LAYOUTSTEP 03WriteLINE ITEMS WRITTENTO A PER-SUPPLIERTAB IN SHEETSSTEP 04FlagDUPLICATES FLAGGED,DAILY SUMMARYEMAILED
DwgHow it runs
SheetRecipe
RevOctober 2026

Supplier invoices arriving as PDFs in Gmail can be read automatically and written as line items into a per-supplier Google Sheets tab, with duplicates flagged and a daily summary sent to the owner. Evenops builds this as Apps Script plus a document parser you own, so any vendor layout is handled and only exceptions need a person.

How does it run?

4 steps
  1. Step 01Trigger

    New invoice email arrives in Gmail

  2. Step 02ParseAI step

    Parser reads the PDF, any layout

  3. Step 03Write

    Line items written to a per-supplier tab in Sheets

  4. Step 04Flag

    Duplicates flagged, daily summary emailed

What do you get?

3 outcomes
  • ✓Invoice entry runs unattended instead of as a weekly manual job
  • ✓Any supplier PDF layout is handled, not just one template
  • ✓The team looks only at what the parser flags

Which tools does it use?

GmailDocument AIGoogle SheetsApps Script

Time saved

Replaces a weekly manual invoice re-keying job.

We built something like this: read the case study.

What does the script do?

A time-driven Apps Script that finds supplier emails with PDF attachments, saves each PDF to a month folder in Drive, and adds one row per invoice to a register sheet (received time, supplier, subject, file name, a link to the file, a status and a duplicate key). A possible repeat from the same sender with the same file name and size is marked Possible duplicate instead of being skipped, so a person decides. A daily email says how many invoices are waiting. This script captures and registers invoices. It does not read the line items out of the PDF: that needs a document parser and is where most suppliers' differing layouts cause trouble, which is the part we build for clients.

What do you need first?

  • A Gmail or Google Workspace mailbox that receives supplier invoices as PDF attachments.
  • A Drive folder to hold the PDFs and its ID, and a Google Sheet for the register and its ID.
  • A person or shared mailbox to receive the daily summary.
  • About 25 minutes. No paid add-on.

How do you set it up?

7 steps
  1. Create the folder and the sheet

    Make a Drive folder called Supplier invoices and a Google Sheet called Invoice register. Copy both IDs from their URLs.

  2. Create the script

    Open script.google.com in the account that receives the invoices, create a project and paste the code below into Code.gs.

  3. Fill in CONFIG

    Set SPREADSHEET_ID, ROOT_FOLDER_ID and ALERT_TO. WINDOW_DAYS sets how far back it looks. If invoices arrive at a dedicated address, add to:accounts@yourcompany.example to the query line in captureInvoices.

  4. Run it once by hand

    Pick captureInvoices and press Run, then authorise Gmail, Drive, Sheets and email access. Check that a month folder appeared in Drive and the register has one row per PDF.

  5. Install both triggers

    Run installTriggers once. It schedules captureInvoices every ten minutes and dailySummary at about 8 AM in the script's time zone (set the time zone under Project Settings), replacing any earlier copies.

  6. Agree the status column

    Tell the team the rule: they change Status to Done once the invoice is entered in the accounts. The daily email counts rows still marked To review or Possible duplicate.

  7. Turn on failure emails

    In Triggers, set Failure notification settings on both triggers to notify you immediately. A run that fails for any reason, including a revoked permission, then reaches a person.

The script

InvoiceRegister.gs
// Saves supplier invoice PDFs from Gmail to Drive and logs each one in a Sheet.
// Paste into Code.gs in a new project at script.google.com, edit CONFIG, run installTriggers() once.
// It captures and registers invoices. Reading line items out of the PDF is a separate step.

const CONFIG = {
  SPREADSHEET_ID: 'PASTE_THE_SPREADSHEET_ID_HERE',
  SHEET_NAME: 'Invoice register',
  ROOT_FOLDER_ID: 'PASTE_THE_DRIVE_FOLDER_ID_HERE',
  DONE_LABEL: 'Invoice-logged',                  // created automatically
  WINDOW_DAYS: 14,                               // how far back to look
  MAX_THREADS: 25,                               // per run
  // Stop well before the 6 minute limit. On a gmail.com account the whole day's
  // trigger time is 90 minutes, so lower this (for example 60 * 1000) there.
  TIME_BUDGET_MS: 4 * 60 * 1000,
  ALERT_TO: 'accounts@example.com',
  ALERT_EVERY_MS: 6 * 60 * 60 * 1000,            // at most one failure email per 6 hours
};

const HEADERS = ['Received', 'Supplier', 'Subject', 'File', 'Drive link', 'Status', 'Attachment ID', 'Duplicate key'];
const STATUS_COL = 6;   // 1-based
const ATTACH_COL = 7;

function captureInvoices() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30 * 1000)) return;
  const started = Date.now();
  const problems = [];
  try {
    const sheet = getSheet_();
    const lastRow = sheet.getLastRow();
    // Read the register once and index it in memory.
    const seenAttachments = {};
    const seenDuplicates = {};
    if (lastRow > 1) {
      sheet.getRange(2, ATTACH_COL, lastRow - 1, 2).getValues().forEach(function (r) {
        seenAttachments[r[0]] = true;
        seenDuplicates[r[1]] = true;
      });
    }
    const me = Session.getEffectiveUser().getEmail().toLowerCase();
    const oldest = Date.now() - CONFIG.WINDOW_DAYS * 24 * 60 * 60 * 1000;
    const root = DriveApp.getFolderById(CONFIG.ROOT_FOLDER_ID);
    const label = GmailApp.getUserLabelByName(CONFIG.DONE_LABEL) || GmailApp.createLabel(CONFIG.DONE_LABEL);
    const query = 'has:attachment filename:pdf newer_than:' + CONFIG.WINDOW_DAYS + 'd -label:' + CONFIG.DONE_LABEL;
    const threads = GmailApp.search(query, 0, CONFIG.MAX_THREADS);
    const monthFolders = {};
    const rows = [];
    const finished = [];

    for (const thread of threads) {
      if (Date.now() - started > CONFIG.TIME_BUDGET_MS) break; // the next run picks up the rest
      try {
        for (const message of thread.getMessages()) {
          const from = message.getFrom();
          const when = message.getDate();
          // Skip old messages in a long thread and anything you sent yourself.
          if (when.getTime() < oldest || (me && from.toLowerCase().indexOf(me) !== -1)) continue;
          const attachments = message.getAttachments({ includeInlineImages: false });
          attachments.forEach(function (att, i) {
            if (!isPdf_(att)) return;
            // Prefixes keep Sheets from reading these as numbers or formulas.
            const attachmentId = 'm:' + message.getId() + ':' + i;
            if (seenAttachments[attachmentId]) return;
            const file = saveFile_(root, monthFolders, att, when, message.getId(), i);
            const dupKey = 'k|' + from.toLowerCase() + '|' + att.getName() + '|' + att.getSize();
            const status = seenDuplicates[dupKey] ? 'Possible duplicate' : 'To review';
            rows.push([when, safe_(from), safe_(message.getSubject()), safe_(file.getName()), file.getUrl(), status, attachmentId, dupKey]);
            seenAttachments[attachmentId] = true;
            seenDuplicates[dupKey] = true;
          });
        }
        finished.push(thread); // labelled only when the whole thread went through
      } catch (err) {
        problems.push(subjectOf_(thread) + ' -> ' + err.message);
      }
    }
    // Good rows are written even if another thread failed. A failed thread is not
    // labelled, so the next run retries it and skips anything already logged.
    if (rows.length) {
      sheet.getRange(lastRow + 1, 1, rows.length, HEADERS.length).setValues(rows);
    }
    finished.forEach(function (t) { t.addLabel(label); });
  } catch (err) {
    problems.push('Run failed: ' + err.message);
  } finally {
    lock.releaseLock();
  }
  alertOnce_(problems);
}

function isPdf_(att) {
  return att.getContentType() === 'application/pdf' || /\.pdf$/i.test(att.getName() || '');
}

function saveFile_(root, cache, att, when, messageId, index) {
  const zone = Session.getScriptTimeZone();
  const month = Utilities.formatDate(when, zone, 'yyyy-MM');
  if (!cache[month]) {
    const found = root.getFoldersByName(month);
    cache[month] = found.hasNext() ? found.next() : root.createFolder(month);
  }
  const day = Utilities.formatDate(when, zone, 'yyyy-MM-dd');
  const name = day + '_' + messageId.slice(-6) + '-' + (index + 1) + '_' + (att.getName() || 'invoice.pdf');
  const existing = cache[month].getFilesByName(name);
  // Reuse the file if an earlier run saved it but stopped before logging the row.
  return existing.hasNext() ? existing.next() : cache[month].createFile(att.copyBlob().setName(name));
}

function getSheet_() {
  const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
  const sheet = ss.getSheetByName(CONFIG.SHEET_NAME) || ss.insertSheet(CONFIG.SHEET_NAME);
  if (sheet.getLastRow() === 0) sheet.appendRow(HEADERS);
  return sheet;
}

// Stops sender-controlled text like "=IMPORTXML(...)" running as a formula.
function safe_(value) {
  return /^[=+@\t\r-]/.test(value) ? "'" + value : value;
}

// A broken thread must not stop the report on it, so never let this throw.
function subjectOf_(thread) {
  try {
    return thread.getFirstMessageSubject();
  } catch (err) {
    return '(unreadable thread)';
  }
}

function alertOnce_(problems) {
  if (!problems.length) return;
  const props = PropertiesService.getScriptProperties();
  const last = Number(props.getProperty('lastAlertAt') || 0);
  if (Date.now() - last < CONFIG.ALERT_EVERY_MS) return;
  MailApp.sendEmail(
    CONFIG.ALERT_TO,
    'Invoice capture needs attention (' + problems.length + ')',
    problems.join('\n')
  );
  props.setProperty('lastAlertAt', String(Date.now()));
}

// Daily email: how many invoices still need a person. Silent when there are none.
function dailySummary() {
  const sheet = getSheet_();
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return;
  let review = 0;
  let duplicates = 0;
  sheet.getRange(2, STATUS_COL, lastRow - 1, 1).getValues().forEach(function (r) {
    if (r[0] === 'To review') review++;
    if (r[0] === 'Possible duplicate') duplicates++;
  });
  if (!review && !duplicates) return;
  MailApp.sendEmail(
    CONFIG.ALERT_TO,
    'Invoices waiting: ' + review + ' to review, ' + duplicates + ' possible duplicates',
    'Open the register: ' + SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID).getUrl() +
      '\nChange Status to Done once an invoice is entered.'
  );
}

// Run once by hand. Safe to run again: it replaces the old triggers.
function installTriggers() {
  ScriptApp.getProjectTriggers()
    .filter(function (t) { return ['captureInvoices', 'dailySummary'].indexOf(t.getHandlerFunction()) !== -1; })
    .forEach(function (t) { ScriptApp.deleteTrigger(t); });
  ScriptApp.newTrigger('captureInvoices').timeBased().everyMinutes(10).create();
  ScriptApp.newTrigger('dailySummary').timeBased().everyDays(1).atHour(8).create();
}

Every Apps Script method in this script was checked against Google's Apps Script reference (developers.google.com/apps-script/reference) on 11/10/2026. Limits come from Google's quota table, checked the same day.

What should you know before relying on it?

10 caveats
Quotas
Google's quota table lists a 6 minute limit per execution, 20 triggers per user per script, and a total trigger runtime of 90 minutes a day on a gmail.com account or 6 hours a day on Google Workspace. Gmail read and write operations (excluding send) are capped at 20,000 a day on gmail.com and 50,000 a day on Workspace. The trigger-runtime budget is the one to watch. This trigger runs every 10 minutes, which is 144 runs a day. On a gmail.com account that budget works out to an average of about 37 seconds per run; on Workspace, about 150. A run that finds nothing new is short, but a backlog run that uses its full 4 minute budget costs 240 seconds, so about 22 of those use up a gmail.com day. Open Executions in the Apps Script editor to see how long your runs really take. To tune it: lengthen the interval by editing everyMinutes in the install function (Google allows 1, 5, 10, 15 or 30; for longer use everyHours), lower TIME_BUDGET_MS, or narrow the search so fewer threads are read. The daily summary adds one short run and one email, well inside the recipient limits (100 a day on gmail.com, 1,500 on Workspace).
The six minute limit
The capture run stops itself after 4 minutes and carries on at the next trigger. Threads are labelled only once every PDF in them is logged, so nothing is skipped.
One bad thread cannot stall the register
Each thread is handled on its own. If one fails (for example a corrupt attachment), the other threads are still logged, the failed thread is left unlabelled so it is retried, and you get one alert email at most every 6 hours with the subject of the thread that failed. A thread that fails every time keeps being retried until someone fixes or relabels it, so act on the alert.
Skipped mail
Messages older than WINDOW_DAYS (14) and mail you sent yourself are skipped, so your own replies with a PDF attached are not logged as supplier invoices. Widen WINDOW_DAYS before restarting after a long pause.
Idempotency
Each attachment gets an ID made from the message ID and its position (stored as text with an m: prefix). The register is read once per run and any attachment already in it is skipped. A file saved by a run that stopped before logging is reused rather than saved again.
Duplicates need a person
The duplicate key is sender, file name and size. It catches the same PDF sent twice. It will not catch a re-issued invoice with a different file name, or a statement that repeats invoices. Mark those by eye.
Nothing is read from the PDF
Supplier, amount and due date are not extracted. Typing them from the PDF is still a person's job unless you add a parser. Scanned and multi-page invoices are where simple parsers stop being reliable.
Formula injection
Subject lines, sender names and file names are controlled by outsiders, so any of them that start with an equals sign, plus, minus or at sign are prefixed with an apostrophe before they are written to the sheet. The IDs and duplicate key are built with fixed prefixes so they can never start that way.
Only PDFs
Invoices sent as links, in the email body or as images are not captured. Decide how those reach you, because they will not appear in the register.
Who gets alerted
ALERT_TO receives the daily summary and the capture-failure emails (at most one every 6 hours). Use a shared accounts mailbox. Google's own trigger failure emails go to whoever owns the script, so install it from a dedicated shared Workspace user the business controls.

When should you call us?

This is a sound way to stop invoices sitting in an inbox. Call us when you want the line items, totals and due dates read automatically, when every supplier sends a different layout, when entries need to reach Xero, QuickBooks or MYOB without re-keying, or when someone has to be accountable for it working every day. That is the invoice pipeline we run for clients, built on the same pattern with a document parser and exception handling.

Tell us what you need

Continues · Want this wired into your stack?

Want this wired into your stack?

Tell us your tools and the task. We'll scope it and build it as production code you own.

Book a Consultation

We typically respond within 24 hours