---
title: "REST API Reference - Endpoints and Auth - PerfLocale"
description: "PerfLocale REST API reference — endpoints for languages, translations, strings, machine translation, XLIFF, imports, jobs, webhooks, and more."
canonical: "https://perflocale.com/docs/rest-api/"
source: "https://perflocale.com/docs/rest-api/"
format: "markdown"
---
# 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/languages
```

Returns 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](#restricting-public-reads).

**Response:**

```json
[
	{
		"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/languages
```

**Requires:** `perflocale_manage_languages`

**Body (JSON):**

```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

```sql
DELETE /perflocale/v1/languages/{slug}
```

**Requires:** `perflocale_manage_languages`

### Reorder Languages

```
POST /perflocale/v1/languages/reorder
```

**Requires:** `perflocale_manage_languages`

**Body:**

```json
{
	"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:**

```json
{ "reordered": 4 }
```

## Translations

### Get Translations for a Post

```
GET /perflocale/v1/translations/post/{id}
```

**Requires:** `edit_post` capability for the specific post.

**Response:**

```json
{
	"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:**

```json
{
	"target_lang": "de",
	"copy_content": true
}
```

**Response:**

```json
{
	"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):**

```json
{
	"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

```sql
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}/language
```

Tag 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:**

```json
{ "slug": "de" }
```

**Response:**

```json
{ "post_id": 42, "slug": "de" }
```

## Strings

### List Translatable Strings

```
GET /perflocale/v1/strings
```

**Requires:** `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:**

```json
[
	{
		"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/scan
```

**Requires:** `manage_options`

**Body:**

```json
{
	"target": "plugins",
	"domain": "woocommerce"
}
```

**Available targets:** `theme`, `plugins`, `parent`

### Machine-Translate Strings

```
POST /perflocale/v1/strings/machine-translate
```

**Requires:** `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-translate
```

**Requires:** `perflocale_use_mt`

**Body:**

```json
{
	"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/estimate
```

Returns 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) - `posts` or `strings`.
-   `post_ids` / `string_ids` - IDs to estimate.
-   `target_lang_ids` - Target language IDs (or `target_langs` slugs).
-   `include_meta` - Include registered meta-field characters (posts).

**Response:**

```json
{
	"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/object
```

Machine-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) - `post` or `term`.
-   `object_id` (required) - Object ID.
-   `target_lang` (required) - Target language slug.
-   `overwrite` - Replace an existing translation (default `false`).
-   `provider` - Provider override.

### Bulk Translate Posts

```
POST /perflocale/v1/translations/bulk-translate
```

Dispatches 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 of `post_types` (default `post`, `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-translate
```

**Requires:** `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:**

```json
{
	"text": "Hello, world.",
	"source_lang": "en",
	"target_lang": "de",
	"provider": "deepl"
}
```

**Response:**

```json
{
	"translated": "Hallo, Welt.",
	"provider": "deepl"
}
```

-   Maximum input length: `50000` characters.
-   Provider response is passed through the same allowlist used by post translation — `wp_kses_allowed_html('post')` minus `object` / `textarea` / `button`, plus `source`, and filterable via `perflocale/mt/allowed_html` — so HTML snippets are safe to apply to block attributes.
-   Shares the per-user hourly budget with `/machine-translate` and the `perflocale/translate-post` Ability (`500`/hour by default, filterable via `perflocale/mt/rate_limit`).
-   When `source_lang` equals `target_lang`, returns the input unchanged.

### Translate a Block from its Source Sibling

```
POST /perflocale/v1/block-translate/from-source
```

**Requires:** `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:**

```json
{
	"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. `value` for quotes, `text` for buttons, `caption / alt / title` for images; `content / text / value` for 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/export
```

**Requires:** `perflocale_import_export`

**Body:**

```json
{
	"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/import
```

**Requires:** `perflocale_import_export`

**Body:**

```json
{
	"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](https://perflocale.com/docs/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/jobs
```

**Requires:** `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:**

```json
{
	"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:**

```json
{
	"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}/cancel
```

**Requires:** `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}/retry
```

**Requires:** `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

```sql
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/webhooks
```

**Requires:** `manage_options`

**Body:**

```json
{
	"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 (use `X-PerfLocale-Attempt` to 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/webhooks
```

**Requires:** `manage_options`

### Delete Webhook

```sql
DELETE /perflocale/v1/webhooks/{id}
```

**Requires:** `manage_options`

## Error Responses

All error responses follow a consistent format:

```json
{
	"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 requested `status` for this post type. Nothing was written. See [what status is allowed to do](#status-rules).

## 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.

```json
{
	"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.
