Home / Blog / Enterprise Web Platforms

Syncing two WordPress sites without stale data: one source of truth, a webhook, a cursor and a deletion cap

If you need to keep a read-only WordPress site in step with a WooCommerce shop, this post covers the design I used: one source of truth, a webhook for speed, a cursor-based sync for safety and a deletion cap for the day the feed goes wrong.

In 2026 I rebuilt a practitioner directory for a US membership-based publisher, working via Curotec. The directory lived across two WordPress sites, a WooCommerce shop and a blog, and the old design could leave stale data in place indefinitely.

This is not a code walkthrough. The code belongs to Curotec and its client, so I describe the work and the decisions. Where a snippet appears, it is a generic illustration I wrote for this post, not their code.

What was wrong with the old setup

I reviewed a directory split across the two sites. It relied on:

  • cross-site login tokens
  • shared cookies
  • a public edit form
  • one-off notifications between the sites

The last one is the one that hurts. A one-off notification is a single message: if it is missed and nothing retries it, the two sites disagree and nothing in the design notices. That is how stale data can sit in place indefinitely.

One source of truth

I redesigned the system so the WooCommerce shop owns identity, membership and profile content. The blog became a read-only showcase, with no logins, no edits and no payments.

That removes a whole class of bugs. When two sites can both change a record, you need conflict rules. When only one can, the other site’s job is simple: copy what the owner says and never argue. Every later decision in this post depends on that.

A showcase with no logins has no use for cross-site login tokens or shared cookies.

What the shop side does

On the shop, a custom plugin handles the following:

  • A practitioners post type with eight taxonomies and ACF profile fields
  • A Practitioner Profile page inside WooCommerce My Account, with draft and publish, an email check on publish and admin-only fields hidden from the practitioner
  • Automatic profile creation when someone buys a membership
  • A custom membership_expired status tied to membership and subscription changes
  • A read-only REST feed (practitioners, a single record and /ping) with token auth using a timing-safe comparison, required HTTPS and cursor pagination (50 records by default, 200 at most)

I’ll come back to the feed. First, the three mechanisms that keep the directory current.

Three layers, each covering a gap in the others

A push is fast but can be missed. A scheduled pull is safer but not instant. So the design uses three layers, each with a different job.

1. A webhook for speed

When a practitioner record changes, the shop notifies the directory through an authenticated /status-change webhook. The directory then refreshes that one record straight away.

This is the fast path: a change can reach the directory without waiting for a schedule.

On the shop side, changes are collected and sent by a batched notifier at the end of the request. That avoids firing several notifications when one request makes several edits.

A webhook alone is not enough, though. It is a push, so it shares a weakness with the old one-off notifications: if the directory is down or the call fails, the change may never arrive by this route.

2. A scheduled incremental sync for safety

Every 15 minutes the directory runs an incremental sync. It asks the feed for what has changed since it last looked, saves its place and processes up to 20 pages per run.

This is the safety net under the webhook. If a webhook is missed, the next sync picks the record up. The difference from the old design is that this one is a pull with a memory. It does not depend on anyone remembering to send a message.

The cap of 20 pages per run keeps each run bounded. A big backlog is worked through over several runs, and because the sync saves its place, each run carries on where the last one stopped.

During a run it also maps terms and downloads profile images, from an allow-list of hosts only. The allow-list matters: the sync follows image URLs found in data, so it should only fetch from hosts on the list.

3. A daily reconciliation for the things a sync cannot see

An incremental sync asks “what changed?” It does not naturally notice what disappeared. So once a day the directory runs a reconciliation that removes profiles deleted at the source.

This layer catches drift the other two cannot, and it is the riskiest, because it deletes things. That is why it has a guard, covered below.

Four WP-CLI commands (incremental, reconcile, record and status) let you run and inspect the sync by hand instead of waiting for the schedule.

Why a cursor instead of page numbers

This was the design decision I would defend most strongly.

Page-number pagination assumes the list stays still while you read it. Here it does not. Profiles are edited, published and expired while a sync is part-way through.

Say the feed is sorted newest-modified first and you have fetched pages 1 and 2. Between requests, a record you have not reached yet is edited, so its modified time changes and it jumps to the front of the list. Every record above its old position shifts down by one, so the record that was last on page 2 is now first on page 3, and you see it twice. The reverse also happens: if a record you have already read is deleted or hidden, everything after it shifts up by one and the record that was first on page 3 slides onto page 2, so you never see it. A skipped record is a stale record.

A cursor avoids this. Instead of “give me page 3”, the request is “give me records after this point”. The point I used is last-modified time plus ID. The ID breaks ties when several records share a modified time, so the ordering is total and the cursor is unambiguous.

Here is a generic illustration of the pattern. I wrote it for this post; it is not the client’s code, and it assumes a simple table with a modified time and an ID. A sync works forward from its saved place, so this query sorts oldest first.

// Illustration only: a generic "modified time + ID" cursor query.
// Not from any client project.
// Assumes $cursor_time (a datetime string), $cursor_id and $page_size
// have already been validated.
global $wpdb;

// The values go through $wpdb->prepare(); the table name comes from the
// trusted $wpdb->prefix, not from input.
$rows = $wpdb->get_results(
	$wpdb->prepare(
		"SELECT id, modified_at
		FROM {$wpdb->prefix}records
		WHERE modified_at > %s
			OR ( modified_at = %s AND id > %d )
		ORDER BY modified_at ASC, id ASC
		LIMIT %d",
		$cursor_time,
		$cursor_time,
		$cursor_id,
		$page_size
	)
);

// get_results() returns null on a database error and an empty array when
// nothing is new, so check before reading the last row.
if ( ! empty( $rows ) ) {
	// The next cursor is the last row returned.
	$last        = end( $rows );
	$cursor_time = $last->modified_at;
	$cursor_id   = (int) $last->id;
}

The consumer stores the last row’s time and ID as its saved place. Edits that happen mid-run do not shift the window. They simply get a newer modified time and are picked up by a later pass. That is how batches avoid skipping or duplicating records while the data changes.

Two smaller decisions that also prevent drift

Terms inside each record. Each record carries its own category terms. The directory does not have to make separate calls to look up what a record belongs to, so a record and its terms are less likely to drift apart.

Admin-only fields stay off the feed. Some fields are for the shop’s admins only. Keeping them off the feed means the showcase has nothing to display by mistake. They are not hidden by a template rule on the other side: the feed does not include them.

Expired profiles disappear immediately

Expired and cancelled profiles are hidden immediately. That was the client’s decision, not mine, and it is a business rule more than a technical one. I raise it because of what it means for timing: a 15-minute schedule on its own would leave an expired profile visible for up to 15 minutes, so a rule like this needs a faster path alongside the sync.

The deletion cap: a guard against a bad feed

The reconciliation refuses to run if it would delete more than 20% of the directory.

The logic is about trust. The reconciler deletes anything that is missing from the feed. So if the feed itself is wrong, for example empty, truncated or broken by a deploy, an uncapped reconciler would treat “the feed is broken” as “everyone was deleted” and wipe the directory to match.

A deletion cap turns that into a stop instead of a disaster. My working assumption is that genuine removals are small and gradual, so a sudden large removal is more likely a fault than a real event. The job compares the number of deletions with the size of the directory, and if the share is over the cap, it refuses to run.

A generic illustration, again written for this post and not from any client project:

/**
 * Illustration only: a generic deletion-cap check. Not from any client project.
 *
 * @param int   $to_delete Number of records the reconciliation would delete.
 * @param int   $total     Number of records currently in the directory.
 * @param float $cap       Largest share of the directory one run may delete.
 * @return bool True if the reconciliation may go ahead.
 */
function example_should_run_reconcile( int $to_delete, int $total, float $cap = 0.20 ): bool {
	if ( 0 === $total ) {
		return false; // Nothing to compare against: do not guess.
	}

	return ( $to_delete / $total ) <= $cap;
}

The design matters more than the code. A destructive job needs a limit on how much damage it can do on its own, and a person should look at anything past that limit.

I also added logging on both sites, linked by a shared per-practitioner ID. When a record looks wrong, the same ID can be followed through the shop’s log and the directory’s log to see where the two stopped agreeing.

A checklist for syncing two sites

If you are syncing two WordPress sites, or WordPress with anything else, this is the checklist I would use:

  1. Pick one owner for each record. The other side is read-only.
  2. Use a push for speed, but never rely on it alone.
  3. Add a scheduled pull that saves its place.
  4. Use a cursor, not page numbers, when the data changes during the read.
  5. Add a reconciliation for deletions, and give it a cap.
  6. Keep admin-only data off the feed instead of hiding it afterwards.
  7. Give yourself CLI commands and linked logs so you can check each step.

I also wrote the client-facing implementation plan for non-technical stakeholders: a before and after comparison, a six-phase rollout, the security model and scaling. A design like this only holds if the people who run it understand why each part is there.

If you’re weighing up a design like this, get in touch through my contact page.

I used AI to help draft this post from my own idea and notes. I verified the information and edited the text myself.

Dhiren Patel

Principal Full-Stack Engineer · Founder, Exivon Tech

I’ve built and run enterprise web platforms and e-commerce for 13 years, and I built ShieldScope, a free WordPress security scanner.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.