TL;DR: One endpoint does the whole job, and one non-code step is what breaks most integrations.
POST /v4/spreadsheets/{spreadsheetId}/values/{range}:appendwithvalueInputOptionandinsertDataOption=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.
/ 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.
/ 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:Eis 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.