Save Mechanic form submissions to a Google Sheet, with Mechanic.

Mechanic is a development and ecommerce automation platform for Shopify. :)

Save Mechanic form submissions to a Google Sheet

Save selected answers from Mechanic form submissions as rows in a Google Sheet, with optional Google Drive uploads and file links.

Runs Occurs when a user manually triggers the task, Occurs whenever user/forms/submission is triggered, and Occurs when a Mechanic action is performed. Configuration includes webhook event topic, form, google account, spreadsheet title, spreadsheet id, column headings and field keys, and save uploaded files to google drive.

15-day free trial – unlimited tasks

Documentation

Save selected answers from Mechanic form submissions as rows in a Google Sheet, with optional Google Drive uploads and file links.

This task is for Mechanic's form builder, not Shopify Forms. Submissions use your ordinary webhook, pass through AWS, and run subscribed tasks in the background. Installing this task does not create or publish a form, change its webhook, or place a theme block.

Connect a form

  1. In the form's Submission settings, choose a webhook. Copy its exact event topic into Webhook event topic in this task; the example user/forms/submission is not automatically connected.
  2. Optionally choose a published form in Form to save only that form. Leave it blank to handle any Mechanic form on this webhook. Form IDs and submission IDs identify visitor input, not authenticated customers.
  3. Complete the destination setup below, save and enable the task, then refresh the form's connected tasks. Disabled tasks do not save submissions.
  4. Send a test from the actual storefront, then check the resulting task action and destination. The builder and theme-editor previews do not send anything. The form's confirmation does not guarantee that a storage action succeeded.

Both ordinary and full-request webhook data modes work. Scalar answers, including numbers and checkbox values, remain strings; multiple-choice arrays and addresses retain their structure. Hidden questions omitted by the form are not saved. The task rejects malformed values and answers/file details larger than 100 KB. Saving uploaded file contents has a separate 3 MB total limit, described below.

Uploaded files

Save uploaded files to Google Drive is off by default. When off, the task saves file names, reported MIME types and sizes, and contents_saved: false. The original contents remain in the ordinary Mechanic event and expire with its retention.

Enable this option to keep the files in the connected Google account. Files are organized under Mechanic form uploads, grouped by form code and Mechanic event. The task uploads them first, then uses mechanic/actions/perform to add a spreadsheet row containing the returned Drive links and contents_saved: true. Base64 contents are never placed in spreadsheet cells. Submissions without files are saved immediately.

No public sharing is enabled by this task. Drive folder/file permissions determine who can open the links; sharing a spreadsheet does not automatically grant access to its linked files. Filenames are made safe and prefixed with their field key so two upload fields can use the same original filename. The original filename is retained in the file details.

Files saved to Drive may total at most 3 MB (3,145,728 bytes) per submission. The task checks the base64 structure and decoded byte count against the reported size. Names and MIME types remain visitor-supplied; the task does not scan file contents. If an upload fails or a returned link is missing, no spreadsheet row is added and the task/action reports an error. Some files may already have uploaded; inspect the action results before retrying. Keep the callback subscription in place while uploads are pending.

Moving shops or stopping

Set up the destination and reconnect the new shop's webhook when moving this task or importing a form. Imported forms have new IDs. Publish the imported form, then select it again in the Form dropdown if this task filters to one form. Disabling the task stops future saves and leaves existing records and the shared webhook intact. Other subscribed tasks can still run.

Google Sheets setup

  1. Connect your Google account under Settings → Authentication in Mechanic.
  2. Fill in Google account and Spreadsheet title. In Column headings and field keys, pair each column heading with a form field's data key (for example, Product → product, Request → issue). The defaults use name and email. The form editor shows data keys; use keys, not question labels. Missing or hidden fields leave a blank cell. Files are listed in the fixed File details and links column. With Drive saving enabled, you can also map an upload field key to its own column to store its Drive link there.
  3. Leave Spreadsheet ID blank, save, and run the task manually. Open the completed create_spreadsheet action, copy its spreadsheet_id, paste it into this task, and save. Mechanic's integration works with spreadsheets created through Mechanic. Manual runs with an ID already configured do not create a sheet or add a row.
  4. The task writes to the spreadsheet's first sheet. Do not reorder the configured columns or the sheet's columns independently; keep headers aligned or create a fresh spreadsheet. Only the selected answer fields are copied. Arrays and addresses are JSON in their selected cells. Answers exceeding 40,000 characters in one cell are rejected.

Every row starts with Received at, Form code, Form revision, Submission ID, Mechanic event ID, and File details and links. If you created a sheet with the older task, rename its sixth header to File details and links; the column positions are unchanged. Visitor input is written as raw values, so answers beginning with = do not execute as spreadsheet formulas.

Retries and duplicates

Appending a row is not idempotent. Replaying a task, receiving the submission again, or retrying after a lost response can add a duplicate row. This task sets retry_on_transient_errors: false for appends so ambiguous writes are not automatically retried by the Google Sheets action. Inspect the destination before manually retrying a failed action. The two IDs help identify repeats; they do not enforce uniqueness. A missing or unconfigured spreadsheet causes a visible task/action error, not a successful save.

Drive retries use stable paths with overwrite enabled for the same event, but concurrent runs can still create duplicate files. A callback replay can append another row. Changing task options while an upload is running does not redirect it: its original account, spreadsheet and column mapping are retained.

See Mechanic's Google Sheets action.

Developer details

Mechanic is designed to benefit everybody: merchants, customers, developers, agencies, Shopifolks, everybody.

That’s why we make it easy to configure automation without code, why we make it easy to tweak the underlying code once tasks are installed, and why we publish it all here for everyone to learn from.

(By the way, have you seen our documentation? Have you joined the Slack community?)

Open source
View on GitHub to contribute to this task
Subscriptions
mechanic/user/trigger
{{ options.webhook_event_topic__required }}
mechanic/actions/perform
Tasks use subscriptions to sign up for specific kinds of events. Learn more
Options
webhook event topic (required) , form (storefrontform) , google account (required) , spreadsheet title (required) , spreadsheet id , column headings and field keys (keyval, required) , save uploaded files to google drive (boolean)
Code
{% comment %}
  {{ options.webhook_event_topic__required }}
  {{ options.form__storefrontform }}
  {{ options.google_account__required }}
  {{ options.spreadsheet_title__required }}
  {{ options.spreadsheet_id }}
  {{ options.column_headings_and_field_keys__keyval_required }}
  {{ options.save_uploaded_files_to_google_drive__boolean }}
{% endcomment %}
{% assign columns = options.column_headings_and_field_keys__keyval_required %}
{% assign column_names = columns | keys %}
{% if column_names == nil or column_names.size == 0 or column_names.size > 50 %}
  {% error "Configure between 1 and 50 column headings and form field keys." %}{% break %}
{% endif %}
{% assign headers = "Received at,Form code,Form revision,Submission ID,Mechanic event ID,File details and links" | split: "," | concat: column_names %}
{% if event.topic == "mechanic/actions/perform" %}
  {% if event.preview %}
    {% capture callback_json %}
      {"type":"google_drive","meta":{"stage":"form_submission_drive_upload_v1","account":{{ options.google_account__required | json }},"spreadsheet_id":{{ options.spreadsheet_id | default: "preview-spreadsheet-id" | json }},"row":["2026-09-14T12:00:00+00:00","11111111-1111-4111-8111-111111111111","1","22222222-2222-4222-8222-222222222222","33333333-3333-4333-8333-333333333333","{}"{% for column in columns %},""{% endfor %}],"attachments":{"receipt":{"name":"receipt.txt","mime_type":"text/plain","size":4,"contents_saved":false}},"upload_paths":{"receipt":"Mechanic form uploads/example/receipt.txt"},"file_columns":{}},"run":{"ok":true,"result":{"uploads":{"Mechanic form uploads/example/receipt.txt":{"id":"preview-file-id","web_view_link":"https://drive.google.com/file/d/preview-file-id/view"}}}}}
    {% endcapture %}
    {% assign action = callback_json | parse_json %}
  {% endif %}
  {% if action.type != "google_drive" or action.meta.stage != "form_submission_drive_upload_v1" %}{% break %}{% endif %}
  {% unless action.run.ok %}
    {% error "Google Drive could not save all uploaded files. No spreadsheet row was added. Inspect the failed Drive action before retrying." %}{% break %}
  {% endunless %}
  {% assign attachments = action.meta.attachments %}
  {% assign missing_upload = false %}
  {% for file_path in action.meta.upload_paths %}
    {% assign saved = action.run.result.uploads[file_path[1]] %}
    {% assign link_prefix = saved.web_view_link | slice: 0, 25 %}
    {% if saved.id == blank or link_prefix != "https://drive.google.com/" %}
      {% assign missing_upload = true %}{% break %}
    {% endif %}
    {% assign file = attachments[file_path[0]] %}
    {% assign file["contents_saved"] = true %}
    {% assign file["google_drive_id"] = saved.id %}
    {% assign file["url"] = saved.web_view_link %}
    {% assign key = file_path[0] %}
    {% assign attachments[key] = file %}
  {% endfor %}
  {% if missing_upload %}
    {% error "Google Drive did not return every expected file link. No spreadsheet row was added. Inspect the Drive action result before retrying." %}{% break %}
  {% endif %}
  {% assign file_details = attachments | json %}
  {% if file_details.size > 40000 %}
    {% error "The file details are too large for a spreadsheet cell. Files were saved to Drive, but no row was added." %}{% break %}
  {% endif %}
  {% assign row = array %}
  {% for cell in action.meta.row %}
    {% assign index = forloop.index0 | append: "" %}
    {% assign file_key = action.meta.file_columns[index] %}
    {% if forloop.index0 == 5 %}
      {% assign row = row | push: file_details %}
    {% elsif file_key != nil %}
      {% assign row = row | push: attachments[file_key].url %}
    {% else %}
      {% assign row = row | push: cell %}
    {% endif %}
  {% endfor %}
  {% action "google_sheets", __perform_event: false %}
    {"account":{{ action.meta.account | json }},"operation":"append_rows","spreadsheet_id":{{ action.meta.spreadsheet_id | json }},"value_input_option":"raw","retry_on_transient_errors":false,"rows":[{{ row | json }}]}
  {% endaction %}
  {% break %}
{% endif %}
{% if event.topic == "mechanic/user/trigger" %}
  {% if options.spreadsheet_id != blank %}
    {% log "A spreadsheet is already configured. Manual runs do not add submission rows or replace its headers." %}
    {% break %}
  {% endif %}
  {% action "google_sheets", __perform_event: false %}
    {"account":{{ options.google_account__required | json }},"operation":"create_spreadsheet","title":{{ options.spreadsheet_title__required | json }},"value_input_option":"raw","rows":[{{ headers | json }}]}
  {% endaction %}
  {% break %}
{% endif %}
{% if event.topic != options.webhook_event_topic__required %}{% break %}{% endif %}

{% assign forward_files = options.save_uploaded_files_to_google_drive__boolean %}
{% assign payload = event.data %}
{% if payload.body != nil %}{% assign payload = payload.body %}{% endif %}
{% if event.preview %}
  {% capture sample %}
    {"form":{"id":{{ options.form__storefrontform | default: "11111111-1111-4111-8111-111111111111" | json }},"revision":"1"},"submission_id":"22222222-2222-4222-8222-222222222222","fields":{"name":"Pat Example","email":"pat@example.com","product":"Garden lamp","issue":"The switch stopped working.","agree":"false","receipt":{"name":"receipt.txt","mime_type":"text/plain","size":4,"content_base64":"dGVzdA=="}}}
  {% endcapture %}
  {% assign payload = sample | parse_json %}
{% endif %}
{% assign form_keys = payload.form | keys %}
{% assign payload_keys = payload | keys %}
{% assign submitted_fields = payload.fields %}
{% unless payload_keys contains "fields" %}
  {% comment %}Empty optional uploads and checkbox groups produce no multipart field parts.{% endcomment %}
  {% assign submitted_fields = hash %}
{% endunless %}
{% assign field_keys = submitted_fields | keys %}
{% unless form_keys and field_keys %}
  {% error "This event is not a form submission. Expected form and fields objects in the webhook body." %}
  {% break %}
{% endunless %}
{% assign form_id_type = payload.form.id | json | slice: 0, 1 %}
{% assign submission_id_type = payload.submission_id | json | slice: 0, 1 %}
{% unless form_id_type == '"' and submission_id_type == '"' %}
  {% error "The form and submission IDs must be strings." %}{% break %}
{% endunless %}
{% assign valid_form_id = payload.form.id | match: '\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z' %}
{% assign valid_submission_id = payload.submission_id | match: '\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z' %}
{% unless valid_form_id and valid_submission_id %}
  {% error "The form or submission ID is invalid." %}{% break %}
{% endunless %}
{% if options.form__storefrontform != blank and options.form__storefrontform != payload.form.id %}
  {% log "This submission belongs to another form; nothing was saved." %}{% break %}
{% endif %}
{% assign revision = payload.form.revision | append: "" %}
{% assign valid_revision = revision | match: '\A[1-9][0-9]{0,9}\z' %}
{% unless valid_revision %}
  {% error "The form revision is missing or invalid." %}{% break %}
{% endunless %}
{% if field_keys.size > 50 %}
  {% error "A form submission may have at most 50 fields." %}{% break %}
{% endif %}
{% assign event_id = event.id | append: "" %}
{% assign received_at = event.created_at | date: "%Y-%m-%dT%H:%M:%S%:z" %}
{% if event.preview %}
  {% assign event_id = "33333333-3333-4333-8333-333333333333" %}
  {% assign received_at = "2026-09-14T12:00:00+00:00" %}
{% endif %}
{% if event_id == blank or received_at == blank %}
  {% error "The Mechanic event ID and creation time are required." %}{% break %}
{% endif %}
{% assign answers = hash %}
{% assign attachments = hash %}
{% assign invalid_field = false %}
{% assign generated_files = hash %}
{% assign file_names = hash %}
{% assign total_file_bytes = 0 %}
{% assign address_keys = "line1,line2,city,region,postal_code,country" | split: "," %}
{% for field in submitted_fields %}
  {% assign key = field[0] %}
  {% assign value = field[1] %}
  {% assign key_valid = key | match: '\A[a-z][a-z0-9_]{0,63}\z' %}
  {% unless key_valid %}{% assign invalid_field = true %}{% break %}{% endunless %}
  {% assign value_type = value | json | slice: 0, 1 %}
  {% if value_type == '"' %}
    {% if value.size > 4000 %}{% assign invalid_field = true %}{% break %}{% endif %}
    {% assign answers[key] = value %}
  {% elsif value_type == "[" %}
    {% if value.size > 50 %}{% assign invalid_field = true %}{% break %}{% endif %}
    {% for item in value %}
      {% assign item_type = item | json | slice: 0, 1 %}
      {% if item_type != '"' or item.size > 4000 %}{% assign invalid_field = true %}{% break %}{% endif %}
    {% endfor %}
    {% assign answers[key] = value %}
  {% elsif value_type == "{" %}
    {% if value.content_base64 != nil %}
      {% assign name_type = value.name | json | slice: 0, 1 %}
      {% assign mime_type = value.mime_type | json | slice: 0, 1 %}
      {% assign content_type = value.content_base64 | json | slice: 0, 1 %}
      {% assign size_json = value.size | json %}
      {% assign size_valid = size_json | match: '\A[0-9]+\z' %}
      {% if name_type != '"' or mime_type != '"' or content_type != '"' or size_valid == nil or value.size > 4194304 or value.name.size > 255 or value.mime_type.size > 255 %}
        {% assign invalid_field = true %}{% break %}
      {% endif %}
      {% assign file = hash %}
      {% assign file["name"] = value.name %}
      {% assign file["mime_type"] = value.mime_type %}
      {% assign file["size"] = value.size %}
      {% assign file["contents_saved"] = false %}
      {% assign attachments[key] = file %}
      {% if forward_files %}
        {% if value.content_base64.size > 4194304 %}{% assign invalid_field = true %}{% break %}{% endif %}
        {% assign valid_base64 = value.content_base64 | match: '\A[A-Za-z0-9+/]*={0,2}\z' %}
        {% assign remainder = value.content_base64.size | modulo: 4 %}
        {% unless valid_base64 and remainder == 0 %}{% assign invalid_field = true %}{% break %}{% endunless %}
        {% assign decoded_bytes = value.content_base64.size | divided_by: 4 | times: 3 %}
        {% assign tail = value.content_base64 | slice: -2, 2 %}
        {% if tail == "==" %}
          {% assign decoded_bytes = decoded_bytes | minus: 2 %}
        {% elsif tail contains "=" %}
          {% assign decoded_bytes = decoded_bytes | minus: 1 %}
        {% endif %}
        {% assign total_file_bytes = total_file_bytes | plus: decoded_bytes %}
        {% if decoded_bytes != value.size or total_file_bytes > 3145728 %}{% assign invalid_field = true %}{% break %}{% endif %}
        {% assign safe_name = value.name | scan: '[A-Za-z0-9._-]' | join: "" | slice: 0, 120 | default: "upload.bin" %}
        {% assign safe_name = key | append: "-" | append: safe_name %}
        {% assign generator = hash %}
        {% assign generator["base64"] = value.content_base64 %}
        {% assign generated_files[safe_name] = generator %}
        {% assign file_names[key] = safe_name %}
      {% endif %}
    {% else %}
      {% for part in value %}
        {% assign part_type = part[1] | json | slice: 0, 1 %}
        {% unless address_keys contains part[0] %}{% assign invalid_field = true %}{% break %}{% endunless %}
        {% if part_type != '"' or part[1].size > 200 %}{% assign invalid_field = true %}{% break %}{% endif %}
      {% endfor %}
      {% assign answers[key] = value %}
    {% endif %}
  {% else %}
    {% assign invalid_field = true %}
  {% endif %}
  {% if invalid_field %}{% break %}{% endif %}
{% endfor %}
{% if invalid_field %}
  {% error "A field has an unsupported value or exceeds the form limits. Files being saved or attached must contain valid base64, match their reported size, and total at most 3 MB. Nothing was sent or saved." %}{% break %}
{% endif %}
{% assign answers_json = answers | json %}
{% assign attachments_json = attachments | json %}
{% assign encoded_size = answers_json | append: attachments_json | base64 | size %}
{% if encoded_size > 133332 %}
  {% error "The answers and file details exceed this task's 100 KB storage limit. Nothing was saved." %}{% break %}
{% endif %}

{% assign spreadsheet_id = options.spreadsheet_id %}
{% if event.preview %}{% assign spreadsheet_id = spreadsheet_id | default: "preview-spreadsheet-id" %}{% endif %}
{% if spreadsheet_id == blank %}
  {% error "Run this task once to create the spreadsheet, copy its spreadsheet_id into the task options, and save before collecting submissions." %}{% break %}
{% endif %}
{% assign file_columns = hash %}
{% assign row = array | push: received_at, payload.form.id, revision, payload.submission_id, event_id, attachments_json %}
{% for column in columns %}
  {% assign value = answers[column[1]] %}
  {% if forward_files and attachments[column[1]] %}
    {% assign index = row.size | append: "" %}
    {% assign file_columns[index] = column[1] %}
  {% endif %}
  {% assign value_type = value | json | slice: 0, 1 %}
  {% if value_type == "[" or value_type == "{" %}
    {% assign value = value | json %}
  {% elsif value == nil %}
    {% assign value = "" %}
  {% endif %}
  {% if value.size > 40000 %}
    {% error "An answer is too large for a spreadsheet cell. Nothing was appended." %}{% break %}
  {% endif %}
  {% assign row = row | push: value %}
{% endfor %}
{% if row.size != headers.size %}{% break %}{% endif %}
{% if attachments_json.size > 40000 %}
  {% error "The file details are too large for a spreadsheet cell. Nothing was saved." %}{% break %}
{% endif %}
{% if forward_files and generated_files.size > 0 %}
  {% assign uploads = hash %}
  {% assign uploads["overwrite"] = true %}
  {% assign upload_paths = hash %}
  {% assign event_folder = event_id | sha256 %}
  {% for file_name in file_names %}
    {% assign path = "Mechanic form uploads/" | append: payload.form.id | append: "/" | append: event_folder | append: "/" | append: file_name[1] %}
    {% assign uploads[path] = generated_files[file_name[1]] %}
    {% assign key = file_name[0] %}
    {% assign upload_paths[key] = path %}
  {% endfor %}
  {% assign meta = hash %}
  {% assign meta["stage"] = "form_submission_drive_upload_v1" %}
  {% assign meta["account"] = options.google_account__required %}
  {% assign meta["spreadsheet_id"] = spreadsheet_id %}
  {% assign meta["row"] = row %}
  {% assign meta["attachments"] = attachments %}
  {% assign meta["upload_paths"] = upload_paths %}
  {% assign meta["file_columns"] = file_columns %}
  {% action "google_drive", __meta: meta %}
    {"account":{{ options.google_account__required | json }},"uploads":{{ uploads | json }}}
  {% endaction %}
{% else %}
{% action "google_sheets", __perform_event: false %}
  {"account":{{ options.google_account__required | json }},"operation":"append_rows","spreadsheet_id":{{ spreadsheet_id | json }},"value_input_option":"raw","retry_on_transient_errors":false,"rows":[{{ row | json }}]}
{% endaction %}

{% endif %}
Task code is written in Mechanic Liquid, an extension of open-source Liquid enhanced for automation. Learn more
Defaults
Webhook event topic
user/forms/submission
Spreadsheet title
Mechanic form submissions
Column headings and field keys
{"Name" => "name", "Email" => "email"}