WP Webhooks / Blog / WordPress integrations
Article · WordPress integrations

Appending WordPress Form Entries to a Google Sheet With the API

Send WordPress form entries to Google Sheets with the API: service account auth, the sharing step that 403s, valueInputOption, and the 60 writes per minute cap.

7 min 2026-08-26
#google-sheets#integrations#forms

TL;DR: One endpoint does the whole job, and one non-code step is what breaks most integrations.

  • POST /v4/spreadsheets/{spreadsheetId}/values/{range}:append with valueInputOption and insertDataOption=INSERT_ROWS.
  • Authenticate with a service account: sign a JWT, exchange it for a bearer token, cache the token.
  • Share the spreadsheet with the service account's email address. Skip this and every call returns 403 with perfectly valid credentials.
  • Limits are 300 writes per minute per project and 60 per minute per user. Over that is a 429, and Google asks for truncated exponential backoff.

/ Overview

What does it take to write a form entry into Google Sheets?

Three things: a credential Google will accept, one HTTP call, and a sharing step that has nothing to do with code. The call is the easy part and the sharing step is where most integrations die, so it is worth taking them in that order.

There is exactly one endpoint you need. The values.append method takes a spreadsheet ID, a range in A1 notation, and an array of rows, and adds them after the last row of the table it finds. You do not have to know how many rows already exist, and you do not have to read before you write.

Everything else — which form plugin fired, how the fields are named, whether it is a WooCommerce order rather than a form at all — is just the shape of the array you hand it.

/ Auth

Which credential should a WordPress site use?

A service account, not OAuth. The distinction matters: OAuth authorises a person and needs a browser consent screen plus refresh-token handling. A service account authorises the server, which is what a form handler running at 3am actually is.

Create one in the Google Cloud console, enable the Sheets API on the project, and download the JSON key. Two fields in that file matter — client_email and private_key. The flow is: build a JWT asserting who you are and what scope you want, sign it with the private key, and exchange it at Google's token endpoint for a bearer token that lasts about an hour.

The scope for writing values is https://www.googleapis.com/auth/spreadsheets. The append endpoint also accepts the broader drive and drive.file scopes, but there is no reason to ask for the ability to touch every file in a Drive when you only need one spreadsheet.

FIG 02 — Service-account auth and the append call, including the step that fails silently

/ The 403

Why does a valid credential still return 403?

Because a service account owns nothing. It is a separate identity with its own email address, and it has no more access to your spreadsheet than a stranger does — a perfectly valid token proves who it is, not that it is allowed in.

The fix is a sharing step in the Google Sheets UI, not a code change: open the sheet, click Share, paste the client_email value from the JSON key, and give it Editor. The address looks like [email protected] and Sheets will treat it like any other collaborator.

This is worth stating plainly because the failure is so misleading. The token request succeeds. The credentials are correct. The API key is not the problem, the scope is not the problem, and every debugging instinct points at the auth code — which is fine. The sheet was simply never shared.

Gotcha: a spreadsheet created by the service account has the opposite problem — the service account owns it and no human can see it, because it was never shared with you. Create the sheet as yourself, then share it with the service account.

A cybernetic archivist kneeling to lay one bright new row at the foot of a towering wall of lit ledger rows, her other palm on a reader plate fractured with magenta light, cyberpunk illustration

/ The call

What does the append request look like?

A POST with the values in the body and the behaviour in the query string. Two query parameters decide what actually lands in the cells.

PHP — append a row to a sheet

function sheets_append_row( string $spreadsheet_id, string $range, array $row, string $token ) {
    $url = sprintf(
        'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s:append',
        rawurlencode( $spreadsheet_id ),
        rawurlencode( $range )          // e.g. 'Entries!A:E'
    );

    $url = add_query_arg( [
        'valueInputOption'  => 'USER_ENTERED',   // or RAW — see below
        'insertDataOption'  => 'INSERT_ROWS',    // never OVERWRITE for form data
    ], $url );

    return wp_remote_post( $url, [
        'timeout' => 10,
        'headers' => [
            'Authorization' => 'Bearer ' . $token,
            'Content-Type'  => 'application/json',
        ],
        // A ValueRange: values is an ARRAY OF ROWS, not a flat array.
        'body' => wp_json_encode( [ 'values' => [ $row ] ] ),
    ] );
}

Two details cause most of the confusion. values is an array of rows, so a single row is [[a, b, c]] and not [a, b, c] — passing the flat version writes one value per row down a column. And insertDataOption defaults to OVERWRITE, which will happily write over existing cells below your table; INSERT_ROWS is what you want for anything append-shaped.

/ Value parsing

What is the difference between RAW and USER_ENTERED?

RAW stores exactly the string you send. USER_ENTERED parses it the way the Sheets UI would if a person typed it — so 2026-08-28 becomes a real date, 3 becomes a number, and =SUM(A1:A2) becomes an actual formula.

For form data that is a genuine trade-off rather than a preference:

InputRAWUSER_ENTERED
2026-08-28Text "2026-08-28"A date value, sortable
00123Text "00123" — leading zeros keptNumber 123 — zeros lost
+48 22 000 00 00Text, intactMay be parsed as a number
=SUM(A1:A2)Literal textAn executable formula

The last row is the one to think about. Every value in a form submission was typed by a stranger, and USER_ENTERED is the setting that turns a stranger's string into a formula in your spreadsheet. If any field is free text, either use RAW for that column or prefix the value with an apostrophe so Sheets treats it as text.

/ Limits

What are the rate limits, and what happens at the boundary?

Google publishes them plainly.¹ Per project: 300 read requests per minute and 300 write requests per minute. Per user per project: 60 of each per minute. Exceeding a quota "generates a 429: Too many requests HTTP status code response".

The per-user figure is the one that binds in practice, because a service account is a single user. 60 writes per minute is 1 per second — comfortable for a contact form, and not comfortable at all for a bulk import or a flash sale. Two arithmetic checks worth doing before you ship:

  • Importing 5,000 historical entries one row at a time: 5,000 ÷ 60 = 83 minutes of sustained requests, assuming nothing else uses the quota.
  • Batching 100 rows per call instead: 5,000 ÷ 100 = 50 requests, which fits inside a single minute with room to spare.

The endpoint already accepts multiple rows in one call, so batching is free — you were sending an array of rows all along.

On a 429, Google's guidance is explicit: "you should use an exponential backoff algorithm", specifically truncated exponential backoff of the form min(((2^n) + random_number_milliseconds), maximum_backoff) with a maximum around 32 or 64 seconds. The random component is not decorative — without it, every queued job that failed together retries together.

/ What Google does not do for you

What is left for you to handle?

This is the section every Sheets tutorial skips, and it is the part that decides whether the integration is still working in six months.

  • Nothing retries. The API returns a status and that is the end of its involvement. A 429 or a 503 loses the row unless you queue and retry it yourself.
  • The private key is a full credential in your database. Anyone who can read wp_options — or a backup of it — can write to that spreadsheet. Store it outside the database if you can, and never in a theme file.
  • A renamed tab breaks the range silently. Entries!A:E is a string. Rename the tab in the UI and every call returns 400 with no warning that anything upstream changed.
  • There is no schema. Sheets will not reject a row with the columns in the wrong order — it writes it. The data is simply wrong from that point on, and nothing surfaces an error.
  • The 10-million-cell ceiling is real. A high-volume form append will reach it eventually, and the failure arrives as a hard stop rather than a warning.

/ Placement

Should the call run inline on submission?

No. An inline call makes the visitor wait for Google, and a rate-limited or slow response becomes a slow form — or a form that appears to fail when the entry was actually saved. The submission and the delivery are two different jobs with two different reliability requirements.

Queue the write. Capture the entry on the form plugin's completion hook, hand the payload to a background worker, and let that worker own the token refresh, the 429 backoff and the attempt log. WordPress's own WP-Cron is the obvious first stop and is also not enough on its own, for reasons worth reading before you rely on it: a proper job queue in WordPress and why WP-Cron alone will not carry async delivery.

Do that, and a Google outage becomes a delayed row instead of a lost customer.

/Footnotes
¹ Quota figures and the 429 wording from Usage limits, Google Sheets API, read 2026-08-28.
² Request shape, query parameters and scopes from the spreadsheets.values.append reference.
FAQ

Things engineers always ask.

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

How do you append a row to Google Sheets from WordPress? +
POST to https://sheets.googleapis.com/v4/spreadsheets/{spreadsheetId}/values/{range}:append with a bearer token, valueInputOption set to USER_ENTERED or RAW, and insertDataOption set to INSERT_ROWS. The request body is a ValueRange object whose values key holds an array of row arrays.
Why does the Google Sheets API return 403 when my credentials are valid? +
Because the spreadsheet was never shared with the service account. A service account is a separate identity that owns nothing by default, so a valid token still grants no access to your file. Copy the client_email from the JSON key and share the sheet with that address as an Editor.
What is the difference between RAW and USER_ENTERED? +
RAW stores exactly the string you send, so 2026-08-28 stays text and =SUM(A1:A2) stays a literal. USER_ENTERED parses the value the way the Sheets UI would, turning it into a date or an actual formula. For form data USER_ENTERED is usually right for numbers and dates, and dangerous for anything a visitor typed.
What are the Google Sheets API rate limits? +
Three hundred read requests and three hundred write requests per minute per project, and sixty of each per minute per user per project. Exceeding a quota returns HTTP 429, and Google recommends truncated exponential backoff rather than an immediate retry.
Should the Sheets call run inline on form submission? +
No. An inline call ties the visitor to Google being reachable, and a slow or rate-limited response becomes a slow or broken form. Queue the write and let a background worker perform it, so a 429 turns into a retry rather than a lost entry.
Ready

Your next automation is
one sentence away.

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