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.

LevelCapabilityUsed By
PublicNoneGET /languages, GET /languages/{slug}
Translateperflocale_translateTranslation CRUD, strings list, jobs read
Machine Translationperflocale_use_mtPOST /machine-translate, POST /machine-translate/estimate, POST /machine-translate/object, POST /block-translate, POST /block-translate/from-source
Bulk Translationperflocale_manage_translations (and perflocale_use_mt for strings)POST /translations/bulk-translate, POST /strings/machine-translate
Languagesperflocale_manage_languagesLanguage create/update/delete/reorder
Import/Exportperflocale_import_exportXLIFF export/import
Adminmanage_optionsString scan, GET /webhooks, DELETE /webhooks/{id}
Network Adminmanage_network_options on multisite, manage_options on single sitePOST /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 and the perflocale/list-languages ability (which stays listed) 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.

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

An inactive language answers 404 not_found, the same answer as an unknown slug, unless the caller has perflocale_manage_languages.

Create Language

POST /perflocale/v1/languages

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

Deletes a language and unlinks its translations; the posts, terms and media themselves stay. The default language cannot be deleted (400 cannot_delete_default). A successful delete answers {"deleted": true}.

A language that still has content is refused. While any post of the language - a post, page, product or any other post type - is outside the Trash (auto-drafts do not count), the route answers 409 language_has_content and changes nothing. Move those posts to the Trash first, or deactivate the language instead with PUT /languages/{slug} and "is_active": false: an inactive language keeps its translations linked, so shared stock and SKUs keep working. Terms, media and block theme template copies in the language do not block the delete; they are unlinked from their translations and kept.

{
	"code": "language_has_content",
	"message": "The language still has content. Move its posts to the Trash first, or deactivate the language instead (is_active false): an inactive language keeps its translations linked.",
	"data": {
		"status": 409,
		"posts": { "post": 12, "page": 3, "product": 40 },
		"attachments": 5,
		"terms": 8,
		"templates": 2
	}
}

posts counts the blocking posts by post type. attachments, terms and templates (block theme templates and template parts) are counted for information only. When the content cannot be counted, or the delete is rolled back, the route answers 500 delete_failed and nothing is changed.

Reorder Languages

POST /perflocale/v1/languages/reorder

Requires: 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.

These routes handle post translations only: GET, POST, PUT and DELETE with a type other than post in the path (/translations/term/{id}, for example) return 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: perflocale_translate, plus edit rights on the source post (edit_post, or the owning plugin’s own permission for a type such as WPForms forms; see perflocale/object/user_can).

The new post is copied from the group’s default-language post when it has one, which is not necessarily the post named in the URL, and it gets that post’s type. The route checks that post as well, before anything is created: without edit and read rights on it the request is refused with 403 source_forbidden, and without the create capability of its post type (publish_posts for a synced pattern) with 403 cannot_create_type. When the translation already exists - including one another request created a moment earlier - its ID is returned and nothing new is created.

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 statusRequiresRefused with
draft, pendingEdit 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 statusThe target post type’s publish_posts capability403 rest_forbidden
trashThe target post type’s delete_post capability - the same gate the DELETE route uses403 rest_forbidden
Any status registered internal, including auto-draft and inheritNever accepted through this endpoint at any capability level403 rest_forbidden
An unregistered status-Silently ignored; the other fields in the same request still apply

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}/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:

{ "slug": "de" }

Response:

{ "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:

[
	{
		"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:

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

Available targets: theme, plugins, parent

Machine-Translate Strings

POST /perflocale/v1/strings/machine-translate

Requires: perflocale_manage_translations and 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:

{
	"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+).

When no translation exists yet, this call creates one, so it applies the same two checks as Create Translation (403 source_forbidden, 403 cannot_create_type). They run before the rate limit is counted and before any provider call, so a refused request spends no quota.

A password-protected post is sent unless perflocale/mt/send_password_protected refuses it (trigger rest); a refused post answers 403 password_protected before any provider call.

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. For strings, only as many as one run translates are counted (5,000 by default, the perflocale/mt/bulk_string_max_per_dispatch filter); capped in the response says how many were left out.
  • target_lang_ids - Target language IDs (or target_langs slugs).
  • include_meta - Include registered meta-field characters (posts).
  • overwrite - Count strings that already have a translation too, as an Overwrite run sends them (kind=strings).

Response:

{
	"chars": 12400, "items": 34, "skipped_existing": 6,
	"monthly_used": 5200, "monthly_limit": 500000,
	"monthly_remaining": 494800, "would_exceed": false
}

A strings estimate also returns capped: the number of given string IDs past the per-run limit, which the estimate does not count.

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

When no translation exists yet, the same creation checks apply as for Create Translation, before the rate limit is counted: for a post, edit and read rights on the default-language post it is copied from (403 source_forbidden) and its type’s create capability (403 cannot_create_type); for a term, edit_term on the default-language term (403 source_forbidden).

For a post, a password-protected source is sent unless perflocale/mt/send_password_protected refuses it (trigger rest); a refused post answers 403 password_protected before any provider call.

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.

A password-protected source is sent unless perflocale/mt/send_password_protected refuses it, with the trigger rest for post_ids (sent by default) and site_translate for site_wide (not sent by default). A refused source’s pairs count as skipped.

Translate a Single Text Block

POST /perflocale/v1/block-translate

Requires: perflocale_use_mt

Single-string translation - 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",
	"post_id": 42
}

post_id is optional: the post being edited, which the block toolbar sends. When it names a password-protected post the user may edit, the request is checked against perflocale/mt/send_password_protected (trigger editor, sent by default) and a refusal answers 403 password_protected.

Response:

{
	"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, reads each text attribute from where the block type stores it (a paragraph's inner HTML, an image's alt and caption, a quote's citation), and machine-translates them into the editing language. Source language is derived server-side from the source post; the caller never has to supply it. When the source block, or a block above it, is marked “Do not translate”, its text comes back unchanged and nothing is sent to the provider.

Body:

{
	"target_post_id": 117,
	"block_path":     [3, 1, 2],
	"target_lang":    "de",
	"source_attrs":   ["caption", "alt", "title"],
	"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_attrs - optional. The attribute names to read from the source block, in order (the editor sends its own chain for the block type). When omitted, every text attribute of the block type is read (e.g. citation for quotes, caption / alt / title for images).
  • source_attr - optional single-name form of source_attrs. It names a preference: when that attribute is empty, the block type’s own chain is read.
  • provider - optional MT provider override.

Returns attributes (attribute name ⇒ translated text, one entry per attribute that had text), formats (html or text per attribute), skipped_attrs, translated and source_attr (the first attribute), provider, source_post_id, and source: mt, tm (from translation memory), or kept with kept_reason: "do_not_translate". A source block whose text cannot be read for its block type answers 422 unreadable_source_block. A password-protected source post is sent unless perflocale/mt/send_password_protected refuses it (trigger editor, sent by default); a refusal answers 403 password_protected.

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

For a step-by-step walkthrough with copyable curl commands, Application Password setup, what the translator edits in the file, and the error codes, see Export & Import → XLIFF handoff to a translator.

Export XLIFF

POST /perflocale/v1/xliff/export

Requires: perflocale_import_export, plus edit_post on the posts involved.

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/import

Requires: perflocale_import_export

Body:

{
	"xliff": "<?xml version=\"1.0\"?>..."
}

The xliff field carries the text of the file. It can be sent in a JSON body, as above, or as a form field (curl -F 'xliff=<file.xliff'); there is no file-upload field. Returns JSON: { "imported": 5, "skipped": 0, "errors": [] }.

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. A retry also requires the capability of the job’s type and, for another user’s job, every capability that user holds (see Retry Job).

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.

stage is set while a WPML, Polylang or TranslatePress import runs: the key of the step it is on (posts, terms, single_posts, single_terms, term_names, term_check, menus, strings, site, slugs or orders). processed and total then count that stage’s items (total is 0 while not known), and progress stays the overall percent. Other jobs send an empty stage, with processed and total counting the whole job.

Response:

{
	"jobs": [
		{
			"id": "5fc94964-29f6-4697-8c4e-64a8ffb5c72d",
			"type": "string_scan",
			"status": "running",
			"progress": 47,
			"stage": "",
			"processed": 47,
			"total": 100,
			"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}/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) AND the capability of the job’s type

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.

The retrying user needs the capability the job’s type requires on this site (see Background Jobs → Job types); without it the route answers 403 rest_forbidden with “You do not have permission to run this job.” The worker runs a job as the user who dispatched it, so retrying another user’s job also needs every capability that user holds on this site, and on a multisite network super admin rights when that user is a super admin. Otherwise the route answers 403 rest_forbidden and asks you to start the job again yourself. A job whose dispatcher no longer holds the job type’s capability is refused with 403 as well.

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

Requires: manage_network_options on multisite, manage_options on single site

On a multisite network, only an account with manage_network_options can register a webhook. Listing (GET) and removing (DELETE) webhooks take manage_options, so a subsite administrator can still review and delete whatever points at their site. A network that wants subsite administrators to register webhooks can change the capability with the perflocale/webhooks/register_capability filter.

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.

URLs refused at registration: the target is validated before it is stored, and a refusal comes back as 400 unsafe_url with a message naming the actual rule rather than a single catch-all. Registration is refused when the URL cannot be parsed; when the scheme is anything other than http or https; when the URL embeds a username or password (https://user:[email protected]/hook); when it uses a port WordPress will not connect to (80, 443 and 8080, read through core’s own http_allowed_safe_ports filter, so a site that widens it for WordPress widens it here too); when the host cannot be resolved; when it resolves to a private, shared, reserved or loopback address; or when a site policy filter (perflocale/webhooks/url_safe) rejects 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).

Payload: the request body is JSON with event, data and timestamp, the UTC time the event fired (for example 2026-10-08T09:30:00Z). X-PerfLocale-Signature is sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the webhook secret. Only the body is signed; the headers are not.

Verifying a delivery:

  1. Signature. Compute the HMAC-SHA256 of the raw body with your secret and compare it with the value after sha256= in constant time (hash_equals() in PHP). Code running alongside PerfLocale can call \PerfLocale\Api\WebhookController::verify_signature( $body, $secret, $signature_header ).
  2. Freshness. Reject a body whose timestamp lies outside a window. This bounds how long a captured request can be replayed. The window is your choice; 5 minutes is a common value. The timestamp is the same on every attempt and deliveries run from the background queue, so a 5-minute window also rejects a third attempt (sent at least 330 seconds after the first) and any delivery that WP-Cron or a paused webhook holds back for longer. A wider window keeps those. Answer a stale body with a 4xx other than 401 or 403 (400, for example): a 401 or 403 pauses the webhook on the first answer and so holds back its other deliveries too.
  3. Duplicates. De-duplicate on X-PerfLocale-Delivery-ID: every attempt and every redelivery of one delivery carries the same id. Keep each processed id at least as long as your window, and answer a repeat with a 2xx without acting on it again. The header is not signed, so this removes PerfLocale’s own repeats; the timestamp window is what bounds a replay.

Any answer outside 2xx counts as a failed attempt and is retried with the same body, up to three attempts in all. A 401 or 403 also pauses the webhook’s further deliveries at once (circuit breaker).

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). Separately, the target URL is re-validated before each delivery attempt: a host that cannot be resolved is treated as transient and goes through the same retry ladder, while a URL refused on policy grounds (for example one resolving to a private address) is recorded once and not retried. Either way it is never dropped silently. 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

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.
  • unsupported_type (400) - A /translations/{type}/... route was called with a type other than post.
  • 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.
  • unsafe_url (400) - A webhook URL was refused at registration. The message names the rule that refused it: unparseable URL, unsupported scheme, credentials in the URL, a port outside WordPress’s allowed set (80, 443, 8080), an unresolvable host, a private, shared, reserved or loopback address, or a rejection by a site policy filter.
  • weak_secret (400) - A supplied webhook secret was shorter than 32 characters.
  • missing_lang (400) - Required language parameter missing.
  • create_failed (500) - Server error during creation.
  • delete_failed (500) - The deletion could not be saved, so the item is still there.
  • language_has_content (409) - The language still has posts outside the Trash, so it was not deleted. data carries the counts (see Delete Language).
  • source_forbidden (403) - Creating the translation would copy a default-language original you cannot edit and read (for a term, edit). Returned by the create and machine-translation routes when they would create a translation.
  • cannot_create_type (403) - You lack the create capability of the post type the new translation would have, for example publish_posts for a synced pattern.
  • password_protected (403) - The post is password-protected and the perflocale/mt/send_password_protected filter refused to send it: “This post is password-protected and was not sent for machine translation.” Returned by the machine-translation and block-translate routes.
  • 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.

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. While anything hooks the perflocale/edge_worker/config_permission_callback filter, and for any logged-in caller, the response is sent with Cache-Control: private, no-store instead.

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