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_submissionalso fires for entries marked as spam. Checkstatusbefore you write a row.- Name and address fields are sub-inputs: first name is
1.3, last name1.6. Read them withrgar(). - Editing an entry in wp-admin fires
gform_after_update_entry, with different arguments.values.appendcannot update a row, so store the row number the first time. - A value starting with
=becomes a live formula underUSER_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.
/ 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.
| Concern | Hand-rolled hook + Apps Script | Webhook Actions |
|---|---|---|
| Writing the row | Your Apps Script, as above | Not 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 entries | add_action on gform_after_submission_5, plus your spam check | gform_after_submission as a trigger, with a condition on args.0.status equals active |
| Sub-input ids like 1.3 | rgar( $entry, '1.3' ) | Field mapping with the dot escaped: args.0.1\.3 |
| Where the call runs | Inside the submission unless you build a queue | Queued in its own table, delivered in the background |
| Google is slow or returns 5xx | Row lost unless you wrote retry logic | Retried with exponential backoff, five attempts by default |
| Proof that it arrived | Whatever you log | Per-attempt log with the response body, and replay from wp-admin |
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.