Polylang Free REST API: What Works and How to Link Translations
This blog uses the free version of Polylang for its Japanese and English articles. I publish the English versions through the WordPress REST API, which kept raising one question: how much can the free version do over REST?
The short answer: Polylang Free still creates a post in English when you add ?lang=en to the create request. What it cannot do is link a Japanese post and its English version as translations, return language fields in responses, or filter post lists by language. I filled the linking gap with a small plugin that exposes Polylang’s pll_save_post_translations() function as a REST endpoint.
This article shows what I confirmed on the free version and how to build that endpoint.
What can the Polylang Free REST API do?
It can set the language when creating a post. It cannot link translations, return language fields, or filter lists by language.
I installed Polylang 3.8.9 (free) on a local WordPress 7.1 site with Japanese as the default language and English as the second. I also checked the same points on the live rinblog.org site using read-only requests.
| What I tried | Result on the free version |
|---|---|
Add ?lang=en when creating a post |
The post is created in English |
| Create a post without a language | The post uses the default language (Japanese) |
Look for lang and translations in post responses |
Not present (also not present on the live site) |
Add ?lang=en when listing posts |
Not filtered (Japanese posts are returned too) |
Add translations[ja]=post ID when creating |
Ignored, the posts are not linked |
The pll/v1 namespace |
Only languages and settings (same on the live site) |
Polylang’s REST API documentation says its REST API features, including the lang parameter and the translations field, are available only in Polylang Pro. Of the items above, only ?lang=en on create worked in the free version.
Why does ?lang=en work in the free version?
Because the free version’s code also reads the request’s lang value to decide a new post’s language.
In the Polylang 3.8.9 source, src/Capabilities/Create/Post.php reads lang from the request and uses it as the language of the post being created.
if ( ! isset( $this->pref_lang ) && ! empty( $_REQUEST['lang'] ) && $lang = $this->model->get_language( sanitize_key( $_REQUEST['lang'] ) ) ) {
This code exists to pick the language of new content. It is not documented as a REST API feature. Because I rely on undocumented behavior, I always check that English posts are still created in English after updating Polylang.
How do you link translations through the REST API?
Register one REST endpoint with register_rest_route() that calls pll_save_post_translations().
pll_save_post_translations() takes pairs of language slugs and post IDs and saves them as one translation group. The result is the same as clicking “+” in the admin post list to create an English version.
The code below is a trimmed-down excerpt of the plugin this blog actually uses.
add_action( 'rest_api_init', function () {
register_rest_route(
'rinblog-poster/v1',
'/translations',
array(
'methods' => 'POST',
'callback' => 'rinblog_poster_bridge_save_translations',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
} );
function rinblog_poster_bridge_save_translations( WP_REST_Request $request ) {
$translations = $request->get_param( 'translations' ); // e.g. { "ja": 123, "en": 456 }
if ( ! is_array( $translations ) || count( $translations ) < 2 ) {
return new WP_Error( 'invalid_translations', 'translations needs at least two entries.', array( 'status' => 400 ) );
}
$clean = array();
foreach ( $translations as $lang => $post_id ) {
$lang = sanitize_key( $lang );
$post_id = absint( $post_id );
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error( 'forbidden', "You cannot edit post {$post_id}.", array( 'status' => 403 ) );
}
// Make sure the key matches the language actually set on the post
$actual = pll_get_post_language( $post_id, 'slug' );
if ( $actual !== $lang ) {
return new WP_Error( 'language_mismatch', "Post {$post_id} is '{$actual}' but was passed as '{$lang}'.", array( 'status' => 409 ) );
}
$clean[ $lang ] = $post_id;
}
pll_save_post_translations( $clean );
// Read the group back so the caller can verify it
$first = (int) reset( $clean );
return rest_ensure_response(
array(
'id' => $first,
'lang' => pll_get_post_language( $first, 'slug' ),
'translations' => array_map( 'intval', pll_get_post_translations( $first ) ),
)
);
}
The real plugin also checks that the Polylang functions exist and that each post exists. It adds a second GET endpoint in the same style to read the current language and links.
Why check for language mismatches?
If a post’s actual language does not match the key it was passed under, stop with an error instead of linking.
pll_save_post_translations() saves exactly what you give it. Passing a Japanese post as en breaks the language column in the post list, and you end up fixing it by hand in the admin.
On my local site, I deliberately swapped the Japanese and English post IDs. The endpoint stopped with a 409:
409 language_mismatch Post 6 is 'en' but was passed as 'ja'.
The actual plugin returns this message in Japanese, but the status code and error code are the same.
What does the calling code look like?
Authenticate with a WordPress application password over Basic auth, and POST the pair of Japanese and English post IDs.
import base64
import requests
token = base64.b64encode(f"{USERNAME}:{APP_PASSWORD}".encode()).decode()
headers = {"Authorization": f"Basic {token}"}
# Create the English post with ?lang=en (works on the free version)
en = requests.post(f"{SITE}/wp-json/wp/v2/posts", params={"lang": "en"},
json={"title": "English post", "content": "...", "status": "draft"},
headers=headers, timeout=60).json()
# Link it to the Japanese post
r = requests.post(f"{SITE}/wp-json/rinblog-poster/v1/translations",
json={"translations": {"ja": ja_id, "en": en["id"]}},
headers=headers, timeout=30)
print(r.status_code, r.json())
On my local site, the response was {"id": 4, "lang": "ja", "translations": {"ja": 4, "en": 5}}. Reading from the English post’s side returned {"en": 5, "ja": 4}, the same group.
What steps does this blog use to publish an English version?
Four steps: create the Japanese post, create the English post with ?lang=en, link them, then read both back to confirm.
▼How this blog publishes an English version
① Create the Japanese post (without a language, it uses the default, Japanese)
② Create the English post with ?lang=en
③ Send {"ja": Japanese ID, "en": English ID} to the linking endpoint
④ Use the GET endpoint to read both posts’ languages and links again
Step 4 matters because the free version does not return the language in responses. The create response alone does not tell you whether the post really became English.
The English post’s status follows the Japanese post. For the cases where a REST API update changes a post’s status, see When Does a WordPress REST API Update Publish a Post?.
Why did I build this instead of buying Polylang Pro?
The only missing piece was linking translations, and the code for that fits in a few dozen lines.
With Pro, the translations field links posts at creation time. On this blog, though, linking was the single operation I lacked. Setting the language on create already works in the free version, and linking only needs a call to pll_save_post_translations().
| Aspect | Custom endpoint | Polylang Pro |
|---|---|---|
| Linking | A separate request | At creation time |
| Language in responses | Read via the GET endpoint | lang and translations fields |
| Maintenance | Needs re-checking after Polylang updates | Maintained as an official feature |
The weak point of the custom approach is maintenance. If a function name changes, it breaks. So the plugin checks that the functions exist and returns a 500 with the reason if they do not. For a blog run by one person, this has been enough.
I explain how I approach the English versions in the “English articles” section of Blog Policy and How I Think About Writing Articles.
Summary
▼Key points
① Even the free version creates an English post when you add ?lang=en on create (undocumented)
② The free version cannot link translations, return language fields, or filter lists by language
③ One endpoint that calls pll_save_post_translations() fills the linking gap
④ Stop language mismatches with a 409, and always read the posts back after creating them
The free version’s REST API does slightly more than the documentation suggests. That “slightly more” is undocumented, so I re-check the results every time I update Polylang.
Tested with WordPress 7.1 and Polylang 3.8.9 (free) on a local site (WordPress Playground CLI), and with read-only requests to rinblog.org, on September 12, 2026.