WP Syndication Publisher
Publish once, syndicate to many WordPress sites automatically via signed webhooks
by Your Name · github.com/tejeshvenkat/wp-syndication-publisher · website
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.js → assets/editor.js and src/admin/index.js → assets/admin.js.
For development with live reloading: npm run start
Publisher site
- Upload and activate
wp-syndication-publisher - Go to Settings → Syndication
- Add subscriber sites — the plugin generates a unique secret key and JWT per subscriber
Subscriber site
- Upload and activate
wp-syndication-subscriber - 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"
}'
- 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) |