Rex Automaton
All posts
CRM & Pipeline AutomationAugust 11, 202611 min read

How to Send Elementor Forms to Google Sheets with Uploads

We built a reliable Elementor Forms to Google Sheets sync that also saves file uploads to Drive: stable field mappings, dedupe, and an audit trail for compliance.

By Jacky Lei

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.

StepManual processAutomated flow
CaptureDownload CSV periodically and copy into a master SheetEach submission appends a row to the Sheet instantly
FilesAttachments live in inbox threadsFiles are saved to Drive, and Drive links are written to the row
MappingColumn assignment redone whenever labels changeStable field map enforces names and order
DedupeNone, duplicate rows pile upHash-based dedupe blocks replays and retries
AuditScattered emails as proofJSON 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.

Elementor form submission to Google Sheets and Drive: Elementor Form posts to a webhook endpoint. The endpoint normalizes fields and dedupes. Then it appends a row in Google Sheets and downloads any uploaded files into a Drive folder, writing back share links.

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 Submissions

Answer 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.

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 structure

Answer 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 _hash

Answer 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

Related reading