Google Forms and Apps Script
The Sheets add-on makes documents when you click Generate. To make them with nobody clicking, for example a certificate for each form response or a quote when a row is ticked, a short Apps Script sends the data to the AutomaDoc document API and does what you want with the file: save it to Drive, email it, or write its link into the sheet.
The script runs in your Google account, under Google’s usual Apps Script rules. AutomaDoc only sees the JSON it sends.
What you need
Section titled “What you need”- A template. Any AutomaDoc template works: the JSON the script sends is the record, so the template’s own data source is not used. Its placeholders are named after the keys the script sends (see Names).
- The template ID. In the Company Portal, open Templates, open the template’s menu and choose Copy template ID.
- An API key. A company API key, created by an admin in the Company Portal under API keys (it starts with
amk_and is shown once), or, for a Lark Base template, that template’s own key, which opens only that template. The smaller the key’s reach the better: anyone who can edit the script can read it.
Recipe: a document for every form response
Section titled “Recipe: a document for every form response”This script is bound to the form. For each response it makes a PDF, saves it in a Drive folder, and emails it to the person who answered.
- Open the form in Google Forms, open the ⋮ menu and choose Apps Script.
- In the Apps Script editor, open Project Settings and add a script property named
AUTOMADOC_API_KEYwith your key as its value. The key then stays out of the code. - Replace the contents of
Code.gswith the script below, and fill inTEMPLATE_IDandFOLDER_ID(the last part of the Drive folder’s address, after/folders/). - Choose
setUpin the function list and click Run once. Google asks you to allow the script to connect to an external service, use your Drive and send email as you.
From then on every submission runs handleSubmit. Its runs and errors are listed under Executions in the editor.
const AUTOMADOC_API = 'https://api.automadoc.cc';const TEMPLATE_ID = 'PASTE-THE-TEMPLATE-ID';const FOLDER_ID = 'PASTE-THE-DRIVE-FOLDER-ID';
/** Run once: creates the trigger that calls handleSubmit on every response. */function setUp() { ScriptApp.newTrigger('handleSubmit') .forForm(FormApp.getActiveForm()) .onFormSubmit() .create();}
function handleSubmit(e) { const response = e.response;
// One key per question: its title is the placeholder's name. const data = {}; response.getItemResponses().forEach(function (itemResponse) { data[itemResponse.getItem().getTitle()] = answerOf(itemResponse.getResponse()); }); data['Submitted'] = Utilities.formatDate( response.getTimestamp(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm');
const doc = createDocument(data); const file = DriveApp.getFolderById(FOLDER_ID).createFile(downloadFile(doc)); console.log('Saved ' + file.getName() + ': ' + file.getUrl());
// Needs "Collect email addresses" in the form's settings. const email = response.getRespondentEmail(); if (email) { MailApp.sendEmail({ to: email, subject: doc.filename, body: 'Thank you. Your document is attached.', attachments: [file.getBlob()], }); }}
/** Grid questions answer with a list of lists, which the API refuses: one text instead. */function answerOf(value) { if (Array.isArray(value) && value.some(Array.isArray)) { return value.map(function (row) { return [].concat(row).join(', '); }).join('; '); } return value;}
/** Fills the template and waits until the document is finished. */function createDocument(data) { let doc = callApi(AUTOMADOC_API + '/api/v1/templates/' + TEMPLATE_ID + '/documents?wait=20', { method: 'post', contentType: 'application/json', payload: JSON.stringify({ data: data, format: 'pdf' }), }); // Not finished within the 20 seconds: read its status every 5 seconds, for up to 4 minutes. for (let i = 0; doc.status === 'processing' && i < 48; i++) { Utilities.sleep(5000); doc = callApi(doc.statusUrl); } if (doc.status !== 'completed') { throw new Error(doc.error || 'The document is still being made: ' + doc.statusUrl); } if (doc.warnings) console.log(doc.warnings.join('\n')); return doc;}
/** The finished file, as a blob named like the document. */function downloadFile(doc) { const response = UrlFetchApp.fetch(doc.statusUrl + '/file', { headers: { 'X-Webhook-API-Key': apiKey() }, muteHttpExceptions: true, }); if (response.getResponseCode() !== 200) { throw new Error('Download failed: ' + response.getContentText()); } return response.getBlob().setName(doc.filename);}
function callApi(url, options) { options = options || {}; options.headers = { 'X-Webhook-API-Key': apiKey() }; options.muteHttpExceptions = true; const response = UrlFetchApp.fetch(url, options); const body = JSON.parse(response.getContentText()); if (response.getResponseCode() >= 400) throw new Error(body.code + ': ' + body.error); return body;}
function apiKey() { return PropertiesService.getScriptProperties().getProperty('AUTOMADOC_API_KEY');}What happens on each call:
POST /api/v1/templates/{templateId}/documents?wait=20sends{ "data": …, "format": "pdf" }. Withwait, the answer comes when the document is finished (200) or after 20 seconds (202, stillprocessing).formatcan also be"native"(the template’s own Word, Excel or PowerPoint format) or"png", andfilenamesets a filename pattern for this call.GET /api/v1/documents/{generationId}(thestatusUrlin the answer) reads itsstatus:processing,completedorfailed, with the reason inerror. A completed document has afilenameand adownloadUrl.GET /api/v1/documents/{generationId}/filereturns the file itself, with the same key.
If you only need a link, skip downloadFile: doc.downloadUrl opens the file with no key, for as long as your company keeps generated documents (30 days if they are kept indefinitely). Each status read gives a fresh one.
Recipe: a document when a box is ticked in a sheet
Section titled “Recipe: a document when a box is ticked in a sheet”The same helpers work from a spreadsheet. Open it, choose Extensions → Apps Script, add the script property, and paste the helpers above (createDocument, downloadFile, callApi, apiKey and the three constants) with this in place of setUp and handleSubmit.
The tab here is called Orders. Row 1 holds the column names; a checkbox column named Generate starts a document, and a column named Document receives its link.
const SHEET_NAME = 'Orders';
/** Run once: creates the trigger that calls handleEdit on every edit. */function setUp() { ScriptApp.newTrigger('handleEdit') .forSpreadsheet(SpreadsheetApp.getActive()) .onEdit() .create();}
function handleEdit(e) { const sheet = e.range.getSheet(); // Only a single checkbox ticked on a data row of the right tab if (sheet.getName() !== SHEET_NAME || e.range.getRow() < 2 || e.value !== 'TRUE') return;
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getDisplayValues()[0]; if (headers[e.range.getColumn() - 1] !== 'Generate') return;
const row = e.range.getRow(); const values = sheet.getRange(row, 1, 1, headers.length).getDisplayValues()[0]; const data = {}; headers.forEach(function (header, i) { if (header && header !== 'Generate' && header !== 'Document') data[header] = values[i]; });
const doc = createDocument(data); const file = DriveApp.getFolderById(FOLDER_ID).createFile(downloadFile(doc)); const linkColumn = headers.indexOf('Document') + 1; if (linkColumn > 0) sheet.getRange(row, linkColumn).setValue(file.getUrl());}The trigger must be the installable one that setUp creates. A function named onEdit would run as a simple trigger, which Google does not allow to call outside services.
getDisplayValues() sends each cell as the sheet shows it, so numbers arrive as text formatted by the sheet. To use filters such as number, currency or sum on a column, send that column’s value from getValues() instead.
Each key becomes a placeholder name the way a column or field name does: spaces, dots and other symbols become _, letters and digits of any language are kept, and case matters.
| Question or column | Placeholder |
|---|---|
Full name |
{Full_name} |
Invoice No. |
{Invoice_No_} |
Submitted (added by the script) |
{Submitted} |
A title with no letter or digit is refused (DATA_INVALID_NAME), and two titles that become the same name are refused (DATA_NAME_COLLISION). A checkbox question answers with a list, which prints joined with commas.
Dates sent as text without a time zone, such as 2026-10-17 09:30, are read in the template’s time zone, so the date filter can reformat them.
Pictures must be public web addresses (https://, or http://), sent where the template has a picture placeholder. Files uploaded through a form question are private files in the form owner’s Drive: AutomaDoc cannot fetch them, and their answer is only the files’ ids. Leave such questions out of the template.
Limits and costs
Section titled “Limits and costs”| Requests per template | 60 a minute |
| Documents per company | 600 a minute, all automations together |
| Status reads per document | 60 a minute; file downloads have their own 60 |
wait |
25 seconds at most |
| Request body | 1 MB |
| Characters in a value | 100,000 |
A refusal comes back with a code and an error, and the script above stops with both in its Executions log. RATE_LIMITED (429) means too many calls in the minute: a form that may receive many responses at once should catch it and try again a little later. The API reference lists every code.
Google stops a single script run after 6 minutes, which is why the script stops reading the status after 4. A document still being made then is not lost: its statusUrl in the error still answers.
Documents made this way are automated documents. They count against your Business seats’ automation allowance first, then the free monthly allowance, then your document packs; when all three are used up, the API answers QUOTA_EXCEEDED. On the Free plan they carry the “Generated with AutomaDoc” footer unless your company holds a purchased pack.