---
title: "WordPress Form to Google Sheets: The Sheets API v4 Way"
description: "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."
url: "https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/"
date: "2026-08-26"
---

# WordPress Form to Google Sheets: The Sheets API v4 Way

**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](https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets.values/append) 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](https://developers.google.com/identity/protocols/oauth2/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 `something@your-project.iam.gserviceaccount.com` 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](https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/og_image.jpg)

/ 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:

| Input | RAW | USER\_ENTERED |
| --- | --- | --- |
| 2026-08-28 | Text "2026-08-28" | A date value, sortable |
| 00123 | Text "00123" — leading zeros kept | Number 123 — zeros lost |
| +48 22 000 00 00 | Text, intact | May be parsed as a number |
| \=SUM(A1:A2) | Literal text | An 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](https://developer.wordpress.org/plugins/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](https://wpwebhooks.org/blog/wordpress-job-queue/) and [why WP-Cron alone will not carry async delivery](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/).

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](https://developers.google.com/workspace/sheets/api/limits), read 2026-08-28.

² Request shape, query parameters and scopes from the [spreadsheets.values.append reference](https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets.values/append).

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"WordPress Form to Google Sheets: The Sheets API v4 Way","description":"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.","datePublished":"2026-08-26","dateModified":"2026-08-26","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/og_image.jpg","width":1200,"height":630,"caption":"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"},"keywords":["wordpress form to google sheets","wordpress google sheets integration","google sheets api wordpress","contact form 7 to google sheets","woocommerce to google sheets","google sheets service account"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Form to Google Sheets: The Sheets API v4 Way","item":"https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do you append a row to Google Sheets from WordPress?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Why does the Google Sheets API return 403 when my credentials are valid?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"What is the difference between RAW and USER_ENTERED?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"What are the Google Sheets API rate limits?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Should the Sheets call run inline on form submission?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}

{"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-to-google-sheets.png","caption":"FIG 02 — Service-account auth and the append call, including the step that fails silently","description":"The site signs a JWT with the service account private key and exchanges it at the Google token endpoint for a short-lived bearer token, which is cached until shortly before it expires. The append call is a POST to the values append endpoint carrying valueInputOption and insertDataOption. The step that catches most integrations is the one that involves no code at all: the spreadsheet must be shared with the service account email address, because a service account is a separate identity that owns nothing by default. Skip it and every request returns 403 with a permission denied error even though the credentials are valid. Exceeding 60 write requests per minute per user returns 429 and requires truncated exponential backoff.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"}
```
