Pulling custom-field updates from a Google Sheet into WordPress comes down to three server-side steps: authenticate as a Google service account, read the rows over the Sheets API, and write each one with update_post_meta. No Apps Script, no button, no one logged into wp-admin. This is the mirror image of pushing edits from the sheet with a REST endpoint: instead of the sheet reaching into WordPress, WordPress reaches out to the sheet, which is the right shape when the job should run on a schedule (a nightly cron) rather than when a human clicks.
The whole thing is a WP-CLI command. The sheet has a post_id column and a column per field you want to set; the command reads it, compares each row to what WordPress already has, and writes only the rows that differ.
The sheet
One row per post. The join key is post_id; the other columns are the values to apply.
| post_id | new_currency |
|---|---|
| 5001 | EUR |
| 5002 | EUR |
| 5003 | EUR |
| 5004 | EUR |
Authenticating as a service account
The push model needed no Google credentials because the sheet did the talking. Pulling is the reverse: WordPress is now the API client, so it needs to prove who it is. A service account is the right identity for server-to-server access (the simpler but public alternative, publishing the sheet to the web as CSV, and when that is acceptable, is weighed in reading a Google Sheet in PHP: CSV vs the API). Create one in the Google Cloud console, enable the Google Sheets API, download its JSON key, and share the sheet with the service account's email (read-only is enough for a pull). The key never goes in the repo; keep it outside the web root and reference it by path.
There is no SDK requirement for this. A service-account token is a signed JWT exchanged for an access token, and PHP can mint it with openssl_sign and WordPress's HTTP API:
function te_b64url( $s ) { return rtrim( strtr( base64_encode( $s ), '+/', '-_' ), '=' ); }
/** Mint a short-lived access token from the service-account key (JWT bearer grant). */
function te_sheet_token( array $key ) {
$now = time();
$header = te_b64url( wp_json_encode( array( 'alg' => 'RS256', 'typ' => 'JWT' ) ) );
$claims = te_b64url( wp_json_encode( array(
'iss' => $key['client_email'],
'scope' => 'https://www.googleapis.com/auth/spreadsheets.readonly',
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $now,
'exp' => $now + 3600,
) ) );
$sig = '';
openssl_sign( "{$header}.{$claims}", $sig, $key['private_key'], 'sha256WithRSAEncryption' );
$jwt = "{$header}.{$claims}." . te_b64url( $sig );
$res = wp_remote_post( 'https://oauth2.googleapis.com/token', array(
'body' => array(
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
),
) );
return json_decode( wp_remote_retrieve_body( $res ), true )['access_token'] ?? '';
}The command
This registers wp te-sheet pull. It reads the sheet, applies the change-only rule from the safe batch updater (skip rows already at the target, log every real change), and writes nothing unless you pass --live.
<?php
/**
* Plugin Name: TE Sheet Pull
* Plugin URI: https://techearl.com/wordpress-bulk-update-custom-fields-google-sheet
* Description: WP-CLI command that reads a Google Sheet via a service account and applies rows to WordPress.
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
*/
defined( 'ABSPATH' ) || exit;
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { return; }
class TE_Sheet_Pull_Command {
/**
* Pull a sheet and apply a column to a post-meta key.
*
* ## OPTIONS
* --sheet=<id> : Spreadsheet ID.
* --key=<path> : Path to the service-account JSON key.
* --field=<key> : The post-meta key to write.
* [--range=<a1>] : Sheet range. Default: Sheet1!A:B
* [--live] : Actually write. Without it, dry run.
*/
public function pull( $args, $assoc ) {
$key = json_decode( file_get_contents( $assoc['key'] ), true );
$range = $assoc['range'] ?? 'Sheet1!A:B';
$field = $assoc['field'];
$live = isset( $assoc['live'] );
$token = te_sheet_token( $key );
if ( ! $token ) { WP_CLI::error( 'Could not get an access token.' ); }
$url = sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s', $assoc['sheet'], rawurlencode( $range ) );
$res = wp_remote_get( $url, array( 'headers' => array( 'Authorization' => "Bearer {$token}" ) ) );
$rows = json_decode( wp_remote_retrieve_body( $res ), true )['values'] ?? array();
array_shift( $rows ); // drop the header row
$changes = array();
foreach ( $rows as $row ) {
$post_id = (int) ( $row[0] ?? 0 );
$value = (string) ( $row[1] ?? '' );
if ( ! $post_id || '' === $value ) { continue; }
$current = (string) get_post_meta( $post_id, $field, true );
if ( $current === $value ) { continue; } // change-only
$changes[] = array( 'post_id' => $post_id, 'old' => $current, 'new' => $value );
if ( $live ) { update_post_meta( $post_id, $field, $value ); }
}
if ( empty( $changes ) ) { WP_CLI::success( 'Sheet and WordPress already match.' ); return; }
WP_CLI\Utils\format_items( 'table', $changes, array( 'post_id', 'old', 'new' ) );
$mode = $live ? 'updated' : 'WOULD update (dry run)';
WP_CLI::success( sprintf( '%d posts %s from the sheet.', count( $changes ), $mode ) );
}
}
WP_CLI::add_command( 'te-sheet', 'TE_Sheet_Pull_Command' );If the field is an ACF field rather than native meta, swap get_post_meta/update_post_meta for get_field/update_field (the reason is in ACF fields vs native post meta).
Run it
Dry run first, always. It reads the sheet, compares, and prints what would change without writing:

The old column is what WordPress had; the new column is what the sheet says. Only the rows that differ appear, so re-running right after changes nothing.
It really landed in the database
The point of the pull is that the sheet's value ends up as a real row in wp_postmeta. Query it directly after the run and the EUR from the sheet is there, on each post:

That is the whole loop, server-side: WordPress authenticated to Google, read the sheet, and wrote each row to wp_postmeta, with no browser and no human in the path. Put the command on a cron (wp cron or a system crontab) and the sheet becomes a live source of truth that WordPress reconciles to on a schedule.
Push or pull?
Use pull when the sync should run on a schedule with nobody in the loop, and use push when a person decides the moment to apply changes. Both keep a sheet and WordPress in step; they differ only in who initiates the call and what credentials it needs:
| Push (REST endpoint) | Pull (this article) | |
|---|---|---|
| Initiated by | the sheet (button / onEdit) | WordPress (cron / CLI) |
| Google credentials | none | a service account |
| Best for | a human deciding "push now" | scheduled, hands-off reconciliation |
| Auth lives in | a shared secret in WordPress | a service-account key on the server |
The push model is simpler to stand up and great for on-demand edits; the pull model is what you want when nobody should have to remember to click anything. Many sites end up running both.
Sources
Authoritative references this article was fact-checked against.
- spreadsheets.values.get - Google Sheets APIdevelopers.google.com
- Using OAuth 2.0 for Server to Server Applications - Google Identitydevelopers.google.com
- update_post_meta() - WordPress Developer Referencedeveloper.wordpress.org
- openssl_sign - PHP Manualphp.net
- wp_remote_post() - WordPress Developer Referencedeveloper.wordpress.org





