Skip to content
← All articles
WordPress to Social Media Without Duplicate Posts

WordPress to Social Media Without Duplicate Posts

Developer workflow: first publication, durable claims, human review

WordPress to social media automation needs one lasting announcement claim per article, plus a separate record of whether delivery succeeded. Filter out ordinary updates, keep the claim through republishing, and let a background worker create a reviewable draft. Sending directly from every WordPress save is how a typo correction becomes another announcement.

In short

  • Distinguish first publication from an update before preparing content.
  • Keep an immutable request and permanent per-post claim through retries and republishing.
  • Verify the saved draft, then leave scheduling to a human.

A WordPress save is not a new announcement

WordPress can fire status actions when an existing published article is updated. Both the old and new statuses can be publish; the hook name alone does not establish a new publication. The WordPress status-transition reference documents this behavior explicitly.

Consider this illustrative sequence: an editor publishes a pending article, fixes its title, returns it to draft, then republishes it. That is one original announcement and several editorial actions. Treating every move into publish as new still creates a duplicate at the last step.

WordPress eventAnnouncement policy
pending to publishPrepare the original announcement if no claim exists.
publish to publishIgnore the update.
publish to draftPreserve the claim and receipt.
draft to publish after earlier publicationReuse history; do not announce again.
future to publishCapture publication, subject to the same durable claim.

Use the transition_post_status action hook. Do not call wp_transition_post_status() to publish an article: the function fires actions; it does not change the post’s database status.

The first announcement needs a claim that outlives status changes

Use a unique operation identity built from a permanent site ID and WordPress post ID. Retain that identity even after successful delivery, rather than relying on the article’s current status.

The example uses wp:main-editorial-site:42:announce:1. SQLite stores it as a primary key alongside the exact request body. INSERT OR IGNORE means a repeated event cannot overwrite the original caption. A failed delivery keeps the row too. A claim means “this operation exists,” not “social media delivery succeeded.”

Do not put an edit timestamp in that key. Every correction would become a different operation. A deliberate second campaign needs a separately reviewed identity, such as an announcement revision, linked to the original receipt. Never implement that policy by clearing the first claim or changing the suffix globally.

For an existing WordPress site, establish a baseline before activation. Import previously announced post IDs as suppressed claims, including articles currently back in draft. Current status cannot reconstruct publication history. If that history is unavailable, conservatively suppress pre-existing posts and begin with newly created content. The download documents the baseline SQL.

The editor request should only save a local outbox record

Capture a small immutable request locally and return without contacting the social media API. A separate worker handles network latency; the WordPress publishing request should not wait for it.

Download the runnable WordPress hook, worker, and fixture tests. The plugin targets one X integration and uses PHP with PDO SQLite. This callback is from that file; its ps_db() helper creates the unique-key table:

function ps_capture($new, $old, $post) {
    if ($new !== 'publish' || $old === 'publish' || $post->post_type !== 'post'
        || wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) { return; }
    if (!preg_match('/^[A-Za-z0-9-]{1,80}$/', POSTSIDER_SITE_ID)) {
        throw new RuntimeException('Use a permanent ASCII site identifier');
    }
    $key = 'wp:' . POSTSIDER_SITE_ID . ':' . $post->ID . ':announce:1';
    $body = wp_json_encode([
        'type' => 'draft',
        'date' => str_replace(' ', 'T', $post->post_date_gmt) . 'Z',
        'shortLink' => false, 'tags' => [],
        'posts' => [[
            'integration' => ['id' => POSTSIDER_CHANNEL_ID],
            'value' => [['content' => wp_strip_all_tags($post->post_title)
                . "\n" . get_permalink($post->ID), 'image' => []]],
            'settings' => ['__type' => 'x', 'who_can_reply_post' => 'everyone']
        ]]
    ]);
    if ($body === false) { throw new RuntimeException('Cannot encode outbox event'); }
    $query = ps_db()->prepare('INSERT OR IGNORE INTO outbox (event_key, body) VALUES (?, ?)');
    $query->execute([$key, $body]);
}
add_action('transition_post_status', 'ps_capture', 10, 3);

Start on staging. Create a private directory outside the document root, writable by the Unix account shared by WordPress and WP-CLI. Restrict it to that account. Configure these illustrative paths and your verified X integration ID in wp-config.php:

define('POSTSIDER_OUTBOX_DB', '/srv/private/postsider/outbox.sqlite');
define('POSTSIDER_SITE_ID', 'main-editorial-site');
define('POSTSIDER_CHANNEL_ID', 'YOUR_VERIFIED_X_INTEGRATION_ID');

Install only wordpress-outbox.php in wp-content/mu-plugins/. Confirm both PHP runtimes have PDO SQLite, initialize storage, and run one worker tick after staging publication:

wp --path=/srv/www/wordpress eval 'ps_db();'
wp --path=/srv/www/wordpress postsider-outbox run

Provision POSTSIDER_API_KEY through protected worker service configuration, not source code or command arguments. The editor does not need the key. No anonymous webhook is exposed.

The worker handles one due row per invocation. Add a system timer only after staging checks; ordinary WP-Cron depends on page loads and can run later than the intended time. This starter assumes one host and a local filesystem. Its worker file lock prevents overlapping delivery without holding a SQLite transaction open during HTTP.

A create receipt needs readback before it means success

The worker posts the saved body to https://api.postsider.com/public/v1/posts with type: draft. It sends the raw organization key in Authorization, without Bearer, and the stored event key in Idempotency-Key.

These details were checked against PostSider’s current API controller, authentication middleware, DTO, and idempotency service. The API scheduling walkthrough covers the wider request contract. Here, the supplied title and permalink become the draft text; the API is not an embedded AI generator.

Creation returns array entries containing postId and integration, not full stored posts. The worker requires exactly one entry for the intended destination and persists its ID immediately. It then calls GET /public/v1/posts/:id and compares the returned ID, destination, content, and DRAFT state.

Only a match earns the local label draft_verified. A failed GET retains the ID and retries readback, never creation. Unexpected response shapes stop for manual reconciliation. This also catches a job that is unexpectedly queued rather than left as a draft.

Obtain the destination through authenticated GET /public/v1/integrations, then inspect /integration-settings/:id. Replacing only the integration ID does not make X settings valid for another network.

A retry must recover the prepared operation

Retry with the saved body and key, not content rebuilt from the latest WordPress revision. Once an ID exists, recovery reads that record instead of creating a replacement.

FailureWorker behavior
Network timeout, HTTP 429, or 5xxDefer the unchanged operation with bounded backoff.
HTTP 400, 401, 402, 409, or 423Stop in local manual state for investigation.
Accepted ID followed by failed readbackKeep the ID and retry GET only.
Receipt or content mismatchStop without a success marker.

The starter stops automatic recovery after four failed attempts. Its local backoff does not consume Retry-After; adapt that policy before expanding traffic. Inspect error messages rather than assuming every authorization-related response means an expired key.

The inspected idempotency service hashes JSON.stringify(body). Keep property order as well as values. Completed matching requests replay their stored response; changed bodies conflict. No expiration field or expiry job was found in the inspected implementation. That is source evidence, not a permanent retention promise.

There is also a boundary worth stating plainly: remote creation and idempotency completion are separate operations, and the controller releases a claim when creation throws. Partial creation or a crash can require manual reconciliation even with the same key. This is duplicate prevention for ordinary delivery paths, not a guarantee of exactly-once effects. The idempotency guide explains that distinction.

A stored draft still needs a human scheduling decision

Creating a draft does not enforce an approval lock. This worker never schedules it; a person reviews the caption and selects its publishing time in the dashboard.

For formal review routing, a separate authenticated POST /public/v1/posts/:id/request-approval submits an existing draft. Read its review outcome through GET /public/v1/posts/:id/approval. The sample intentionally calls neither endpoint. Treat approval and scheduling as explicit later steps, with appropriate permissions.

draft_verified is a workflow label, not an API status. A queued job is still not a published post. After scheduling, verify the stored job; after its due time, check publication state and the destination URL when available.

If your source is approved spreadsheet rows rather than WordPress publication events, use the n8n social media scheduling tutorial. Its row-receipt problem is different from deciding whether an article deserves its first announcement.

Local fixtures test recovery, not live publication

The downloadable PHP code executed locally under PHP-WASM with real SQLite storage. WordPress functions and HTTP responses were synthetic fixtures; no live CMS or PostSider create request was involved.

Run php test-outbox.php with native PHP, or use the included Node runner when PHP is absent:

npm install --no-save @php-wasm/cli@3.1.60
node run-tests.mjs

The suite checks first publication, updates, republishing, duplicate events, and scheduled WordPress publication. Failure fixtures cover timeouts and HTTP errors, including accepted IDs followed by failed readback. Assertions require no success marker on failure. The runner also checks PHP syntax and detects fixture failures independently of the PHP-WASM process exit status.

One gap remains outside those tests: WordPress publication and the local outbox insert are separate commits. Storage failure can leave an article published without an announcement row. Monitor enqueue failures, retain backups of the claim database, and reconcile missed events through reviewed backfill. Do not assume an editor error rolled publication back.

I build PostSider. My recommendation is to keep the original claim longer than the editorial workflow that created it. See the API integration options.

Lukasz Blania is the founder of PostSider, a social media scheduling product with a human dashboard and API access for developer workflows.

Frequently asked questions

Why does updating WordPress trigger another social media post?

WordPress status hooks can fire with both statuses set to publish. Reject publish-to-publish updates, then use a durable per-post announcement claim to prevent republishing from creating another operation.

Does returning a post to draft reset its announcement?

No. Retain the original announcement key and receipt when a post returns to draft. Publishing it again must not erase that history or automatically create a new campaign.

Does creating a PostSider draft enforce human approval?

No. A draft is a stored content state, not an approval lock. This example leaves scheduling to a human; a formal approval request is a separate API operation.

What should happen when the create request times out?

Keep the prepared payload and Idempotency-Key unchanged. Retry within a bounded policy, or reconcile an ambiguous result manually. If a post ID is already saved, retry readback rather than creation.

Was this example tested against live WordPress or social accounts?

No. The PHP hook and worker ran locally under PHP-WASM with real SQLite storage and synthetic WordPress and HTTP fixtures. No live WordPress installation, PostSider draft creation, or social publication was tested.

Let your AI post
to social media for you.

Connect your accounts, add PostSider to your AI and publish your first post in a few minutes.

7-day free trial · 30+ networks · No credit card