TechEarl

Update WordPress From a Google Sheet With a REST Endpoint

Edit posts in a Google Sheet, press a button, and have the changes land in WordPress. A one-file MU-plugin REST endpoint plus a small Apps Script, with the auth done properly.

Ishan Karunaratne⏱️ 9 min readUpdated
Share thisCopied
Updating WordPress posts in bulk from a Google Sheet through a custom REST API endpoint

Editing posts one at a time in wp-admin is fine for three posts and miserable for three hundred. When the content already lives in a spreadsheet (a content team owns it, it came from a supplier feed, it's a list of locations), the fastest workflow is to keep editing it there and push the changes into WordPress in one move.

Here is the whole loop: a Google Sheet holds the rows, a small Apps Script POSTs them to a custom WordPress REST endpoint, and a one-file MU-plugin applies each row with wp_update_post. Press a button in the sheet, the posts update, and each row turns green when it lands. No plugin marketplace, no CSV import dance, no logging into wp-admin at all.

The shape of it

code
Google Sheet  ──(Apps Script: POST JSON + secret header)──▶  /wp-json/te-sheet/v1/post  ──▶  wp_update_post()
     ▲                                                                                              │
     └──────────────────────────── status cell turns "updated" ◀───────────────────────────────────┘

Three pieces: the receiver (a WordPress REST route), the sender (an Apps Script bound to the sheet), and a shared secret so the route only listens to you. Build them in that order.

The WordPress side: a one-file MU-plugin

Drop this in wp-content/mu-plugins/te-sheet-sync.php. MU-plugins load automatically and can't be deactivated by accident, which is what you want for an integration endpoint.

php
<?php
/**
 * Plugin Name: TE Sheet Sync
 * Description: Receives post edits pushed from a Google Sheet over an authenticated REST request.
 * Author:      TechEarl
 * Author URI:  https://techearl.com
 */

defined( 'ABSPATH' ) || exit;

const TE_SHEET_NS = 'te-sheet/v1';

add_action( 'rest_api_init', function () {
	register_rest_route( TE_SHEET_NS, '/post', array(
		'methods'             => 'POST',
		'callback'            => 'te_sheet_update_post',
		'permission_callback' => 'te_sheet_authorize',
		'args'                => array(
			'post_id' => array( 'required' => true, 'sanitize_callback' => 'absint' ),
			'title'   => array( 'sanitize_callback' => 'sanitize_text_field' ),
			'excerpt' => array( 'sanitize_callback' => 'sanitize_textarea_field' ),
		),
	) );
} );

/**
 * Authorize before the handler runs. Constant-time compare of the key sent in
 * the X-TE-Key header against the secret stored in wp-config.php.
 */
function te_sheet_authorize( WP_REST_Request $request ): bool {
	if ( ! defined( 'TE_SHEET_SECRET' ) || '' === TE_SHEET_SECRET ) {
		return false;
	}
	$sent = (string) $request->get_header( 'x-te-key' );
	return '' !== $sent && hash_equals( TE_SHEET_SECRET, $sent );
}

/**
 * Apply one row from the sheet to one post.
 */
function te_sheet_update_post( WP_REST_Request $request ) {
	$id = (int) $request['post_id'];

	if ( 'post' !== get_post_type( $id ) ) {
		return new WP_Error( 'te_not_found', 'No editable post with that ID.', array( 'status' => 404 ) );
	}

	$patch = array( 'ID' => $id );
	if ( null !== $request['title'] ) {
		$patch['post_title'] = $request['title'];
	}
	if ( null !== $request['excerpt'] ) {
		$patch['post_excerpt'] = $request['excerpt'];
	}

	$result = wp_update_post( $patch, true );
	if ( is_wp_error( $result ) ) {
		return new WP_Error( 'te_update_failed', $result->get_error_message(), array( 'status' => 500 ) );
	}

	return array(
		'code'    => 200,
		'updated' => $id,
		'title'   => get_the_title( $id ),
	);
}

If you want the mechanics of register_rest_route on their own (namespace convention, methods, the args map, returning WP_REST_Response or WP_Error), I cover that in adding a custom REST API endpoint in WordPress. This article is that pattern put to a specific job. Three things in there matter more than the rest:

  • permission_callback is the gate, and it runs before the handler. A common mistake is setting permission_callback to __return_true and then checking the key inside the handler. Do the check in permission_callback so an unauthorized request never reaches your update logic.
  • hash_equals compares the secret in constant time. A plain === on a secret leaks timing information an attacker can use to guess it byte by byte. hash_equals is the right tool any time you compare a secret.
  • Every field is sanitized at the door via sanitize_callback, and post_id is forced through absint. The handler refuses anything that isn't an existing post, so a stray ID can't create or clobber the wrong object.

The secret lives in wp-config.php, never in the plugin file and never in the repo:

php
define( 'TE_SHEET_SECRET', 'a-long-random-string-from-a-password-manager' );

One requirement that's easy to forget: the WordPress site has to be reachable over HTTPS from the public internet, because Apps Script runs on Google's servers, not on your machine. Any real production site already is.

Test the endpoint before touching the sheet

Prove the auth works with curl first. No key and a wrong key should both be rejected; the correct key should apply the change.

bash
URL="https://example.com/wp-json/te-sheet/v1/post"

# 1. No key -> 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$URL" -d 'post_id=42&title=nope'

# 2. Wrong key -> 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$URL" -H 'X-TE-Key: wrong' -d 'post_id=42&title=nope'

# 3. Correct key -> 200, and the post is updated
curl -s -X POST "$URL" -H "X-TE-Key: $TE_SHEET_SECRET" \
  -d 'post_id=42&title=Brewing the Perfect Cup&excerpt=A practical guide.'

The first two return 401. WordPress derives that status from rest_authorization_required_code(), which returns 401 when the request isn't logged in (an unauthenticated curl like this) and 403 when it is, so a bare curl with no WordPress cookie gets the 401 you see here. The third returns {"code":200,"updated":42,"title":"Brewing the Perfect Cup"} and the change is live. If that works, the WordPress half is done.

The Google Sheet

Lay the sheet out so each row is one post and one column is the WordPress post ID. A status column gives the script somewhere to report back.

A Google Sheet with columns post_id, current_title, new_title, new_excerpt, and status, holding eight rows of post edits with status set to pending
One row per post. post_id is the join key; status is where the script reports back.

post_id is the join key between the sheet and WordPress. new_title and new_excerpt are what you want applied. current_title is just there for your own sanity while editing, the script ignores it.

The Apps Script: a "Push to WordPress" button

In the sheet, open Extensions → Apps Script and paste this. It adds a custom menu and, on click, POSTs each row to the endpoint.

javascript
/**
 * TE Sheet Sync: push spreadsheet rows to WordPress over a REST request.
 */

const TE_ENDPOINT = PropertiesService.getScriptProperties().getProperty('TE_ENDPOINT');
const TE_KEY      = PropertiesService.getScriptProperties().getProperty('TE_KEY');

/** Build the custom menu when the spreadsheet opens. */
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('TechEarl')
    .addItem('Push to WordPress', 'tePushToWordPress')
    .addToUi();
}

/** Read every row and POST it to WordPress, colouring the status cell per result. */
function tePushToWordPress() {
  const sheet  = SpreadsheetApp.getActiveSheet();
  const values = sheet.getDataRange().getValues();
  const head   = values[0];
  const col    = name => head.indexOf(name);

  for (let r = 1; r < values.length; r++) {
    const postId = values[r][col('post_id')];
    if (!postId) continue;

    const ok = tePost('post', {
      post_id: postId,
      title:   values[r][col('new_title')],
      excerpt: values[r][col('new_excerpt')],
    });

    const cell = sheet.getRange(r + 1, col('status') + 1);
    cell.setValue(ok ? 'updated' : 'failed');
    cell.setBackground(ok ? '#d9ead3' : '#f4cccc');
  }
  SpreadsheetApp.flush();
}

/** POST one JSON payload to the endpoint. Returns true on HTTP 200. */
function tePost(path, payload) {
  const res = UrlFetchApp.fetch(TE_ENDPOINT + '/' + path, {
    method:             'post',
    contentType:        'application/json',
    headers:            { 'X-TE-Key': TE_KEY },
    payload:            JSON.stringify(payload),
    muteHttpExceptions: true,
  });
  return res.getResponseCode() === 200;
}
The Google Apps Script editor showing the Sheet Sync project with onOpen, tePushToWordPress, and tePost functions reading the endpoint and key from Script Properties
The bound Apps Script. The endpoint URL and secret come from Script Properties, not the code.

Notice the script reads TE_ENDPOINT and TE_KEY from Script Properties, not from hardcoded constants. That keeps the secret out of the code you'd screenshot, share, or paste into a support thread. Set them once under Project Settings → Script Properties (TE_ENDPOINT = https://example.com/wp-json/te-sheet/v1, TE_KEY = the same value as TE_SHEET_SECRET).

The first run prompts for authorization (the script needs permission to read the sheet and to make external requests). Approve it once. After that, reload the sheet and the custom menu appears:

The TechEarl custom menu open in the Google Sheets menu bar, showing a single Push to WordPress item
onOpen() builds the menu. One click runs the push.

Run it

Here are the eight posts before the push, all with rough placeholder titles:

The WordPress All Posts admin screen listing eight posts with placeholder titles like untitled draft 1, TBD title here, and fix this title
Before: eight posts with placeholder titles.

Click TechEarl → Push to WordPress. The script walks the rows, POSTs each one, and paints each status cell green on success (or red on failure, so a partial run is obvious at a glance):

The same Google Sheet after the push, with every row's status cell now reading updated on a green background
After: every row reports updated. A failed row would be red.

And the posts in WordPress now carry the titles from the sheet:

The WordPress All Posts admin screen after the push, showing the eight posts with their polished final titles
The eight posts, retitled from the sheet in a single click.

Eight posts in one click. The same loop scales to hundreds, with the practical limit being Apps Script's per-execution runtime (six minutes per run, which now applies to both consumer Gmail and Google Workspace accounts) rather than anything on the WordPress side. For very large sheets, batch the rows or push only the ones whose status isn't already updated.

Three ways to trigger it

The button is the simplest trigger, but it isn't the only one:

  • Button (shown above). A onOpen custom menu. Best when a human decides "push now."
  • On edit. An installable onEdit trigger that fires when a specific column changes, so editing a cell pushes that one row immediately. Filter hard on the sheet and column inside the handler, because onEdit fires on every edit.
  • Scheduled. A time-driven trigger that pushes the whole sheet on a schedule (hourly, nightly). Good for feeds that change on their own.

All three call the same tePushToWordPress logic. Start with the button and add the others only when you actually need them.

Securing a public write endpoint

This endpoint accepts writes from the open internet, so the auth is not optional. The shared-secret-in-a-header approach above is the sane baseline. Here's the checklist, from baseline to hardened:

  • Secret in a header, not the body or the URL. Headers stay out of server access logs and browser history. The body works too, but a header is cleaner.
  • Constant-time compare (hash_equals), never ===.
  • Check in permission_callback, so unauthorized requests never reach the handler.
  • HTTPS only, so the secret isn't sent in cleartext. Reject plain HTTP.
  • Keep the secret out of code and version control. wp-config.php constant on the WordPress side, Script Properties on the Apps Script side.
  • Rotate the secret if it ever leaks, and treat it like a password (long, random, from a manager).

If you need to go further, the next steps are an HMAC signature (sign the payload so the secret itself never travels), a timestamp plus nonce to stop replayed requests, and rate limiting on repeated bad keys. For most internal "the marketing team edits a sheet" workflows, the header secret over HTTPS is enough; reach for HMAC when the endpoint is doing something you'd hate to see replayed.

WordPress also ships Application Passwords for REST auth, which is the right call if you're hitting core endpoints. For a small custom route like this, a dedicated shared secret keeps the surface area tiny and the code obvious.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressGoogle SheetsREST APIApps ScriptBulk EditMU-Plugin

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts