WP Webhooks / Blog / Integrations

Sending Gravity Forms Entries to a Google Sheet Without an Add-On

Send Gravity Forms entries to Google Sheets: the spam entries gform_after_submission still fires for, sub-input ids, admin edits, and formula injection.

8 min 2026-10-02
#gravityforms#googlesheets#integration

TL;DR: Gravity Forms has no official Google Sheets add-on, and the Gravity Forms parts of the job are where hand-built versions go wrong.

  • Gravity Forms itself points to Zapier (Pro and Elite licences). The Sheets add-ons in its directory are third-party, from certified developers.
  • gform_after_submission also fires for entries marked as spam. Check status before you write a row.
  • Name and address fields are sub-inputs: first name is 1.3, last name 1.6. Read them with rgar().
  • Editing an entry in wp-admin fires gform_after_update_entry, with different arguments. values.append cannot update a row, so store the row number the first time.
  • A value starting with = becomes a live formula under USER_ENTERED. Prefix it with an apostrophe.

/ Options

Does Gravity Forms have an official Google Sheets add-on?

No. The official add-on list in the Gravity Forms documentation has no Google Sheets entry. Gravity Forms' own answer, in its guide to sending entries to Google Sheets, is Zapier: the Zapier Add-On comes with the Pro and Elite licences.

The Google Sheets listing in the Gravity Forms add-on directory is Google Sheets: Two Way Connect by Gravity Wiz, a certified third-party developer. It is bought from the developer, not from Gravity Forms, and Gravity Forms does not support it. Its listing says it syncs entry edits to the sheet, removes rows for trashed or spam entries, and can read sheet data back into a form. On WordPress.org, GSheetConnector for Gravity Forms covers one sheet per form for free.

If one of those fits, use it. The rest of this article is for the cases they do not cover: a per-task Zapier bill you would rather not pay, a sheet layout no add-on produces, or a row that has to include something computed in your own code.

/ Hook

Which Gravity Forms hook should send the entry to Google Sheets?

gform_after_submission. It runs at the end of the submission, after validation, notifications and the entry being saved, and passes $entry and $form. Use the per-form variant, gform_after_submission_5 for form 5, so an unrelated form never reaches your sheet.

Two details on that reference page change the code.

It fires for spam. The documentation says the hook "also runs for entries which are marked as spam", and its own example checks rgar( $entry, 'status' ) === 'spam' first. Skip that check and every entry your anti-spam plugin caught still becomes a row in the sheet.

Field ids are not field names. The entry object is an array keyed by field id. A single-line field is '2'. A Name field is split into inputs: '1.3' is first name and '1.6' last name. Address fields work the same way. Read them with rgar(), which returns null instead of a PHP warning when an optional field was left empty. date_created is in UTC.

FIG 01 — Spam is filtered before the queue; the row number from the first append is what makes an edit updatable

/ Route

How do you append a Gravity Forms entry to a sheet without an OAuth flow?

With a Google Apps Script web app bound to the spreadsheet. The Sheets API route, a service account with a signed JWT, is covered step by step in our WordPress form to Google Sheets guide. Its cost is a token that expires after an hour and code to refresh it. An Apps Script web app runs as you, already has access to the sheet, and gives you one URL to POST to.

Open the sheet, choose Extensions, then Apps Script, paste the function below and deploy it as a web app that executes as you. The production URL ends in /exec. Per the web app guide, the body arrives as text in e.postData.contents. The event object has no request headers, so a shared secret has to travel in the body.

Apps Script — doPost bound to the spreadsheet

function doPost(e) {
  var body = JSON.parse(e.postData.contents);
  var secret = PropertiesService.getScriptProperties().getProperty('SECRET');
  if (body.secret !== secret) {
    return json({ ok: false, error: 'forbidden' });
  }

  var lock = LockService.getScriptLock();
  lock.waitLock(10000);              // two submissions at once must not share a row
  try {
    var sheet = SpreadsheetApp.getActive().getSheetByName('Entries');
    if (body.row_number) {           // an edit: overwrite the row written last time
      sheet.getRange(body.row_number, 1, 1, body.row.length).setValues([body.row]);
      return json({ ok: true, row: body.row_number });
    }
    sheet.appendRow(body.row);
    return json({ ok: true, row: sheet.getLastRow() });
  } finally {
    lock.releaseLock();
  }
}

function json(obj) {
  return ContentService.createTextOutput(JSON.stringify(obj))
    .setMimeType(ContentService.MimeType.JSON);
}

The lock is not decoration. Two submissions arriving together each read getLastRow(), and without a lock they can both report the same row number. LockService makes the append and the read happen as one step.

Note what the script cannot do: return a 401 or a 500. A web app built with ContentService has no way to set an HTTP status code, so a rejected secret still looks like a successful request on the network. The ok field in the body is the only real result, and WordPress has to read it.

PHP — send form 5 to the sheet, skip spam, remember the row

add_action( 'gform_after_submission_5', function ( $entry, $form ) {
    if ( rgar( $entry, 'status' ) === 'spam' ) {
        return;
    }
    $row = gf_sheet_row( $entry );

    // In production: hand this to a queue instead of calling inline.
    $res = gf_sheet_post( [ 'secret' => GF_SHEET_SECRET, 'row' => $row ] );
    if ( ! empty( $res['ok'] ) ) {
        gform_update_meta( $entry['id'], 'sheet_row', (int) $res['row'] );
    }
}, 10, 2 );

function gf_sheet_row( array $entry ) {
    return array_map( 'gf_sheet_safe', [
        $entry['id'],
        $entry['date_created'],          // UTC
        rgar( $entry, '1.3' ),           // Name: first
        rgar( $entry, '1.6' ),           // Name: last
        rgar( $entry, '2' ),             // Email
        rgar( $entry, '3' ),             // Message
    ] );
}

// A stranger's "=HYPERLINK(...)" must stay text, not become a formula.
function gf_sheet_safe( $value ) {
    $value = (string) $value;
    return preg_match( '/^[=+\-@\t\r\n]/', $value ) ? "'" . $value : $value;
}

The gf_sheet_safe() prefix is the part most tutorials miss. appendRow and setValues interpret a string starting with = the way the Sheets UI would, so a form field becomes a live formula. The Sheets API has the same split: RAW stores =1+2 as text and USER_ENTERED turns it into a formula, per the values guide. OWASP's CSV injection page lists the dangerous first characters (=, +, -, @, tab, carriage return) and the apostrophe prefix as the fix inside a spreadsheet.

/ Redirect

Why does an Apps Script web app answer with a redirect?

Because the output is not served from the URL you called. The Content Service guide says the content is redirected to a one-time URL at script.googleusercontent.com, and that clients must follow redirects to read it. By the time the redirect comes back, doPost has already run and the row is in the sheet.

That matters for retries. wp_remote_post follows redirects by default, and WordPress's HTTP library re-sends the original method and body to the new location for every redirect code except 303. Do not treat a failure on that second hop as a failed append and send the row again, or a retry writes the same entry twice. Write the entry id in the first column, as above, and you can always find a duplicate.

PHP — post to the web app and read the JSON result

function gf_sheet_post( array $body ) {
    $res = wp_remote_post( GF_SHEET_WEBAPP_URL, [
        'timeout' => 15,
        'headers' => [ 'Content-Type' => 'application/json' ],
        'body'    => wp_json_encode( $body ),
    ] );
    if ( is_wp_error( $res ) ) {
        return [ 'ok' => false, 'error' => $res->get_error_message() ];
    }
    $data = json_decode( wp_remote_retrieve_body( $res ), true );
    return is_array( $data ) ? $data : [ 'ok' => false, 'error' => 'not JSON' ];
}

/ Edits

How do you update the sheet when an entry is edited in wp-admin?

With gform_after_update_entry, which fires after an entry is changed on the entry detail page. Its arguments are not the submission hook's: it passes $form, then $entry_id, then $original_entry. You get an id, not the updated entry, so load it again.

Then you need to know which row to change. values.append and appendRow only add rows; neither can find the row an entry went into. That is why the submission code stored sheet_row in entry meta. The edit handler sends the same row with that number, and the script overwrites it in place.

PHP — push admin edits to the same row

add_action( 'gform_after_update_entry_5', function ( $form, $entry_id, $original_entry ) {
    $row_number = (int) gform_get_meta( $entry_id, 'sheet_row' );
    if ( ! $row_number ) {
        return;                          // never reached the sheet; nothing to update
    }
    $entry = GFAPI::get_entry( $entry_id );
    gf_sheet_post( [
        'secret'     => GF_SHEET_SECRET,
        'row'        => gf_sheet_row( $entry ),
        'row_number' => $row_number,
    ] );
}, 10, 3 );

Stored row numbers have one weakness: someone deleting or sorting rows in the sheet moves every row below. If people work in the sheet, look the entry id up in column A inside the script instead, and treat the stored number as a hint.

/ Limits

What limits apply to Gravity Forms entries going into Google Sheets?

For the Apps Script route, the Apps Script quotas: 6 minutes of runtime per execution, and 30 simultaneous executions per user. A web app that runs as you counts every submission against your 30. With the lock held for well under a second per append, 30 parallel requests is far beyond any contact form.¹

For the Sheets API route, Google's usage limits are 300 write requests per minute per project and 60 per minute per user. A service account is one user, so 60 per minute, one a second, is the real ceiling. Backfilling 3,000 old entries one request each takes 3,000 ÷ 60 = 50 minutes; sending 100 rows per request takes 3,000 ÷ 100 = 30 requests, inside a single minute. Over the limit is a 429, and Google asks for truncated exponential backoff.

ConcernHand-rolled hook + Apps ScriptWebhook Actions
Writing the rowYour Apps Script, as aboveNot by itself. It cannot hold a Google OAuth token that expires every hour, so it delivers to a receiver that owns Google auth: the Apps Script above or an n8n workflow
Which entriesadd_action on gform_after_submission_5, plus your spam checkgform_after_submission as a trigger, with a condition on args.0.status equals active
Sub-input ids like 1.3rgar( $entry, '1.3' )Field mapping with the dot escaped: args.0.1\.3
Where the call runsInside the submission unless you build a queueQueued in its own table, delivered in the background
Google is slow or returns 5xxRow lost unless you wrote retry logicRetried with exponential backoff, five attempts by default
Proof that it arrivedWhatever you logPer-attempt log with the response body, and replay from wp-admin
try_it

Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.

The n8n route is the one that needs no Apps Script at all: send the entry to an n8n Webhook node and let n8n's Google Sheets node, which stores its own Google credential, append the row. Sending Gravity Forms to n8n covers that end to end.

/ Exposure

What does a Google Sheets integration not protect you from?

A public URL that writes to your sheet. An Apps Script web app deployed for anyone accepts requests from anyone. The secret in the body is the only check, so keep it out of front-end code and rotate it in Script Properties if it leaks.

A spreadsheet that is also a user interface. Colleagues sort, filter, insert columns and rename tabs. A renamed tab returns null from getSheetByName and every request fails. A new column shifts every value one place to the right, and nothing reports an error.

Personal data in a second place. If the form has a retention policy, Gravity Forms can delete entries automatically through Personal Data Settings. The copy in the sheet stays until someone deletes it there too.

Payment forms. On a form that takes payment, the entry exists before the payment is confirmed. Send the row only for paid entries, or send it again when the status changes, or the sheet becomes a list of abandoned checkouts.

/Footnotes
¹ Execution and runtime limits from Google, Quotas for Google Services, read 2026-10-07. That a web app executing as the owner draws on the owner's per-user limit is our reading of "execute as me", not a sentence Google publishes.
FAQ

Things engineers always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

Does Gravity Forms have an official Google Sheets add-on? +
No. The official add-on list has no Google Sheets add-on, and Gravity Forms recommends its Zapier Add-On, included with Pro and Elite licences. The Google Sheets add-ons in its directory are third-party, such as Google Sheets: Two Way Connect by Gravity Wiz, which is bought and supported by the developer.
Why are spam entries showing up in my Google Sheet? +
Because gform_after_submission also runs for entries marked as spam. The Gravity Forms documentation says so and its example checks the entry status first. Return early when rgar( $entry, 'status' ) is spam, or add an equivalent condition, before sending anything to the sheet.
How do I get the first name from a Gravity Forms Name field? +
Name and address fields are split into inputs with decimal ids. For a Name field with id 1, rgar( $entry, '1.3' ) is the first name and rgar( $entry, '1.6' ) the last name. A single-line field is just its id, such as rgar( $entry, '2' ).
How do I update the Google Sheet row when an entry is edited? +
Use gform_after_update_entry, which fires after an edit on the entry detail page and passes the form, the entry id and the original entry. Append cannot update a row, so store the row number returned by the first append in entry meta and overwrite that row on edit.
Can a form submission insert a formula into my Google Sheet? +
Yes. Values written with USER_ENTERED, or with Apps Script appendRow and setValues, are parsed like typed input, so a value starting with an equals sign becomes a formula. Prefix values that start with =, +, -, @, tab or a line break with an apostrophe, or write them with RAW.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate