WP Manifestindependent plugin directory
manifest / content / wp-syndication-publisher

WP Syndication Publisher

Publish once, syndicate to many WordPress sites automatically via signed webhooks

by Your Name · github.com/tejeshvenkat/wp-syndication-publisher · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/tejeshvenkat/wp-syndication-publisher/archive/refs/heads/main.zip

WP Syndication

Distributed content federation for WordPress — publish once, deliver everywhere.

Demo · Architecture · Security · Setup · API · Testing · Decisions


Built as a portfolio project for Automattic. This solves a real problem in the WordPress ecosystem: reliable, secure, auditable content syndication across independent WordPress sites — the same domain Jetpack, WP VIP, and WordPress.com operate in.


What it does

A WordPress site (publisher) can push posts to any number of other WordPress sites (subscribers) the moment content is published, updated, or deleted. Each subscriber site receives a full copy and owns it independently.

[Publisher: site-a.com]
       │
       │  POST /wp-json/wpss/v1/receive
       │  Authorization: Bearer <jwt>
       │  X-WPS-Signature: sha256=<hmac>
       │
       ├──────────────► [Subscriber: site-b.com]  ✓ delivered
       ├──────────────► [Subscriber: site-c.com]  ✓ delivered
       └──────────────► [Subscriber: site-d.com]  ↻ retrying (was offline)

What happens when things go wrong:

A subscriber site goes offline. The publisher queues the delivery, waits 1 minute, tries again. Still offline? Waits 5 minutes. Then 15. Then 1 hour. Then 4 hours. When the site comes back, it self-heals — no manual intervention needed. The editor sees the live status in the Gutenberg sidebar the whole time.


Architecture

Two independent plugins with a clean boundary between them:

wp-syndication-publisher/          wp-syndication-subscriber/
├── class-database.php             ├── class-database.php
├── class-jwt-issuer.php           ├── class-jwt-verifier.php
├── class-subscriber-registry.php  ├── class-hmac-verifier.php
├── class-webhook-dispatcher.php   ├── class-webhook-receiver.php
├── class-retry-scheduler.php      ├── class-post-importer.php
├── class-syndication-manager.php  ├── class-origin-tracker.php
├── class-rest-api.php             └── class-rest-api.php
├── class-admin.php
└── src/sidebar/index.js           (React — Gutenberg delivery dashboard)

Why two plugins? Each side has a completely different security model, dependency set, and operator. A subscriber site should not need to install delivery queue code it will never use. Separation also means each plugin can be versioned and updated independently. See DECISIONS.md for the full reasoning.

Data flow

Post published on Site A
        │
        ▼
Syndication_Manager::syndicate()
        │  (fires on transition_post_status hook)
        ▼
Retry_Scheduler::queue()
        │  (writes row to wp_wpsp_delivery_log)
        ▼
WP-Cron fires every 5 minutes
        │
        ▼
Retry_Scheduler::process_queue()
        │
        ├── Webhook_Dispatcher::dispatch()
        │       │  builds payload + signs with HMAC-SHA256 + JWT
        │       ▼
        │   wp_remote_post() ──────────────────► Subscriber site
        │                                              │
        │                                    Webhook_Receiver::handle()
        │                                              │
        │                              ┌───────────────┼───────────────┐
        │                           JWT ok?        HMAC ok?      Idempotent?
        │                              │               │               │
        │                              └───────────────┴───────────────┘
        │                                              │
        │                                    Post_Importer::import()
        │                                              │
        │                                    wp_insert_post() / wp_update_post()
        │                                    + Origin_Tracker::save()
        │
        ├── Success → mark delivered, timestamp recorded
        └── Failure → exponential backoff, surface in dashboard

Database schema

Publisher — wp_wpsp_subscribers

Column Type Notes
id BIGINT UNSIGNED Primary key
site_url VARCHAR(255) Unique — prevents duplicate registration
site_name VARCHAR(255) Display name
secret_key VARCHAR(64) 32-byte random hex, used for HMAC + JWT signing
jwt_token TEXT Pre-signed JWT issued at registration
status ENUM(active,paused,failed) Controls delivery eligibility
created_at DATETIME

Publisher — wp_wpsp_delivery_log

Column Type Notes
id BIGINT UNSIGNED Primary key
post_id BIGINT UNSIGNED Indexed
subscriber_id BIGINT UNSIGNED FK to subscribers
event ENUM(publish,update,delete)
status ENUM(pending,delivered,failed,retrying) Indexed
attempts TINYINT UNSIGNED Max 5 before permanent failure
next_retry DATETIME Indexed — used by cron queue query
last_error TEXT Surfaced in the Gutenberg sidebar
delivered_at DATETIME Set on success

Subscriber — wp_wpss_idempotency

Column Type Notes
idempotency_key VARCHAR(64) Unique — MD5 of post_id+event+subscriber_id+modified_time
processed_at DATETIME

Security

Security was treated as a first-class concern, not an afterthought.

Dual-layer authentication

Every webhook carries two independent security mechanisms:

1. JWT in Authorization: Bearer header — identifies who is calling. Each subscriber gets a unique JWT signed with their own secret key. If a subscriber's token is compromised, only that subscriber is affected.

2. HMAC-SHA256 in X-WPS-Signature header — proves the body wasn't tampered with. The signature covers timestamp + "." + full_body. Changing a single byte in the payload produces a completely different signature.

Neither alone is sufficient. JWT identifies the caller; HMAC proves body integrity.

Replay attack prevention

// Reject requests older than 5 minutes
if ( abs( time() - (int) $timestamp ) > self::TIMESTAMP_TOLERANCE ) {
    return false;
}

An attacker who intercepts a valid signed request cannot replay it more than 5 minutes later.

Constant-time comparison

// hash_equals() prevents timing attacks
return hash_equals( $expected, $signature );

Using === for HMAC comparison leaks timing information that can be used to forge signatures. hash_equals() always takes the same time regardless of where strings differ.

Idempotency

Every payload carries a deterministic idempotency_key:

md5( "{$post_id}_{$event}_{$subscriber_id}_" . get_post_modified_time( 'U', true, $post_id ) )

The subscriber checks this key before processing. If already seen: returns 200 already_processed. This makes webhook delivery safe to retry — no risk of importing the same post twice.

WordPress capability model

Endpoint Required capability
GET /wpsp/v1/subscribers manage_options
POST /wpsp/v1/subscribers manage_options
DELETE /wpsp/v1/subscribers/:id manage_options
POST /wpsp/v1/syndicate edit_posts
GET /wpsp/v1/delivery-log/:post_id edit_posts
POST /wpsp/v1/retry/:log_id manage_options
POST /wpss/v1/receive Public (verified by JWT + HMAC)
POST /wpss/v1/setup manage_options

Data sanitisation

Context Function used
Post content wp_kses_post() — allows safe HTML, strips scripts
Text fields sanitize_text_field()
URLs esc_url_raw() + FILTER_VALIDATE_URL
Taxonomy terms sanitize_text_field() per term
Database queries $wpdb->prepare() everywhere — no raw interpolation

Reliability

Retry queue with exponential backoff

Attempt 1 fails → wait  1 minute  → retry
Attempt 2 fails → wait  5 minutes → retry
Attempt 3 fails → wait 15 minutes → retry
Attempt 4 fails → wait  1 hour    → retry
Attempt 5 fails → mark FAILED, surface in dashboard

After 5 failures, a human decides — they can force-retry from the Gutenberg sidebar. Automatic retries beyond this risk delivering stale content if the subscriber deliberately went offline.

Subscriber owns its copy

When a post is deleted on the publisher, we do not delete the local copy on subscriber sites. We set _wpss_source_removed = 1 meta and fire wpss_source_post_deleted action.

The subscriber site may want to keep the post with a "content has moved" notice, or redirect to the canonical URL, or archive it. That is their editorial decision.

Transients caching

The subscriber list is cached for 5 minutes with set_transient(). Cache is invalidated on any write operation (register, delete, status update) — never stale.


Setup

Requirements

  • WordPress 6.0+
  • PHP 8.0+
  • Node.js 18+ and npm (for building the JS assets)
  • Two or more WordPress installations (use LocalWP for local development)

Building JS assets

cd wp-syndication-publisher
npm install
npm run build

This compiles src/sidebar/index.jsassets/editor.js and src/admin/index.jsassets/admin.js. For development with live reloading: npm run start

Publisher site

  1. Upload and activate wp-syndication-publisher
  2. Go to Settings → Syndication
  3. Add subscriber sites — the plugin generates a unique secret key and JWT per subscriber

Subscriber site

  1. Upload and activate wp-syndication-subscriber
  2. Configure with the credentials generated by the publisher:
curl -X POST https://subscriber.example.com/wp-json/wpss/v1/setup \
  -H "Content-Type: application/json" \
  -H "X-WP-Nonce: $(wp eval 'echo wp_create_nonce("wp_rest");')" \
  -d '{
    "secret_key": "YOUR_SECRET_KEY",
    "jwt_token":  "YOUR_JWT_TOKEN"
  }'
  1. Verify the connection:
curl https://subscriber.example.com/wp-json/wpss/v1/status \
  -H "X-WP-Nonce: YOUR_NONCE"

# → {"configured":true,"version":"1.0.0","site_url":"https://subscriber.example.com"}

Gutenberg sidebar

Open any published post. Open the Syndication panel (RSS icon in the sidebar).

  • First publish — select which sites to send to, click Syndicate
  • After syndicating — see live delivery status per site (delivered / pending / retrying / failed)
  • On failure — see the error message and click Retry

API reference

Publisher endpoints

GET /wp-json/wpsp/v1/subscribers

Returns all active subscriber sites. Secret keys are never exposed.

POST /wp-json/wpsp/v1/subscribers

Register a new subscriber site.

{
  "site_url": "https://subscriber.example.com",
  "site_name": "My Subscriber Site"
}

DELETE /wp-json/wpsp/v1/subscribers/:id

Remove a subscriber. Deletes all delivery log entries for that subscriber.

POST /wp-json/wpsp/v1/syndicate

Manually syndicate a post to specific subscriber IDs.

{
  "post_id": 42,
  "subscriber_ids": [1, 2, 3]
}

GET /wp-json/wpsp/v1/delivery-log/:post_id

Full delivery history for a post — one row per subscriber per event.

POST /wp-json/wpsp/v1/retry/:log_id

Force-retry a failed delivery immediately.

Subscriber endpoints

POST /wp-json/wpss/v1/receive

Receive a syndicated post. Requires valid JWT + HMAC. Public endpoint.

Request headers:

Authorization:    Bearer <jwt_token>
X-WPS-Signature:  sha256=<hmac_hex>
X-WPS-Timestamp:  <unix_timestamp>
Content-Type:     application/json

Payload (publish/update):

{
  "event":           "publish",
  "post_id":         42,
  "source_url":      "https://publisher.example.com",
  "canonical_url":   "https://publisher.example.com/my-post/",
  "idempotency_key": "a1b2c3d4...",
  "post": {
    "title":         "My Post Title",
    "content":       "<p>Full post content...</p>",
    "excerpt":       "Short excerpt",
    "slug":          "my-post-title",
    "date":          "2024-01-15 10:00:00",
    "author":        "Jane Smith",
    "categories":    ["News", "Technology"],
    "tags":          ["wordpress", "php"],
    "featured_image": "https://publisher.example.com/wp-content/uploads/image.jpg"
  }
}

Payload (delete):

{
  "event":           "delete",
  "post_id":         42,
  "source_url":      "https://publisher.example.com",
  "idempotency_key": "a1b2c3d4..."
}

Responses:

Status Meaning
200 {"status":"ok"} Imported successfully
200 {"status":"already_processed"} Duplicate — idempotency key seen before
401 Invalid JWT or HMAC signature
422 Valid auth but invalid payload
503 Subscriber not configured

Testing

# Publisher — run all PHPUnit tests
cd wp-syndication-publisher
composer install
./vendor/bin/phpunit tests/php/ --testdox

# Subscriber — run all PHPUnit tests
cd wp-syndication-subscriber
composer install
./vendor/bin/phpunit tests/php/ --testdox

What's tested:

Test class Covers
Test_JWT_Issuer Token generation, verification, tamper detection, malformed tokens
Test_Webhook_Dispatcher HMAC signing determinism, secret isolation, timestamp sensitivity
Test_Subscriber_Registry Registration, deduplication, caching, status updates, deletion
Test_HMAC_Verifier Valid signatures, wrong secrets, expired timestamps, tampered bodies

Each test class covers both happy path and failure cases. Tests use WP_UnitTestCase so they run against a real WordPress test installation with database access.


Extension points

The plugin is designed to be extended without modifying core files:

// Support custom post types for syndication (publisher)
add_filter( 'wpsp_supported_post_types', function( $types ) {
    return array_merge( $types, array( 'product', 'podcast' ) );
} );

// Allow additional post types on subscriber (subscriber)
add_filter( 'wpss_allowed_post_types', function( $types ) {
    return array_merge( $types, array( 'product' ) );
} );

// React to source post deletion (subscriber)
add_action( 'wpss_source_post_deleted', function( $local_post_id, $payload ) {
    // e.g. redirect to canonical URL, add a notice, archive the post
}, 10, 2 );

Known limitations and scale considerations

This plugin targets small-to-medium networks (up to ~100 subscriber sites). At larger scale:

Bottleneck Appears at Fix
WP-Cron queue processing ~500 subscribers Move to Redis queue + dedicated workers
Fan-out on publish ~1,000 subscribers Queue a single job, fan-out asynchronously
Subscriber list transient ~5,000 subscribers Paginate query, cache in batches
Database indexes ~50,000 log rows Add composite indexes, partition table

See DECISIONS.md for the full scale analysis.


Technical decisions

Every significant architectural choice is documented with the alternatives considered and why they were rejected. Read DECISIONS.md — it covers:

  • Why two plugins instead of one
  • Why HMAC over OAuth or plain API keys
  • Why JWT alongside HMAC (they serve different purposes)
  • Why exponential backoff, and the specific delay values chosen
  • How idempotency works and why a DB table beats transients
  • How "subscriber owns its copy" is implemented
  • What breaks first at 10,000 subscribers and how to fix it
  • What would be done differently with more time

Stack

Layer Technology
Language PHP 8.0+
Standards WordPress Coding Standards (PHPCS)
Backend APIs WordPress REST API, WP-Cron, dbDelta(), WP Hooks
Frontend React, @wordpress/data, @wordpress/components
Auth HMAC-SHA256, JWT (HS256)
Testing PHPUnit, WP_UnitTestCase
Local dev LocalWP (3 sites: publisher + 2 subscribers)