appscript.dev
Automation Advanced Drive

Merge many PDFs from a folder into one

Combine PDFs in a Northwind folder into a single deliverable — for client handovers.

Published Sep 26, 2025

At the end of a Northwind project there is usually a handover folder full of PDFs — the brief, the contract, a few sign-off forms, the final report. The client wants one file, not eleven. Apps Script cannot stitch PDFs together on its own (Drive will not do it, and there is no built-in PDF library), so the trick is to push the bytes out to a service that does and bring back a single merged blob.

This script collects every PDF in a folder, posts them as base64 to a merge endpoint, and writes the result back to the same folder as one deliverable. The endpoint can be anything that takes a JSON array of files and returns a merged PDF — a hosted service like ILovePDF, a self-hosted pdf-lib Cloud Run, or your own Cloud Function. The Apps Script side stays the same.

What you’ll need

  • A Drive folder containing the PDFs to merge, with the script account holding edit access so it can write the merged file back.
  • A PDF-merge endpoint that accepts the payload below and returns { "merged": "<base64>" }. The script is endpoint-agnostic — anything that speaks that shape works.
  • The endpoint’s API key in Script Properties (most services need one) — see Store API keys and secrets securely.

The script

// External merge endpoint. Swap for your own Cloud Function or hosted
// service — the contract is described under "Watch out for".
const MERGE_API = 'https://your-pdf-service.example/merge';

// Maximum number of source PDFs to send in a single request. Most
// services cap the payload size; chunking keeps you under the limit.
const MAX_FILES_PER_REQUEST = 25;

/**
 * Collects every PDF in `folderId`, sends them to the merge service, and
 * writes the merged result back into the same folder.
 *
 * @param {string} folderId    The Drive folder that holds the source PDFs.
 * @param {string} outputName  Filename for the merged PDF, e.g. "Handover.pdf".
 * @returns {GoogleAppsScript.Drive.File|null} The merged file, or null on no input.
 */
function mergePdfsInFolder(folderId, outputName) {
  const folder = DriveApp.getFolderById(folderId);

  // 1. Walk the folder and grab the bytes of every PDF.
  const files = folder.getFilesByType('application/pdf');
  const blobs = [];
  while (files.hasNext()) blobs.push(files.next().getBlob());

  if (!blobs.length) {
    Logger.log('No PDFs found in folder ' + folderId + ' — nothing to merge.');
    return null;
  }
  if (blobs.length > MAX_FILES_PER_REQUEST) {
    throw new Error('Too many PDFs (' + blobs.length + ') — split the folder ' +
      'or raise MAX_FILES_PER_REQUEST and check your service limits.');
  }

  // 2. Encode each PDF as base64 and label it. The order of `payload`
  //    is the order of pages in the merged result.
  const payload = blobs.map((b, i) => ({
    name: 'file' + i + '.pdf',
    content: Utilities.base64Encode(b.getBytes()),
  }));

  // 3. POST to the merge endpoint. Auth header is optional — only added
  //    if a key is stored in Script Properties.
  const key = PropertiesService.getScriptProperties().getProperty('PDF_API_KEY');
  const headers = key ? { Authorization: 'Bearer ' + key } : {};
  const res = UrlFetchApp.fetch(MERGE_API, {
    method: 'post',
    contentType: 'application/json',
    headers,
    payload: JSON.stringify({ files: payload }),
    muteHttpExceptions: true,
  });

  if (res.getResponseCode() >= 300) {
    throw new Error('Merge failed (' + res.getResponseCode() + '): ' + res.getContentText());
  }

  // 4. Decode the merged PDF and drop it back into the folder.
  const merged = Utilities.newBlob(
    Utilities.base64Decode(JSON.parse(res.getContentText()).merged),
    'application/pdf',
    outputName
  );
  const out = folder.createFile(merged);
  Logger.log('Merged ' + blobs.length + ' PDFs into ' + out.getName());
  return out;
}

How it works

  1. mergePdfsInFolder opens the folder and iterates every file with mime type application/pdf, pushing each one’s blob into an array.
  2. If the folder is empty it logs and returns null — no wasted API call. If there are too many files it throws, because most merge services cap the request size and a 413 is harder to debug than a clear error.
  3. The blobs are base64-encoded and wrapped in a { name, content } shape. The array order is the page order in the merged PDF, so name files numerically in the folder (01-brief.pdf, 02-contract.pdf) if order matters.
  4. The script reads an optional PDF_API_KEY from Script Properties and adds a bearer header only when one is present, so the same code works with both authenticated and anonymous endpoints.
  5. It posts the payload, fails fast on a non-2xx response, and decodes the merged base64 string into a fresh blob with the requested filename.
  6. That blob is written back into the source folder via folder.createFile(merged) and the file handle is returned for the caller to share, email, or move.

Example run

A folder for Acme handover contains:

FilenameSize
01-brief.pdf240 KB
02-contract.pdf110 KB
03-final-report.pdf1.8 MB
04-signoff.pdf80 KB

Calling mergePdfsInFolder('1abcAcmeHandoverId', 'Acme handover.pdf') posts the four PDFs to the merge service and writes Acme handover.pdf (about 2.2 MB) back into the same folder. The log line reads Merged 4 PDFs into Acme handover.pdf, and the client gets one file containing pages from the brief, contract, report, and sign-off in that order.

Run it

This is usually a one-off, called at the end of a project rather than on a schedule.

  1. In the Apps Script editor, open the file and edit MERGE_API to point at your endpoint.
  2. From a quick wrapper or the console, call mergePdfsInFolder(folderId, 'Handover.pdf') with the right folder ID.
  3. Approve the Drive and external-request scopes the first time.

To trigger it from the spreadsheet that tracks projects, wrap the call in a custom menu so a project manager can click Merge handover on the row they care about.

Watch out for

  • The endpoint contract is the load-bearing piece. The script assumes a JSON request shaped { files: [{ name, content }] } and a JSON response shaped { merged: "<base64>" }. If your service returns raw bytes instead, swap the decode step for res.getBlob().
  • Base64 inflates payloads by about a third. A 9 MB folder of PDFs becomes a 12 MB request body, which is close to the practical UrlFetchApp ceiling. Chunk in batches and merge the chunks if you outgrow that.
  • File order is the array order. Drive’s getFilesByType order is not guaranteed to be alphabetical — number your source files (01-, 02-) so the result is predictable.
  • muteHttpExceptions: true plus an explicit status check is on purpose. Most services return a JSON error body on failure, and you want that body in the log, not a generic Apps Script exception.
  • Treat the API key the same way you would any third-party credential. Put it in Script Properties — never inline.

Related