{"id":392691,"date":"2026-10-28T12:13:31","date_gmt":"2026-10-28T12:13:31","guid":{"rendered":"https:\/\/wordpress.org\/plugins\/wordspost-content-publisher\/"},"modified":"2026-10-28T12:13:13","modified_gmt":"2026-10-28T12:13:13","slug":"wordspost-content-publisher","status":"publish","type":"plugin","link":"https:\/\/wordpress.org\/plugins\/wordspost-content-publisher\/","author":23592898,"comment_status":"closed","ping_status":"closed","template":"","meta":{"version":"3.6.3","stable_tag":"3.6.3","tested":"7.1.4","requires":"6.0","requires_php":"7.4","requires_plugins":null,"header_name":"WordsPost Content Publisher","header_author":"WordsPost","header_description":"REST API for publishing posts and media to WordPress from external systems, authenticated with an API key.","assets_banners_color":"0a0e1a","last_updated":"2026-10-28 12:13:13","external_support_url":"","external_repository_url":"","donate_link":"","header_plugin_uri":"","header_author_uri":"https:\/\/wordspost.com","rating":0,"author_block_rating":0,"active_installs":0,"downloads":57,"num_ratings":0,"support_threads":0,"support_threads_resolved":0,"author_block_count":0,"sections":["description","installation","faq","changelog"],"tags":{"3.6.3":{"tag":"3.6.3","author":"wordspost","date":"2026-10-28 12:13:13","revision":3770629}},"upgrade_notice":{"3.6.3":"<p>Loads translations bundled with the plugin when no language pack is installed. No API changes.<\/p>","3.6.2":"<p>The API now writes only as an author who is not an administrator. After updating, choose such an author (or create one) in the plugin&#039;s settings \u2014 until then nothing is published through the API.<\/p>","3.6.1":"<p>Renamed to WordsPost Content Publisher. No functional changes.<\/p>","3.6.0":"<p>Writes that check the post hasn&#039;t changed or isn&#039;t open in the editor, quiet writes, field hashes, WPML versions in other languages. SEO now reaches WPML duplicates. Existing calls answer as before.<\/p>","3.5.0":"<p>Texts and posts by ID in the inventory, WPML duplicates turned into translations, restore from trash, terms by ID. Trashing through the API no longer strips iframes. Existing calls answer as before.<\/p>","3.4.0":"<p>Site inventory, rendered SEO values, cache flush, and writes that keep foreign text intact: backslashes, SEO templates, WPML slugs and translation groups. Existing calls answer as before.<\/p>","3.3.1":"<p>noindex no longer follows the site-wide &quot;Discourage search engines&quot; setting; <code>GET \/ping<\/code> reports it as <code>search_engines_discouraged<\/code>.<\/p>","3.3.0":"<p>Read-only access to pages, WooCommerce products, product categories and menus, with each page&#039;s visible text. Existing calls answer exactly as before.<\/p>","3.2.2":"<p>Adds a &quot;Settings&quot; link on the Plugins screen. No other changes.<\/p>","3.2.1":"<p>Small fix: the new-key box no longer shows a key that has since been revoked.<\/p>","3.2.0":"<p>Removes the site-wide primary API key. Before updating, make sure every integration uses a key of its own (created under &quot;API keys&quot; or via &quot;Connect to WordsPost&quot;).<\/p>","3.1.1":"<p>Simpler settings page: all API keys in one table with per-key actions. No API changes.<\/p>","3.1.0":"<p>Adds revocable per-client API keys, an optional WordsPost connection and a 60\/min default rate limit. Fully compatible with 3.0: existing keys and addresses keep working.<\/p>","3.0.0":"<p>Renamed to Content Publisher. Install it next to WP Publisher 2.x and activate it: it keeps the same API key and settings and deactivates 2.x. The old wp-publisher\/v1 address keeps working.<\/p>"},"ratings":[],"assets_icons":{"icon-128x128.png":{"filename":"icon-128x128.png","revision":3771022,"resolution":"128x128","location":"assets","locale":"","width":128,"height":128},"icon-256x256.png":{"filename":"icon-256x256.png","revision":3771022,"resolution":"256x256","location":"assets","locale":"","width":256,"height":256},"icon.svg":{"filename":"icon.svg","revision":3771022,"resolution":false,"location":"assets","locale":false}},"assets_banners":{"banner-1544x500.png":{"filename":"banner-1544x500.png","revision":3771290,"resolution":"1544x500","location":"assets","locale":"","width":1544,"height":500},"banner-772x250.png":{"filename":"banner-772x250.png","revision":3771290,"resolution":"772x250","location":"assets","locale":"","width":772,"height":250}},"assets_blueprints":{},"all_blocks":[],"tagged_versions":["3.6.3"],"block_files":[],"assets_screenshots":[],"screenshots":[]},"plugin_section":[],"plugin_tags":[569,84,7143,23853,103],"plugin_category":[48,50],"plugin_contributors":[289203],"plugin_business_model":[],"class_list":["post-392691","plugin","type-plugin","status-publish","hentry","plugin_tags-automation","plugin_tags-media","plugin_tags-publishing","plugin_tags-rest-api","plugin_tags-wpml","plugin_category-language-tools","plugin_category-media","plugin_contributors-wordspost","plugin_committers-wordspost"],"banners":{"banner":"https:\/\/ps.w.org\/wordspost-content-publisher\/assets\/banner-772x250.png?rev=3771290","banner_2x":"https:\/\/ps.w.org\/wordspost-content-publisher\/assets\/banner-1544x500.png?rev=3771290","banner_rtl":false,"banner_2x_rtl":false},"icons":{"svg":"https:\/\/ps.w.org\/wordspost-content-publisher\/assets\/icon.svg?rev=3771022","icon":"https:\/\/ps.w.org\/wordspost-content-publisher\/assets\/icon.svg?rev=3771022","icon_2x":false,"generated":false},"screenshots":[],"raw_content":"<!--section=description-->\n<p>WordsPost Content Publisher adds a REST API (<code>content-publisher\/v1<\/code>) 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 \u2014 pages, WooCommerce products, product categories and menus \u2014 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 \u2014 created on the plugin's settings page, or obtained by a service through a confirmation screen \u2014 and every key can be renamed, reissued or revoked on its own.<\/p>\n\n<p>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\" \u2014 see External services below.<\/p>\n\n<h4>Features<\/h4>\n\n<ul>\n<li>Posts: create \/ read \/ update \/ delete \/ search, with configurable status (including scheduled publishing via <code>date<\/code> + <code>future<\/code>), 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)<\/li>\n<li>Safe retries: send a <code>request_key<\/code> with <code>POST \/posts<\/code> or <code>POST \/media<\/code> 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<\/li>\n<li>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)<\/li>\n<li>Categories &amp; 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<\/li>\n<li>Site reading (read only): pages and WooCommerce products with their own fields, the visible text of any page \u2014 including pages built with Elementor or WPBakery \u2014 product categories, menus, the site tagline, and whether search engines are told not to index each item<\/li>\n<li>Site inventory: every post of every public type, every term and every menu, thousands per request at a fixed, small database cost \u2014 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<\/li>\n<li>SEO: one set of fields for Rank Math and Yoast SEO (whichever is active) \u2014 title, description, focus keyword, canonical, robots, Open Graph, Twitter Card \u2014 kept with their template variables (<code>%date%<\/code>, <code>%%title%%<\/code>), plus the title and description a search engine actually gets after the templates are applied<\/li>\n<li>Faithful writes: text is stored exactly as sent, backslashes included, so block attributes survive a round trip<\/li>\n<li>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<\/li>\n<li>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<\/li>\n<li>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<\/li>\n<li>Languages: a <code>\/languages<\/code> endpoint listing what the site speaks; with WPML, post language and translation-group linking via <code>lang<\/code> \/ <code>translation_of<\/code>, a <code>translations<\/code> map on every post, filters to find posts that still need translating, and a clear <code>409<\/code> 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<\/li>\n<li>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<\/li>\n<li>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<\/li>\n<li>Rate limiting: a configurable per-key requests-per-minute safety net against a runaway integration (60 by default), reported in <code>X-RateLimit-*<\/code> headers<\/li>\n<li>Activity log: every action the plugin takes, filterable and searchable, kept for 30 days<\/li>\n<li>Debug Mode: optional full request\/response logging for troubleshooting an integration<\/li>\n<\/ul>\n\n<p>The full REST API reference, curl examples and a Postman collection are in the plugin's <code>docs\/<\/code> folder (<code>API.md<\/code>, <code>openapi.yaml<\/code>, <code>postman_collection.json<\/code>).<\/p>\n\n<h3>External services<\/h3>\n\n<p>The plugin itself never sends a request to any external service. Two things involve other sites:<\/p>\n\n<h4>Image downloads requested by your API client<\/h4>\n\n<p>When an authenticated API caller passes an address in the <code>url<\/code> field of <code>POST \/media<\/code>, the file at that address is fetched once with WordPress's own <code>download_url()<\/code> 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 <code>data<\/code> involve no outgoing request at all.<\/p>\n\n<h4>Cache plugins, when your API client asks for a flush<\/h4>\n\n<pre><code>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.\n<\/code><\/pre>\n\n<h4>WordsPost (optional, only when you connect it)<\/h4>\n\n<p>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:<\/p>\n\n<ol>\n<li>\"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.<\/li>\n<li>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.<\/li>\n<li>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.<\/li>\n<\/ol>\n\n<p>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.<\/p>\n\n<p>WordsPost terms of use: https:\/\/wordspost.com\/terms\/\nWordsPost privacy policy: https:\/\/wordspost.com\/privacy-policy\/<\/p>\n\n<!--section=installation-->\n<ol>\n<li>Upload the plugin folder to <code>\/wp-content\/plugins\/<\/code>, or install the zip through Plugins \u2192 Add New \u2192 Upload Plugin.<\/li>\n<li>Activate the plugin.<\/li>\n<li>Go to Settings \u2192 Content Publisher and choose the user that posts published through the API are attributed to.<\/li>\n<li>Under \"API keys\", create a key for your integration and copy it \u2014 it is shown only once. (Or use \"Connect to WordsPost\" if you use that service.)<\/li>\n<li>In your integration, send the <code>Authorization: Bearer &lt;key&gt;<\/code> header with every request (<code>X-WPPUB-Api-Key: &lt;key&gt;<\/code> works the same way).<\/li>\n<\/ol>\n\n<h4>Upgrading from WP Publisher 2.x<\/h4>\n\n<p>Content Publisher is the new name of WP Publisher. WordPress sees it as a separate plugin, so:<\/p>\n\n<ol>\n<li>Install and activate Content Publisher next to WP Publisher. On activation it takes over WP Publisher's settings and deactivates WP Publisher.<\/li>\n<li><p>Delete WP Publisher.<\/p><\/li>\n<li><p>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.<\/p><\/li>\n<\/ol>\n\n<p>The old address <code>wp-publisher\/v1<\/code> keeps working in every 3.x version; move integrations to <code>content-publisher\/v1<\/code> when convenient.<\/p>\n\n<!--section=faq-->\n<dl>\n<dt id=\"do%20i%20need%20wordspost%20to%20use%20this%20plugin%3F\"><h3>Do I need WordsPost to use this plugin?<\/h3><\/dt>\n<dd><p>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.<\/p><\/dd>\n<dt id=\"which%20seo%20plugins%20are%20supported%3F\"><h3>Which SEO plugins are supported?<\/h3><\/dt>\n<dd><p>Rank Math and Yoast SEO. If neither is active, requests that include an <code>seo<\/code> field are rejected with a clear error rather than silently discarding the data.<\/p><\/dd>\n<dt id=\"does%20it%20require%20wpml%3F\"><h3>Does it require WPML?<\/h3><\/dt>\n<dd><p>No. Without WPML the site is treated as monolingual: <code>\/languages<\/code> returns WordPress's own locale with <code>multilingual: false<\/code>, and posts report that language. The <code>lang<\/code> and <code>translation_of<\/code> fields are still accepted but have no effect.<\/p><\/dd>\n<dt id=\"how%20is%20the%20api%20secured%3F\"><h3>How is the API secured?<\/h3><\/dt>\n<dd><p>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 \u2014 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.<\/p>\n\n<p>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 \u2014 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.<\/p>\n\n<p>A key is a powerful credential: it can publish and delete posts, and read the site's content \u2014 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.<\/p><\/dd>\n<dt id=\"does%20the%20plugin%20update%20itself%20automatically%3F\"><h3>Does the plugin update itself automatically?<\/h3><\/dt>\n<dd><p>No. It never turns on automatic updates for itself; updates happen only when a site administrator installs them.<\/p><\/dd>\n\n<\/dl>\n\n<!--section=changelog-->\n<h4>3.6.3<\/h4>\n\n<ul>\n<li>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.<\/li>\n<\/ul>\n\n<h4>3.6.2<\/h4>\n\n<ul>\n<li>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 <code>409 wppub_author_not_allowed<\/code> 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).<\/li>\n<li>Translations come from translate.wordpress.org; the plugin no longer bundles or loads its own.<\/li>\n<\/ul>\n\n<h4>3.6.1<\/h4>\n\n<ul>\n<li>The plugin is now called WordsPost Content Publisher (text domain <code>wordspost-content-publisher<\/code>). Nothing else changes: settings, keys and the REST API stay as they were.<\/li>\n<\/ul>\n\n<h4>3.6.0<\/h4>\n\n<ul>\n<li><code>if_hash<\/code> on <code>PUT \/posts\/{id}<\/code> and <code>POST \/posts\/{id}\/unduplicate<\/code>: the write goes through only if the post's inventory <code>hash<\/code> is still the one given \u2014 otherwise <code>409 wppub_changed<\/code> with the current one, nothing written. The comparison and the write happen under a row lock, so of concurrent writes with one <code>if_hash<\/code> exactly one succeeds.<\/li>\n<li><code>respect_lock<\/code>: <code>409 wppub_locked<\/code> while someone has the post open in the editor.<\/li>\n<li><code>quiet<\/code> on <code>POST \/posts<\/code>, <code>PUT \/posts\/{id}<\/code> and <code>duplicate-to<\/code>: no pingbacks and no enclosure fetching for that write.<\/li>\n<li><code>field_hashes=1<\/code> in the inventory: the MD5 of title, content and excerpt as separate fields.<\/li>\n<li><code>POST \/posts\/{id}\/duplicate-to<\/code>: 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.<\/li>\n<li><code>unfiltered_html<\/code> in <code>GET \/ping<\/code> and in the inventory's <code>env<\/code>: whether texts are stored as sent.<\/li>\n<li><code>PUT \/posts\/{id}<\/code> with <code>seo<\/code> 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).<\/li>\n<li>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).<\/li>\n<li>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).<\/li>\n<li>What WordPress and WPML finish at the end of a write request \u2014 WPML re-saves translations of posts with blocks there \u2014 now runs as the plugin's author, so iframes and scripts in those translations are no longer stripped (both base paths).<\/li>\n<li>The featured image, categories and tags of <code>PUT \/posts\/{id}<\/code> 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).<\/li>\n<li>All new parameters work under <code>content-publisher\/v1<\/code> only; responses without them are as in 3.5.0, apart from <code>unfiltered_html<\/code>.<\/li>\n<\/ul>\n\n<h4>3.5.0<\/h4>\n\n<ul>\n<li><code>GET \/inventory?kind=texts<\/code>: 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 <code>hash<\/code> as <code>kind=posts<\/code>.<\/li>\n<li><code>ids=<\/code> in <code>kind=posts<\/code> and <code>kind=texts<\/code>: those posts, trashed ones included, and <code>missing_ids<\/code> for the ones that no longer exist.<\/li>\n<li>An <code>env<\/code> block in the inventory: WPML, the installation, the post types and taxonomies covered and which of them WPML translates.<\/li>\n<li><code>POST \/posts\/{id}\/unduplicate<\/code> turns a WPML duplicate into an ordinary translation without changing it; <code>POST \/posts\/{id}\/restore<\/code> brings a post back from the trash into its previous status.<\/li>\n<li><code>categories<\/code> and <code>tags<\/code> accept <code>{\"id\": N}<\/code>; <code>PUT<\/code> of a category or tag accepts <code>translation_of<\/code> to link it into a translation group.<\/li>\n<li>Moving a post to the trash through the API no longer strips iframes and scripts from its content (both base paths).<\/li>\n<li>All of this is under <code>content-publisher\/v1<\/code> only; responses without the new parameters are as in 3.4.0, apart from <code>env<\/code> on the first page of the inventory.<\/li>\n<\/ul>\n\n<h4>3.4.0<\/h4>\n\n<ul>\n<li>New <code>GET \/inventory?kind=posts|terms|menus<\/code>: 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 \u2014 WPML language, translation group and duplicate flag, page builder, former slugs, featured image and a <code>hash<\/code> fingerprint for posts; language and group for terms and menus; <code>site_time_gmt<\/code> as the \"changed since\" marker.<\/li>\n<li>Posts carry <code>date_created_gmt<\/code> and <code>date_modified_gmt<\/code>.<\/li>\n<li><code>with_seo_rendered=1<\/code> adds the SEO title and description as search engines get them (Rank Math, Yoast).<\/li>\n<li>Writes keep backslashes (Gutenberg block attributes no longer break) and SEO template variables such as <code>%date%<\/code>; an automatic excerpt leaves shortcodes out.<\/li>\n<li>WPML: a translation keeps its original's slug instead of getting \"-2\"; <code>PUT<\/code> with <code>lang<\/code> moves a post within its translation group instead of taking it out, answers <code>409<\/code> when the language is taken, and <code>detach: true<\/code> takes it out; <code>replace_duplicate<\/code> writes a translation into a WPML duplicate; new categories take a <code>parent<\/code>, and a second translation into a taken language is a <code>409<\/code>.<\/li>\n<li>Saving a term no longer moves a term of another language to the default one, and works while its translation shares its slug.<\/li>\n<li>New <code>POST \/cache\/flush<\/code>.<\/li>\n<li>Posts carry <code>source_lang<\/code> (the language WPML records them as translated from) and <code>translation_jobs<\/code> (open WPML translation jobs in their group); <code>GET \/languages<\/code> adds <code>wpml_auto_translate<\/code> (WPML's automatic translation mode and post types), so an integration can stay out of what WPML translates by itself.<\/li>\n<li>All of this is under <code>content-publisher\/v1<\/code> 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.<\/li>\n<\/ul>\n\n<h4>3.3.1<\/h4>\n\n<ul>\n<li><code>noindex<\/code> now reflects only the item itself \u2014 its SEO settings, its post type's default, a status other than published, or being a WooCommerce cart\/checkout\/account page \u2014 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 <code>search_engines_discouraged<\/code> in <code>GET \/ping<\/code>.<\/li>\n<\/ul>\n\n<h4>3.3.0<\/h4>\n\n<ul>\n<li>Read pages and WooCommerce products: <code>GET \/posts?type=page|product<\/code> and <code>GET \/posts\/{id}?type=\u2026<\/code>, 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.<\/li>\n<li><code>with_text=1<\/code> adds <code>text<\/code>: the content as a visitor reads it \u2014 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.<\/li>\n<li><code>modified_after<\/code> returns only what changed since a given moment.<\/li>\n<li>With an explicit <code>type<\/code>, password-protected items are left out, <code>seo.robots<\/code> shows the robots rules that actually apply (including Rank Math's and Yoast's post type defaults), and a new <code>noindex<\/code> flag sums them up.<\/li>\n<li>New <code>GET \/terms?taxonomy=category|post_tag|product_cat<\/code> with each term's link, description and parent, and <code>hide_empty<\/code>.<\/li>\n<li>New <code>GET \/menus<\/code>: menus in theme locations and unassigned ones, per WPML language.<\/li>\n<li><code>GET \/ping<\/code> adds <code>tagline<\/code>, <code>front_page_id<\/code>, <code>posts_page_id<\/code> and <code>woocommerce<\/code>, and five new <code>features<\/code>.<\/li>\n<li>All of this is under <code>content-publisher\/v1<\/code> only; without the new parameters every response is the same as in 3.2.<\/li>\n<\/ul>\n\n<h4>3.2.2<\/h4>\n\n<ul>\n<li>A \"Settings\" link in the plugin's row on the Plugins screen.<\/li>\n<\/ul>\n\n<h4>3.2.1<\/h4>\n\n<ul>\n<li>When a key is revoked (on the settings page or through <code>DELETE \/keys\/self<\/code>) 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.<\/li>\n<\/ul>\n\n<h4>3.2.0<\/h4>\n\n<ul>\n<li>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.<\/li>\n<li>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\" \u2014 it no longer disappears on a second page load or when another plugin hides admin notices.<\/li>\n<li>Keys created by hand can be reissued: same label, new value, the old value stops working. Service keys can only be revoked.<\/li>\n<li><code>DELETE \/keys\/self<\/code> works for every key (the <code>wppub_primary_key<\/code> error no longer exists). The <code>primary<\/code> field of a key description is always <code>false<\/code>.<\/li>\n<li>The \"Test Connection\" button is removed (it used the primary key). Resetting settings no longer touches API keys.<\/li>\n<\/ul>\n\n<h4>3.1.1<\/h4>\n\n<ul>\n<li>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) \u2014 revoke it and connect again instead.<\/li>\n<li>\"Connect to WordsPost\" moved to a compact line above the keys table; once connected, the service shows up as a row of the table.<\/li>\n<li>No API changes.<\/li>\n<\/ul>\n\n<h4>3.1.0<\/h4>\n\n<ul>\n<li>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.<\/li>\n<li>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\".<\/li>\n<li>New endpoints: <code>GET \/connect\/info<\/code> and <code>POST \/connect\/exchange<\/code> (public), <code>GET \/keys\/self<\/code> and <code>DELETE \/keys\/self<\/code>.<\/li>\n<li>Rate limit: 60 requests per minute by default (was 20; a value you saved is kept), counted atomically, refused requests no longer count, and <code>X-RateLimit-Limit<\/code>, <code>X-RateLimit-Remaining<\/code>, <code>X-RateLimit-Reset<\/code> headers plus <code>Retry-After<\/code> on 429.<\/li>\n<li><code>GET \/ping<\/code> also reports <code>rate_limit<\/code>, <code>key<\/code>, <code>home_url<\/code>, <code>install_id<\/code> and <code>rest_root<\/code>; new features <code>keys<\/code>, <code>connect<\/code>, <code>rate_headers<\/code>.<\/li>\n<li>Deleting the plugin now removes its settings, keys and logs (uninstall).<\/li>\n<\/ul>\n\n<h4>3.0.0<\/h4>\n\n<ul>\n<li>Renamed to Content Publisher (slug <code>content-publisher<\/code>). New REST address <code>content-publisher\/v1<\/code>; the 2.x address <code>wp-publisher\/v1<\/code> keeps working throughout 3.x.<\/li>\n<li>Activating 3.0.0 takes over the API key and settings of WP Publisher 2.x and deactivates it \u2014 the key stays the same.<\/li>\n<li>New <code>request_key<\/code> on <code>POST \/posts<\/code> and <code>POST \/media<\/code>: a repeated request returns the existing item with <code>200<\/code> and the <code>X-WPPUB-Replayed: 1<\/code> 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 <code>GET \/posts?request_key=<\/code> and <code>GET \/media?request_key=<\/code>.<\/li>\n<li><code>POST \/posts<\/code> with <code>translation_of<\/code> now answers <code>409 wppub_translation_exists<\/code> (with the existing post's <code>id<\/code> and <code>link<\/code>) when the translation group already has a post in that language, instead of creating a second post outside the group.<\/li>\n<li><code>PUT \/media\/{id}<\/code> accepts <code>post_id<\/code> to attach an attachment to a post (<code>0<\/code>\/<code>null<\/code> detaches it).<\/li>\n<li><code>GET \/ping<\/code> reports <code>features<\/code>, so a caller can tell what the site's plugin version supports.<\/li>\n<li>Ukrainian translation added.<\/li>\n<\/ul>\n\n<h4>2.0.0<\/h4>\n\n<ul>\n<li>Major version: stable <code>wppub_<\/code> prefix for every option, error code, and internal hook, and a stable <code>\/wp-publisher\/v1<\/code> REST namespace. <code>Authorization: Bearer &lt;key&gt;<\/code> is now the primary way to authenticate (<code>X-WPPUB-Api-Key<\/code> remains available as an equivalent alternative).<\/li>\n<li>This is a new plugin install from WordPress's point of view (different folder\/slug) \u2014 see Upgrade Notice.<\/li>\n<\/ul>\n\n<h4>1.7.0<\/h4>\n\n<ul>\n<li><code>POST \/media<\/code> now accepts a <code>data<\/code> field \u2014 base64-encoded file bytes \u2014 as an alternative to <code>url<\/code>, for images that only exist as bytes with no public link to download from. Mutually exclusive with <code>url<\/code>. The real file type is read from the file's own content, never trusted from a claimed <code>mime_type<\/code>; only JPEG, PNG, and WEBP are accepted this way, with a 10MB limit.<\/li>\n<li>New <code>filename<\/code> field (works with both <code>url<\/code> and <code>data<\/code>) actually takes effect now \u2014 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.<\/li>\n<li>New <code>GET \/media?url=&lt;file URL&gt;<\/code> \u2014 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 (<code>photo-1024x683.jpg<\/code>), and accepts a site-relative path as well as a full URL.<\/li>\n<li>Error code <code>wppub_missing_url<\/code> on <code>POST \/media<\/code> (when neither <code>url<\/code> nor <code>data<\/code> is given) is now <code>wppub_missing_source<\/code>, to describe both required-field cases correctly.<\/li>\n<\/ul>\n\n<h4>1.6.0<\/h4>\n\n<ul>\n<li>New <code>wrap_tables<\/code> field on <code>POST<\/code>\/<code>PUT \/posts<\/code>: wraps every <code>&lt;table&gt;<\/code> in the post content in a <code>.wm-table-scroll<\/code> region (keyboard-focusable, <code>role=\"region\"<\/code>, <code>aria-label<\/code> in the article's own language) so wide tables scroll horizontally on narrow screens instead of squeezing their text unreadably thin \u2014 the actual cause of \"tables look broken on mobile,\" not overflow. <code>true<\/code> wraps (or re-wraps, idempotently), <code>false<\/code> removes the wrapper again; left absent on <code>POST<\/code> it follows the new \"Wrap tables by default\" setting, and on <code>PUT<\/code> it leaves existing markup untouched unless given explicitly.<\/li>\n<li>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 <code>data-label<\/code> 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.<\/li>\n<li>New front-end stylesheet (<code>public\/assets\/tables.css<\/code>), enqueued ahead of the active theme's own CSS so a child theme can override any of it without <code>!important<\/code>; includes a right-edge fade that appears only while there's more table to scroll to, and disappears once fully scrolled.<\/li>\n<li>Two new settings: the table-cards column threshold, and the default for <code>wrap_tables<\/code> on <code>POST<\/code> when the field is omitted.<\/li>\n<\/ul>\n\n<h4>1.5.0<\/h4>\n\n<ul>\n<li>Lowered the minimum required PHP version from 8.0 to 7.4, for wider hosting compatibility. No code change was needed \u2014 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.<\/li>\n<\/ul>\n\n<h4>1.4.0<\/h4>\n\n<ul>\n<li>Categories and tags now carry the same WPML language support posts got in 1.3.0: <code>POST \/categories<\/code>\/<code>\/tags<\/code> accept <code>lang<\/code> and <code>translation_of<\/code>; every term response gains <code>lang<\/code> and a <code>translations<\/code> map (<code>{language code: {id, name, slug}}<\/code>, including the term itself, <code>{}<\/code> without WPML); <code>GET \/categories<\/code>\/<code>\/tags<\/code> gain <code>lang<\/code> (a code, or <code>all<\/code>) and <code>untranslated=&lt;code&gt;<\/code> query filters, matching the ones <code>GET \/posts<\/code> already had.<\/li>\n<li>The important part: attaching <code>categories<\/code>\/<code>tags<\/code> by name on <code>POST<\/code>\/<code>PUT \/posts<\/code> now resolves the name to the term's version in the post's own language \u2014 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 <code>translation_of<\/code>. This means the same <code>categories<\/code>\/<code>tags<\/code> list can be sent for an article and its translation without knowing each term's ID or name in every language.<\/li>\n<\/ul>\n\n<h4>1.3.0<\/h4>\n\n<ul>\n<li>New <code>translations<\/code> field on every post \u2014 a map of language code to <code>{id, status}<\/code> 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.<\/li>\n<li>New <code>lang<\/code> query parameter on <code>GET \/posts<\/code> \u2014 a language code or <code>all<\/code>. Without it, WPML filters results to the site's default language only; this was previously implicit and undocumented.<\/li>\n<li>New <code>untranslated=&lt;language code&gt;<\/code> query parameter on <code>GET \/posts<\/code> \u2014 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.<\/li>\n<li>Fixed three bugs in 1.2.0's scheduled publishing: <code>status: future<\/code> with a past <code>date<\/code> no longer silently published immediately instead of returning <code>400 wppub_missing_date<\/code>; <code>PUT<\/code> with only <code>date<\/code> (no <code>status<\/code>) 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 <code>PUT<\/code> with <code>date<\/code> on a draft\/pending post that never had an explicit date of its own no longer silently discards the new date \u2014 WordPress does that by default unless told otherwise, which the plugin now does. Also, a <code>date<\/code> made only of whitespace used to silently mean \"right now\"; it's now treated as not provided, same as an empty string.<\/li>\n<\/ul>\n\n<h4>1.2.0<\/h4>\n\n<ul>\n<li>New <code>date<\/code> field on <code>POST<\/code>\/<code>PUT \/posts<\/code> \u2014 publish date\/time, read in the site's timezone when the value carries no offset of its own, written to both <code>post_date<\/code> and <code>post_date_gmt<\/code>.<\/li>\n<li>New <code>future<\/code> status. You don't normally need to set it directly: send <code>status: publish<\/code> together with a future <code>date<\/code> and the post is scheduled automatically; WordPress publishes it at that time.<\/li>\n<li>Creating a post with <code>status: future<\/code> requires a <code>date<\/code> that is actually in the future \u2014 a missing or non-future date returns <code>400 wppub_missing_date<\/code>, an unparseable one returns <code>400 wppub_invalid_date<\/code>. (Updating a post with <code>status: future<\/code> and no <code>date<\/code> keeps its existing stored date instead of erroring \u2014 see API.md.)<\/li>\n<li>One minute of slack: a <code>date<\/code> at \"now\" publishes immediately rather than scheduling.<\/li>\n<\/ul>\n\n<h4>1.1.0<\/h4>\n\n<ul>\n<li><code>\/languages<\/code> now works on sites without WPML: it returns WordPress's own locale as a single language plus a <code>multilingual<\/code> flag, instead of an empty list.<\/li>\n<li><code>lang<\/code> in post responses is the site language on non-WPML sites instead of <code>null<\/code>.<\/li>\n<li><code>lang<\/code> \/ <code>translation_of<\/code> no longer return 400 without WPML \u2014 they are accepted and inert, so one integration fits both single- and multi-language sites.<\/li>\n<li>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; <code>wppub_toc_title<\/code> filter to add more).<\/li>\n<li>New <code>toc_title<\/code> field to override that heading per request.<\/li>\n<li><code>generate_toc<\/code> in PUT \/posts\/{id} now works without <code>content<\/code>: 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.<\/li>\n<li>Table of contents markup is now <code>&lt;nav class=\"wppub-toc\"&gt;<\/code> with a class on every element (<code>__title<\/code>, <code>__list<\/code>, <code>__list--sub<\/code>, <code>__item<\/code>, <code>__item--sub<\/code>, <code>__link<\/code>) so it can be styled to match the theme. Blocks written by 1.0.0 are recognised and replaced on the next regeneration.<\/li>\n<\/ul>\n\n<h4>1.0.0<\/h4>\n\n<ul>\n<li>First stable release.<\/li>\n<li>REST API for posts, media, categories, tags.<\/li>\n<li>Rank Math \/ Yoast SEO field support.<\/li>\n<li>WPML language and translation-group support.<\/li>\n<li>Rate limiting and automatic image downscaling, both configurable.<\/li>\n<li>Activity log with filtering, search, and automatic cleanup.<\/li>\n<li>Debug Mode for full request\/response logging.<\/li>\n<li>Reset Settings action (author, Debug Mode, rate limit, max image dimension back to defaults, new API key).<\/li>\n<li>Russian (ru_RU) translation included.<\/li>\n<\/ul>","raw_excerpt":"REST API for publishing and managing posts and media from external systems, authenticated with an API key.","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin\/392691","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin"}],"about":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/types\/plugin"}],"replies":[{"embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/comments?post=392691"}],"author":[{"embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wporg\/v1\/users\/wordspost"}],"wp:attachment":[{"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/media?parent=392691"}],"wp:term":[{"taxonomy":"plugin_section","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_section?post=392691"},{"taxonomy":"plugin_tags","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_tags?post=392691"},{"taxonomy":"plugin_category","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_category?post=392691"},{"taxonomy":"plugin_contributors","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_contributors?post=392691"},{"taxonomy":"plugin_business_model","embeddable":true,"href":"https:\/\/wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_business_model?post=392691"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}