WordsPost Content Publisher

Description

WordsPost Content Publisher adds a REST API (content-publisher/v1) that lets an external system create, update, delete and search posts, upload and manage media, manage categories and tags, set SEO fields and, when WPML is active, manage post languages and translation links. It can also read the rest of the site — pages, WooCommerce products, product categories and menus — so the external system can see what the site is about and link to it, and list the whole site’s content in a light inventory, so the external system can keep its own copy in sync. Every request is authenticated with an API key. Each client gets its own key — created on the plugin’s settings page, or obtained by a service through a confirmation screen — and every key can be renamed, reissued or revoked on its own.

Everything works without any external service. Optionally, the settings page has a “Connect to WordsPost” button for the WordsPost content service (https://wordspost.com): it gives WordsPost its own revocable key after you approve it on a confirmation screen, instead of copying the key by hand. Nothing is sent to WordsPost unless you start the connection and press “Allow” — see External services below.

Features

  • Posts: create / read / update / delete / search, with configurable status (including scheduled publishing via date + future), comments, auto excerpt, auto table of contents (from H2/H3 headings), mobile-friendly table wrapping with an optional card layout on narrow screens, and Cyrillic-to-Latin slug transliteration (Russian and Ukrainian)
  • Safe retries: send a request_key with POST /posts or POST /media and a repeated request returns the item created the first time instead of a duplicate, even when two copies arrive at once or the first one was interrupted halfway
  • Media: upload by URL or as inline base64 data, with optional WebP conversion, automatic downscaling of oversized images, a custom filename, alt/title/caption/description, attaching to a post, and lookup by file URL (including generated size variants)
  • Categories & tags: full CRUD, with slug transliteration and find-or-create by name when attaching to a post; with WPML, per-language terms and translation links
  • Site reading (read only): pages and WooCommerce products with their own fields, the visible text of any page — including pages built with Elementor or WPBakery — product categories, menus, the site tagline, and whether search engines are told not to index each item
  • Site inventory: every post of every public type, every term and every menu, thousands per request at a fixed, small database cost — with WPML language and translation group, page builder, WPML duplicate flag, former slugs and a content fingerprint that changes with the text, SEO fields and categories
  • SEO: one set of fields for Rank Math and Yoast SEO (whichever is active) — title, description, focus keyword, canonical, robots, Open Graph, Twitter Card — kept with their template variables (%date%, %%title%%), plus the title and description a search engine actually gets after the templates are applied
  • Faithful writes: text is stored exactly as sent, backslashes included, so block attributes survive a round trip
  • Repairs: raw texts in the inventory, posts looked up by ID (trash included), WPML duplicates turned into ordinary translations, posts restored from the trash into their previous status, terms named by ID and linked into translation groups
  • Safe rewrites: a write that goes through only if the post hasn’t changed since it was read and nobody has it open in the editor, writes without pingbacks, per-field fingerprints in the inventory, and a version of a post in another language made by WPML itself
  • Cache flush: one call clears the cached pages of the given posts (or of the whole site) in W3 Total Cache, WP Rocket, LiteSpeed Cache, WP Super Cache or WP Fastest Cache, and in WordPress’s object cache
  • Languages: a /languages endpoint listing what the site speaks; with WPML, post language and translation-group linking via lang / translation_of, a translations map on every post, filters to find posts that still need translating, and a clear 409 instead of a duplicate when a language in the group is already taken; translations keep their original’s slug, change language within their group, and can take the place of a WPML duplicate
  • Keys: a separate API key per client, each revocable on its own, with label, creation date and last use; create one by hand on the settings page (shown once, reissue it if lost), or let a service obtain one through the “Allow” screen
  • Optional connection to WordsPost: a button on the settings page and an approval screen that states exactly what the service will be able to do; disconnect at any time
  • Rate limiting: a configurable per-key requests-per-minute safety net against a runaway integration (60 by default), reported in X-RateLimit-* headers
  • Activity log: every action the plugin takes, filterable and searchable, kept for 30 days
  • Debug Mode: optional full request/response logging for troubleshooting an integration

The full REST API reference, curl examples and a Postman collection are in the plugin’s docs/ folder (API.md, openapi.yaml, postman_collection.json).

External services

The plugin itself never sends a request to any external service. Two things involve other sites:

Image downloads requested by your API client

When an authenticated API caller passes an address in the url field of POST /media, the file at that address is fetched once with WordPress’s own download_url() and stored in the media library. Which addresses are fetched is entirely up to the site’s own API client; nothing about the site or its visitors is sent. Uploads sent as inline data involve no outgoing request at all.

Cache plugins, when your API client asks for a flush

POST /cache/flush asks the cache plugin installed on the site to clear its cache, through that plugin's own functions. The plugin sends nothing anywhere itself; if the cache plugin is set up to purge a CDN as well, it does so on its own, as it would when you clear the cache from its screen.

WordsPost (optional, only when you connect it)

WordsPost (https://wordspost.com) is a content service that writes and publishes posts to connected sites through this plugin’s API. Connecting it is optional and happens only when a site administrator starts it:

  1. “Connect to WordsPost” on the settings page opens the WordsPost panel (panel.wordspost.com) in your browser, with this site’s address in the link.
  2. WordsPost sends your browser back to a confirmation screen on your own site. Only after you press “Allow” does your browser return to WordsPost carrying a one-time code (valid for 5 minutes), the request’s state value and this site’s address.
  3. WordsPost’s server exchanges that code at this site’s REST API for its own API key. The site answers with the key and basic facts about itself: site name, home address, an installation ID, the REST address, the plugin and WordPress versions, the rate limit and whether the site is multilingual.

From then on WordsPost uses that key to create, edit and delete posts and media, as the confirmation screen states. You can revoke it at any time with “Disconnect” on the settings page. “Cancel” on the confirmation screen sends WordsPost only a refusal.

WordsPost terms of use: https://wordspost.com/terms/
WordsPost privacy policy: https://wordspost.com/privacy-policy/

Installation

  1. Upload the plugin folder to /wp-content/plugins/, or install the zip through Plugins Add New Upload Plugin.
  2. Activate the plugin.
  3. Go to Settings Content Publisher and choose the user that posts published through the API are attributed to.
  4. Under “API keys”, create a key for your integration and copy it — it is shown only once. (Or use “Connect to WordsPost” if you use that service.)
  5. In your integration, send the Authorization: Bearer <key> header with every request (X-WPPUB-Api-Key: <key> works the same way).

Upgrading from WP Publisher 2.x

Content Publisher is the new name of WP Publisher. WordPress sees it as a separate plugin, so:

  1. Install and activate Content Publisher next to WP Publisher. On activation it takes over WP Publisher’s settings and deactivates WP Publisher.
  2. Delete WP Publisher.

  3. Create a new API key under “API keys” and put it into your integration: since 3.2 the old site-wide key is not carried over.

The old address wp-publisher/v1 keeps working in every 3.x version; move integrations to content-publisher/v1 when convenient.

FAQ

Do I need WordsPost to use this plugin?

No. The plugin is complete on its own: any system that can send HTTP requests (your own script, an automation tool) can publish through it with an API key. The WordsPost button is optional and does nothing until you use it.

Which SEO plugins are supported?

Rank Math and Yoast SEO. If neither is active, requests that include an seo field are rejected with a clear error rather than silently discarding the data.

Does it require WPML?

No. Without WPML the site is treated as monolingual: /languages returns WordPress’s own locale with multilingual: false, and posts report that language. The lang and translation_of fields are still accepted but have no effect.

How is the API secured?

Every request carries an API key in a header. Keys are stored only as SHA-256 hashes, so the site never keeps them in readable form — which is also why a key is shown only once. Reissue or revoke any key at any time from the settings page; the old value stops working immediately.

Everything the API writes is written as the post author chosen in the plugin’s settings, which must be a user who can write posts but cannot administer the site — an Editor, for example. Administrators are not accepted, so a key can never change the site’s settings, users, plugins or themes. The settings page (and the “Connect to WordsPost” screen) can create such an Editor for you, under a name you choose; until one is chosen, nothing is published through the API.

A key is a powerful credential: it can publish and delete posts, and read the site’s content — posts, pages, products and the other public post types, including drafts, private and password-protected ones, pages a membership plugin hides from visitors and, by ID, posts in the trash. It can also restore posts from the trash (republishing what was published), turn WPML duplicates into translations and make WPML versions of posts in other languages. Give a key only to a service you trust as you would an editor of the site.

Does the plugin update itself automatically?

No. It never turns on automatic updates for itself; updates happen only when a site administrator installs them.

Reviews

There are no reviews for this plugin.

Contributors & Developers

“WordsPost Content Publisher” is open source software. The following people have contributed to this plugin.

Contributors

“WordsPost Content Publisher” has been translated into 2 locales. Thank you to the translators for their contributions.

Translate “WordsPost Content Publisher” into your language.

Interested in development?

Browse the code, check out the SVN repository, or subscribe to the development log by RSS.

Changelog

3.6.3

  • Translations shipped inside the plugin’s own languages/ folder (as in builds handed out outside WordPress.org) are loaded again. A translation in wp-content/languages, such as a language pack from translate.wordpress.org, still takes precedence. No API changes.

3.6.2

  • The API writes only as a post author who cannot administer the site (an Editor, for example). Administrators can no longer be chosen; without a suitable author, writes are refused with 409 wppub_author_not_allowed and the dashboard says so. The settings page and the “Connect to WordsPost” screen can create such an Editor, named as you like (“WordsPost” by default).
  • Translations come from translate.wordpress.org; the plugin no longer bundles or loads its own.

3.6.1

  • The plugin is now called WordsPost Content Publisher (text domain wordspost-content-publisher). Nothing else changes: settings, keys and the REST API stay as they were.

3.6.0

  • if_hash on PUT /posts/{id} and POST /posts/{id}/unduplicate: the write goes through only if the post’s inventory hash is still the one given — otherwise 409 wppub_changed with the current one, nothing written. The comparison and the write happen under a row lock, so of concurrent writes with one if_hash exactly one succeeds.
  • respect_lock: 409 wppub_locked while someone has the post open in the editor.
  • quiet on POST /posts, PUT /posts/{id} and duplicate-to: no pingbacks and no enclosure fetching for that write.
  • field_hashes=1 in the inventory: the MD5 of title, content and excerpt as separate fields.
  • POST /posts/{id}/duplicate-to: a version of the post in another language made by WPML’s own duplication, turned straight into an ordinary translation; the SEO canonical address is not carried over.
  • unfiltered_html in GET /ping and in the inventory’s env: whether texts are stored as sent.
  • PUT /posts/{id} with seo now writes the SEO fields before saving the post, so WPML duplicates get the new title and description along with the new text; if the save fails, they go back to what they were (both base paths).
  • A category or tag of a taxonomy WPML doesn’t translate, or one without a WPML language, is attached as it is instead of being copied into the post’s language (both base paths).
  • A translation whose text is written through the API is marked as edited in WordPress’s own editor, so WPML no longer rebuilds it from its original when the original is saved (posts with blocks or page-builder content; both base paths).
  • What WordPress and WPML finish at the end of a write request — WPML re-saves translations of posts with blocks there — now runs as the plugin’s author, so iframes and scripts in those translations are no longer stripped (both base paths).
  • The featured image, categories and tags of PUT /posts/{id} are written before the post is saved, so WPML duplicates get them right away; they go back to what they were if the save fails (both base paths).
  • All new parameters work under content-publisher/v1 only; responses without them are as in 3.5.0, apart from unfiltered_html.

3.5.0

  • GET /inventory?kind=texts: raw title, content and excerpt by id cursor, up to 500 per page and about 8 MB, at a fixed number of database queries per page, with the same hash as kind=posts.
  • ids= in kind=posts and kind=texts: those posts, trashed ones included, and missing_ids for the ones that no longer exist.
  • An env block in the inventory: WPML, the installation, the post types and taxonomies covered and which of them WPML translates.
  • POST /posts/{id}/unduplicate turns a WPML duplicate into an ordinary translation without changing it; POST /posts/{id}/restore brings a post back from the trash into its previous status.
  • categories and tags accept {"id": N}; PUT of a category or tag accepts translation_of to link it into a translation group.
  • Moving a post to the trash through the API no longer strips iframes and scripts from its content (both base paths).
  • All of this is under content-publisher/v1 only; responses without the new parameters are as in 3.4.0, apart from env on the first page of the inventory.

3.4.0

  • New GET /inventory?kind=posts|terms|menus: the whole site’s content a page of up to 5000 items at a time, by id cursor, at a fixed number of database queries per page — WPML language, translation group and duplicate flag, page builder, former slugs, featured image and a hash fingerprint for posts; language and group for terms and menus; site_time_gmt as the “changed since” marker.
  • Posts carry date_created_gmt and date_modified_gmt.
  • with_seo_rendered=1 adds the SEO title and description as search engines get them (Rank Math, Yoast).
  • Writes keep backslashes (Gutenberg block attributes no longer break) and SEO template variables such as %date%; an automatic excerpt leaves shortcodes out.
  • WPML: a translation keeps its original’s slug instead of getting “-2”; PUT with lang moves a post within its translation group instead of taking it out, answers 409 when the language is taken, and detach: true takes it out; replace_duplicate writes a translation into a WPML duplicate; new categories take a parent, and a second translation into a taken language is a 409.
  • Saving a term no longer moves a term of another language to the default one, and works while its translation shares its slug.
  • New POST /cache/flush.
  • Posts carry source_lang (the language WPML records them as translated from) and translation_jobs (open WPML translation jobs in their group); GET /languages adds wpml_auto_translate (WPML’s automatic translation mode and post types), so an integration can stay out of what WPML translates by itself.
  • All of this is under content-publisher/v1 only (the term fix applies everywhere); without the new parameters responses are the same as in 3.3.1, apart from the two new dates.

3.3.1

  • noindex now reflects only the item itself — its SEO settings, its post type’s default, a status other than published, or being a WooCommerce cart/checkout/account page — and no longer the site-wide “Discourage search engines” setting, which made every item of a site under development look the same. That setting is reported on its own as search_engines_discouraged in GET /ping.

3.3.0

  • Read pages and WooCommerce products: GET /posts?type=page|product and GET /posts/{id}?type=…, with page fields (parent, order, template, front page, posts page) and product fields (categories, SKU, stock, catalog visibility). Writing is still for blog posts only.
  • with_text=1 adds text: the content as a visitor reads it — rendered like the site’s front end (blocks, shortcodes, Elementor, WPBakery), then reduced to plain text of up to 3000 characters. Embeds are not fetched while doing so.
  • modified_after returns only what changed since a given moment.
  • With an explicit type, password-protected items are left out, seo.robots shows the robots rules that actually apply (including Rank Math’s and Yoast’s post type defaults), and a new noindex flag sums them up.
  • New GET /terms?taxonomy=category|post_tag|product_cat with each term’s link, description and parent, and hide_empty.
  • New GET /menus: menus in theme locations and unassigned ones, per WPML language.
  • GET /ping adds tagline, front_page_id, posts_page_id and woocommerce, and five new features.
  • All of this is under content-publisher/v1 only; without the new parameters every response is the same as in 3.2.

3.2.2

  • A “Settings” link in the plugin’s row on the Plugins screen.

3.2.1

  • When a key is revoked (on the settings page or through DELETE /keys/self) while its value is still shown in the “new key” box, the box disappears, so a dead key can’t be copied by mistake. A reissue also removes the old value from other administrators’ boxes.

3.2.0

  • The site-wide primary API key is gone. Every key now belongs to one client: created by hand under “API keys” or obtained through “Connect to WordsPost”. Updating removes the old primary key; anything still using it must switch to a key of its own.
  • A newly created or reissued key is shown in its own box with a Copy button and stays there (for up to 10 minutes) until you press “I have copied it” — it no longer disappears on a second page load or when another plugin hides admin notices.
  • Keys created by hand can be reissued: same label, new value, the old value stops working. Service keys can only be revoked.
  • DELETE /keys/self works for every key (the wppub_primary_key error no longer exists). The primary field of a key description is always false.
  • The “Test Connection” button is removed (it used the primary key). Resetting settings no longer touches API keys.

3.1.1

  • Settings page: API keys are now one table. The primary key is its first row, with icons to show, copy and regenerate it; every other key has icons to rename and revoke it. A service’s key can’t be regenerated from here (the service would never learn the new key) — revoke it and connect again instead.
  • “Connect to WordsPost” moved to a compact line above the keys table; once connected, the service shows up as a row of the table.
  • No API changes.

3.1.0

  • Named API keys: a separate, individually revocable key per client, stored only as a hash; a “Keys” section on the settings page to create (shown once) and revoke them. The primary key works exactly as before.
  • Optional “Connect to WordsPost” button and approval screen (authorization code with PKCE, modelled on core’s Application Passwords flow). The plugin makes no outgoing request; the key is handed over only after “Allow”.
  • New endpoints: GET /connect/info and POST /connect/exchange (public), GET /keys/self and DELETE /keys/self.
  • Rate limit: 60 requests per minute by default (was 20; a value you saved is kept), counted atomically, refused requests no longer count, and X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers plus Retry-After on 429.
  • GET /ping also reports rate_limit, key, home_url, install_id and rest_root; new features keys, connect, rate_headers.
  • Deleting the plugin now removes its settings, keys and logs (uninstall).

3.0.0

  • Renamed to Content Publisher (slug content-publisher). New REST address content-publisher/v1; the 2.x address wp-publisher/v1 keeps working throughout 3.x.
  • Activating 3.0.0 takes over the API key and settings of WP Publisher 2.x and deactivates it — the key stays the same.
  • New request_key on POST /posts and POST /media: a repeated request returns the existing item with 200 and the X-WPPUB-Replayed: 1 header instead of creating a duplicate. Concurrent requests with one key are serialized; a retry after an interrupted request finishes the missing steps without a second insert. Lookup via GET /posts?request_key= and GET /media?request_key=.
  • POST /posts with translation_of now answers 409 wppub_translation_exists (with the existing post’s id and link) when the translation group already has a post in that language, instead of creating a second post outside the group.
  • PUT /media/{id} accepts post_id to attach an attachment to a post (0/null detaches it).
  • GET /ping reports features, so a caller can tell what the site’s plugin version supports.
  • Ukrainian translation added.

2.0.0

  • Major version: stable wppub_ prefix for every option, error code, and internal hook, and a stable /wp-publisher/v1 REST namespace. Authorization: Bearer <key> is now the primary way to authenticate (X-WPPUB-Api-Key remains available as an equivalent alternative).
  • This is a new plugin install from WordPress’s point of view (different folder/slug) — see Upgrade Notice.

1.7.0

  • POST /media now accepts a data field — base64-encoded file bytes — as an alternative to url, for images that only exist as bytes with no public link to download from. Mutually exclusive with url. The real file type is read from the file’s own content, never trusted from a claimed mime_type; only JPEG, PNG, and WEBP are accepted this way, with a 10MB limit.
  • New filename field (works with both url and data) actually takes effect now — previously it was accepted but silently ignored, so uploads kept the source URL’s filename regardless. The extension is always the real one (from file content), never whatever the caller or the source URL happened to say.
  • New GET /media?url=<file URL> — resolves an already-uploaded attachment’s ID from its file URL, for adding metadata to images already sitting in published content. Understands the size-variant URLs WordPress itself generates (photo-1024x683.jpg), and accepts a site-relative path as well as a full URL.
  • Error code wppub_missing_url on POST /media (when neither url nor data is given) is now wppub_missing_source, to describe both required-field cases correctly.

1.6.0

  • New wrap_tables field on POST/PUT /posts: wraps every <table> in the post content in a .wm-table-scroll region (keyboard-focusable, role="region", aria-label in the article’s own language) so wide tables scroll horizontally on narrow screens instead of squeezing their text unreadably thin — the actual cause of “tables look broken on mobile,” not overflow. true wraps (or re-wraps, idempotently), false removes the wrapper again; left absent on POST it follows the new “Wrap tables by default” setting, and on PUT it leaves existing markup untouched unless given explicitly.
  • Narrow tables (2 up to a configurable column count, default 4, with an unambiguous single-row header) additionally get a card layout below 600px: each row becomes its own block, the first column as a bold heading and the rest as “label: value” lines built from data-label attributes set at publish time. Wider tables, or ones without a clean header row, still get the scroll wrapper but keep their normal row/column layout at every width.
  • New front-end stylesheet (public/assets/tables.css), enqueued ahead of the active theme’s own CSS so a child theme can override any of it without !important; includes a right-edge fade that appears only while there’s more table to scroll to, and disappears once fully scrolled.
  • Two new settings: the table-cards column threshold, and the default for wrap_tables on POST when the field is omitted.

1.5.0

  • Lowered the minimum required PHP version from 8.0 to 7.4, for wider hosting compatibility. No code change was needed — the plugin never used any PHP 8.0+ syntax; the 8.0 floor was just how it was first declared. Confirmed with a static PHPCompatibility scan targeting 7.4 (zero issues) in addition to a manual review.

1.4.0

  • Categories and tags now carry the same WPML language support posts got in 1.3.0: POST /categories//tags accept lang and translation_of; every term response gains lang and a translations map ({language code: {id, name, slug}}, including the term itself, {} without WPML); GET /categories//tags gain lang (a code, or all) and untranslated=<code> query filters, matching the ones GET /posts already had.
  • The important part: attaching categories/tags by name on POST/PUT /posts now resolves the name to the term’s version in the post’s own language — not just whichever language that name happens to already exist in. A term found in the “wrong” language gets its translation looked up (and reused, not duplicated, on every later post) or, if none exists yet, a new term under the same name is created and linked via translation_of. This means the same categories/tags list can be sent for an article and its translation without knowing each term’s ID or name in every language.

1.3.0

  • New translations field on every post — a map of language code to {id, status} across the post’s whole WPML translation group, including the post itself. Includes translations in any status (a draft-in-progress counts as “translated” for this purpose), so a workflow scanning for missing translations doesn’t duplicate one that’s already started. Empty object without WPML.
  • New lang query parameter on GET /posts — a language code or all. Without it, WPML filters results to the site’s default language only; this was previously implicit and undocumented.
  • New untranslated=<language code> query parameter on GET /posts — returns only posts whose translation group has no version (any status) in that language, so a workflow doesn’t have to page through the whole site filtering client-side.
  • Fixed three bugs in 1.2.0’s scheduled publishing: status: future with a past date no longer silently published immediately instead of returning 400 wppub_missing_date; PUT with only date (no status) on an already-published post now correctly schedules it when the new date is in the future (an undefined-variable bug meant it never did); and PUT with date on a draft/pending post that never had an explicit date of its own no longer silently discards the new date — WordPress does that by default unless told otherwise, which the plugin now does. Also, a date made only of whitespace used to silently mean “right now”; it’s now treated as not provided, same as an empty string.

1.2.0

  • New date field on POST/PUT /posts — publish date/time, read in the site’s timezone when the value carries no offset of its own, written to both post_date and post_date_gmt.
  • New future status. You don’t normally need to set it directly: send status: publish together with a future date and the post is scheduled automatically; WordPress publishes it at that time.
  • Creating a post with status: future requires a date that is actually in the future — a missing or non-future date returns 400 wppub_missing_date, an unparseable one returns 400 wppub_invalid_date. (Updating a post with status: future and no date keeps its existing stored date instead of erroring — see API.md.)
  • One minute of slack: a date at “now” publishes immediately rather than scheduling.

1.1.0

  • /languages now works on sites without WPML: it returns WordPress’s own locale as a single language plus a multilingual flag, instead of an empty list.
  • lang in post responses is the site language on non-WPML sites instead of null.
  • lang / translation_of no longer return 400 without WPML — they are accepted and inert, so one integration fits both single- and multi-language sites.
  • Table of contents heading is now written in the post’s language, not the site’s admin language (built-in translations for ru, uk, en, de, fr, es, pl; wppub_toc_title filter to add more).
  • New toc_title field to override that heading per request.
  • generate_toc in PUT /posts/{id} now works without content: true adds or rebuilds the table of contents in the existing post, false removes it. Regeneration replaces the old block instead of stacking a second one.
  • Table of contents markup is now <nav class="wppub-toc"> with a class on every element (__title, __list, __list--sub, __item, __item--sub, __link) so it can be styled to match the theme. Blocks written by 1.0.0 are recognised and replaced on the next regeneration.

1.0.0

  • First stable release.
  • REST API for posts, media, categories, tags.
  • Rank Math / Yoast SEO field support.
  • WPML language and translation-group support.
  • Rate limiting and automatic image downscaling, both configurable.
  • Activity log with filtering, search, and automatic cleanup.
  • Debug Mode for full request/response logging.
  • Reset Settings action (author, Debug Mode, rate limit, max image dimension back to defaults, new API key).
  • Russian (ru_RU) translation included.