Guide

WordPress REST API broken after a plugin update — finding which endpoint regressed

Your WordPress block editor is a spinner. The Gutenberg sidebar shows "Updating failed. The response is not a valid JSON response." A user opens /wp-admin/post-new.php and gets a blank canvas with a red dialog. The mobile app stops syncing.

1. Problem

Your WordPress block editor is a spinner. The Gutenberg sidebar shows "Updating failed. The response is not a valid JSON response." A user opens /wp-admin/post-new.php and gets a blank canvas with a red dialog. The mobile app stops syncing. Your headless Next.js front-end starts returning 500s for /wp-json/wp/v2/posts. Your Zapier integration fires red error emails. The site itself loads fine for anonymous visitors — homepage up, posts render, nav menus work — which is exactly why this slips past every uptime check you have.

DevTools on the editor shows the real shape of it:

POST https://example.com/wp-json/wp/v2/posts/1234/autosaves 500 (Internal Server Error)
GET  https://example.com/wp-json/acf/v3/posts/1234 403 (Forbidden)
GET  https://example.com/wp-json/some-plugin/v1/settings 401 (Unauthorized)

Or, if the plugin's REST controller throws inside its permission_callback:

{"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.","data":{"status":403}}

This is the textbook "wordpress rest api broken after update" / "wordpress block editor not loading after plugin update" / "wordpress gutenberg 500 error after update" scenario, and it surfaces as a wp_rest_errors_total spike in your error log within seconds of the bad update — long before the first author opens a ticket. The standard advice — "deactivate all plugins" — is the right ending and the wrong starting point. You don't need to deactivate twenty plugins. You need to know which endpoint regressed and which wp_state_changes_total event preceded it.

2. Impact

A broken REST API on WordPress in 2026 is functionally a broken site, even when the public front-end looks fine. The block editor talks to itself over /wp-json — every save, every autosave, every block insert is a REST call. WooCommerce's checkout block, the WP mobile app, every headless front-end, every Zapier integration, every plugin admin page using React — all routed through /wp-json/*.

For a 50-author publishing site, a four-hour REST outage costs roughly 200 unsaved drafts (autosave fires every 60s), 30+ "I keep getting an error" tickets, and one inevitable Slack message from someone who hand-typed a 2,000-word piece that vanished on first save. For a WooCommerce store using the Cart/Checkout blocks, a /wp-json/wc/store/v1/cart 500 means the checkout button is grey — every minute is conversions silently dropped, wp_rest_errors_total rising, Threads_running flat (no orders being placed).

For a headless setup, the failure cascades upstream. Your Next.js ISR build hits /wp-json/wp/v2/posts, the call 500s, your CDN serves stale until the cache expires, and your homepage 404s the post that "definitely went live an hour ago." The author swears they hit Publish. They did. The REST endpoint that publishes it just started throwing fatals inside a plugin's rest_pre_dispatch filter.

Quieter cost: WordPress's admin only flashes a generic "Updating failed" toast. The browser network tab has the response body, your error_log has the PHP fatal, your wp_rest_errors_total counter has the rate — none of which are visible to the editor staring at a spinner.

3. Why It’s Hard to Spot

WordPress REST failures are uniquely silent because they happen on a side-channel that nothing official watches.

  1. The front-end is fine. Page-cache plugins (WP Super Cache, LiteSpeed Cache, Cloudflare APO) keep serving anonymous HTML. Your uptime monitor pings /, gets 200 OK, stays green. Pingdom does not test /wp-json/wp/v2/users/me with a logged-in nonce.
  1. WordPress treats REST errors as application data. When a callback returns a WP_Error or throws, WordPress wraps it in JSON and returns a 4xx/5xx with a valid body. From nginx's perspective the request succeeded — it logs a 200-shaped POST that carries a 500 payload. Access-log dashboards that aggregate by HTTP status never see it.
  1. PHP fatals inside REST callbacks die quietly. A fatal raised inside register_rest_route()'s callback is caught by WordPress's shutdown handler. The user sees {"code":"internal_server_error"}, wp_php_fatals_total increments, no admin email fires, no Site Health warning appears. The Site Health REST check probes /wp-json unauthenticated — it doesn't exercise the endpoints that just regressed.
  1. The block editor swallows context. Gutenberg's "Updating failed. The response is not a valid JSON response." is the same string for "plugin returned HTML instead of JSON," "permission_callback threw," "nonce expired," and "PHP ran out of memory." The author cannot tell you which.

The result is canonical silent failure: public site up, monitoring green, admin sees a generic toast, and the only place the truth lives is in PHP's error log and the rate of wp_rest_errors_total since the last wp_state_changes_total.

4. Cause

WordPress's REST API is a thin router on top of register_rest_route(). Every request to /wp-json/{namespace}/{route} flows through WP_REST_Server::dispatch(), which runs the permission_callback, runs the callback, then wraps the return into a WP_REST_Response or WP_Error. Plugins hook this at three filter points: rest_pre_dispatch, rest_dispatch_request, rest_request_after_callbacks. Any of those can throw, return null, or return malformed data after a plugin update — and each produces a different status code on the same underlying break.

The Logystera WP plugin watches WP_REST_Response::get_status() on every dispatched request. When status is >=400, it emits a wp_rest_errors_total signal, labelled with route (the registered pattern, e.g. /wp/v2/posts/(?P\d+)), method, status, namespace, and the truncated error_code. Status is the disambiguator:

  • 5xx — callback ran and crashed. PHP fatal, uncaught exception, or unserializable return. Co-fires with wp_php_fatals_total.
  • 403 rest_forbiddenpermission_callback returned false. User is logged in but the plugin's capability check now rejects them. T5 #11 territory — broken current_user_can() after an update.
  • 401 rest_cookie_invalid_nonce / rest_not_logged_in — auth front-end broken: nonce lifetime changed, JWT plugin broke token validation, cookie filter regressed. Co-fires with wp_auth_failures_total.
  • 500 with wp_db_errors_total — callback ran a query the new schema rejects (update added a column the live DB hasn't migrated).

Because the plugin emits wp_rest_errors_total from within rest_request_after_callbacks — after the response is built but before it's sent — the signal records what the user actually saw, even when the PHP fatal that caused it was already swallowed by the shutdown handler.

5. Solution

5.1 Diagnose (logs first)

Mechanical flow: confirm REST is failing, identify the offending route, distinguish 5xx (broken endpoint) from 403 (cap-stripped) from 401 (auth broken), then time-correlate against the most recent wp_state_changes_total.

1. PHP error log — confirms a REST callback crashed and names the plugin file.

# Aggregate the last hour of fatals, grouped by source plugin
tail -n 5000 /var/log/nginx/error.log /var/www/html/wp-content/debug.log 2>/dev/null \
  | grep -iE "PHP Fatal|PHP Uncaught|rest_pre_dispatch|rest_dispatch" \
  | awk -F 'wp-content/plugins/' '/wp-content\/plugins\// {print $2}' \
  | cut -d/ -f1 | sort | uniq -c | sort -rn

The plugin name with the highest count is the suspect. This is what produces wp_rest_errors_total{status="500"} in the WP plugin signal stream.

2. Web server access log — finds the broken route by status code.

# Top 10 failing REST routes in the last hour, grouped by status.
# 5xx = endpoint broken, 403 = cap-stripped, 401 = auth broken.
awk '$7 ~ /\/wp-json\// && ($9 ~ /^[45]/) {print $9, $7}' /var/log/nginx/access.log \
  | sed -E 's#/[0-9]+(\?|$|/)#/\\d+\1#g' \
  | sort | uniq -c | sort -rn | head -20

What you see decides the path:

  • A single 5xx route dominating the count → endpoint regression. wp_rest_errors_total{route=...,status="500"} spike. Expect a co-fire of wp_php_fatals_total from the same plugin.
  • A single 403 route dominating → capability check regression (T5 #11 territory). wp_rest_errors_total{status="403",error_code="rest_forbidden"}. No PHP fatal — the request never reached the callback.
  • A wide spread of 401s across many routes → auth front-end broken (nonce, JWT plugin, cookie filter). wp_rest_errors_total{status="401"} co-fires with wp_auth_failures_total.
  • 5xx with wp_db_errors_total co-firing → schema drift after update (plugin's dbDelta() didn't run or didn't complete).

3. Hit the failing route as the editor would.

# Cookie auth + nonce, simulating the block editor:
curl -i -b cookies.txt -H "X-WP-Nonce: $(wp eval 'echo wp_create_nonce("wp_rest");')" \
     'https://example.com/wp-json/wp/v2/posts?per_page=1'

# Public REST root — what Site Health checks. Often 200 even when authed routes are dead.
curl -s 'https://example.com/wp-json/' | jq '.namespaces'

If /wp-json/ returns 200 and lists namespaces but /wp/v2/posts 500s, your Site Health is lying to you and wp_rest_errors_total is the only signal that catches it.

4. Time-correlate with wp_state_changes_total — the smoking gun.

The WP plugin emits wp_state_changes_total for plugin.activated, plugin.deactivated, plugin.updated, theme.switched, and core.upgraded. A wp_rest_errors_total spike that starts within 60s of a wp_state_changes_total{type="plugin.updated"} is the diagnostic gold standard.

# Plugin/theme files modified in the last 2 hours:
find /var/www/html/wp-content/plugins -maxdepth 2 -name '*.php' -mmin -120 \
  -printf '%TY-%Tm-%Td %TH:%TM  %p\n' 2>/dev/null | sort | head -20

# Cross-reference: REST errors in the last 30 minutes
grep -iE "REST API|wp-json" /var/www/html/wp-content/debug.log | awk '{print $1, $2}' | sort | uniq -c

If the first wp_rest_errors_total event lines up with the mtime of wp-content/plugins/some-plugin/some-plugin.php, you have your answer: that plugin's auto-update at 14:03 broke a REST endpoint at 14:04. That correlation turns "the editor is broken" into "wp_rest_errors_total{route=/wp/v2/posts,status=500} started at 14:04 UTC, immediately after some-plugin auto-updated to 2.4.1 at 14:03 UTC."

5.2 Root Causes

Each cause maps to a specific status and signal pattern. Prioritized by frequency.

  • Plugin update introduced a fatal in a REST callback — most common. New version calls a function removed in PHP 8.2, mistypes a parameter, or autoloads a missing class. Produces wp_rest_errors_total{status="500",error_code="internal_server_error"} co-firing with wp_php_fatals_total. PHP error log names the plugin file.
  • Plugin update tightened permission_callback — produces wp_rest_errors_total{status="403",error_code="rest_forbidden"} with NO wp_php_fatals_total. Callback runs cleanly and rejects. Cap-stripped subclass — fix is in the capability map (T5 #11).
  • JWT or auth plugin broke nonce/token validationwp_rest_errors_total{status="401"} across many routes simultaneously, paired with wp_auth_failures_total. Wide spread, not a single endpoint.
  • Plugin update added DB columns but dbDelta() didn't run — callback runs SELECT some_new_column against the old schema. wp_rest_errors_total{status="500"} co-fires with wp_db_errors_total{error_code="1054"} ("Unknown column").
  • Plugin update changed JSON response shape — endpoint returns 200 with wrong field names. Core doesn't flag it; the block editor's React reducer throws on the missing field, surfacing as "not a valid JSON response." Caught only if the WP plugin's contract-validation rule is enabled.
  • Theme update broke a custom REST route in functions.php — same surface as a plugin fatal, but the file is in wp-content/themes/. Look for the theme name in the stack frame and a wp_state_changes_total{type="theme.switched"}.

5.3 Fix

Match the fix to what wp_rest_errors_total told you. Do not guess.

Cause A — Plugin update fatal: roll the plugin back. WP-CLI is the fastest path.

# Force-install the previous version from the .org repo:
wp plugin install some-plugin --version=2.3.7 --force
# Paid/private plugins: restore from your nightly backup of wp-content/plugins/some-plugin/

If you can't roll back, deactivate from CLI to restore the editor immediately and triage in staging:

wp plugin deactivate some-plugin
curl -s -b cookies.txt -H "X-WP-Nonce: ..." 'https://example.com/wp-json/wp/v2/posts?per_page=1' -o /dev/null -w '%{http_code}\n'

Cause B — Cap-stripped (403): the plugin update tightened a permission check. If the new check is correct, grant the missing capability via your role-manager plugin or WP_Role::add_cap(). If the check is wrong, roll the plugin back. This is T5 #11 territory — see that guide for the full cap-debugging path.

Cause C — Auth broken (401 wide spread): identify the auth plugin (JWT Authentication, Application Passwords, Members) from wp_state_changes_total and roll that plugin back. Do not clear cookies as a fix — every authenticated user gets logged out and the underlying break still exists.

Cause D — Schema drift: force the plugin's upgrade routine.

wp plugin deactivate some-plugin && wp plugin activate some-plugin
wp db query "SHOW COLUMNS FROM wp_some_plugin_table LIKE 'new_column';"

Cause E — Response shape changed: if you control the API consumer, pin it to the old shape via a contract test and file a bug with the plugin author. If you don't (block editor), roll the plugin back.

Cause F — Theme update broke a custom route: revert the theme file, then move the REST registration into a must-use plugin so the next theme deploy can't touch it.

5.4 Verify

You're looking for two things to hold simultaneously: wp_rest_errors_total returns to baseline, and the block editor saves a post end-to-end.

# Should return zero new 5xx REST entries for 15 minutes under normal admin traffic:
tail -f /var/www/html/wp-content/debug.log | grep -iE "wp-json|REST"

# Should return 200 with valid JSON ("array"):
curl -s -b cookies.txt -H "X-WP-Nonce: $NONCE" \
  'https://example.com/wp-json/wp/v2/posts?per_page=1' | jq 'type'

In Logystera's entity view, healthy state looks like: wp_rest_errors_total{status="500"} at 0/min for 30 minutes, wp_rest_errors_total{status="403"} at the site's baseline, and wp_php_fatals_total back to 0–2/hour (deprecation noise).

The baseline matters. Unlike db.connection_failed, wp_rest_errors_total has a non-zero baseline for any real WP site:

  • status="500": healthy baseline 0/hour. Any non-zero rate over 5 minutes is anomalous.
  • status="403": healthy baseline 5–30/hour for a public site (bots probing /wp-json/wp/v2/users). A 10x spike from a single authenticated session is the signal.
  • status="401": healthy baseline 1–10/hour (expired nonces, mistyped passwords). A wide spread across many routes within 60s is the signal.

If wp_rest_errors_total{status="500"} is back at zero but the editor still throws "not a valid JSON response," check for a response-shape regression — wp_rest_errors_total won't catch it, but a synthetic "create a draft, autosave it, publish it" round-trip will.

If errors reappear within an hour, you addressed a symptom (deactivated wrong plugin, granted wrong cap), not the cause. Go back to the wp_state_changes_total time-correlation.

6. How to Catch This Early

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as wp_rest_errors_total.

Everything you just did manually — grep debug.log for fatals, group access-log lines by status and route, separate 5xx from 403 from 401, time-correlate with the most recent wp_state_changes_total — Logystera does automatically. The WP plugin hooks rest_request_after_callbacks to record route, status, and WP_Error code on every REST response, and emits wp_rest_errors_total to the gateway out-of-band — independent of dblog and the options API, so the signal survives even when the plugin update broke the database the audit log writes to.

!Logystera dashboard — wp_rest_errors_total over time wp_rest_errors_total rate by route and status, last 24h — spike at 14:04 UTC, immediately after some-plugin auto-updated to 2.4.1 at 14:03.

The rule that fires is id 614 — WordPress REST endpoint regression after state change, severity critical, threshold 5 events of wp_rest_errors_total{status="5xx"} in 60 seconds following a wp_state_changes_total event in the last 5 minutes. The state-change correlation suppresses noise from background bot scans (they never co-fire with a state change) and makes the alert actionable: the body names the plugin that just updated.

!Logystera alert — REST endpoint regression after plugin update Critical alert fires within 60s of the first 5xx burst, naming the broken route, the status, and the wp_state_changes_total event that preceded it.

The alert payload includes the timestamp, the offending route pattern (placeholders, not user data), the dominant status (endpoint-broken vs cap-stripped vs auth-broken before you open a terminal), the affected entity, and the preceding wp_state_changes_total event — so the alert body itself tells you "plugin some-plugin updated from 2.3.7 to 2.4.1 at 14:03; /wp/v2/posts/(?P\d+)/autosaves started returning 500 at 14:04." Enough to roll back from your phone.

The fix is simple once you know which plugin and which endpoint. The hard part is knowing it happened at all — front-end up, monitoring green, the only humans who see the break are the authors who can't save their drafts. Logystera turns this from a "the editor isn't working again" Slack message into a 60-second notification with the plugin, the route, the status, and the update event that caused it.

7. Related Silent Failures

  • wp_rest_errors_total{status="403",error_code="rest_forbidden"} — the cap-stripped subclass of this guide, in depth: capability checks regressed by a plugin update, current_user_can() returning false for routes it used to allow.
  • wp_state_changes_total{type="plugin.updated"} — the upstream signal. Auto-updates that nobody approved, silent reactivations, plugin replacement during composer update. Often the cause of a wp_rest_errors_total spike.
  • wp_php_fatals_total — the lower-level partner of REST 5xx. Every REST 5xx that crashed inside a callback co-fires here; the inverse isn't true (fatals also fire from cron, admin-ajax, front-end render).
  • wp_auth_failures_total — paired signal for the 401 subclass. JWT plugin regressions, broken application-password verification, cookie auth filter changes that invalidate live sessions.
  • wp_db_errors_total{error_code="1054"} — schema drift after a plugin update where dbDelta() didn't complete. Surfaces as 5xx REST errors with Unknown column in the underlying log.

See what's actually happening in your WordPress system

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.