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_keywithPOST /postsorPOST /mediaand 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
/languagesendpoint listing what the site speaks; with WPML, post language and translation-group linking vialang/translation_of, atranslationsmap on every post, filters to find posts that still need translating, and a clear409instead 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:
- “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.
- 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.
- 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
- Upload the plugin folder to
/wp-content/plugins/, or install the zip through Plugins Add New Upload Plugin. - Activate the plugin.
- Go to Settings Content Publisher and choose the user that posts published through the API are attributed to.
- 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.)
- 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:
- Install and activate Content Publisher next to WP Publisher. On activation it takes over WP Publisher’s settings and deactivates WP Publisher.
-
Delete WP Publisher.
-
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
seofield 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:
/languagesreturns WordPress’s own locale withmultilingual: false, and posts report that language. Thelangandtranslation_offields 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_allowedand 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_hashonPUT /posts/{id}andPOST /posts/{id}/unduplicate: the write goes through only if the post’s inventoryhashis still the one given — otherwise409 wppub_changedwith the current one, nothing written. The comparison and the write happen under a row lock, so of concurrent writes with oneif_hashexactly one succeeds.respect_lock:409 wppub_lockedwhile someone has the post open in the editor.quietonPOST /posts,PUT /posts/{id}andduplicate-to: no pingbacks and no enclosure fetching for that write.field_hashes=1in 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_htmlinGET /pingand in the inventory’senv: whether texts are stored as sent.PUT /posts/{id}withseonow 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/v1only; responses without them are as in 3.5.0, apart fromunfiltered_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 samehashaskind=posts.ids=inkind=postsandkind=texts: those posts, trashed ones included, andmissing_idsfor the ones that no longer exist.- An
envblock in the inventory: WPML, the installation, the post types and taxonomies covered and which of them WPML translates. POST /posts/{id}/unduplicateturns a WPML duplicate into an ordinary translation without changing it;POST /posts/{id}/restorebrings a post back from the trash into its previous status.categoriesandtagsaccept{"id": N};PUTof a category or tag acceptstranslation_ofto 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/v1only; responses without the new parameters are as in 3.4.0, apart fromenvon 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 ahashfingerprint for posts; language and group for terms and menus;site_time_gmtas the “changed since” marker. - Posts carry
date_created_gmtanddate_modified_gmt. with_seo_rendered=1adds 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”;
PUTwithlangmoves a post within its translation group instead of taking it out, answers409when the language is taken, anddetach: truetakes it out;replace_duplicatewrites a translation into a WPML duplicate; new categories take aparent, and a second translation into a taken language is a409. - 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) andtranslation_jobs(open WPML translation jobs in their group);GET /languagesaddswpml_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/v1only (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
noindexnow 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 assearch_engines_discouragedinGET /ping.
3.3.0
- Read pages and WooCommerce products:
GET /posts?type=page|productandGET /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=1addstext: 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_afterreturns only what changed since a given moment.- With an explicit
type, password-protected items are left out,seo.robotsshows the robots rules that actually apply (including Rank Math’s and Yoast’s post type defaults), and a newnoindexflag sums them up. - New
GET /terms?taxonomy=category|post_tag|product_catwith each term’s link, description and parent, andhide_empty. - New
GET /menus: menus in theme locations and unassigned ones, per WPML language. GET /pingaddstagline,front_page_id,posts_page_idandwoocommerce, and five newfeatures.- All of this is under
content-publisher/v1only; 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/selfworks for every key (thewppub_primary_keyerror no longer exists). Theprimaryfield of a key description is alwaysfalse.- 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/infoandPOST /connect/exchange(public),GET /keys/selfandDELETE /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-Resetheaders plusRetry-Afteron 429. GET /pingalso reportsrate_limit,key,home_url,install_idandrest_root; new featureskeys,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 addresscontent-publisher/v1; the 2.x addresswp-publisher/v1keeps 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_keyonPOST /postsandPOST /media: a repeated request returns the existing item with200and theX-WPPUB-Replayed: 1header 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 viaGET /posts?request_key=andGET /media?request_key=. POST /postswithtranslation_ofnow answers409 wppub_translation_exists(with the existing post’sidandlink) when the translation group already has a post in that language, instead of creating a second post outside the group.PUT /media/{id}acceptspost_idto attach an attachment to a post (0/nulldetaches it).GET /pingreportsfeatures, 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/v1REST namespace.Authorization: Bearer <key>is now the primary way to authenticate (X-WPPUB-Api-Keyremains 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 /medianow accepts adatafield — base64-encoded file bytes — as an alternative tourl, for images that only exist as bytes with no public link to download from. Mutually exclusive withurl. The real file type is read from the file’s own content, never trusted from a claimedmime_type; only JPEG, PNG, and WEBP are accepted this way, with a 10MB limit.- New
filenamefield (works with bothurlanddata) 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_urlonPOST /media(when neitherurlnordatais given) is nowwppub_missing_source, to describe both required-field cases correctly.
1.6.0
- New
wrap_tablesfield onPOST/PUT /posts: wraps every<table>in the post content in a.wm-table-scrollregion (keyboard-focusable,role="region",aria-labelin 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.truewraps (or re-wraps, idempotently),falseremoves the wrapper again; left absent onPOSTit follows the new “Wrap tables by default” setting, and onPUTit 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-labelattributes 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_tablesonPOSTwhen 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//tagsacceptlangandtranslation_of; every term response gainslangand atranslationsmap ({language code: {id, name, slug}}, including the term itself,{}without WPML);GET /categories//tagsgainlang(a code, orall) anduntranslated=<code>query filters, matching the onesGET /postsalready had. - The important part: attaching
categories/tagsby name onPOST/PUT /postsnow 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 viatranslation_of. This means the samecategories/tagslist can be sent for an article and its translation without knowing each term’s ID or name in every language.
1.3.0
- New
translationsfield 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
langquery parameter onGET /posts— a language code orall. Without it, WPML filters results to the site’s default language only; this was previously implicit and undocumented. - New
untranslated=<language code>query parameter onGET /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: futurewith a pastdateno longer silently published immediately instead of returning400 wppub_missing_date;PUTwith onlydate(nostatus) 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); andPUTwithdateon 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, adatemade 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
datefield onPOST/PUT /posts— publish date/time, read in the site’s timezone when the value carries no offset of its own, written to bothpost_dateandpost_date_gmt. - New
futurestatus. You don’t normally need to set it directly: sendstatus: publishtogether with a futuredateand the post is scheduled automatically; WordPress publishes it at that time. - Creating a post with
status: futurerequires adatethat is actually in the future — a missing or non-future date returns400 wppub_missing_date, an unparseable one returns400 wppub_invalid_date. (Updating a post withstatus: futureand nodatekeeps its existing stored date instead of erroring — see API.md.) - One minute of slack: a
dateat “now” publishes immediately rather than scheduling.
1.1.0
/languagesnow works on sites without WPML: it returns WordPress’s own locale as a single language plus amultilingualflag, instead of an empty list.langin post responses is the site language on non-WPML sites instead ofnull.lang/translation_ofno 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_titlefilter to add more). - New
toc_titlefield to override that heading per request. generate_tocin PUT /posts/{id} now works withoutcontent: 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.
