REST API
PerfLocale exposes a RESTful API under the perflocale/v1 namespace for managing languages, translations, strings, imports, and more.
All endpoints except the public language reads (GET /languages, GET /languages/{slug}) and the opt-in GET /config require authentication via WordPress cookies or application passwords. Write operations require appropriate capabilities.
Base URL: /wp-json/perflocale/v1/
Language scoping: collection queries are language-aware and follow the same URL detection as the frontend. In subdirectory mode, request the language-prefixed REST base (for example /de/wp-json/wp/v2/posts); in query mode, pass ?lang=<slug>. With no language in the URL, anonymous requests get the default language; logged-in users who can edit posts see all languages (mirroring the admin list tables). If you cache REST responses at a CDN or edge, include the language (prefix or lang parameter) in the cache key so languages don’t bleed across.
Authentication & Permissions
Responses are returned as the bare payload shown in each example — there is no { "success": ..., "data": ... } wrapper. Errors follow the standard WordPress REST shape ({ "code": ..., "message": ..., "data": { "status": ... } }). List endpoints paginate via the standard X-WP-Total / X-WP-TotalPages headers.
| Level | Capability | Used By |
|---|---|---|
| Public | None | GET /languages, GET /languages/{slug} |
| Translate | perflocale_translate | Translation CRUD, strings list, jobs read |
| Machine Translation | perflocale_use_mt | POST /machine-translate, POST /machine-translate/estimate, POST /machine-translate/object |
| Bulk Translation | perflocale_manage_translations | POST /translations/bulk-translate |
| Languages | perflocale_manage_languages | Language create/update/delete/reorder |
| Import/Export | perflocale_import_export | XLIFF export/import |
| Admin | manage_options | String scan, webhooks |
All write endpoints are protected by WordPress REST nonce verification.
Restricting Public Reads
The language endpoints (GET /languages, GET /languages/{slug}) are public by default because every field they expose - slug, locale, name, native name, flag, RTL flag, default flag - is already rendered to anonymous visitors via the language switcher, hreflang tags, and URL structure. There is nothing private to protect.
Site owners who still want to require authentication for these reads can hook the perflocale/api/languages_public filter. When it returns false, both endpoints require the read capability (any logged-in user). Write endpoints are unaffected - they always require perflocale_manage_languages.
// Drop this in a MU-plugin or functions.php to lock down /languages.
add_filter( 'perflocale/api/languages_public', '__return_false' );The GET /config endpoint is already opt-in: the route is only registered when Edge Worker Integration is enabled in Settings → Advanced. On a fresh install it responds with 404.
Languages
List Languages
GET /perflocale/v1/languagesReturns all active languages (deactivated languages are omitted). Public by default - no authentication required. Can be gated behind login via the perflocale/api/languages_public filter.
Response:
[
{
"id": "1",
"slug": "en",
"locale": "en_US",
"name": "English",
"native_name": "English",
"flag": "",
"is_default": "1",
"is_active": "1",
"sort_order": "1",
"date_format": "",
"time_format": "",
"text_direction": "ltr"
}
]Get Single Language
GET /perflocale/v1/languages/{slug}Parameters:
slug(string, required) - Language slug (e.g.en,fr,de).
Create Language
POST /perflocale/v1/languagesRequires: perflocale_manage_languages
Body (JSON):
{
"slug": "fr",
"locale": "fr_FR",
"name": "French",
"native_name": "Français",
"flag": "fr",
"is_active": true,
"text_direction": "ltr"
}Update Language
PUT /perflocale/v1/languages/{slug}Requires: perflocale_manage_languages
Body: Any of name, native_name, flag, is_active, sort_order, text_direction (partial updates supported). slug, locale, and is_default cannot be changed via update.
Delete Language
DELETE /perflocale/v1/languages/{slug}Requires: perflocale_manage_languages
Reorder Languages
POST /perflocale/v1/languages/reorderRequires: perflocale_manage_languages
Body:
{
"order": [3, 1, 2, 4],
"offset": 0
}order is a list of language IDs in the desired display order. offset is optional (default 0) and lets you reorder a contiguous slice without sending the full list — useful for paginated UIs.
Response:
{ "reordered": 4 }Translations
Get Translations for a Post
GET /perflocale/v1/translations/post/{id}Requires: edit_post capability for the specific post.
Response:
{
"languages": [
{
"slug": "en",
"bcp47": "en",
"language_id": 1,
"name": "English",
"native_name": "English",
"is_current": true,
"is_default": false,
"has_translation": true,
"post_id": 42,
"status": "published",
"status_label": "Published",
"edit_url": "https://example.com/wp-admin/post.php?post=42&action=edit"
},
{
"slug": "de",
"bcp47": "de",
"language_id": 3,
"has_translation": false,
"post_id": null,
"status": null
}
],
"is_unassigned": false
}is_unassigned is true when the post isn’t attached to any language yet; the editor sidebar uses it to offer “Set as <lang>” instead of “+ Create”. It is always false for terms.
Terms are not readable through this endpoint — GET /translations/term/{id} returns 400 unsupported_type. Term translations are created via POST /machine-translate/object with object_type=term.
Create Translation
POST /perflocale/v1/translations/post/{id}Requires: edit_post capability for the source post.
Body:
{
"target_lang": "de",
"copy_content": true
}Response:
{
"post_id": 99,
"edit_url": "https://example.com/wp-admin/post.php?post=99&action=edit"
}Update Translation
PUT /perflocale/v1/translations/post/{id}/{lang}Requires: edit_post capability for the translated post.
Body (partial updates supported):
{
"title": "Translated Title",
"content": "<p>Translated content.</p>",
"excerpt": "Short description",
"status": "publish"
}What status is allowed to do
Being able to edit a post is not authority to publish or delete it. A status change is mapped to the capabilities of the target post type, exactly as WP_REST_Posts_Controller does, so a custom post type with its own capability_type is judged by its own capabilities rather than by the built-in post ones.
| Requested status | Requires | Refused with |
|---|---|---|
draft, pending | Edit rights only — drafting is the translator’s job | — |
Any status registered protected by another plugin (an editorial workflow state) | Edit rights only. Deliberately supported: core registers draft and pending as protected too | — |
publish, private, future, and any custom public or private status | The target post type’s publish_posts capability | 403 rest_forbidden |
trash | The target post type’s delete_post capability — the same gate the DELETE route uses | 403 rest_forbidden |
Any status registered internal, including auto-draft and inherit | Never accepted through this endpoint at any capability level | 403 rest_forbidden |
| An unregistered status | — | Silently ignored; the other fields in the same request still apply |
auto-draft is refused for a reason worth knowing. WordPress’s own wp_delete_auto_drafts() permanently force-deletes auto-drafts whose post_date is more than seven days old, on a daily cron — post_date, not post_modified. Anything published more than a week ago already qualifies, so accepting that status would have let a role explicitly denied delete rights have content destroyed within a day.
If WordPress itself refuses a status the endpoint accepted — a custom status hidden from both admin status lists, for instance — the response is 400 translation_status_rejected and the post is left exactly as it was. That is a client error, not a server fault, so it is deliberately not a 500.
Delete Translation
DELETE /perflocale/v1/translations/post/{id}/{lang}Requires: delete_post capability for the translated post.
Permanently deletes the translated post and removes the translation link.
Assign Language to Existing Post
POST /perflocale/v1/translations/post/{id}/languageTag a previously-untagged post with a language so it joins (or seeds) a translation group. Only valid for posts that have no language yet — to prevent accidental re-parenting of an existing translation group, posts that already belong to a group return already_assigned (HTTP 409).
Requires: edit_post capability for the post. Only {type}=post is accepted; terms cannot be reassigned this way.
Body:
{ "slug": "de" }Response:
{ "post_id": 42, "slug": "de" }Strings
List Translatable Strings
GET /perflocale/v1/stringsRequires: perflocale_translate
Query Parameters:
domain(string) - Filter by text domain (e.g.woocommerce).per_page(int) - Results per page. Default: 50, max: 100.offset(int) - Pagination offset. Default: 0.
Response:
[
{
"id": 1,
"domain": "woocommerce",
"context": "",
"original": "Add to cart",
"file_path": "templates/single-product/add-to-cart.php",
"line_number": 42
}
]Totals arrive in the X-WP-Total / X-WP-TotalPages response headers.
Scan for Strings
POST /perflocale/v1/strings/scanRequires: manage_options
Body:
{
"target": "plugins",
"domain": "woocommerce"
}Available targets: theme, plugins, parent
Machine-Translate Strings
POST /perflocale/v1/strings/machine-translateRequires: perflocale_use_mt
Dispatches the bulk bulk_string_translate job. Three selection modes:
mode=ids+string_ids— the selected rows.mode=filter+filter(domain,context,search) — the current filter set.mode=all— the entire strings table.
Plus target_lang_ids, an optional provider_id, and overwrite. Routes through the Dispatcher, so the response is either mode: "async" with a job_id or mode: "sync" with the inline result, depending on the bulk_string_translate threshold (default 50 source × target pairs).
Machine Translation
Only available when machine translation is enabled (PerfLocale → Addons, then Settings → Addons → Machine Translation).
Translate a Post
POST /perflocale/v1/machine-translateRequires: perflocale_use_mt
Body:
{
"post_id": 42,
"target_lang": "de",
"provider": "deepl"
}Available providers: deepl, google, microsoft, libretranslate, external_agency, wp_ai_client (whichever are configured; wp_ai_client requires WordPress 7.0+).
Estimate Machine-Translation Cost
POST /perflocale/v1/machine-translate/estimateReturns the character volume a bulk translation would send — before any provider spend. Read-only (POST because the payload carries ID arrays). Estimates mirror the jobs' skip-existing rules and are approximate, not billing-grade.
Requires: perflocale_use_mt, plus read_post on every named post ID
Body (JSON):
kind(required) -postsorstrings.post_ids/string_ids- IDs to estimate.target_lang_ids- Target language IDs (ortarget_langsslugs).include_meta- Include registered meta-field characters (posts).
Response:
{
"chars": 12400, "items": 34, "skipped_existing": 6,
"monthly_used": 5200, "monthly_limit": 500000,
"monthly_remaining": 494800, "would_exceed": false
}Translate One Object
POST /perflocale/v1/machine-translate/objectMachine-translates a single post or term, including registered meta fields. If a translation already exists the endpoint returns status: "exists" instead of overwriting — pass overwrite: true to replace it explicitly.
Requires: perflocale_use_mt plus edit_post / edit_term on the target object
Body (JSON):
object_type(required) -postorterm.object_id(required) - Object ID.target_lang(required) - Target language slug.overwrite- Replace an existing translation (defaultfalse).provider- Provider override.
Bulk Translate Posts
POST /perflocale/v1/translations/bulk-translateDispatches the same bulk machine-translation job as the Translations-page bulk action (existing translations are always skipped). With site_wide: true it starts a resumable, chunked background chain over every published post of the given post types. estimate_only: true returns the cost estimate without dispatching.
Requires: perflocale_manage_translations
Body (JSON):
post_ids- Source post IDs (subset mode).site_wide- Translate all published posts ofpost_types(defaultpost,page).target_lang_ids(required) - Target language IDs.include_meta- Also translate registered meta fields.estimate_only- Estimate without dispatching.
Translate a Single Text Block
POST /perflocale/v1/block-translateRequires: perflocale_use_mt
Stateless single-string translation - no post, no group, no DB writes. Powers the Gutenberg block-toolbar “Translate with MT” action but usable by any caller that wants to translate a snippet without touching the post store.
Body:
{
"text": "Hello, world.",
"source_lang": "en",
"target_lang": "de",
"provider": "deepl"
}Response:
{
"translated": "Hallo, Welt.",
"provider": "deepl"
}- Maximum input length:
50000characters. - Provider response is passed through the same allowlist used by post translation —
wp_kses_allowed_html('post')minusobject/textarea/button, plussource, and filterable viaperflocale/mt/allowed_html— so HTML snippets are safe to apply to block attributes. - Shares the per-user hourly budget with
/machine-translateand theperflocale/translate-postAbility (500/hour by default, filterable viaperflocale/mt/rate_limit). - When
source_langequalstarget_lang, returns the input unchanged.
Translate a Block from its Source Sibling
POST /perflocale/v1/block-translate/from-sourceRequires: perflocale_use_mt + edit_post on target_post_id
Sibling-aware "Fill in from source" endpoint. Locates the corresponding block in the post's source-language sibling using a position-path walk, extracts the relevant text attribute, and machine-translates it into the editing language. Source language is derived server-side from the source post; the caller never has to supply it.
Body:
{
"target_post_id": 117,
"block_path": [3, 1, 2],
"target_lang": "de",
"source_attr": "",
"provider": ""
}target_post_id- the sibling currently being edited (the translation, not the source).block_path- position path into the block tree:[3, 1, 2]= top-level index 3, then inner index 1, then inner index 2. The server walks the same indices in the source post's tree.target_lang- language slug of the post being edited.source_attr- optional. Which attribute to extract from the source block (e.g."content"). When omitted, the server uses a per-block attribute chain (e.g.valuefor quotes,textfor buttons,caption / alt / titlefor images;content / text / valuefor everything else), picks the longest-text candidate, and falls back to the block’s inner HTML when no attribute carries text.provider- optional MT provider override.
Returns the same shape as /block-translate plus the resolved source_attr so the editor knows which attribute to write back.
Import / Export
Bulk data import/export (full-site and per-table) is handled through the admin PerfLocale → Settings → Export & Import screen and the wp perflocale export / wp perflocale import CLI commands — large imports run as background jobs. It is not exposed as a REST API. The XLIFF translation round-trip below is the REST-exposed import/export surface.
XLIFF
Export XLIFF
POST /perflocale/v1/xliff/exportRequires: perflocale_import_export
Body:
{
"post_ids": [42, 43, 44],
"source_lang": "en",
"target_lang": "de"
}Returns JSON: { "xliff": "<xliff 2.0 XML>", "filename": "perflocale_en_de_20260828.xliff" } — the XLIFF 2.0 document is carried in the xliff field.
Import XLIFF
POST /perflocale/v1/xliff/importRequires: perflocale_import_export
Body:
{
"xliff": "<?xml version=\"1.0\"?>..."
}The target language is read from the document’s trgLang attribute (set by the export); there is no separate target_lang parameter.
Background Jobs
The Jobs API exposes the active background-jobs queue. See the Background Jobs doc for the full feature reference (settings, hooks, GC, recovery). All Jobs endpoints are namespaced under /wp-json/perflocale/v1/jobs/.
Authorisation model: read endpoints require perflocale_translate; mutation endpoints (cancel/retry/delete) additionally require either being the user who originally dispatched the job, or holding the perflocale_manage_translations supervisor cap.
List Jobs
GET /perflocale/v1/jobsRequires: perflocale_translate
Returns the bounded active-jobs index — never the full per-job payload (which would be expensive to serialize on every admin-page poll). Each row is a compact summary; fetch /jobs/{id} for full detail when you need it. Callers without perflocale_manage_translations only see the jobs they dispatched themselves.
Response:
{
"jobs": [
{
"id": "5fc94964-29f6-4697-8c4e-64a8ffb5c72d",
"type": "string_scan",
"status": "running",
"progress": 47,
"updated_at": 1735689640
}
],
"engine": "action_scheduler"
}Get Single Job
GET /perflocale/v1/jobs/{id}Requires: perflocale_translate
Returns the full state row including the log ring buffer (last 20 entries), error message (if any), result payload (truncated to 64 KB), and dispatch args.
Response:
{
"id": "5fc94964-29f6-4697-8c4e-64a8ffb5c72d",
"type": "string_scan",
"engine": "action_scheduler",
"status": "running",
"created_at": 1735689600,
"started_at": 1735689601,
"completed_at": 0,
"progress": 47,
"total": 100,
"processed": 47,
"attempts": 1,
"error": "",
"result": {},
"log": [ { "t": 1735689601, "m": "Started" } ],
"created_by": 42,
"created_by_label": "Jane Doe",
"blog_id": 1,
"args": { "mode": "directory", "directory": "wp-content/themes/twentytwentyfour" },
"args_redacted": null
}Args redaction: the args field carries real values only if you dispatched the job yourself OR you hold perflocale_manage_translations. For other users’ jobs args comes back as null and the sibling args_redacted field carries a human-readable notice instead, so file paths / sensitive args don’t leak across the team. Other fields are returned in full to everyone with perflocale_translate.
Cancel Job
POST /perflocale/v1/jobs/{id}/cancelRequires: perflocale_translate AND (creator OR perflocale_manage_translations)
Cancels a queued or running job. Returns the updated job state. Long-running workers cooperatively abort on the next progress tick.
Returns 404 rest_not_found both when the job ID is unknown and when the caller may not mutate that job — the two cases are deliberately indistinguishable so the existence of another translator’s job isn’t observable. A job that has already reached a terminal status returns 409 rest_invalid_state. Callers without perflocale_translate at all get 403 rest_forbidden.
Retry Job
POST /perflocale/v1/jobs/{id}/retryRequires: perflocale_translate AND (creator OR perflocale_manage_translations)
Re-enqueues a failed or canceled job. Resets status to queued and schedules a fresh worker event with the original hook + args. Returns 409 rest_invalid_state for non-terminal jobs.
Delete Job
DELETE /perflocale/v1/jobs/{id}Requires: perflocale_translate AND (creator OR perflocale_manage_translations)
Hard-deletes a job — the row is removed from the jobs table. Only allowed on jobs that have already finished (complete, failed, canceled); running/queued jobs must be canceled first so the runner has a chance to unschedule cleanly.
Webhooks
Register Webhook
POST /perflocale/v1/webhooksRequires: manage_options
Body:
{
"url": "https://example.com/webhook",
"events": ["translation.created", "translation.updated"],
"secret": "3f9c2b7a41d85e6c90aa4b21f7c83d5ea8b1c4d2"
}secret is optional and must be at least 32 characters when supplied. Omit it and PerfLocale auto-generates a strong secret. The secret is returned once, in the registration response only — GET /webhooks never includes it.
Available events: translation.created, translation.updated, content.changed
Delivery headers:
X-PerfLocale-Signature-sha256=<hmac>for payload verification.X-PerfLocale-Event- event name (e.g.translation.created).X-PerfLocale-Delivery-ID- UUID identifying one logical delivery; stable across retry attempts so your receiver can de-duplicate (useX-PerfLocale-Attemptto tell attempts apart).X-PerfLocale-Attempt- delivery attempt number (1, 2, or 3).
Retry behaviour: If your endpoint returns a non-2xx status or is unreachable, PerfLocale retries automatically via WP-Cron - after 30 seconds (attempt 2) and after 5 minutes (attempt 3). After three failures the event is written to the perflocale_webhook_failures WordPress option so admins can inspect it. A successful delivery clears any prior failures for that webhook.
List Webhooks
GET /perflocale/v1/webhooksRequires: manage_options
Delete Webhook
DELETE /perflocale/v1/webhooks/{id}Requires: manage_options
Error Responses
All error responses follow a consistent format:
{
"code": "error_code",
"message": "Human-readable error description.",
"data": {
"status": 400
}
}Common error codes:
rest_forbidden(401 when not logged in, 403 when logged in without the capability) - Missing authentication or insufficient permissions.invalid_type(400) - Invalid object type parameter.not_found(404) - Translation or resource not found.slug_exists/locale_exists(409) - A language with that slug or locale is already registered.invalid_xliff(400) - The uploaded XLIFF could not be parsed.missing_lang(400) - Required language parameter missing.create_failed(500) - Server error during creation.rate_limited(429) - Machine-translation per-user hourly cap reached.rate_limited_site(429) - Machine-translation site-wide hourly cap reached, summed across every user and every entry point.rate_limit_lock_busy(429) - The rate-limit check could not take its lock. Fails closed on purpose; retry after a moment.translation_status_rejected(400) - WordPress refused the requestedstatusfor this post type. Nothing was written. See what status is allowed to do.
Edge Configuration
Public JSON describing URL mode, default language, detection order, and the full active-language matrix. Used by Cloudflare Workers / Vercel Edge / Netlify Edge to pre-route visitors before PHP.
Availability: only registered when the Edge Worker Integration toggle is on (Settings → Advanced) or the perflocale/edge/enabled filter returns true.
GET /perflocale/v1/config
Public endpoint, no authentication. Response is sent with Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400 and an ETag so edges can revalidate cheaply.
{
"version": "1.0.0",
"url_mode": "subdirectory",
"url_prefix_type": "slug",
"default_slug": "en",
"hide_default_prefix": true,
"excluded_paths": [ "/wp-json/", "/wp-admin/", "/wp-login.php" ],
"detection_order": [ "url", "cookie", "browser", "default" ],
"edge_hint_header": "X-PerfLocale-Lang",
"edge_hint_cookie": "perflocale_edge_lang",
"languages": [
{
"slug": "en",
"locale": "en_US",
"hreflang": "en-US",
"prefix": "en",
"domain": "",
"text_direction": "ltr",
"is_default": true
}
]
}url_mode is one of subdirectory | subdomain | domain | query (returned verbatim by the plugin’s get_url_mode()). In query mode the site routes non-default languages by a ?lang=<slug> query parameter instead of a path prefix — the default language always keeps clean, un-prefixed URLs — so an edge worker that pre-routes by path segment must branch on this value and fall back to query-string handling when it is query.
A reference Cloudflare Worker implementation ships in assets/js/edge-helper.js inside the plugin folder.