We built and shipped an Elementor Forms to Google Sheets pipeline that also saves uploaded files to Google Drive. In production it captures every submission with clean field mappings, deduplicates retries, and keeps a JSON audit trail you can trust. This guide shows how we implement it safely using Elementor's webhook action or Make.com's trigger.
Elementor Forms to Google Sheets automation is the server-side capture of each form submission into a structured spreadsheet row, with file uploads stored and linked, plus deduplication and an audit log.
The problem it solves
You want Elementor Forms data in Google Sheets with the file uploads preserved, not stuck in email or lost when labels change. Copy-paste breaks, CSV exports are manual, and renaming a field label can silently corrupt your mappings. You also need a tamper-evident log for compliance and a way to prevent duplicates when a webhook retries.
| Step | Manual process | Automated flow |
|---|---|---|
| Capture | Download CSV periodically and copy into a master Sheet | Each submission appends a row to the Sheet instantly |
| Files | Attachments live in inbox threads | Files are saved to Drive, and Drive links are written to the row |
| Mapping | Column assignment redone whenever labels change | Stable field map enforces names and order |
| Dedupe | None, duplicate rows pile up | Hash-based dedupe blocks replays and retries |
| Audit | Scattered emails as proof | JSON audit log per submission with timestamps |
How the automation works
At the core, Elementor Pro posts each submission to a webhook endpoint. We receive it in Apps Script or via Make's watch trigger, normalize labels into stable keys, write a row into Google Sheets, and for any file fields we download the file to Drive then store the Drive URL in the row. A hash of the payload prevents duplicates and a Raw tab stores the original JSON for auditing.
- Elementor has no public REST API for reading submissions: we use the built-in Webhook action or Make's Watch Forms trigger to stream each submission server-side. Elementor documents PHP hooks for form events inside WordPress, but not a public submissions endpoint.
- Field labels drive the webhook keys: we freeze mappings so renaming a label does not break the Sheet.
- Upload handling respects Elementor settings: if files are only emailed, they are not visible in the Submissions page. We default to storing uploads so Drive can fetch by URL and persist them.
Step-by-step: how to build it
1) Configure Elementor Forms to post to a webhook
Set up your form in Elementor Pro and add Actions after submit: Webhook. Paste your endpoint URL. Keep field labels stable because Elementor uses labels in its webhook payload. If you toggle Advanced Data, the payload structure changes to an array shape, so decide that up front and keep it consistent.
Elementor Form
Actions after submit: Webhook
Webhook URL: https://script.google.com/macros/s/AKfycb.../exec?token=YOUR_SHARED_SECRET
Store Submissions: On (so file URLs exist)
Email attachments: Off if you want files visible in SubmissionsAnswer first: Elementor's Webhook action is the reliable push path. There is no documented public REST endpoint for reading submissions later, so push on submit rather than trying to pull.
2) Deploy an Apps Script Web App receiver
Publish a container-bound Apps Script as a Web App. It accepts JSON, validates a shared secret, computes a dedupe hash, and appends to a Sheet. We keep a Raw tab for the unmodified payload.
// Code.gs
const CONFIG = {
SHEET_NAME: 'Submissions',
RAW_NAME: 'Raw',
DRIVE_PARENT: 'YOUR_DRIVE_FOLDER_ID', // create per-form subfolders
TOKEN: 'YOUR_SHARED_SECRET'
};
function doPost(e) {
if (!e || !e.postData) return _resp(400, 'Bad Request');
const token = (e.parameter && e.parameter.token) || '';
if (token !== CONFIG.TOKEN) return _resp(401, 'Unauthorized');
const bodyText = e.postData.contents || '{}';
const payload = JSON.parse(bodyText);
// Compute a stable hash for dedupe from the raw JSON text
const hash = Utilities.base64EncodeWebSafe(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, bodyText))
.slice(0, 22);
const ss = SpreadsheetApp.getActive();
const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
const raw = ss.getSheetByName(CONFIG.RAW_NAME);
// Stop if we have seen this hash recently
if (_seen(hash, sheet)) return _resp(200, JSON.stringify({ status: 'duplicate', hash }));
// Normalize fields: flatten object or array-of-fields into key:value map
const map = _normalize(payload);
// Handle file-like fields: download to Drive and replace with Drive URL
const files = _extractFileUrls(map);
const driveLinks = files.map(f => _saveToDrive(f.url, f.name));
driveLinks.forEach(link => map[link.key] = link.url);
// Append header-safe row
const headers = _ensureHeaders(sheet, Object.keys(map).concat(['_hash', '_ts']));
const row = headers.map(h => h === '_hash' ? hash : h === '_ts' ? new Date() : (map[h] ?? ''));
sheet.appendRow(row);
// Raw log for audit
raw.appendRow([new Date(), hash, bodyText]);
return _resp(200, JSON.stringify({ status: 'ok', hash }));
}
function _normalize(payload) {
// If Advanced Data is off: keys are labels; if on: often an array of fields
if (Array.isArray(payload)) {
const out = {};
payload.forEach(item => {
const key = (item.label || item.id || 'field_' + Math.random()).trim();
out[key] = item.value != null ? String(item.value) : '';
});
return out;
}
if (payload && payload.fields && Array.isArray(payload.fields)) {
const out = {};
payload.fields.forEach(f => out[(f.label || f.id).trim()] = String(f.value ?? ''));
return out;
}
return Object.keys(payload || {}).reduce((m, k) => (m[k] = String(payload[k] ?? ''), m), {});
}
function _extractFileUrls(map) {
const urls = [];
Object.keys(map).forEach(k => {
const v = map[k];
if (typeof v === 'string' && /^https?:\/\//i.test(v) && /\.(pdf|png|jpe?g|docx?|xlsx?)($|\?)/i.test(v)) {
urls.push({ key: k, url: v, name: k });
}
});
return urls;
}
function _saveToDrive(url, name) {
try {
const resp = UrlFetchApp.fetch(url, { followRedirects: true, muteHttpExceptions: true });
const blob = resp.getBlob();
const folder = DriveApp.getFolderById(CONFIG.DRIVE_PARENT);
const file = folder.createFile(blob).setName(name + ' ' + Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd_HH.mm.ss'));
return { key: name + ' File', url: file.getUrl() };
} catch (e) {
return { key: name + ' File', url: url }; // fall back to original URL
}
}
function _ensureHeaders(sheet, keys) {
const headers = sheet.getLastRow() >= 1 ? sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0] : [];
const set = new Set(headers.filter(Boolean));
keys.forEach(k => set.add(k));
const arr = Array.from(set);
sheet.getRange(1, 1, 1, arr.length).setValues([arr]);
return arr;
}
function _seen(hash, sheet) {
const data = sheet.getDataRange().getValues();
const idx = data[0].indexOf('_hash');
if (idx === -1) return false;
for (let i = 1; i < data.length; i++) {
if (data[i][idx] === hash) return true;
}
return false;
}
function _resp(code, body) {
return ContentService.createTextOutput(body).setMimeType(ContentService.MimeType.JSON);
}Key gotcha: Apps Script deployments do not auto-update on push. Create a new deployment version after edits so the live /exec URL picks up changes.
3) Freeze a stable field map
We avoid depending on human-friendly labels by creating a map from form labels to canonical keys. This keeps your Sheet columns stable when a label changes for copy edits.
// Optional mapping layer
const FIELD_MAP = {
'Full Name': 'name',
'Email Address': 'email',
'Company': 'company',
'Resume Upload': 'resume_url'
};
function _normalizeWithMap(payload) {
const raw = _normalize(payload);
const out = {};
Object.keys(raw).forEach(k => {
const key = FIELD_MAP[k] || k;
out[key] = raw[k];
});
return out;
}Answer first: treat labels as unstable. Map them to canonical keys so your Sheet is not hostage to copy changes.
4) Save uploads to Drive and link them in the row
Elementor can email uploads or store them. If you only email files, they will not be accessible from the Submissions page. For reliable archiving we store uploads, fetch via URL, write the Drive link in the Sheet, and keep the original URL if a download fails.
// Already shown in _saveToDrive. Consider per-form subfolders and a YYYY/MM structureAnswer first: do not pipe binary through Sheets. Store files in Drive and write links in your data row.
5) Make.com alternative: Elementor Watch Forms to Sheets
If you prefer no code, Make offers a Watch Forms trigger for Elementor. Use it to add a row in Google Sheets and, for files, call HTTP to fetch the file then use Google Drive: Upload a file module to persist it. Note that Make's field names follow Elementor's labels, so a future label change will break your mapping unless you insert a mapping filter layer.
Answer first: Make works fine for this flow. Plan for label drift and retry behavior when webhooks replay.
6) Backfill and replays
Use Elementor Submissions to Export All to CSV for historical data and import into the Sheet. We also write the raw JSON and the dedupe hash so webhook retries and admin re-sends do not create duplicate rows. If you re-import, leave the _hash column populated.
Elementor: Submissions -> Export All to CSV
Google Sheets: File -> Import -> Insert new sheet(s)
Then vlookup/match into your master tab by email + timestamp or by _hashAnswer first: rely on CSV export for backfill. Use your hash to keep imports idempotent.
7) Add a lightweight audit trail
We append the raw JSON and a timestamp to a Raw tab. This gives you a simple, tamper-evident record for compliance reviews. If you need more, create a second Google Sheet as an append-only log and write audit rows there in parallel.
// Already in doPost: raw.appendRow([new Date(), hash, bodyText])Answer first: keep the raw payload. It is your proof when a contact disputes what they submitted.
Where it gets complicated
- Field label drift: Elementor uses labels in its webhook keys. Renaming a label breaks downstream mappings. We fix this with a canonical key map and by versioning our mapping file.
- Advanced Data shape differences: Enabling Advanced Data changes the payload to an array-like structure. Commit to one shape and code to it.
- Upload storage choice: If uploads are configured to be emailed rather than stored, those files will not be accessible from the Submissions page. Decide storage and retention before go-live.
- No official submissions REST API: There is no documented public REST endpoint for submissions. Programmatic reads rely on CSV export, PHP hooks inside WordPress, or third-party connectors like Make.
- Webhook security: Add a shared token parameter and verify it. Also rate-limit your receiver and log unexpected shapes so retries do not flood your Sheet.
What this actually changes
For WordPress sites that rely on email and occasional CSV exports, the production pipeline removed copy-paste work and stopped lost uploads. Sales teams get a live Sheet and a file link per row, ops gets an audit trail, and retries no longer create duplicates. Fast capture matters: one Harvard Business Review analysis found firms that responded to web leads within five minutes were much more likely to qualify prospects than those that waited longer. Source: https://hbr.org/2011/03/the-short-life-of-online-sales-leads
Frequently asked questions
Does Elementor Forms have an API to pull submissions?
Elementor Pro documents PHP hooks you can use inside WordPress. There is no documented public REST API for fetching submissions. Use the built-in Webhook action, Make's Watch Forms trigger, or export CSV from the Submissions screen when you need a backfill.
Can this include file uploads in Google Sheets?
Yes. Store uploads in Elementor so the webhook includes accessible URLs. The receiver downloads each file to a Drive folder, then writes the Drive link into your row. If uploads are only emailed, they will not appear in Submissions and cannot be fetched the same way.
Zapier, Make, or custom code: which should I use?
All three work. Zapier uses a Catch Hook URL and is easy for simple rows. Make has a native Watch Forms trigger and rich branching. Custom code gives you full control over dedupe, auditing, and file handling. We pick based on label stability, volume, and whether you need an audit trail.
How do you prevent duplicates from webhook retries?
We compute a hash from the raw JSON body and write it to a _hash column. Before appending a row, we check if the hash already exists and skip if it does. This makes the pipeline idempotent across retries and replays.
What breaks most Elementor to Sheets builds?
Label renames, toggling Advanced Data midstream, emailing uploads instead of storing them, and not versioning the webhook mapping. We address those with a canonical key map, a fixed payload shape, stored uploads, and an audit-first design.
If you want this deployed without surprises, we have built this exact pipeline for WordPress sites that needed reliable Sheets and Drive capture. See our service overview at /services#workflow-automation, read our related how-to on Jotform to Google Sheets with attachments, and when you are ready, book a 15-minute call.
Want us to build this for you?
15-minute discovery call. No pitch. We tell you what to automate first.
Book a Discovery Call