This document describes the REST API exposed by the plugin. It is based on src/routes.php and on the logic actually implemented in the controllers.
Base REST namespace:
/wp-json/onpage/v1
- Endpoint index
- Authentication
- General conventions
- Handling
acf_fields - Error format
- Field Groups
- Post Types
- Posts
- WooCommerce: brands, attributes, attribute terms, categories, tags, products, variations
- Media
- Taxonomies
- Terms
- Languages
- Maintenance
- cURL examples
- Implementation notes
Every endpoint at a glance. Paths are relative to the base namespace /wp-json/onpage/v1. Click an endpoint to open its full specification.
In the Notes column, Paginated marks the only two endpoints that paginate. ?ignore marks the DELETE endpoints that accept the ?ignore flag.
| Area | Method | Endpoint | Description | Notes |
|---|---|---|---|---|
| Field Groups | GET |
/field-groups |
List all ACF field groups | |
| Field Groups | POST |
/field-groups |
Create or update ACF field groups | |
| Field Groups | DELETE |
/field-groups |
Delete field groups by ID or title | ?ignore |
| Post Types | GET |
/post-types |
List all ACF post types | |
| Post Types | POST |
/post-types |
Create or update ACF post types | |
| Post Types | DELETE |
/post-types |
Delete post types by ID or key | ?ignore |
| Posts | GET |
/posts |
List posts, with filters and exact-title search | Paginated |
| Posts | GET |
/posts/{id} |
Get a single post | |
| Posts | POST |
/posts |
Create or update posts in batch | |
| Posts | DELETE |
/posts |
Delete posts by ID, local_key or post type |
?ignore |
| WooCommerce | GET |
/woocommerce/brands |
List WooCommerce brands | |
| WooCommerce | POST |
/woocommerce/brands |
Create or update brands | |
| WooCommerce | DELETE |
/woocommerce/brands |
Delete brands by local_key |
?ignore |
| WooCommerce | GET |
/woocommerce/attributes |
List global product attributes | |
| WooCommerce | POST |
/woocommerce/attributes |
Create or update global product attributes | |
| WooCommerce | DELETE |
/woocommerce/attributes |
Delete global product attributes by local_key |
?ignore |
| WooCommerce | GET |
/woocommerce/attributes/{attribute}/terms |
List the terms of an attribute | |
| WooCommerce | POST |
/woocommerce/attributes/{attribute}/terms |
Create or update the terms of an attribute | |
| WooCommerce | DELETE |
/woocommerce/attributes/{attribute}/terms |
Delete attribute terms by local_key |
?ignore |
| WooCommerce | GET |
/woocommerce/categories |
List product categories | |
| WooCommerce | POST |
/woocommerce/categories |
Create or update product categories | |
| WooCommerce | DELETE |
/woocommerce/categories |
Delete product categories by local_key |
?ignore |
| WooCommerce | GET |
/woocommerce/tags |
List product tags | |
| WooCommerce | POST |
/woocommerce/tags |
Create or update product tags | |
| WooCommerce | DELETE |
/woocommerce/tags |
Delete product tags by local_key |
?ignore |
| WooCommerce | GET |
/woocommerce/products |
List WooCommerce products | |
| WooCommerce | POST |
/woocommerce/products |
Create or update products | |
| WooCommerce | DELETE |
/woocommerce/products |
Delete products by local_key |
?ignore |
| WooCommerce | GET |
/woocommerce/variant-products |
List product variations | |
| WooCommerce | POST |
/woocommerce/variant-products |
Create or update variations of a variable product | |
| WooCommerce | DELETE |
/woocommerce/variant-products |
Delete variations by local_key |
?ignore |
| Media | GET |
/media |
List Media Library attachments | Paginated |
| Media | POST |
/media |
Upload files (multipart/form-data) |
|
| Media | POST |
/media/link |
Import files by URL and link them to a post's ACF field | |
| Media | DELETE |
/media |
Delete attachments by ID | ?ignore |
| Taxonomies | GET |
/taxonomies |
List all ACF taxonomies | |
| Taxonomies | POST |
/taxonomies |
Create or update ACF taxonomies | |
| Taxonomies | DELETE |
/taxonomies |
Delete taxonomies by ID or slug | ?ignore |
| Terms | GET |
/terms |
List or search terms, of one taxonomy or of all | |
| Terms | POST |
/terms |
Create or update terms in batch | |
| Terms | DELETE |
/terms |
Delete terms by ID or local_key |
?ignore |
| Languages | GET |
/languages |
List the active WPML languages and the default one | |
| Maintenance | DELETE |
/indexes |
Remove all local_key associations |
|
| Maintenance | POST |
/migration |
Run the plugin's data migrations |
Path parameters:
{id}and{attribute}each match a single path segment (no/).{attribute}accepts an attribute ID, or a slug with or without thepa_prefix.colorandpa_colorboth work.
Every endpoint requires a Bearer token.
Required header:
Authorization: Bearer <token>| Error | When |
|---|---|
500 onpage_auth_not_configured |
No API token is configured in the WordPress options (onpage_auth_token). |
401 onpage_auth_missing_token |
The Authorization header is missing or does not contain a valid Bearer token. |
403 onpage_auth_invalid_token |
The token sent does not match the configured one. |
Once the token is accepted, every endpoint returns 500 acf_version_unsupported when the active Advanced Custom Fields is older than 6.1. The message names the active version. Without ACF at all, the plugin registers no routes, so WordPress answers 404 rest_no_route.
The token is generated from the On Page® page in the WordPress admin. Only administrators can access that page (capability manage_options). Generating and regenerating the token is also protected by a CSRF nonce. No REST endpoint can read or write the token: it is managed exclusively from the admin UI.
-
Batch writes. Almost every write endpoint works in batch: the expected JSON body is an array. See Request bodies.
-
Exceptions.
POST /mediausesmultipart/form-dataand accepts one or more files in the same request.POST /media/linktakes a single JSON object.POST /migrationandDELETE /indexestake no body. -
Fail fast. If one element of a batch fails, the request stops immediately and returns a
WP_Error. -
?ignoreon DELETE. MostDELETEendpoints accept?ignoreto skip elements that are not found. See?ignoreon DELETE. -
Taxonomy identifier. The term endpoints identify the taxonomy with the
taxonomyparameter (query string onGET/DELETE /terms, body field onPOST /terms). It accepts either the taxonomy slug (e.g.product_cat,brand,pa_color) or the numeric ACF ID of the taxonomy. The slug takes precedence and is recommended: it is stable across environments (the ACF ID depends on creation order) and it also covers non-ACF taxonomies (WooCommerceproduct_cat/product_tag/pa_*). The numeric ID is still supported for backward compatibility. -
Multilingual values. When WPML is active, some fields can be sent as a language map
{ "<lang>": <value> }. -
Exact post types.
/postsand/post-typesuse the exact post type sent in the payload. No prefix is added. -
Maps are always objects. In responses,
acf_fieldsandtranslationsare always JSON objects. When they are empty (no ACF values, no WPML translations) they are{}, never[],falseornull. -
Numeric IDs. By default a WordPress ID (
id,post_id,parent,parent_id, an attachment ID) is read with a lenient rule:- a positive JSON integer, a whole-number float (
12.0) or a string of digits ("12", surrounding spaces allowed) is accepted; - booleans, decimals (
1.5,"1.9") and other notations ("1e3","12abc") are never cast to an ID. They count as invalid, sotruecan never address object 1. - on a
POST, anidthat is present and invalid answers400 invalid_param.null,0and"0"still mean "no ID" (POST /woocommerce/productsrejects0). It is never ignored, so it cannot create a new object instead of updating one.
Some endpoints are stricter:
DELETE /field-groups,/post-types,/taxonomiesand/postsread an ID only from a JSON integer. A string, even"12", is a title, key, slug or post type.DELETE /termsaccepts a positive JSON integer or a string of digits only, as a plain element or asidin an object. Leading zeros are allowed ("012"). Spaces and floats (12.0) are rejected.
- a positive JSON integer, a whole-number float (
These rules apply to the POST and DELETE endpoints of field-groups, post-types, posts, taxonomies, terms and every woocommerce/* resource, and to DELETE /media. They do not apply to POST /media (multipart), /media/link, /migration or /indexes.
- The body must be a JSON array. A missing body, a body that is not JSON, a scalar or an object (named keys, or the empty object
{}) returns400 invalid_paramwith the message<Prefix> :: Request body must be a JSON array. - Send
Content-Type: application/json. WordPress parses the body as JSON only with that header. Without it the body counts as missing, so the request also fails with400 invalid_param. - An empty array is valid.
[]does nothing and returns200([]onPOST,nullonDELETE). - Every
POSTelement must be a non-empty JSON object. A scalar,null, a list or{}returns400 invalid_paramwith the message<Prefix> :: Element N :: Invalid payload; expected a non-empty JSON object. - Every
DELETEelement is validated. An element of the wrong type returns400before anything is looked up. The accepted types and the error code are listed under eachDELETEendpoint.
<Prefix> names the resource: FieldGroup, PostType, Post, Media, Taxonomy, Term, WooCommerce Brand, WooCommerce Attribute, WooCommerce Attribute Term, WooCommerce Category, WooCommerce Tag, WooCommerce Product or WooCommerce Variant Product. N is the 0-based position of the element in the array.
?ignore makes a DELETE skip elements that are not found, instead of failing with 404 not_found.
- It is enabled by the mere presence of the
ignorequery parameter, whatever its value.?ignore,?ignore=1and?ignore=0all enable it. - It is supported by
DELETE /field-groups,/post-types,/posts,/taxonomies,/terms,/mediaand everyDELETE /woocommerce/*. - It is not supported by
DELETE /indexes, which has no body. - It only skips missing elements. An element of the wrong type still returns
400, and a real delete failure still returns500. - Examples of what it skips: an integer ID that matches nothing, a
local_keyheld by no object, a title, key or slug that matches nothing. OnDELETE /taxonomiesa slug that is not an ACF taxonomy of this plugin (for exampleproduct_cat) is skipped and nothing is changed. OnDELETE /postsa post type string that is not registered is skipped and nothing is deleted.
local_key is the external On Page® identifier. It can be a positive integer or a non-empty string.
- Input (payload and query string):
- The value is trimmed.
- Only the empty string,
"0"and non-scalar types are rejected, with400 invalid_param(400 input_invalidonDELETE /postsandDELETE /terms). DELETE /postsandDELETE /termsalso reject booleans with400 input_invalid, sotruenever deletes the object withlocal_key"1".- Integers and numeric strings are equivalent (
123≡"123"; this is how WordPress stores meta values). The same object is resolved whatever type you send. - A text key (e.g.
"SKU-ABC") is kept as is, except for surrounding whitespace.
- Output (HTTP response):
- A key that is a canonical integer is returned as an integer (backward compatible with existing consumers).
- A non-numeric key is returned as a string.
- A key that is not set is returned as
null. - This applies to every endpoint that exposes it:
posts,products,variant-products,terms/brands/categories/tags/attributes/{}/terms/attributes.
- Posts expose
local_keyas a top-level response field (int|string|null). It is not an ACF field: it is a technical post meta (onpage_local_key) and does not appear insideacf_fields. - Term references:
- For products (
brand,categories,tags,terms), references are local_keys (integers or strings). Slugs are not supported. - For posts (
terms/term), a reference can be a local_key (integer or string) or a slug. It is first looked up as a local_key, otherwise it is treated as a slug. - The
parentof WooCommerce categories is also a local_key (integer or string).
- For products (
An existing term with the same name under the same parent but with a different local_key is not modified, so that a term from another source is never overwritten.
WordPress does not allow two sibling terms with the same name unless the caller provides a free explicit slug. In that case the upsert therefore creates a separate term with the requested name and a technical slug (<slug-base>-<language>, with a numeric suffix if already taken). The upsert fails only if no technical slug is available. It then returns 500 request_failed with the message Term :: Element N :: <WordPress message> (Term :: Element N :: Unable to upsert translation '<lang>' :: <WordPress message> for a translation). The code term_exists itself is not returned.
The destination then holds two terms with the same name and different local_keys. This is the typical symptom of local_keys that are out of sync between source and destination (e.g. the source regenerated its keys). To fix it, call DELETE /indexes and then run a top-down re-import. The re-import re-adopts the existing terms and rewrites their local_key instead of duplicating them.
Every field that accepts a remote file URL also accepts the attachment_id of a file already in the Media Library, for example one uploaded earlier with POST /media. It must be a JSON integer, not a string. The one exception is acf_fields, where a string of digits ("123") is also read as an attachment_id.
With an attachment_id the plugin downloads nothing. It only checks that the ID matches an existing attachment and assigns it directly. If the field has a parent post, product or variation, the attachment is also attached to that parent, as with a URL import.
This applies to:
imageandgalleryofPOST /woocommerce/productsandPOST /woocommerce/variant-products;thumbnailofPOST /woocommerce/brandsandPOST /woocommerce/categories;downloads[].file/downloads[].urlofPOST /woocommerce/products;filesofPOST /postsandPOST /media/link;- every ACF field of type
image/fileinsideacf_fields, on any endpoint (top level, or inside arepeateror agroup).
A value that is neither an existing attachment_id nor a valid URL is rejected with 400. The error code depends on the field: input_invalid for files of POST /posts and for acf_fields, invalid_param everywhere else. In acf_fields only string and integer values are checked (see Handling acf_fields).
These rules apply to every URL the plugin downloads: the fields listed above and POST /media/link.
URL validation. The value is trimmed and passed through esc_url_raw(), then it must pass wp_http_validate_url(). A value that fails is not a valid URL.
Deduplication. A URL already imported is not downloaded again. The existing attachment is reused, but only while its file still exists on disk. If the file is gone, the stale index entry is dropped and the URL is downloaded again.
- For an On Page® storage URL the plugin also extracts the storage segment (
<token>[.<format>]) and saves it in the_onpage_file_tokenmeta. The lookup tries the segment first, then the exact URL. A file renamed on On Page® changes URL but not segment, so it is reused too. - Only two URL shapes carry a segment, and only on the host
onpage.itor one of its subdomains:https://storage.onpage.it/<token>[.<format>]/<name>;https://<host>/api/storage/<token>[.<format>]/<name>, where<host>isonpage.ititself or a subdomain, for examplehttps://app.onpage.it/api/storage/….
- Every other URL has no segment. It is reused by exact URL only (
_onpage_source_urlmeta).
Allowed file types. A remote import is always allowed for these extensions, even when the site restricts uploads:
- images:
jpg,jpeg,jpe,gif,png,bmp,tif,tiff,webp,avif,heic,heif,ico; - video:
mp4,m4v,mov,qt,wmv,avi,mpeg,mpg,mpe,ogv,webm,3gp,3gpp,3g2,3gp2; - audio:
mp3,m4a,m4b,aac,wav,ogg,oga,flac,wma,mka; - documents:
pdf,rtf, Microsoft Office (doc,docx,xls,xlsx,ppt,pptxand their variants), OpenDocument (odt,ods,odp, …), Apple (key,numbers,pages); - text:
txt,csv,tsv; - archives:
zip,7z,rar,tar,gz,gzip.
SVG files are sanitized before import and allowed as image/svg+xml. If sanitizing fails, the import fails with 500 request_failed.
Any other extension (for example html, js or exe) follows the site's normal WordPress upload rules. It is usually rejected with 500 request_failed and the WordPress message Sorry, you are not allowed to upload this file type.
Public addresses only. The URL and every redirect must resolve to public IP addresses. The check looks at the host's IPv4 addresses only. Private, loopback, link-local (including the cloud metadata address 169.254.169.254), shared (100.64.0.0/10) and reserved ranges are refused:
- a URL whose host resolves to such an address returns
400 invalid_param; - a redirect to such an address fails the download with
500 request_failed.
The site's own host and hosts allowed by the WordPress filter http_request_host_is_external are exempt. With cURL, the checked IPv4 address is also used for the connection, so a DNS answer that changes in between cannot reach an internal host.
Size limit. A remote file larger than 512 MB returns 413 file_too_large, and nothing is imported. A site can change the limit with the onpage_remote_media_max_bytes filter (bytes; 0 or a negative value removes it).
Timeout. A download times out after 45 seconds. Files in downloads of POST /woocommerce/products use a 12-second timeout.
Only two endpoints paginate: GET /posts and GET /media. Every other list endpoint returns all matching items in a single response.
Paginated endpoints accept:
?per_page=<n>— page size. Default100, maximum100.?page=<n>— page number. Default1.
Invalid values, 0 and negatives fall back to the defaults.
They return two response headers, following the same convention as the WordPress core REST API:
X-WP-Total— total number of items that match the filters, regardless of the page.X-WP-TotalPages— total number of pages, calculated asceil(X-WP-Total / per_page).
The client can tell from the current response whether it is on the last page (page >= X-WP-TotalPages). There is no need to request one extra page and wait for an empty array.
Example walk-through (250 items, per_page=100 → 3 pages):
GET /media?page=1&per_page=100 → X-WP-Total: 250, X-WP-TotalPages: 3 (continue)
GET /media?page=2&per_page=100 → X-WP-Total: 250, X-WP-TotalPages: 3 (continue)
GET /media?page=3&per_page=100 → X-WP-Total: 250, X-WP-TotalPages: 3 (page == X-WP-TotalPages → stop)
Client pseudocode:
page = 1
items = []
loop:
response = GET /media?page={page}&per_page=100
items += response.body
if page >= response.headers['X-WP-TotalPages']:
break
page += 1
On GET /posts the headers are conditional. The ?id= and ?title= branches are lookups, not listings, and return no headers. A missing X-WP-Total there means "you are on a lookup branch", not "the list is empty".
An invalid ?id= (not a positive ID) or an empty ?title= is ignored. The request then falls through to the paginated listing, with the headers.
| Endpoint | Paginated | per_page/page |
X-WP-Total/X-WP-TotalPages |
Notes |
|---|---|---|---|---|
GET /posts |
✅ | ✅ | ✅ | Not paginated when using ?id= or ?title=: these always return every match, with no pagination headers. |
GET /media |
✅ | ✅ | ✅ | — |
GET /terms |
❌ | — | — | Always returns every matching term. taxonomy is optional: without it every taxonomy is listed. parent_id/parent_lk filter the list to direct children. |
GET /field-groups |
❌ | — | — | Always returns every field group (acf_get_field_groups()). |
GET /taxonomies |
❌ | — | — | Always returns every ACF taxonomy (acf_get_acf_taxonomies()). |
GET /post-types |
❌ | — | — | Always returns every ACF post type (acf_get_acf_post_types()). |
GET /woocommerce/brands |
❌ | — | — | Always returns every brand. |
GET /woocommerce/attributes |
❌ | — | — | Always returns every attribute (wc_get_attribute_taxonomies()). |
GET /woocommerce/attributes/{attribute}/terms |
❌ | — | — | Always returns every term of the attribute. |
GET /woocommerce/categories |
❌ | — | — | Always returns every category. |
GET /woocommerce/tags |
❌ | — | — | Always returns every tag. |
GET /woocommerce/products |
❌ | — | — | numberposts => -1, explicitly unlimited. Without a query it returns all products. |
GET /woocommerce/variant-products |
❌ | — | — | numberposts => -1, explicitly unlimited. Without a query it returns all variations. |
It depends on how many items each endpoint is expected to return:
- Paginated:
postsandmedia(Media Library) can easily grow to thousands of items on a real site. Pagination avoids heavy queries and huge JSON responses. - Not paginated: terms, taxonomies, field groups and post types are usually small, bounded sets (tens, at most hundreds of items). Returning everything in one response was considered acceptable.
Notable exception: WooCommerce products and variations can be as numerous as posts, but they are not paginated. A very large catalog can therefore produce heavy responses on GET /woocommerce/products and GET /woocommerce/variant-products.
These write endpoints accept an acf_fields object:
POST /postsPOST /woocommerce/productsPOST /woocommerce/variant-productsPOST /woocommerce/brandsPOST /woocommerce/categoriesPOST /woocommerce/tagsPOST /woocommerce/attributes/{attribute}/termsPOST /terms
They all share the same assignment logic, centralized in Acf::updateFieldValue. For each key inside acf_fields, the behaviour depends on the ACF field type.
Field names are case-insensitive. POST /field-groups stores field names through sanitize_key(), which lowercases them. A key in acf_fields is matched against the exact stored name first, then against its sanitize_key() form. So "SKU" finds a field stored as sku. The same rule applies to the sub-field names inside repeater rows and group objects.
Simple fields (text, textarea, select, number, url, email, …)
- The value is passed to ACF unchanged.
image / file
- The value can be a valid URL string (imported into, or reused from, the Media Library).
- It can also be the
attachment_idof a file already in the Media Library (e.g. uploaded withPOST /media). A JSON integer or a string of digits ("123") both work. - In both cases the field stores the resulting
attachment_id. - In the
postcontext the attachment is also attached to the parent post. For a URL this happens through the download. For anattachment_idthere is no download: the attachment is only verified and re-attached. - To clear the field, pass
null, an empty string,0or"0". - Any other string or integer returns
400 input_invalidwith the messageField '<name>' in acf_fields must be an existing attachment ID or a valid URL. This covers anattachment_idthat matches no attachment and a malformed URL. - Only strings and integers are validated. A value of another type (a float, a boolean, an object) is passed to ACF unchanged. Inside a repeater or a group,
<name>is the path of the sub-field, for examplecertifications[0][attachment]ordatasheet[attachment].
tab
- ACF tabs are UI separators with no value. Keys sent for a
tabfield are silently ignored. - Tabs do not appear on read (
get_fields()does not expose them).
repeater
- The value must be a list of objects, one per row. Each object contains the row's sub-fields. Example:
"certifications": [{"name": "CE marking", "year": 2024}, {"name": "VOC A+"}]. image/filesub-fields with a URL orattachment_idare resolved to anattachment_idwith the same rules as top-level fields,400 input_invalidincluded.- Nested
repeaterandgroupsub-fields are supported recursively. tabsub-fields are ignored.- A WPML language map must be applied to the whole repeater (
"certifications": {"it": [...rows...], "en": [...rows...]}), not to individual sub-fields. Language maps inside a row are not supported. - If a repeater payload is not a list of objects, the endpoint returns
400 invalid_paramwith the messageRepeater '<name>' must be a list of rowsorRepeater '<name>' row N must be an object.
group
- The value is an object with the group's sub-fields, for example
"datasheet": {"title": "…", "attachment": 1234}. - Sub-fields are handled like the sub-fields of a repeater row:
image/filesub-fields with a URL orattachment_idare resolved to anattachment_idwith the same rules as top-level fields,400 input_invalidincluded (the message names the path, for exampledatasheet[attachment]). - Nested
repeaterandgroupsub-fields are supported recursively.tabsub-fields are ignored. Other sub-field values are passed to ACF unchanged. - To clear the group, pass
nullor an empty string. - If the value is not an object (a string, a number, a non-empty list), the endpoint returns
400 invalid_paramwith the messageGroup '<name>' must be an object. - A WPML language map must be applied to the whole group.
flexible_content
- The value is a list of rows. Each row is an object whose
acf_fc_layoutis the name of one of the field's layouts, plus the sub-fields of that layout, for example"blocks": [{"acf_fc_layout": "hero", "title": "…", "image": "https://…"}, {"acf_fc_layout": "quote", "text": "…"}]. - Sub-fields are resolved against the row's layout like the sub-fields of a repeater row:
image/fileURLs orattachment_ids, nestedrepeater,groupandflexible_content. - The endpoint returns
400 invalid_paramif the value is not a list (Flexible content '<name>' must be a list of rows), a row has noacf_fc_layout, or the layout is unknown (Flexible content '<name>' row N has unknown layout '<layout>'). - To clear the field, pass
nullor an empty string. A WPML language map must be applied to the whole field.
group whose sub-fields all have short names
- A group such as
{"lat": 45.1, "lng": 9.2},{"sku": …, "ean": …}or{"url": …, "alt": …}is written as sent. Its keys are not language codes, so it is not read as a language map. - A key counts as a language code only when it is an active WPML language or an ISO 639-1 code (for example
en,it,pt-br). See How a language map is detected. - The exception is a group whose sub-field names are all two-letter language codes (for example
{"id": …}). It is read as a language map: give it another sub-field, or rename the sub-field.
Any value in acf_fields can be sent as a WPML language map { "<lang>": <value> }. Each language is resolved independently. The value for a language can be a string, a number, null/"", a list of objects (repeater) or an object (group). Different languages in the same map can have different types.
"acf_fields": {
"product_subtitle": { "it": "Sottotitolo", "en": null },
"certifications": {
"it": [ { "name": "Marcatura CE", "year": 2024 } ],
"en": [ { "name": "CE marking", "year": 2024 } ]
}
}Consistency with the ACF type is still checked after the language is resolved:
- For a
repeaterfield, the resolved value of every language must still be a list of objects. A single object or a string for that language returns400 invalid_param. - For
image/file, the URL →attachment_idconversion applies. tabfields are still ignored.- On
POST /posts, theacf_fields,filesandtermsof each translation are written in that translation's WPML language context, on insert as on update. Term slugs therefore resolve to the term of the translation's language.
The language map always goes at field level (or on the whole repeater/group), never on individual sub-fields.
On read (GET), repeaters are returned natively by \get_fields() as lists of objects. Tabs do not appear.
Except for image, file, repeater, group, flexible_content and tab (described above), the plugin passes the value to ACF as it is. It does not convert or validate it. Send the shape ACF stores for that field type. The table lists the common types; the ACF documentation is the reference.
| ACF type | Value to send | Example |
|---|---|---|
text, textarea, wysiwyg, email, url, password |
a string | "CE marking" |
number, range |
a number | 49.9 |
true_false |
1 or 0 |
1 |
select |
one choice value; a list of values when the field allows multiple values | "red", ["red", "blue"] |
radio, button_group |
one choice value | "red" |
checkbox |
a list of choice values | ["wifi", "bluetooth"] |
date_picker |
a string in Ymd format |
"20260929" |
date_time_picker |
a string in Y-m-d H:i:s format |
"2026-09-29 14:30:00" |
time_picker |
a string in H:i:s format |
"14:30:00" |
color_picker |
a hex color string | "#c0392b" |
link |
an object with title, url and target |
{"title": "Datasheet", "url": "https://…", "target": "_blank"} |
gallery |
a list of attachment_ids |
[812, 813] |
post_object, page_link |
a WordPress post ID; a list of IDs when the field allows multiple values | 321 |
relationship |
a list of WordPress post IDs | [321, 322] |
taxonomy |
a WordPress term ID, or a list of term IDs | [10, 11] |
user |
a WordPress user ID | 1 |
Choice values are the keys of the field's choices, not their labels.
Relational fields take WordPress IDs, not local_keys. The plugin does not resolve a local_key inside post_object, relationship, page_link or taxonomy. Look the IDs up first:
- posts:
GET /posts?local_key=<key>&type=<type>, or the IDs returned byPOST /posts; - terms:
GET /terms?taxonomy=<taxonomy>, which returns each term with itslocal_key.
So the related objects must be imported before the objects that point at them.
A gallery is not imported from URLs. Upload or link each file first (POST /media, or a URL in a files slot), then send the list of attachment_ids.
Clearing a field. Send null or "" for a single value, and [] for a list (checkbox, gallery, relationship, a multiple select). image and file also accept 0 or "0". A field left out of acf_fields is not changed.
Errors are returned as WP_Error using the standard WordPress REST shape:
{
"code": "duplicate_title",
"message": "Post :: Element 0 :: Title 'Example' already exists for PostType 'news'",
"data": {
"status": 409
}
}In this document, errors are written as <status> <code>, for example 400 invalid_param.
Most messages name the element with Element N: the 0-based position of the element in the request array, as in the 409 duplicate_title above.
An unexpected PHP error or exception during a request (for example one thrown by WooCommerce or WordPress) is returned in the same shape as 500 request_failed, with the original message.
Returns all ACF field groups.
Pagination: none. The response always contains all field groups; there is no per_page/page.
Response 200:
[
{
"ID": 123,
"key": "group_example",
"title": "Example Group"
}
]Creates or updates one or more ACF field groups.
Body:
[
{
"title": "Product Fields",
"key": "group_product_fields",
"locations": [
{
"param": "post_type",
"operator": "==",
"value": "post_product"
}
],
"description": "Extra fields for products",
"fields": [
{
"key": "sku",
"label": "SKU",
"name": "SKU",
"type": "text"
},
{
"key": "price",
"label": "Price",
"name": "Price",
"type": "number"
}
]
}
]Group fields:
| Field | Required | Description |
|---|---|---|
title |
yes | Group title. Missing, empty or not a string → 400 missing_title. |
key |
no | ACF group key (convention group_...). If missing, it is generated from title (group_ + title slug). |
locations |
no | ACF location rules, as a flat list of {param, operator, value} objects. The list is one ACF rule group: all its rules must match (AND). See Location rules. |
description |
no | Group description. |
fields |
no | List of fields (see below). If omitted or null, the group's existing fields are left unchanged. [] removes them all. A value that is not a list returns 400 invalid_param. |
active |
no | Whether the group is active. Default true. |
position |
no | ACF position. Default normal. |
style |
no | ACF style. Default default. |
label_placement |
no | Default top. |
instruction_placement |
no | Default label. |
hide_on_screen |
no | List of screen elements to hide. Default []. |
menu_order |
no | Integer. Default 0. |
Upsert rules:
- If a field group with the same
keyor the sametitlealready exists, it is updated instead of creating a new one. - When
keyis not sent and a group with thattitleexists, its existing key is reused (it is not changed). - On update, when
fieldsis sent, the group's fields are replaced by the payload. Fields with the same technical key keep their internal ACF field key. Fields no longer present are removed from the field group. Whenfieldsis omitted, only the group's own settings (title, description, locations…) are updated. - The group's own settings are always rewritten from the payload. A setting that is omitted,
locationsincluded, goes back to its default (forlocations: no rules).
Per-field rules:
keyis the technical ACF key, saved as the field name. It is the key to use in theacf_fieldsobjects ofPOST /posts,POST /woocommerce/productsand the term endpoints.nameandlabelare descriptive. Iflabelis missing,nameis used.- If
keyis missing,nameis used as the technical key (backward compatibility). - Every field is sanitized at least for
key,label,nameandtype. typedefaults totext.- A field that is not an object returns
400 invalid_paramwith the messageFieldGroup :: Field N must be an object. repeaterandgroupfields take their children insub_fields.flexible_contentfields take a list oflayouts. Each layout has aname(required;keyis used whennameis missing), an optionallabel,display(block,tableorrow; defaultblock),min,max, and its ownsub_fields. A layout keeps its internal ACF key across updates, matched byname, so saved values stay attached. A layout no longer sent is removed with its sub-fields.layoutsthat is not a list, or a layout without anameorkey, returns400 invalid_param.
If WPML is active, the group is flagged with an ACFML translation mode.
locationsis a flat list. It becomes a single ACF rule group, so every rule in it must match (AND). There is no way to send alternatives (OR) in one group.- For the same fields on two targets, create two field groups. For example, a field on both
productandproduct_variationneeds one group withpost_type == productand one withpost_type == product_variation. A single group with both rules would match nothing. - The fields of the group are accepted in
acf_fieldsof every post type or taxonomy the rules can match:==and!=onpost_typeortaxonomy, the valueall, and post rules without a post type (post_template,post_category, …, matching every post type) or page rules (page_template, …, matchingpage). - A group without
locationsis saved (200), but its fields match nothing. Writing them later fails with400 invalid_param(ACF field '<name>' not found for <context> '<target>').
Example: a field group on a taxonomy, then a term that fills it.
[
{
"title": "Collection Fields",
"key": "group_collection_fields",
"locations": [
{ "param": "taxonomy", "operator": "==", "value": "collection" }
],
"fields": [
{ "key": "season", "label": "Season", "type": "text" }
]
}
]Then POST /terms:
[
{
"taxonomy": "collection",
"local_key": 501,
"name": "Summer",
"acf_fields": { "season": "2026" }
}
]Response 200:
[123, 124]Main errors:
400 missing_title400 invalid_paramif the body is not a JSON array, an element is not a non-empty object,fieldsis not a list, a field is not an object or has nokey/name, or a layout is malformed500 acf_error500 delete_failed
Deletes field groups by numeric ID or by title.
Body:
[123, "Product Fields"]Optional query:
?ignore=1
- An integer is a field group ID. A string is an exact field group title.
- Any other element (
null, a float, an object, a list) returns400 input_invalidwith the messageFieldGroup :: Element N :: Invalid delete value; expected field group ID (int) or title (string). - With
?ignore, an ID or title that matches nothing is skipped.
Response 200:
nullMain errors:
400 invalid_paramif the body is not a JSON array400 input_invalidfor an element that is neither an integer nor a string404 not_foundif an ID or title does not exist andignoreis not set500 delete_failed
Returns all registered ACF post types. The response is the raw output of acf_get_acf_post_types(): one ACF post type definition per item, with ACF's own keys. The plugin does not reshape it.
Pagination: none. The response always contains all registered post types; there is no per_page/page.
Creates or updates one or more ACF post types (upsert by the post_type key).
Body:
[
{
"post_type": "product",
"singular_label": "Product",
"plural_label": "Products",
"hierarchical": false,
"icon": "dashicons-cart",
"supports": ["title", "editor", "thumbnail", "revisions"],
"taxonomies": ["brand"],
"rewrite_slug": "catalog/products"
}
]| Field | Required | Default | Notes |
|---|---|---|---|
post_type |
yes | Must already be a valid key: lowercase letters, digits, _ or -. See below. |
|
singular_label |
yes | Non-empty string. | |
plural_label |
yes | Non-empty string. | |
hierarchical |
no | false |
|
icon |
no | dashicons-admin-post |
|
supports |
no | ["title", "editor", "thumbnail", "revisions"] |
|
taxonomies |
no | [] |
|
rewrite_slug |
no | none | Custom WordPress permalink slug. Only a non-empty string sets it. Any other value (null, "", a number, omitted) sets no custom slug. |
Behaviour:
- Key validation.
post_typemust be non-empty and must not change undersanitize_key(). A value with uppercase letters, spaces or accents (e.g."Product") returns400 invalid_paramwith the messagePostType :: Parameter 'post_type' must be a non-empty key of lowercase letters, digits, '_' or '-', got 'Product'. This keeps the ACF key and the registered post type identical. - Required labels. A missing or empty
singular_labelorplural_labelreturns400 invalid_paramwith the messagePostType :: Element N :: Parameter 'singular_label' is required(or'plural_label'). - Upsert. If a post type with the same key exists, its values are updated; otherwise it is created. A duplicate never returns an error.
- Payload as source of truth. Every call fully overwrites the ACF fields. Optional properties missing from the payload go back to their default. For example, a previously set
rewrite_slugis removed if the key is no longer sent. - The ACF key of the post type is the value of
post_type. - The permalink slug is
rewrite_slugwhen it is a non-empty string. Otherwise no custom slug is set, and the permalink uses the post type key (post_type) as WordPress does by default. Nopost_prefix is added. flush_rewrite_rules()runs once per batch.
Response 200:
[45]Main errors:
400 invalid_paramif the body is not a JSON array, an element is not a non-empty object,post_typeis not a valid key, orsingular_label/plural_labelis missing or empty500 acf_error
Deletes post types by ACF ID or by string key.
Body:
[45, "product"]Optional query:
?ignore=1
Notes:
- An integer is an ACF post type ID. A string is a post type key; it is sanitized with
sanitize_key. - Any other element returns
400 input_invalidwith the messagePostType :: Element N :: Invalid delete value; expected post type ID (int) or key (string). - With
?ignore, an ID or key that matches nothing is skipped. flush_rewrite_rules()runs once per batch, also when the batch fails.
Response 200:
nullMain errors:
400 invalid_paramif the body is not a JSON array400 input_invalidfor an element that is neither an integer nor a string404 not_foundif an ID or key does not exist andignoreis not set500 delete_failed
Returns generic posts, with optional filters useful for sync and mapping. It also supports exact-title search (title parameter), which can be scoped to a post type with ?type=.
Pagination: yes, through per_page/page and the X-WP-Total/X-WP-TotalPages headers (details below). Exception: ?id= and ?title= always return every match, without pagination.
Optional query:
?id=321
?type=product
?local_key=1001
?updated_after=2026-05-14T10:00:00Z
?status=publish
?status=trashed
?page=1
?per_page=100
?title=My post
?title[it]=Chi siamo&title[en]=About us
| Parameter | Description |
|---|---|
id |
Returns the single matching post (as a one-element list), trashed posts included. A missing post returns 404 no_post. id wins over every other filter, title included. An invalid value is ignored. |
type |
Exact post type. Default any. In the normal listing an unknown type is not an error (empty list). Combined with title, an unknown type returns 404 not_found. |
local_key |
Filters on the onpage_local_key post meta. The status filter still applies, so trashed posts are left out unless status=trashed. An invalid value (empty, 0) is ignored. |
status |
Default any, which excludes trashed posts and auto-drafts. trashed (alias of the WP status trash) returns only trashed items. Any other value (publish, draft, pending, private, …) is passed to WordPress unchanged. |
updated_after |
Filters on post_modified_gmt. A value with Z or an offset (2026-05-14T12:00:00+02:00) is converted to UTC. A value without a timezone is read as UTC. An invalid date/time returns 400 invalid_param. The bound is inclusive: a post modified exactly at that time is returned. |
per_page |
Default 100, maximum 100. An invalid value is ignored (default used). |
page |
Page number. An invalid value is ignored (page 1). |
title |
Exact-title search (see below). |
The normal listing is ordered by modification date, newest first (modified DESC). With WPML active it only contains posts in the current request language, and X-WP-Total counts only those.
Pagination headers (normal listing only, not with id/title):
X-WP-Total: total number of posts matching the filters, regardless of the page.X-WP-TotalPages: total number of pages,ceil(X-WP-Total / per_page).- The client knows from the current response whether it is on the last page (
page >= X-WP-TotalPages). There is no need to request an extra page and wait for an empty array.
Walk-through (250 posts, per_page=100 → 3 pages):
GET /posts?page=1&per_page=100 → X-WP-Total: 250, X-WP-TotalPages: 3 (continue)
GET /posts?page=2&per_page=100 → X-WP-Total: 250, X-WP-TotalPages: 3 (continue)
GET /posts?page=3&per_page=100 → X-WP-Total: 250, X-WP-TotalPages: 3 (page == X-WP-TotalPages → stop, no request for page=4)
Search by title:
titleperforms an exact-title search. It accepts two forms:- string (
?title=My post): searches in the current language of the request; - language map (
?title[it]=Chi siamo&title[en]=About us): searches each title in the context of its WPML language and returns the union of the results.
- string (
- The search is scoped to the given
type(validated;404 not_foundif it does not exist). Withouttype, it searches every post type. - The response is always a list with one object per distinct translation group. The representative is the default-language post when available, and it carries the
translationsmap. Titles that are translations of the same post → one object. Titles from different groups → several objects. This mirrorsGET /woocommerce/products?name=. - When
titleis present, the other filters (local_key,updated_after,status,page,per_page) do not apply.
Response 200:
[
{
"id": 321,
"title": "My post",
"content": "Body",
"type": "product",
"status": "publish",
"local_key": 1001,
"translations": {
"it": 321,
"en": 322
},
"acf_fields": {
"sku": "ABC123"
},
"terms": []
}
]local_keyis always present: an integer, a string, ornullwhen the post has no key (seelocal_key).termslists the post's terms as WordPress term objects (term_id,name,slug,taxonomy,parent,count, …).translationsmaps each language code to the ID of the post in that language (WPML translation group). Without WPML it is an empty map. It is present in every post response (single, list andtitlesearch).
Note: there is no
GET /post-types/{post_type}/postsendpoint. To list or search the posts of a specific post type, useGET /posts?type={post_type}(add&title=...for exact-title search).
Returns a single post.
Pagination: not applicable (returns one object, not a list).
Optional query:
| Parameter | Description |
|---|---|
keyfield |
id (default) or local_key. Any other value returns 400 invalid_keyfield. |
type |
Only with keyfield=local_key: restricts the lookup to a post type, to avoid ambiguity across post types. |
- With
keyfield=id,{id}is the numeric post ID. A value that is not a positive ID returns404 no_post. - With
keyfield=local_key,{id}is thelocal_keyvalue (an invalid value returns400 invalid_param). - With
keyfield=local_key, if several posts share the key (WPML translations), the post in the group's default language is returned. The other languages are in thetranslationsmap. - With
keyfield=local_key, trashed posts are left out, as inGET /posts?local_key=: alocal_keyheld only by trashed posts returns404 no_post. (Upserts and deletes bylocal_keystill see the trash; see Trashed posts.) Withkeyfield=ida trashed post is returned; checkstatusin the response. - Without
type, alocal_keyheld by posts of more than one post type returns409 ambiguous_local_key.
Example:
curl -X GET \
"https://example.com/wp-json/onpage/v1/posts/321"Response 200:
{
"id": 321,
"title": "My post",
"content": "Body",
"type": "product",
"status": "publish",
"local_key": 1001,
"translations": {
"it": 321,
"en": 322
},
"acf_fields": {
"sku": "ABC123"
},
"terms": [
{
"term_id": 10,
"taxonomy": "brand",
"name": "Acme",
"slug": "acme"
}
]
}The terms entries are abridged: they are full WordPress term objects.
Errors:
400 invalid_keyfieldifkeyfieldis neitheridnorlocal_key400 invalid_paramifkeyfield=local_keyand{id}is not a validlocal_key404 no_postif no post matches409 ambiguous_local_keyifkeyfield=local_key, notypeis given and the key exists on several post types
Creates or updates posts in batch.
How the target post is resolved:
- If the element has a valid
id, that post is updated (idtakes precedence, as inPOST /woocommerce/products). The post must exist, otherwise404 no_post. An invalidid(not a positive ID) is silently ignored, and the element falls through to step 2. - Otherwise the upsert is driven by
local_key: if thelocal_keyalready exists (on a post oftype, when sent), that post is updated. Trashed posts count: see Trashed posts. - Otherwise a new post is created.
[
{
"type": "product",
"title": "Red Chair",
"content": "Description",
"status": "publish",
"local_key": 1001,
"files": {
"image": "https://cdn.example.com/red-chair.jpg"
},
"acf_fields": {
"sku": "CHAIR-RED",
"price": 49.9
},
"terms": {
"brand": ["acme"],
"collection": ["summer"]
}
}
]| Field | Required | Notes |
|---|---|---|
type |
on insert | Used as the exact post type; no post_ prefix is added. Missing or empty on insert → 400 invalid_param; not registered → 404 not_found. On update it can be omitted; if sent, it must equal the post's current type (400 invalid_param otherwise). |
title |
on insert | A non-empty string or a WPML language map. Missing or empty on insert → 400 invalid_param, and so is a map with no non-empty title in an active language. On update an empty string is accepted. |
content |
no | |
description |
no | Saved as the post excerpt (post_excerpt). GET responses do not return it. |
status |
no | Default draft on insert. Must be a registered post status (publish, draft, pending, private, future, or a custom one); trash, auto-draft, inherit or any other value returns 400 invalid_param. |
local_key |
yes | Always required, also when updating by id. |
files |
no | Map acf_field_name => URL or attachment_id. null or "" as a value does not clear the field: it returns 400 input_invalid. |
acf_fields |
no | See Handling acf_fields. |
term |
no | Term assignments per taxonomy. |
terms |
no | Alias of term. When both are sent, term wins and terms is ignored. |
Supported term/terms formats:
<taxonomy_slug> => [term_slug, ...]<taxonomy_slug> => [lang => term_slug|[term_slug, ...]]<taxonomy_slug> => [[lang => term_slug, ...], ...]
type,titleandlocal_keyare required.typemust be a registered post type (404 not_foundwith the messagePost :: PostType '<type>' not found).titlemust be a non-empty string, or a language map with a non-empty title in at least one active language. Otherwise the request returns400 invalid_paramwith the messagePost :: Element N :: Parameter 'title' is required. For example,{"title": {"fr": "Chaise"}}on a site withoutfris rejected, and so is{"title": {"en": null}}.- The title must not already exist in the same post type (see
409 duplicate_titlebelow for the exact rule). - A
local_keythat already exists never reaches the insert: the element updates that post instead (see above). - If anything fails after the post was created, the post and every translation created so far are deleted before the error is returned.
files:- Each value must be a valid URL reachable by WordPress, or the
attachment_id(JSON integer) of a file already in the Media Library (e.g. uploaded withPOST /media). - Each remote file is downloaded, imported into the Media Library and attached to the new post.
- An
attachment_idis assigned directly, without download, and re-attached to the post as parent. - The field named in
filesstores the WordPressattachment_idof the imported or given media.
- Each value must be a valid URL reachable by WordPress, or the
acf_fieldsof typeimage:- A valid URL is imported as remote media, and the field stores the resulting
attachment_id. null, an empty string,0or"0"leaves the field without an image.
- A valid URL is imported as remote media, and the field stores the resulting
terms:- Terms are assigned by reference. Each reference (e.g.
123,"SKU-ABC") is first looked up as alocal_key, otherwise it is treated as a slug. Unlike products, slugs are supported here (posts accept an integer/string local_key or a slug). - For multilingual payloads you can pass a per-language map, for example
{"manufacturer":{"en":"ford-en","it":"ford-it"}}. - A list of language maps is also supported, for example
{"manufacturer":[{"en":"ford-en","it":"ford-it"}]}. - If a term is not found by
local_keyor slug, the request fails with404.
- Terms are assigned by reference. Each reference (e.g.
local_keyis required (also when updating byid). It is written on the resolved post and propagated to all its WPML translations.- Resolution order:
- if the payload has an
id, that post is updated (404 no_postif theiddoes not exist); - otherwise the post is resolved through
local_key, and the WordPress ID is looked up internally. Withouttype, a key held by posts of several post types returns409 ambiguous_local_key.
- if the payload has an
- Explicit
idandlocal_key. With anid, thelocal_keymust not be held by another post of the same type outside this post's WPML translation group. Otherwise the request returns409 duplicate_local_keywith the messagePost :: Element N :: local_key '<key>' already exists for PostType '<type>'. Withoutidthere is no such check: the key is what identifies the post. typecannot change.typeidentifies the post; it does not retype it. Atypedifferent from the post's current type returns400 invalid_param(… the post type cannot be changed).- Any of
type,title,content,description,acf_fields,files,termsorstatusmissing from the payload is left unchanged. - There is no rollback on update. If a step fails (for example an ACF field or a file), the changes already written stay.
- With WPML, a language of the payload that the post's translation group does not have yet is created as a new translation. Before that, group slots whose post no longer exists are removed. If the translation group cannot be resolved, the request returns
500 wpml_error. - If
titlechanges, it is checked for uniqueness against other posts of the same type. - The slug (
post_name) is never sent or changed by the plugin. WordPress generates it from the title when the post is first published. A later change oftitlekeeps the old slug. acf_fieldsupdates only the fields sent.filesupdates only the fields sent.- ACF
imagefields received as URLs are imported or reused as attachments and saved asattachment_id. - An ACF
imagefield set tonull, an empty string,0or"0"is cleared. - If a URL in
fileswas already imported by the plugin, the same attachment is reused and re-attached to the current post. If the remote file is not yet in the Media Library, it is downloaded and a new attachment is created. - An
attachment_idinfilesis verified and re-attached to the current post, without download. termsoverwrites the assignments for the taxonomies sent.- In the multilingual
termsformat, the terms of the current post/translation language are used.
A trashed post still owns its local_key. Every local_key lookup of this endpoint includes the trash. (Reads do not: GET /posts/{id}?keyfield=local_key and GET /posts?local_key= leave trashed posts out.)
- Re-importing a trashed element updates it instead of creating a second post with the same key.
- A live post wins: when both a live post and a trashed post hold the key, the live post is updated and the trashed one stays in the trash.
- The post is restored (
wp_untrash_post()) together with its trashed translations that hold the same key, then the payload is applied. - The restore happens after a first set of checks: the post type exists, WPML is available for language maps, the title is not a duplicate and
typematches the post. A request rejected by these checks (for example409 duplicate_title) leaves the post in the trash. acf_fields,filesandtermsare validated only after the restore. A request that fails there (for example400 input_invalidon a file) leaves the post restored, because the update path has no rollback.- The payload
statusthen applies. Withoutstatus, the post keeps the status WordPress restores it to, which isdraft. - A trashed post of another translation group that holds the same key stays in the trash.
- A failed restore returns
500 request_failed.
title, content, description, terms and every value in acf_fields and files can be sent as a per-language map:
[
{
"type": "product",
"title": {
"it": "Sedia Rossa",
"en": "Red Chair"
},
"content": {
"it": "Descrizione IT",
"en": "English description"
},
"acf_fields": {
"subtitle": {
"it": "Sottotitolo",
"en": "Subtitle"
},
"sku": "CHAIR-RED"
}
}
]files also supports the per-language format:
[
{
"type": "product",
"title": {
"it": "Sedia Rossa",
"en": "Red Chair"
},
"files": {
"image": {
"it": "https://cdn.example.com/it/chair.jpg",
"en": "https://cdn.example.com/en/chair.jpg"
}
}
}
]Rules:
- If
title,content,description,acf_fields,filesortermscontain multilingual values and WPML is not installed or active, the request returns500 wpml_required. - On insert, the plugin creates the base post in the WPML default language, then the translations. When
titleis sent only per language and the default language is not among them, the base post is created in the first language that has a title instead, so no post is created in a language the payload did not send. Terms and WooCommerce products follow the same rule. - On update, the plugin updates the current post and its linked translations. A language map writes only the languages it contains:
{"title": {"en": "Red Chair"}}renames the English translation and leaves the Italian title as it is. The same applies tocontent,descriptionand every value inacf_fieldsandfiles. A shared (non-map) value is still written to every language. A map with no active language, for example{"title": {"es": "Silla"}}on an it/en site, writes no language: every title stays as it is. - Only language maps create translations. A payload with scalar values only creates or updates a single post. To create a translation with the same text, repeat the value in the map, for example
{"en": "Chair", "it": "Chair"}. - Language maps in
termsonly choose the term for each translation. They do not create translations. - If
titleis a string and other fields are multilingual, the same title is used unchanged for every translation. For different titles per language, use a WPML map. - When
filesis multilingual, each translation receives its ownattachment_ids in the target fields. - When a translation is created and its title is missing from the map, the shared title or the fallback language is used, without automatic suffixes. An existing translation keeps its title instead.
- For multilingual
terms, terms are resolved in the target language through WPML. - On insert, the translations are written in their own WPML language: term slugs and
acf_fieldsresolve in the language of each translation, not in the default language.
Response 200:
[321, 322]The response lists one ID per element: the post in the base language. Translation IDs are not listed. With an explicit id, the response holds that id, even when it is a translation.
Main errors:
400 invalid_paramwhen:- the body is not a JSON array, or an element is not a non-empty object;
local_keyis missing or invalid;statusis not a registered post status, or istrash,auto-draftorinherit;typeortitleis missing on insert;typediffers from the type of the post being updated;- an
acf_fieldskey is not an ACF field of the post type (ACF field '<name>' not found for post '<type>'); - a repeater, group or flexible content value is malformed.
400 input_invalidif a value infilesor animage/filevalue inacf_fieldsis neither an existingattachment_idnor a valid URL404 no_postif theidsent does not exist404 not_foundiftypeis not a registered post type404 input_invalidif a referenced term does not exist409 ambiguous_local_keyif notypeis sent and thelocal_keyexists on several post types409 duplicate_local_keywith an explicitid, if thelocal_keybelongs to another post of the same type409 duplicate_titleif the title already exists on an object without alocal_key, outside this element's translation group. An object carrying a differentlocal_keyis another On Page® element and does not conflict, so two elements with the same title are both imported.500 acf_errorif ACF refuses to save a field413 file_too_largeif a remote file is over the size limit500 request_failed, also whenacf_fields,files,termortermsis not a JSON object or array (for example a string)500 wpml_errorif WPML cannot resolve the translation group (trid) of the post500 wpml_requiredif the payload contains multilingual values but WPML is not installed or active
Deletes posts by ID or by local_key, or deletes all posts of a given post type.
Body:
[321, "product", {"local_key": 1001, "type": "product"}]Optional query:
?ignore=1
?keyfield=local_key&type=product
Accepted body elements (default mode):
| Element | Effect |
|---|---|
| integer | Deletes the post with that ID. 0 or a negative integer matches no post: 404 not_found, or skipped with ?ignore. |
| string | Interpreted as an exact post type: all posts of that type are deleted, in every WPML language. |
{"local_key": …, "type": …} |
Deletes by local_key; type is optional (falls back to the type query parameter). |
{"id": …} |
Deletes the post with that ID. id must be a positive JSON integer. |
{"type": …} |
Deletes all posts of that type, but only when the object has neither a local_key nor an id. |
Notes:
- To delete by
local_key(integer or non-empty string) with plain values, use?keyfield=local_key.typeis optional but recommended. In this mode every element must be a validlocal_key(or alocal_keyobject), otherwise400 input_invalid. A boolean is not a validlocal_key:[true]is rejected, it does not delete the post withlocal_key"1". - Alternatively, use objects with
local_keyandtypewithout changingkeyfield. - An object never falls back to
typebecause of a bad identifier. It returns400 input_invalidinstead:- when
local_keyis present (notnull) but invalid ("","0", a boolean, an object…):Post :: Element N :: Parameter 'local_key' must be a positive integer or a non-empty string; - in the default mode, when
idis present (notnull) but not a positive JSON integer ("12",0,1.5…):Post :: Element N :: Parameter 'id' must be a positive JSON integer.
- when
- Any other element (
null, a float, a list, an object with none oflocal_key,idortype) returns400 input_invalidwith the messagePost :: Element N :: Invalid delete value; …. - Any other
keyfieldvalue (besideslocal_keyand the defaultid_or_post_type) returns400 invalid_keyfield. - If a
local_keynot filtered bytypematches several post types, the request returns409 ambiguous_local_key. - Deletion is permanent (
wp_delete_post($id, true)), not a move to the trash. - Trashed posts are included: a
local_keyor a post type also deletes the matching posts that are in the trash. - A
local_keydeletes every post that holds it, so the whole WPML translation group. With the WPML option that deletes translations together with the original, members already removed that way are skipped, not reported as errors. The same applies to the terms deleted byDELETE /taxonomies. - With
?ignore: a missing ID orlocal_keyis skipped, and so is a post type that is not registered: nothing is deleted for it. Withoutignorean unregistered post type returns404 not_found.
Response 200:
nullMain errors:
400 invalid_paramif the body is not a JSON array400 input_invalidfor an element of an unsupported type, or an object with an invalidlocal_keyorid400 invalid_keyfield404 not_foundif an ID,local_keyor post type does not exist andignoreis not set409 ambiguous_local_key500 request_failedif WordPress fails to delete a post
For WooCommerce-specific background see dev/woocommerce.md.
Every /woocommerce/* endpoint, on every method (GET, POST and DELETE), returns 500 woocommerce_required when WooCommerce is not active.
The list endpoints for brands, categories, tags and attribute terms share the rules below. Each endpoint section states which of them apply.
Lookup filters (id, local_key, slug)
idreturns the term with that WordPress ID, as a one-element list. An ID that is not a term of this taxonomy returns404 not_found. A malformedid(not a positive ID) is ignored, and the next filter applies.local_keyreturns every term that holds the key. WPML translations are not grouped: each translation is a separate item.slugreturns zero or one term.
Exact-name search (name)
nameperforms an exact-name search on the term. It accepts two forms:- string (
?name=Ford): searches in the current language of the request; - language map (
?name[it]=Ford&name[en]=Ford): searches each name in the context of its WPML language and returns the union of the results.
- string (
- The response is always a list with one object per distinct translation group (the default-language term is the representative), carrying the
translationsmap. Names that are translations of the same term → one object. Names from different groups → several objects. This mirrorsGET /woocommerce/products?name=.
Parent filters (parent_id, parent_lk)
parent_id(WP term ID) andparent_lk(the parent's local_key) return the direct children of the given parent.parent_lkis expanded to all WPML translations of the parent, and children are searched under each of them. Per-language visibility of the children follows the same rules as the normal listing.- The two parameters are mutually exclusive (
400if both are sent). - A
parent_lkthat resolves to no term returns an empty list. - Filter precedence:
id>local_key>slug>name>parent_*.
translations
translationsmaps each language code to the term ID in that language. It is present in every response (single, list, search). Without WPML it is an empty map.
Every DELETE /woocommerce/* endpoint takes a JSON array of local_keys.
- Each element must be a positive integer or a non-empty string. Any other element returns
400 invalid_paramwith the message<Prefix> :: Element N :: Invalid delete value; expected a positive integer or non-empty string local_key. - A
local_keythat matches nothing returns404 not_found, or is skipped with?ignore. - A
local_keyshared by several objects (WPML translations) deletes all of them. With the WPML option that deletes translations together with the original, members already removed that way are skipped, not reported as errors. - A failed delete returns
500 delete_failed.
Errors that apply to every method of the term endpoints (categories, tags, attributes/{attribute}/terms), and that ?ignore never skips:
404 not_found(<Prefix> :: Taxonomy '<taxonomy>' not found) when the taxonomy is not registered;- on
attributes/{attribute}/terms,404 not_found(WooCommerce Attribute Term :: Attribute '<attribute>' not found) when{attribute}matches no attribute, and400 invalid_param(WooCommerce Attribute Term :: Attribute ID or slug is required) when it is empty.
Returns WooCommerce brands, stored as terms of the product_brand taxonomy.
Pagination: none. The response always contains all brands matching the filters; there is no per_page/page.
Optional query:
?id=701
?local_key=4002
?slug=ford
?name=Ford
?name[it]=Ford&name[en]=Ford
?parent_id=700
?parent_lk=4001
id,local_key,slug: lookup filters, see Shared behaviour.name: exact-name search, see Shared behaviour.parent_id/parent_lk: direct-children filters, see Shared behaviour.- If the
product_brandtaxonomy does not exist yet (neither registered nor defined in ACF), the response is[].
Response 200:
[
{
"id": 701,
"name": "Ford",
"slug": "ford",
"local_key": 4002,
"description": "",
"parent": 0,
"count": 0,
"taxonomy": "product_brand",
"translations": {
"it": 701,
"en": 702
},
"acf_fields": {}
}
]Creates or updates WooCommerce brands. The endpoint makes sure the product_brand taxonomy exists for the product post type, then uses the same payload as the terms endpoint (POST /terms).
Body:
[
{
"local_key": 4002,
"name": {
"en": "Ford",
"it": "Ford",
"es": "Ford",
"ru": "Ford"
},
"slug": {
"en": "ford-en",
"it": "ford-it",
"es": "ford-es",
"ru": "ford-ru"
},
"description": "Vehicle manufacturer",
"thumbnail": "https://cdn.example.com/ford-thumbnail.png",
"parent": 4001,
"acf_fields": {
"logo": "https://cdn.example.com/ford.png"
}
}
]Behaviour:
- If
product_brandis not registered and has no ACF definition, it is created as a hierarchical ACF taxonomy attached toproduct. When WooCommerce or another plugin already registers it, nothing is created. name,slug,description,local_keyandacf_fieldsbehave as inPOST /terms.nameis required on every save.id(optional): the WordPress term ID of the brand to update, as inPOST /terms. Unlike categories, tags and attribute terms, a staleidis never dropped: anidthat is not a brand returns404 not_found, even with a validlocal_key.parent(optional):- must be
nullor thelocal_key(integer or string) of the parent brand, for example"parent": 4001; - does not accept
0or numeric WordPress IDs. For a top-level brand, usenullor omit the field; - the parent brand must already exist, or be created earlier in the same batch;
- with WPML, the endpoint tries to use the parent's translation in the language of the brand being created or updated.
- must be
thumbnail:- a remote URL to import into the Media Library, or the
attachment_id(integer) of a file already in the Media Library (e.g. uploaded withPOST /media); - the resolved value is saved as
thumbnail_id; nullremoves the thumbnail; omitting it leaves the existing one unchanged.
- a remote URL to import into the Media Library, or the
- With WPML, language maps create or update the brand's translations.
- If the payload contains language maps but WPML is not installed or active, the request returns
500 wpml_required. - Brands are assigned to products through the
brandfield ofPOST /woocommerce/products, using the brand'slocal_key(integer or string), for example"brand": 4002.
Response 200:
[701]Main errors:
400 invalid_paramfor an invalidparent,thumbnail,local_keyoracf_fieldsvalue, or a missingname400 input_invalidif animage/filevalue inacf_fieldsis neither an existingattachment_idnor a valid URL404 not_foundif theidor theparentdoes not exist409 duplicate_local_keywith an explicitid, when thelocal_keybelongs to another brand500 woocommerce_required500 wpml_requiredif the payload contains multilingual values but WPML is not installed or active500 acf_errorif ACF refuses to save a field500 request_failed
Deletes WooCommerce brands by local_key. If several terms share the same local_key (e.g. WPML translations), all of them are deleted.
Body:
[4002]Optional query:
?ignore=1
If the product_brand taxonomy does not exist, every element returns 404 not_found with the message WooCommerce Brand :: Brand taxonomy not found, or is skipped with ?ignore.
Response 200:
nullReturns global WooCommerce product attributes, i.e. the definitions that generate pa_* taxonomies such as pa_color or pa_size.
Pagination: none. The response always contains all attributes matching the filters; there is no per_page/page.
Optional query:
?id=31
?local_key=5001
?slug=color
?slug=pa_color
?name=Color
- Only one filter applies. Precedence:
id>slug>local_key>name. idreturns the attribute with that ID. An ID that matches no attribute returns404 not_found.slugandlocal_keyreturn zero or one attribute.nameperforms an exact-name search on the attribute label (e.g.Color, case-sensitive) and returns every attribute with that name.nameis a plain string: a language map (?name[it]=…) is ignored.- Global attributes cannot be translated with WPML, so there is no translation grouping. The
translationsfield is present for a consistent shape but is always empty ({}).
Response 200:
[
{
"id": 31,
"local_key": 5001,
"name": "Color",
"slug": "pa_color",
"attribute_slug": "color",
"type": "select",
"order_by": "menu_order",
"has_archives": false,
"taxonomy": "pa_color",
"translations": {}
}
]Creates or updates global WooCommerce product attributes in batch.
Body:
[
{
"local_key": 5001,
"name": "Color",
"slug": "color",
"type": "select",
"order_by": "menu_order",
"has_archives": false
}
]| Field | Required | Default | Notes |
|---|---|---|---|
id |
no | If present, updates that attribute (takes precedence, as in POST /woocommerce/products). local_key is then not required. A malformed id is ignored, as if it were absent. |
|
local_key |
only if id is absent |
Without id, a missing or invalid local_key returns 400 invalid_param. With id, a valid local_key is (re)associated with the attribute, and an invalid one is ignored. |
|
name |
on create | On update it can be omitted, but an empty string returns 400 invalid_param. |
|
slug |
no | Can be sent as color or pa_color. An empty string counts as omitted: the existing slug is kept (on create, WooCommerce derives it from name). |
|
type |
no | WooCommerce default select |
|
order_by |
no | WooCommerce default menu_order |
WooCommerce accepts menu_order, name, name_num and id. The plugin passes the value to WooCommerce without checking it. |
has_archives |
no | false |
A boolean, a number (non-zero is true) or a string. The strings 1, true, yes and on (any case) are true; any other value is false. |
Resolution, in order of precedence:
- If
idis present, that attribute is updated (404if the ID does not exist). Iflocal_keyis also sent, it is (re)associated with that attribute. - Otherwise, if the
local_keyalready exists, the attribute associated with it is updated. - Otherwise, if
slugmatches an existing attribute, that attribute is updated. - Otherwise a new attribute is created.
Other notes:
name,slug,typeandorder_bymust be scalars. An object or a list returns400 invalid_paramwith the messageWooCommerce Attribute :: Element N :: Parameter '<key>' must be a string.- The response contains the IDs of the created or updated attributes.
- WooCommerce limits the unprefixed slug to 28 characters and rejects reserved or already used names.
- Attribute values are managed as terms of the generated taxonomy, for example
pa_color(see attribute terms).
Response 200:
[31]Main errors:
400 invalid_param- A WooCommerce error is passed through with its own code, status and message (prefixed with
WooCommerce Attribute :: Element N ::). For example:400 invalid_product_attribute_slug_too_long400 invalid_product_attribute_slug_reserved_name400 invalid_product_attribute_slug_already_exists
409 duplicate_local_keyif thelocal_keyis already used by another attribute404 not_foundif theiddoes not exist500 woocommerce_required500 request_failed
Deletes global WooCommerce product attributes by local_key. Deletion uses wc_delete_attribute(), which also removes the terms of the attribute taxonomy when the taxonomy is registered.
Body:
[5001]Optional query:
?ignore=1
Response 200:
nullReturns the values (terms) of a global WooCommerce product attribute.
{attribute} can be:
- the attribute ID, for example
31; - the unprefixed slug, for example
color; - the attribute taxonomy, for example
pa_color.
Pagination: none. The response always contains all terms of the attribute matching the filters; there is no per_page/page.
Optional query:
?id=101
?local_key=5101
?slug=red
?name=Red
?name[it]=Rosso&name[en]=Red
?parent_id=100
?parent_lk=5100
id,local_key,slug: lookup filters, see Shared behaviour.name: exact-name search, see Shared behaviour.parent_id/parent_lk: direct-children filters, see Shared behaviour. Attribute terms are usually flat, so these filters rarely matter.
Response 200:
[
{
"id": 101,
"name": "Red",
"slug": "red",
"local_key": 5101,
"description": "",
"parent": 0,
"count": 0,
"taxonomy": "pa_color",
"translations": {
"it": 102,
"en": 101
},
"acf_fields": {}
}
]acf_fieldsis the value ofget_fields(), or{}when the term has no ACF values.
Creates or updates the values (terms) of a global WooCommerce product attribute.
Body:
[
{
"local_key": 5101,
"name": {
"en": "Red",
"it": "Rosso"
},
"slug": {
"en": "red",
"it": "rosso"
},
"description": "Color option"
}
]Behaviour:
{attribute}is resolved to the WooCommercepa_*taxonomy.local_keyis saved as theonpage_local_keyterm meta.- If you send an
idthat exists, that term is updated, and the givenlocal_keyis written on it and on all its translations. Anidthat does not exist (stale) is dropped only when a validlocal_keyis also sent: the upsert is then driven bylocal_key. Without a validlocal_key, a staleidreturns404 not_found. nameis required on every save, update included. It can be a string or a WPML language map.parentis a WordPress term ID (or0/nullfor none), as inPOST /terms. Parents bylocal_keyare only supported for categories and brands.thumbnailis not supported and is ignored.- If
nameis a string and other fields are language maps, the same name is used unchanged for every translation. If a per-languageslugis missing, the translations get a distinct technical slug. slug,descriptionandacf_fieldssupport WPML language maps, as for other terms.- A
slugalready used by another element's term is not taken over, as inPOST /terms. - The response contains the IDs of the created or updated terms in the base language.
Response 200:
[101]Main errors:
400 invalid_paramfor a missingname, an invalidlocal_keyorparent, or an invalidacf_fieldsvalue; also when{attribute}is empty400 input_invalidif animage/filevalue inacf_fieldsis neither an existingattachment_idnor a valid URL404 not_foundif{attribute}does not resolve to an attribute, or theidorparentdoes not exist409 duplicate_local_keyonly with an explicitidthat exists, when thelocal_keybelongs to another term500 woocommerce_required500 wpml_requiredif the payload contains multilingual values but WPML is not installed or active500 acf_errorif ACF refuses to save a field500 request_failed
Deletes terms of a global WooCommerce product attribute by local_key.
Body:
[5101]Optional query:
?ignore=1
Response 200:
nullReturns WooCommerce product categories, stored in the product_cat taxonomy.
Pagination: none. The response always contains all categories matching the filters; there is no per_page/page.
Optional query:
?id=12
?local_key=6001
?slug=chairs
?name=Chairs
?name[it]=Sedie&name[en]=Chairs
?parent_id=575
?parent_lk=6000
id,local_key,slug: lookup filters, see Shared behaviour.name: exact-name search, see Shared behaviour.parent_id/parent_lk: direct-children filters, see Shared behaviour.
Response 200:
[
{
"id": 12,
"name": "Chairs",
"slug": "chairs",
"local_key": 6001,
"description": "",
"parent": 0,
"count": 0,
"taxonomy": "product_cat",
"translations": {
"it": 12,
"en": 13
},
"acf_fields": {}
}
]Creates or updates WooCommerce product categories, using the same payload as the terms endpoint (POST /terms).
Body:
[
{
"local_key": 6001,
"name": {
"en": "Chairs",
"it": "Sedie"
},
"slug": {
"en": "chairs",
"it": "sedie"
},
"description": "Product category",
"thumbnail": "https://cdn.example.com/chairs-thumbnail.png",
"parent": 6000,
"acf_fields": {
"image": "https://cdn.example.com/chairs.png"
}
}
]Behaviour:
name,slug,description,local_keyandacf_fieldsbehave as inPOST /terms.- If
nameis a string and other fields are language maps, the same category name is used unchanged for every translation. If a per-languageslugis missing, the translations get a distinct technical slug. parent(optional):- must be
nullor thelocal_key(integer or string) of the parent category, for example"parent": 6000; - does not accept
0or numeric WordPress IDs. For a top-level category, usenullor omit the field; - the parent category must already exist, or be created earlier in the same batch;
- with WPML, the endpoint tries to use the parent's translation in the language of the category being created or updated.
- must be
- Resolution:
- If you send an
idthat exists, that category is updated, and the givenlocal_keyis written on it and on all its translations. Anidthat does not exist (stale) is dropped only when a validlocal_keyis also sent: the upsert is then driven bylocal_key. Without a validlocal_key, a staleidreturns404 not_found. - If the
local_keyalready exists, the endpoint updates in place theproduct_catcategories and WPML translations with thatlocal_key, preserving product-category associations. - If the
local_keydoes not exist, it creates new categories for the languages in the payload.
- If you send an
thumbnail:- a remote URL to import into the Media Library, or the
attachment_id(integer) of a file already in the Media Library (e.g. uploaded withPOST /media); - the resolved value is saved as
thumbnail_id; nullremoves the thumbnail; omitting it leaves the existing one unchanged.
- a remote URL to import into the Media Library, or the
- With WPML, language maps create or update the category's translations.
- A category with a different
local_keyis never modified:- if it has the same name under the same parent, the collision is handled as described in
term_existsconflicts on terms; - if it is under a different parent, WordPress creates a new term with a de-duplicated slug (
silicone-acetico→silicone-acetico-2).
- if it has the same name under the same parent, the collision is handled as described in
- If the payload contains language maps but WPML is not installed or active, the request returns
500 wpml_required. - Categories are assigned to products through the
categoriesfield ofPOST /woocommerce/products, using the category'slocal_key(integer or string), for example"categories": [6001].
Response 200:
[12]Main errors:
400 invalid_paramfor an invalidparent,thumbnail,local_keyoracf_fieldsvalue, or a missingname400 input_invalidif animage/filevalue inacf_fieldsis neither an existingattachment_idnor a valid URL404 not_foundif theidor the parent category does not exist409 duplicate_local_keywith an explicitid, when thelocal_keybelongs to another category500 wpml_requiredif the payload contains multilingual values but WPML is not installed or active500 acf_errorif ACF refuses to save a field500 request_failed
Deletes WooCommerce product categories by local_key. If several terms share the same local_key (e.g. WPML translations), all of them are deleted.
Body:
[6001]Optional query:
?ignore=1
Response 200:
nullReturns WooCommerce product tags, stored in the product_tag taxonomy.
Pagination: none. The response always contains all tags matching the filters; there is no per_page/page.
Optional query:
?id=22
?local_key=7001
?slug=featured
?name=Featured
?name[it]=In evidenza&name[en]=Featured
?parent_id=21
?parent_lk=7000
id,local_key,slug: lookup filters, see Shared behaviour.name: exact-name search, see Shared behaviour.parent_id/parent_lk: direct-children filters, see Shared behaviour.
Response 200:
[
{
"id": 22,
"name": "Featured",
"slug": "featured",
"local_key": 7001,
"description": "",
"parent": 0,
"count": 0,
"taxonomy": "product_tag",
"translations": {
"it": 22,
"en": 23
},
"acf_fields": {}
}
]Creates or updates WooCommerce product tags, using the same payload as the terms endpoint (POST /terms).
Body:
[
{
"local_key": 7001,
"name": {
"en": "Featured",
"it": "In evidenza"
},
"slug": {
"en": "featured",
"it": "in-evidenza"
},
"description": "Product tag"
}
]Behaviour:
name,slug,description,local_keyandacf_fieldsbehave as inPOST /terms.- Each body element must be a tag object. A plain string returns
400 invalid_param(Invalid payload; expected a non-empty JSON object). A language-value map as a top-level element ({"en": "petrol", "it": "benzina"}) has noname, so it returns400 invalid_paramwith the messageTerm :: Element N :: Name is required. nameandslugcan be language-value maps inside the tag object, for example{ "en": "petrol", "it": "benzina" }.- If
nameis a string and other fields are language maps, the same tag name is used unchanged for every translation. If a per-languageslugis missing, the translations get a distinct technical slug. - Resolution:
- If you send an
idthat exists, that tag is updated, and the givenlocal_keyis written on it and on all its translations. Anidthat does not exist (stale) is dropped only when a validlocal_keyis also sent: the upsert is then driven bylocal_key. Without a validlocal_key, a staleidreturns404 not_found.
- If you send an
parentis a WordPress term ID (or0/nullfor none), as inPOST /terms. Parents bylocal_keyare only supported for categories and brands.thumbnailis not supported for tags and is ignored.- If the
local_keyalready exists, the endpoint updates in place theproduct_tagtags and WPML translations with thatlocal_key, preserving product-tag associations. - If the
local_keydoes not exist, it creates new tags for the languages in the payload.
- If the
- With WPML, language maps create or update the tag's translations.
- If the payload contains language maps but WPML is not installed or active, the request returns
500 wpml_required. - Tags are assigned to products through the
tagsfield ofPOST /woocommerce/products, using the tag'slocal_key(integer or string), for example"tags": [7001].
Response 200:
[22]Main errors:
400 invalid_paramfor an invalidparent,local_keyoracf_fieldsvalue, or a missingname400 input_invalidif animage/filevalue inacf_fieldsis neither an existingattachment_idnor a valid URL404 not_foundif theidor theparentdoes not exist409 duplicate_local_keywith an explicitid, when thelocal_keybelongs to another tag500 wpml_requiredif the payload contains multilingual values but WPML is not installed or active500 acf_errorif ACF refuses to save a field500 request_failed
Deletes WooCommerce product tags by local_key. If several terms share the same local_key (e.g. WPML translations), all of them are deleted.
Body:
[7001]Optional query:
?ignore=1
Response 200:
nullReturns WooCommerce products. Without a query, it returns every product. With WPML active, the unfiltered listing only contains products in the current request language.
Pagination: none. The response always contains all products matching the filters; there is no per_page/page. On very large catalogs the response can be heavy.
Optional query:
?id=501
?local_key=1001
?name=Red Chair
?name[it]=Sedia Rossa&name[en]=Red Chair
- Only one filter applies. Precedence:
id>local_key>name. A malformedidorlocal_keyis ignored. idandlocal_keyreturn the single matching product. A product that does not exist returns404 not_found.- All WPML translations share the same
local_key, so?local_key=returns the product in the group's default language (the same representative used by?name=). The other IDs are in thetranslationsmap. nameperforms an exact-title search. It accepts two forms:- string (
?name=Red Chair); - language map (
?name[it]=Sedia Rossa&name[en]=Red Chair): returns the union of the results for every title, de-duplicated by translation group.
- string (
- The title search is not limited to a language. A title matches a product in any language, and the language codes of a map do not restrict the match.
- Results are grouped by translation group. The response is always a list with one object per distinct translation group among the matches (the default-language product is the representative when available). The
translationsmap contains all language IDs.- If the values are translations of the same product → one object, with every language in
translations. - If the values match products from different groups → several objects, one per group.
- A language whose title matches nothing contributes no results (no error).
- If the values are translations of the same product → one object, with every language in
Response 200:
[
{
"id": 501,
"title": "Red Chair",
"long_description": "Long description",
"short_description": "Short description",
"content": "Long description",
"description": "Short description",
"type": "product",
"status": "publish",
"local_key": 1001,
"translations": {
"it": 501,
"en": 502,
"fr": 503
},
"woocommerce": {
"product_type": "simple",
"sku": "CHAIR-RED",
"regular_price": "49.90",
"sale_price": "",
"price": "49.90",
"stock_status": "instock",
"image_id": 601,
"gallery_image_ids": []
},
"acf_fields": {
"custom_badge": "New",
"manual_pdf": 1234,
"certifications": [
{ "name": "CE marking", "year": 2024 },
{ "name": "VOC A+", "year": 2023 }
]
},
"terms": []
}
]The example is abridged. woocommerce also contains manage_stock, stock_quantity, backorders, sold_individually, weight, length, width, height, virtual, downloadable, featured, catalog_visibility, tax_status, tax_class, purchase_note, menu_order, reviews_allowed, downloads (the native downloadable files) and permalink.
Response notes:
acf_fieldsholds the values resolved byget_fields(). Repeaters are returned natively as lists of objects (one per row).image/filesub-fields are attachment IDs or arrays, depending on thereturn_formatset on the field group.- ACF
tabfields do not appear inacf_fields(they are UI separators with no value). translationsmaps each language code to the product ID in that language (WPML translation group). Without WPML the map may be empty. The top-levelidis the ID of the returned representative.
Creates or updates WooCommerce products, using a product-specific payload.
Body:
[
{
"local_key": 1001,
"name": {
"en": "Red Chair [en]",
"it": "Red Chair [it]",
"es": "Red Chair [es]",
"ru": "Red Chair [ru]"
},
"long_description": {
"en": "<p>Long product description [en]</p>",
"it": "<p>Descrizione lunga prodotto [it]</p>"
},
"short_description": {
"en": "Short product description [en]",
"it": "Descrizione breve prodotto [it]"
},
"slug": {
"en": "red-chair",
"it": "sedia-rossa"
},
"update_slug": false,
"brand": 4002,
"categories": [6011, 6012],
"tags": [7001, 7002],
"terms": {
"tipologia": [112631840],
"materiale": [8001, 8002]
},
"props": {
"product_type": "simple",
"sku": "CHAIR-RED",
"regular_price": "49.90",
"stock_status": "instock"
},
"image": "https://cdn.example.com/red-chair.jpg",
"gallery": [
"https://cdn.example.com/red-chair-side.jpg",
"https://cdn.example.com/red-chair-back.jpg"
],
"attributes": {
"Color": {
"en": "Red",
"it": "Rosso",
"es": "Rojo",
"ru": "Red"
},
"Doors": 5,
"Fuel": ["Petrol", "Hybrid"]
},
"acf_fields": {
"custom_badge": "New",
"manual_pdf": "https://cdn.example.com/manual.pdf",
"certifications": [
{ "name": "CE marking", "year": 2024, "attachment": "https://cdn.example.com/ce.pdf" },
{ "name": "VOC A+", "year": 2023 }
],
"product_subtitle": {
"it": "Sigillante monocomponente ad alto modulo",
"en": "High-modulus one-component sealant"
},
"list_of_main_charatteristics": {
"it": [
{ "lomc_field": "Pasta tissotropica" },
{ "lomc_field": "Varie colorazioni" }
],
"en": [
{ "lomc_field": "Thixotropic paste" },
{ "lomc_field": "Various colours" }
]
},
"datasheet": {
"it": { "title": "Scheda IT", "attachment": 1301 },
"en": { "title": "Datasheet EN", "attachment": 1302 }
}
},
"downloads": [
{
"id": "technical_datasheet",
"name": "Technical datasheet",
"url": "https://cdn.example.com/technical-datasheet.pdf"
},
{
"name": "Safety datasheet",
"url": "https://cdn.example.com/safety-datasheet.pdf"
},
{
"name": "Installation software",
"url": "https://cdn.example.com/installer.zip",
"public": false
}
],
"status": "publish",
"id": 501
}
]local_keyandnameare required.id, when present, must be a positive ID ornull. Any other value returns400 invalid_param(Parameter 'id' must be a positive integer or null).- Resolution: if the element contains
id, the existing product is updated. Ifidis missing but thelocal_keyalready exists, that product is updated. Otherwise a new WooCommerce product is created. local_keyis saved as theonpage_local_keypost meta and must be unique among products.- The
local_keyis written on every language of the WPML translation group (it identifies the same On Page® element, not a single language). It is written before the slow part of the import (media, ACF, terms), so an interruption never leaves translations without a key. - Leftover duplicates. Other products outside the group may still carry the same
local_key(leftovers of an interrupted import, or of two concurrent imports of the same element). The update reconciles them instead of rejecting the request:- a product occupying a language that is still free is attached to the group;
- a product duplicating a language already present is deleted.
409 duplicate_local_keyis returned only when the request sends an explicitidand thatlocal_keybelongs to another product.
namecan be a string or a WPML language map. If it is a string, the same name is used unchanged for every translation created by other multilingual fields. For different names per language, use a WPML map.long_descriptionsets the WooCommerce long description. It can be a string or a WPML language map.short_descriptionsets the WooCommerce short description. It can be a string or a WPML language map.long_descriptionandshort_descriptionare top-level product fields, not part ofpropsoracf_fields.- For compatibility,
contentanddescriptionare still accepted as aliases oflong_descriptionandshort_description. If both are present,long_descriptionandshort_descriptionwin. - On update, a WPML language map writes only the languages it contains.
{"name": {"en": "Red Chair"}}renames the English product and leaves the Italian name as it is. The same applies toslug,long_description,short_description,image,gallery, each value inprops,acf_fieldsandattributes. An attribute sent as a map without a language keeps that language's current attribute. A new translation created by the same request still falls back to the first language of the map, so it never starts empty. - Only language maps in the fields above create translations. A payload with scalar values only never creates a translation:
- On create, it makes one product in the site's default language.
- On update, it writes each scalar value on every translation that already exists.
{"name": "Chair"}renames the English and the Italian product alike. To change one language only, send a map with that language, for example{"name": {"en": "Chair"}}.
- To create a translation with the same text, repeat the value in the map, for example
{"en": "Chair", "it": "Chair"}. Maps incategories,tagsandtermsonly choose the term for each translation. statusis optional. On create it defaults topublish. On update, a save withoutstatuskeeps the current status of every language, so a draft stays a draft. A translation created by an update withoutstatustakes the status of the existing product.statusmust be a non-empty string, otherwise400 invalid_param(Parameter 'status' must be a string).
On update, one rule covers most keys:
| In the payload | Effect |
|---|---|
| key missing | the value stays as it is |
| key present | the value is replaced entirely (lists such as gallery, attributes and downloads are not merged) |
null or an empty list |
the value is cleared |
Exceptions:
slug:nullor""is ignored; the slug is not cleared (see Slug).attributes: a non-empty object replaces the custom attributes and the global ones it names. Globalpa_*attributes it does not name stay.nullor{}removes every attribute, global ones included (seeattributes).- Enum and boolean props (
stock_status,backorders,catalog_visibility,tax_status,manage_stock,featured, …):nullleaves the value as it is, because WooCommerce has no empty value for them (seeprops).
Unknown top-level keys are ignored with no error: a misspelled key answers 200 and writes nothing.
slugis optional and customizes the product permalink (post_name). The value is sanitized withsanitize_title.- If omitted, the slug stays as it is: on create WordPress generates it from the title, on update it is not touched. Changing only
namedoes not change the permalink. - It can be a string or a WPML language map, for example
{ "it": "sedia-rossa", "en": "red-chair" }. If you send a single string with several translations, WordPress makes the slugs unique by adding a suffix. - An empty or
nullslugis ignored (it does not reset the existing slug). So is a value that does not resolve to a non-empty scalar for the language, such as a list: no error is returned. update_slugis optional, boolean, defaultfalse. It controls when the sentslugis applied:false: the slug is set on create only; later updates do not touch it (existing permalinks are preserved);true: the slug is rewritten on update too.- It only has an effect when
slugis in the payload.
- Native WooCommerce fields go inside
props:sku,regular_price,sale_price,price,manage_stock,stock_quantity,stock_status,backorders,sold_individually,weight,length,width,height,virtual,downloadable,featured,catalog_visibility,tax_status,tax_class,purchase_note,menu_order,reviews_allowed,product_type. props.product_typecan besimpleorvariable. Any other value returns400 invalid_param. If omitted on create, it defaults tosimple.- Unknown keys in
propsare ignored with no error.
Value types:
| Keys | Type | Notes |
|---|---|---|
sku, regular_price, sale_price, price, weight, length, width, height, tax_class, purchase_note |
string or number | null or "" clears the value. |
stock_quantity |
integer | null, "" or a non-numeric value clears it. |
menu_order |
integer | null becomes 0. |
manage_stock, sold_individually, virtual, downloadable, featured, reviews_allowed |
boolean | null leaves the value as it is. See the boolean rule below. |
stock_status |
instock, outofstock or onbackorder |
null leaves the value as it is. "" sets instock. |
backorders |
no, notify or yes |
null leaves the value as it is. "" sets no. |
catalog_visibility |
visible, catalog, search or hidden |
null leaves the value as it is. "" sets visible. |
tax_status |
taxable, shipping or none |
null leaves the value as it is. "" sets taxable. |
The allowed values of stock_status, backorders, catalog_visibility and tax_status are WooCommerce's. The plugin does not check them itself: WooCommerce rejects an unknown value, and the request returns 400 invalid_param.
Boolean rule. A boolean prop is true only for true, a number equal to 1 (1, "1"), or the strings "true", "yes" and "on" (case-insensitive). Every other value is false, including "0", "false" and "". null is not a value: it leaves the prop as it is. The same rules apply to the enum and boolean props of variations (manage_stock, stock_status, backorders, virtual, downloadable).
- Changing
variabletosimpledeletes the variations. WooCommerce permanently deletes every variation of a product whose type changes fromvariabletosimple(woocommerce_product_type_changed), including those created withPOST /woocommerce/variant-productsand those a site admin added. Omitproduct_typeon updates when you do not mean to change it.
image:- a remote URL to import and set as the main product image, or the
attachment_id(JSON integer) of a file already in the Media Library (e.g. uploaded withPOST /media); image: nullremoves the image; omitting it leaves the existing one unchanged;- it can be a WPML language map, for example
{ "en": "https://cdn.example.com/en/chair.jpg", "it": "https://cdn.example.com/it/chair.jpg" }. Each translation receives its own image.
- a remote URL to import and set as the main product image, or the
gallery:- replaces the whole product gallery with the list sent. Items can be remote URLs and/or
attachment_ids; - each URL is imported/reused in the Media Library with the same rules as
image: already imported URLs reuse the existing attachment, with no duplicates even within the same list; - an
attachment_idis assigned directly, without download; - list order determines gallery order;
- each entry of the list can also be a per-language map, for example
{ "it": "https://…/it.jpg", "en": null }. Anullor empty value skips that position for that language; - if omitted, the gallery is unchanged;
gallery: []orgallery: nullempties it; - it can be a WPML language map of lists, for example
{ "en": ["https://cdn.example.com/en/1.jpg"], "it": ["https://cdn.example.com/it/1.jpg"] }. Each translation receives its own gallery. The resolved value for each language must be a list of URLs (an object or a single string returns400 invalid_param).
- replaces the whole product gallery with the list sent. Items can be remote URLs and/or
- Replaces the product's whole set of custom attributes with the ones sent. A value can be a string/number, a list of values or a WPML language map.
- Global attributes. A key that names a registered global attribute taxonomy (e.g.
pa_color, created withPOST /woocommerce/attributes) sets that global attribute on the product. Each option can be a term ID, slug or name, and the term must already exist (see attribute terms). Otherwise the request returns404 not_found. With WPML the term is mapped to the product's language, or the original term is used when it has no translation. - Any other key creates a custom attribute, including a
pa_*key whose taxonomy does not exist. - Global attributes not sent are kept. When
attributesis a non-empty object, globalpa_*attributes already on the product (for example added by a site admin in WooCommerce) and missing from the payload keep their options, visibility and variation flag. - When the product is
variable(becauseprops.product_typesays so, or because it already is andproduct_typeis omitted), the attributes sent are flagged as usable by variations (variation=true). - If
attributesis omitted, existing attributes are not changed. If it isnullor{}, every attribute is removed, custom and global, including global attributes a site admin added. On avariableproduct, the plugin-managed variations built on them becomeprivate(see below). - Inside
attributes, a key whose value isnullor an empty list is left out of the new set. For a globalpa_*key this removes that global attribute from the product. - An empty string, as a value or as a list option, returns
400 invalid_param. - Variations left without an option. On a
variableproduct, after a save that sendsattributes, a plugin-managed variation (one with alocal_key) that uses a value the parent no longer offers is madeprivate. Nothing is deleted. Its previous status is kept in the_onpage_held_statusmeta, and the parent is resynced.- The next
POST /woocommerce/variant-productsof that variation whose attribute values are all offered again (sent in the payload or already stored) applies the payloadstatus, or the held one when nostatusis sent, and removes the meta. - If the variation still uses a value the parent does not offer, it stays
privateand the requested status is held instead. Sendingstatus: "private"clears the held status.
- The next
- Reserved for the product's ACF fields. Keys must be ACF technical names or ACF field keys that exist on the product's field group. Unknown fields return
400 invalid_param. - For the full type semantics (
tab,repeater,image/fileURL →attachment_id, etc.) see Handlingacf_fields.
- Manages native WooCommerce downloadable files, separate from ACF. It can be a list of objects or
null. - Each item accepts
urlorfilewith a valid remote URL, or theattachment_id(JSON integer) of a file already in the Media Library (e.g. uploaded withPOST /media).filewins when both are sent. The value can benullor an empty string to skip that download in the resolved language.name,idandpublicare optional. - Without
name, the download is named after the file name in its (local) file URL. - Download
id. WooCommerce keys customers' download permissions by product and downloadid, so theidmust stay the same across syncs. Whenidis sent, it is used as is. When it is omitted, the plugin picks a stable one:- the
idof the download the product already has for the same file URL (this also keeps the random ids of earlier plugin versions); - otherwise an id derived from the file's source: a UUID-shaped MD5 of the On Page® storage segment, or of the URL, or of
attachment:<id>. The same file gets the same id on every sync and in every language. - Two entries of the same payload never share an id: a second entry for the same file gets a different derived id.
- With
refresh: truethe local file URL can change. The old id is then replaced once by a derived one.
- the
- If
downloads.filedoes not already point to a URL inside the site's uploads directory, the file is imported/reused in the Media Library and WooCommerce uses the imported local URL. - Approved download directories. When WooCommerce's approved-directories feature is enabled, the plugin approves the uploads base URL. The remote URL is never approved. A local file served from outside it (for example by a CDN offload plugin) gets an approved-directory rule for its own directory.
- If
downloads.fileis anattachment_id, nothing is downloaded: the plugin only checks that the ID matches an existing attachment and uses its local URL directly. - If the remote URL was already imported, the existing attachment is reused without downloading the file again.
- If a remote PDF changes while keeping the same URL, pass
refresh: trueon the download to replace the attachment in the Media Library, keeping the same attachment ID when possible. - Downloads managed by this endpoint are attached to the parent product in the Media Library.
public(boolean, defaulttrue) decides whether the download is listed with a direct link in the documents section of the WooCommerce product page. Use it for data sheets or manuals anyone may read.falsekeeps it a regular WooCommerce download, delivered only to customers who bought the product. A value that is not a JSON boolean ("false",0,null) returns400 invalid_paramwith the messageParameter 'downloads.N.public' must be a boolean, before anything is saved.GET /woocommerce/productsreturns each download underwoocommerce.downloadswith the keysid,name,file,enabledandpublic.- An error while importing a download file (a non-public host, a file over the size limit, a failed download) is returned as
500 request_failed, with the messageWooCommerce Product :: Element N :: Failed to import downloadable file for 'downloads.N.file' :: <original message>. Downloads use a 12-second timeout. - If
downloadscontains at least one valid file, the product is automatically flagged as downloadable (downloadable=true). - For
variableproducts, WooCommerce shows downloads in the admin on individual variations: the endpoint automatically copies the parent's downloads to the existing variations.- Only variations that inherit the parent's downloads are updated. The last copied set is fingerprinted in the
_onpage_inherited_downloadsmeta. - A variation whose own non-empty downloads differ from that fingerprint was edited on purpose and is skipped.
- A variation without a fingerprint (saved by an earlier plugin version) is overwritten once, then tracked.
- A variation already in sync is not saved again.
- Only variations that inherit the parent's downloads are updated. The last copied set is fingerprinted in the
downloads: []ordownloads: nullremoves all WooCommerce downloadable files from the product.name,urlandfileinsidedownloadscan be WPML language maps. Each translation receives its own resolved value;nullor empty values are ignored.
brandassigns a single reference from theproduct_brandtaxonomy. The reference is the brand'slocal_key(integer or string); slugs are not supported.nullremoves the brand if the taxonomy exists. Abrandreference when theproduct_brandtaxonomy does not exist returns404 not_found.categoriesreplaces the whole set ofproduct_catcategories with the references sent. Each reference is the category'slocal_key(integer or string); slugs are not supported.tagsreplaces the whole set ofproduct_tagtags with the references sent. Each reference is the tag'slocal_key(integer or string); slugs are not supported.categoriesandtagscan be lists oflocal_keys, for example[6001]or[7001, 7002]. A bare value outside a list ("categories": 6001) returns400 invalid_param.categoriesandtagscan be WPML language maps, for example{ "en": 6011, "it": 6013 }. Each product translation uses thelocal_keyof its language. Since thelocal_keyidentifies the whole translation group anyway, a single value is usually enough.- Inside a language map, each value can be a single
local_key, a list oflocal_keys, ornullto assign no terms in that language. - If
brand,categoriesortagsare omitted, that taxonomy is not changed.brand: nullremoves the brand;categories: [],categories: null,tags: []ortags: nullremove categories or tags. terms(optional) assigns terms from any taxonomy registered on the product (including ACF/custom taxonomies such astipologia), in addition to brand/categories/tags:- it is an object
{"<taxonomy_slug>": <references>}; - references follow the same rules as
categories/tags: a list oflocal_keys (positive integers or non-empty strings), a WPML language map, ornull/[]to empty that taxonomy. A single value is accepted only inside a language map; - each taxonomy sent replaces its whole assigned set;
- references are resolved by
local_key, in any taxonomy; slugs are not supported; - if a taxonomy in
termsisproduct_cat/product_tag/product_brand, the dedicated field (categories/tags/brand) wins when present; - an unknown taxonomy returns
404 not_found, but only when references are sent for it (null/[]for an unknown taxonomy is skipped); a term that is not found returns404 input_invalid; - a taxonomy that exists but is not attached to products, or WooCommerce's internal
product_typeandproduct_visibility, returns400 invalid_param. Useprops.product_type,props.featuredandprops.catalog_visibilityinstead.
- it is an object
- To create variations with
/woocommerce/variant-products, the parent product must bevariableand must have variation attributes configured.
Multilingual categories:
"categories": {
"en": 6011,
"it": 6013
}Several categories per language:
"categories": {
"en": [6011, 6012],
"it": [6013, 6014]
}Brand, categories and tags by local_key:
"brand": 4002,
"categories": [6011, 6012],
"tags": [7001, 7002]Variable parent for variations:
[
{
"local_key": 1002,
"name": "Shirt",
"props": {
"product_type": "variable"
},
"attributes": {
"pa_color": ["red", "blue"],
"pa_size": ["s", "m"]
}
}
]Response 200:
[501]Main errors:
400 invalid_paramwhen:- the body is not a valid JSON array, or an element is not a non-empty object;
local_key/nameis missing;idis not a positive integer ornull;nameis not a valid string or language map;props.product_typeis notsimpleorvariable;update_slugis not a boolean;props/attributes/acf_fieldsare not objects;- a
propsvalue does not resolve to a scalar ornull; acf_fieldscontains ACF fields that do not exist for the product;downloadsis not a valid list, ordownloads[].publicis not a boolean;- an
attributesvalue or option is an empty string; categories/tagsare not valid lists or language maps;termsis not a validtaxonomy => referencesobject, or names a taxonomy that is not attached to products (orproduct_type/product_visibility);image,galleryordownloads[].fileis neither an existingattachment_idnor a valid URL;- the host of
imageorgallerydoes not resolve to a public address (see Remote file imports); - WooCommerce rejects a
propsvalue, for example askualready used by another product or an unknowntax_statusorcatalog_visibility.
400 input_invalidif animage/filevalue inacf_fieldsis neither an existingattachment_idnor a valid URL.404 not_foundif theidsent, a taxonomy intermsor a global attribute term inattributesdoes not exist.404 input_invalidif a referenced term (brand,categories,tags,terms) does not exist.409 duplicate_local_keyonly with an explicitidin the payload, when thatlocal_keybelongs to a different product. Withoutid, the key identifies the product to update and any leftovers are reconciled.409 duplicate_titleif the title already exists on an object without alocal_key, outside this element's translation group. An object carrying a differentlocal_keyis another On Page® element and does not conflict, so two elements with the same title are both imported.500 woocommerce_requiredif WooCommerce is not active.413 file_too_largeifimageorgallerypoints to a file over the size limit.500 acf_errorif ACF refuses to save a field.500 wpml_requiredif the payload contains multilingual values but WPML is not installed or active. Language maps cannot be handled until WPML is installed and activated.500 wpml_errorif WPML cannot resolve the translation group of the product.500 request_failedif WooCommerce or WordPress fail to save the product, or adownloadsfile cannot be imported.
Deletes WooCommerce products by local_key. If several products share the same local_key (WPML translations), all of them are deleted: the whole translation group, as for brands.
Body:
[1001]Optional query:
?ignore=1
Response 200:
nullMain errors:
400 invalid_paramif the body is not a JSON array, or an element is not a validlocal_key(WooCommerce Product :: Element N :: Invalid delete value; expected a positive integer or non-empty string local_key)404 not_foundif alocal_keymatches no product andignoreis not set500 delete_failedif a product could not be deleted500 woocommerce_required
Returns WooCommerce variations (product_variation). Without a query, it returns every variation.
Pagination: none. The response always contains all variations matching the filters; there is no per_page/page. On very large catalogs the response can be heavy.
Optional query:
?id=701
?local_key=2001
?parent_id=501
?parent=1002
parent_idis the WordPress ID of the parent product;parentis the parent product'slocal_key.- Only one filter applies. Precedence:
id>local_key>parent_id>parent. A malformed value is ignored. idreturns that variation. A variation that does not exist returns404 not_found.local_keyreturns the first variation (lowest ID) that holds the key, as a one-element list. A key that matches nothing returns[].parentresolves the parent product bylocal_key. A parent that does not exist returns404 not_found.parent_idis not checked: an ID that is not a product returns[].
Response 200:
[
{
"id": 701,
"parent_id": 501,
"parent": 1002,
"local_key": 2001,
"status": "publish",
"description": "Red / Small",
"attributes": {
"pa_color": "red",
"pa_size": "s"
},
"woocommerce": {
"product_type": "variation",
"sku": "SHIRT-RED-S",
"regular_price": "29.90",
"sale_price": "",
"price": "29.90",
"stock_status": "instock",
"image_id": 601
},
"acf_fields": {
"material": "cotton"
}
}
]The example is abridged. woocommerce also contains manage_stock, stock_quantity, backorders, weight, length, width, height, virtual, downloadable, tax_class, menu_order and permalink.
Creates or updates WooCommerce variations for an existing variable parent product.
Body:
[
{
"local_key": 2001,
"parent": 1002,
"attributes": {
"pa_color": "red",
"pa_size": "s"
},
"props": {
"sku": "SHIRT-RED-S",
"regular_price": "29.90",
"manage_stock": true,
"stock_quantity": 10,
"stock_status": "instock"
},
"description": "Red / Small",
"status": "publish",
"image": "https://cdn.example.com/shirt-red-s.jpg",
"acf_fields": {
"material": "cotton",
"swatch": "https://cdn.example.com/swatch-red.jpg"
}
}
]local_keyis required and is saved as theonpage_local_keypost meta on the variation.- Resolution: if the element contains a valid
id, that variation is updated. Ifidis missing but thelocal_keyalready exists under the parent, that variation is updated. Otherwise a new variation is created. - A malformed
id(not a positive ID) is silently ignored, as if it were absent. - An explicit
idthat names a variation of another parent returns409 duplicate_local_key(… local_key '<key>' already exists for another parent product). - You must send
parent_id(WordPress ID) orparent(local_key).parent_idwins when both are sent. Both must then refer to the same product or to translations of it (same WPML translation group); otherwise the request returns409 parent_mismatch. - The parent must be a
variableWooCommerce product. It can be created or converted withPOST /woocommerce/productsusingprops.product_type: "variable". - The endpoint does not create or configure the parent's attributes automatically.
- Required on create, and must be a non-empty object. On update it can be omitted to leave the variation's attributes unchanged. When sent,
{}ornullreturns400 invalid_param(Parameter 'attributes' must not be empty). - Each attribute sent must exist on the parent and be flagged as a variation attribute (
variation=true). - Each variation attribute must resolve to a single scalar option. WPML language maps such as
{ "en": "Red", "it": "Rosso" }are accepted. Lists such as["Petrol", "Hybrid"]are not valid for a single variation. - If a variation attribute contains a language map but WPML is not installed or active, the request returns
500 wpml_required. - For global attributes (
pa_color) the value can be the term slug, name or ID. The variation stores the WooCommerce term slug. The term must be one of the options set on the parent, through the product'sattributesor in the WooCommerce admin. - For custom attributes, the value must be one of the options configured on the parent.
- A value that is not one of the parent's options returns
400 invalid_param(… option '<value>' is not enabled on the parent product). A parent attribute that has no options at all accepts any value. - Unique combination. Two variations of the same parent cannot have the same attribute combination: WooCommerce would always sell the first one. When
attributesis sent and another variation of the parent (not in the trash) already has the same values, the request returns409 duplicate_variation, for exampleWooCommerce Variant Product :: Element 0 :: Parent product 501 already has variation 702 with attributes [pa_color=red, size=(any)].- Values are compared case-insensitively.
- A parent variation attribute that the variation leaves unset counts as "any" (
(any)in the message).
-
name(optional) sets the variation name. It can be a string or a WPML language map. An empty value leaves the name unchanged; a value that resolves to an object or a list returns400 invalid_param. -
short_description(optional) sets the variation description, shown asdescriptioninGETresponses.descriptionis accepted as an alias;short_descriptionwins when both are sent. Both can be WPML language maps, andnullclears the description. -
long_descriptionis not saved on variations. It is only read to detect the payload languages. -
Variations have no
terms,gallery,slugordownloads. These keys, and any other unknown key, are ignored with no error. -
propsaccepts:sku,regular_price,sale_price,price,manage_stock,stock_quantity,stock_status,backorders,weight,length,width,height,virtual,downloadable,tax_class,menu_order,image_id. -
Each
propsvalue can be a WPML language map, as for products. The value resolved for a language must be a scalar ornull; an object or a list returns400 invalid_paraminstead of clearing the field. -
With WPML,
props.skuis applied only to the source variation: WooCommerce requires globally unique SKUs, so translated variations cannot store the same SKU. -
statuson variations:- optional. When omitted on update, the variation keeps its current status;
- any value other than those below returns
400 invalid_paramwith the messageParameter 'status' must be one of: publish, private, enabled, disabled, draft, pending; publish/enabled→ enabled variation;private/disabled→ disabled variation;- for compatibility,
draftandpendingare saved asprivate, because the WooCommerce admin does not show variations with statusdraft; - a variation that uses an attribute value the parent no longer offers stays
private, and the requested status is held until its attributes are valid again (seeattributesof products).
-
image:- a remote URL to import and set as the variation image, or the
attachment_id(JSON integer) of a file already in the Media Library (e.g. uploaded withPOST /media); image: nullremoves the image;- it can be a WPML language map, for example
{ "en": "https://cdn.example.com/en/shirt.jpg", "it": "https://cdn.example.com/it/shirt.jpg" }. Each translated variation receives its own image.
- a remote URL to import and set as the variation image, or the
- Optional associative object
field_name => valuefor ACF fields attached to theproduct_variationpost type. - Keys must be ACF technical names or ACF field keys that exist on the variation's field group. Unknown fields return
400 invalid_param. - An
imageorfileACF field with a valid URL is imported or reused from the Media Library, and the field stores theattachment_id. To clear animageorfilefield, passnull, an empty string,0or"0". - Every value in
acf_fieldsaccepts a WPML language map. Each translated variation receives the value for its language.
- If the variable parent has native WooCommerce downloads, the variation inherits them automatically, so they are visible in the WooCommerce UI. A variation with its own, different downloads is left alone (see
downloads). - After saving the variation, the endpoint syncs the variable parent and clears the parent's WooCommerce transients.
- If
name,description,short_description,long_description,image,attributes,acf_fieldsor apropsvalue contain language maps, the endpoint updates the variation of the resolved parent. It then creates or updates the variations for the payload languages that already have a translation of the parent. - Languages without a translated parent are ignored until that parent translation exists.
- On update, a language map writes only the languages it contains, as on
POST /woocommerce/products.{"short_description": {"en": "Red"}}changes the English variation and leaves the Italian description as it is. The same applies toname,description,image, each value inprops,acf_fieldsandattributes. An attribute sent as a map without a language keeps that language's current value. This includes the variation in the parent's language: a map without that language leaves it as it is. - Shared values are written to every existing translated variation, including those in a language no map contains. A language that no map contains is never created. A new translation created by the same request still falls back to the first language of the payload, so it never starts empty.
- Each translated variation is looked up under its own translated parent: first the variation of that language in the WPML group of the source variation, then the variation with the same
local_key. If neither exists, it is created there and linked to the WPML group. This also works when the payload has anid: theidnames the variation in the parent's language only. - The response contains the variation ID in the language of the parent resolved from
parent_idorparent. The other translated variations are created or updated in the same batch.
Response 200:
[701]Main errors:
400 invalid_paramwhen:- the body is not a valid JSON array, or an element is not a non-empty object;
local_keyis missing;- the parent is missing;
- the parent is not
variable; attributesoracf_fieldsare not valid objects, orattributesis empty;- an attribute is not configured as a variation attribute on the parent, or its value is not an option enabled on the parent;
statusis not one of the supported values;imageis neither an existingattachment_idnor a valid URL, or its host does not resolve to a public address;acf_fieldscontains ACF fields that do not exist for the variation;- a
propsvalue does not resolve to a scalar ornull; - WooCommerce rejects a
propsvalue, for example askualready used by another product.
400 input_invalidif animage/filevalue inacf_fieldsis neither an existingattachment_idnor a valid URL.404 not_foundif the parent, the variation or an attribute term does not exist.409 duplicate_local_keyif thelocal_keyalready belongs to another variation of the same parent, or to a variation of a parent outside this parent's translation group, or if the explicitidnames a variation of another parent.409 duplicate_variationif another variation of the parent already has the same attribute combination.409 parent_mismatchifparent_idandparentare not the same product or translations of it.413 file_too_largeifimagepoints to a file over the size limit.500 acf_errorif ACF refuses to save a field.500 woocommerce_requiredif WooCommerce is not active.500 wpml_requiredif the payload contains multilingual values but WPML is not installed or active.500 wpml_errorif WPML cannot resolve the translation group of the variation.500 request_failedif WooCommerce or WordPress fail to save the variation.
Deletes WooCommerce variations by local_key. If several variations share the same local_key (one per language of the parent's group), all of them are deleted.
After the deletions, each parent that lost a variation is resynced once (WC_Product_Variable::sync()), so its price range, _price and stock status no longer count the deleted variations.
Body:
[2001]Optional query:
?ignore=1
Response 200:
nullMain errors:
400 invalid_paramif the body is not a JSON array, or an element is not a validlocal_key(WooCommerce Variant Product :: Element N :: Invalid delete value; expected a positive integer or non-empty string local_key)404 not_foundif alocal_keymatches no variation andignoreis not set500 delete_failedif a variation could not be deleted500 request_failedif a parent product cannot be resynced500 woocommerce_required
Returns the list of media attachments (Media Library), with optional filters. Use it to find the attachment_ids to pass to DELETE /media, and to find files already in the library by token without uploading them again.
Pagination: yes, through per_page/page and the X-WP-Total/X-WP-TotalPages headers (details below).
Optional query:
?post_id=321
?mime_type=image/jpeg
?token=aaa111bbb222.1920x1920-contain.jpg
?token=aaa111bbb222.1920x1920-contain.jpg,ccc333ddd444.600x600-contain.webp
?source_url=https://storage.onpage.it/aaa111bbb222.1920x1920-contain.jpg/foto.jpg
?page=1
?per_page=100
| Parameter | Description |
|---|---|
post_id |
Filters by parent post (post_parent). 0 returns the media attached to no post. A value that is not a positive ID or 0 returns 400 invalid_param. The post itself is not checked: a positive ID that matches no post returns 200 with []. |
mime_type |
Filters by MIME type, with WordPress post_mime_type rules: a full type (image/jpeg) or a main type alone (image, which matches every image). |
token |
Filters by On Page® storage segment (_onpage_file_token, see POST /media). Accepts several comma-separated tokens, so files already in the library can be re-adopted in bulk with one request instead of one per file. Empty tokens are ignored; if none is left, the request returns 400 invalid_param. |
source_url |
Filters by exact source URL (_onpage_source_url). Single value. |
per_page |
Default 100, maximum 100. |
page |
Default 1. |
Notes:
- The list stays paginated with
tokentoo: if you ask for more thanper_pagetokens (max100), results span several pages. Each row reports its owntoken, so map tokens to attachments from the response. tokenandsource_urlare combined with the other filters (post_id,mime_type) with AND.- Results are sorted by creation date, newest first.
- Only attachments with the WordPress status
inheritare listed.
Response 200:
[
{
"attachment_id": 501,
"filename": "image-1.jpg",
"title": "image-1",
"url": "https://example.com/wp-content/uploads/2026/04/image-1.jpg",
"mime_type": "image/jpeg",
"post_id": 321,
"hash": "1d8b874f1a5f4a9f9b8c3e4f6a7b2c5d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b",
"token": "aaa111bbb222.1920x1920-contain.jpg",
"source_url": "https://storage.onpage.it/aaa111bbb222.1920x1920-contain.jpg/image-1.jpg"
},
{
"attachment_id": 502,
"filename": "brochure.pdf",
"title": "brochure",
"url": "https://example.com/wp-content/uploads/2026/04/brochure.pdf",
"mime_type": "application/pdf",
"post_id": 0,
"hash": null,
"token": null,
"source_url": null
}
]Response notes:
hashis the SHA-256 checksum of the physical file's content (hash_file('sha256', ...)onget_attached_file()). It is cached in the_onpage_file_hashattachment meta, together with the file path, size and modification time it was computed for. When one of them changes, the file is hashed again. It isnullif the physical file is missing or unreadable.tokenis the On Page® storage segment indexed on the attachment. It isnullfor media added in other ways (manually in the Media Library, imported from a URL that is not an On Page® storage URL, or imported before the segment was indexed; seePOST /migration).- Earlier plugin versions could index a wrong segment for URLs outside
onpage.it. Such a value is still shown here and can still be found with?token=, but the URL imports no longer use it for deduplication.
- Earlier plugin versions could index a wrong segment for URLs outside
source_urlis the remote URL the media was imported from. It isnullfor files uploaded directly.
Pagination headers:
X-WP-Total: total number of attachments matching all the filters (post_id,mime_type,token,source_url), regardless of the page.X-WP-TotalPages: total number of pages,ceil(X-WP-Total / per_page).- The client knows from the current response whether it is on the last page (
page >= X-WP-TotalPages). There is no need to request an extra page and wait for an empty array.
Walk-through (250 attachments, per_page=100 → 3 pages):
GET /media?page=1&per_page=100 → X-WP-Total: 250, X-WP-TotalPages: 3 (continue)
GET /media?page=2&per_page=100 → X-WP-Total: 250, X-WP-TotalPages: 3 (continue)
GET /media?page=3&per_page=100 → X-WP-Total: 250, X-WP-TotalPages: 3 (page == X-WP-TotalPages → stop, no request for page=4)
Uploads one or more files via multipart/form-data, using the native WordPress media flow.
Content-Type:
multipart/form-dataSupported fields:
| Field | Description |
|---|---|
| file fields | One or more file fields, e.g. file, files[] or any other name compatible with PHP $_FILES. |
post_id |
Optional. Attaches the attachments to an existing post. |
attachment_id |
Optional. Replaces the content of an existing attachment, when uploading a single file. With more than one file it returns 400 invalid_param. |
attachment_ids |
Optional. Array aligned with the uploaded files, to replace one or more existing attachments in multi-file requests (also accepted as a JSON string). Empty positions (null or empty string) mean "no replacement for that file". Entries beyond the number of files are ignored. |
token |
Optional. The On Page® storage segment the file comes from (<token>[.<format>], e.g. aaa111bbb222.1920x1920-contain.jpg), when uploading a single file. |
tokens |
Optional. Array aligned with the uploaded files, same meaning, for multi-file requests (also accepted as a JSON string). Empty positions (null or empty string) mean "no token for that file". |
Behaviour:
- The controller accepts both single files and arrays of files in the same field.
- Files are flattened into one list and processed in order.
- Each file is saved with
wp_handle_upload(). - SVG. A file whose name ends in
.svgis sanitized in place (Svg::sanitizeFile()) beforewp_handle_upload(), as for remote imports. If sanitizing fails, the request returns500 request_failed(Media :: Element N :: Failed to upload file '<name>' :: <reason>, orreplacewhen an attachment is replaced). WordPress itself still refuses SVG uploads unless another plugin allows the type; in that case the request fails with500 upload_failed. - If no existing attachment is given, the controller creates a new attachment with
wp_insert_attachment(). - If
attachment_idor a value inattachment_idsis given, the controller keeps the same WordPress attachment and replaces only the physical file and metadata. - Token:
- The
tokenis saved on the attachment in the_onpage_file_tokenmeta, exactly as sent. - It identifies the file's content, not its location. The same file requested in different formats (
.1920x1920-contain.jpg,.600x600-contain.webp) therefore stays on separate attachments. - The endpoint is idempotent on the token: if a
tokenis already on an attachment in the library (and its physical file still exists), the uploaded bytes are discarded and that attachment is returned withaction: "linked", without creating anything. Ifpost_idis set, the existing attachment is still re-attached to that post. - An explicit
attachment_id/attachment_idstakes precedence: the replacement always happens, and anytokensent is rewritten on the replaced attachment. A replacement without atokenremoves the stored token, because it described the bytes just overwritten.
- The
- Metadata is generated with
wp_generate_attachment_metadata(). - When an existing attachment is replaced, the old file and its generated sizes are removed.
- If a file fails, the request stops immediately and returns a
WP_Error.
Multiple upload:
curl -X POST \
"https://example.com/wp-json/onpage/v1/media" \
-H "Authorization: Bearer <token>" \
-F "files[]=@/path/image-1.jpg" \
-F "files[]=@/path/image-2.png" \
-F "post_id=321"Single-field upload:
curl -X POST \
"https://example.com/wp-json/onpage/v1/media" \
-H "Authorization: Bearer <token>" \
-F "file=@/path/brochure.pdf"Replacing an existing attachment:
curl -X POST \
"https://example.com/wp-json/onpage/v1/media" \
-H "Authorization: Bearer <token>" \
-F "file=@/path/image-new.jpg" \
-F "attachment_id=501"Upload with the On Page® storage token:
curl -X POST \
"https://example.com/wp-json/onpage/v1/media" \
-H "Authorization: Bearer <token>" \
-F "file=@/path/image-1.jpg" \
-F "token=aaa111bbb222.1920x1920-contain.jpg"Multiple upload with tokens aligned to the files:
curl -X POST \
"https://example.com/wp-json/onpage/v1/media" \
-H "Authorization: Bearer <token>" \
-F "files[]=@/path/image-1.jpg" \
-F "files[]=@/path/image-2.png" \
-F "tokens[]=aaa111bbb222.1920x1920-contain.jpg" \
-F "tokens[]=ccc333ddd444.1920x1920-contain.png"Mixed create + replace in the same multiple upload:
curl -X POST \
"https://example.com/wp-json/onpage/v1/media" \
-H "Authorization: Bearer <token>" \
-F "files[]=@/path/image-1.jpg" \
-F "files[]=@/path/image-2.png" \
-F "attachment_ids[]=501" \
-F "attachment_ids[]="Response 200:
[
{
"field": "files",
"key": "0",
"action": "created",
"attachment_id": 501,
"filename": "image-1.jpg",
"title": "image-1",
"url": "https://example.com/wp-content/uploads/2026/04/image-1.jpg",
"mime_type": "image/jpeg",
"post_id": 321
},
{
"field": "files",
"key": "1",
"action": "replaced",
"attachment_id": 502,
"filename": "image-2.png",
"title": "image-2",
"url": "https://example.com/wp-content/uploads/2026/04/image-2.png",
"mime_type": "image/png",
"post_id": 321
}
]Response notes:
fieldis the name of the file field received in the form.keyis the original position of the file in the payload. Forfiles[]it is typically"0","1", and so on. For a single, non-array field (file=@…) it is"". For nested arrays the positions are joined with dots, for example"0.1".actionis:createdfor new attachments;replacedwhen an existing attachment was updated;linkedwhen thetokensent was already in the library and the attachment was reused without uploading anything.
tokenis present only if the file was sent with a token, and reports the value indexed on the attachment.post_idis0if the file was not attached to a post.
Main errors:
400 invalid_paramif the request contains no files orpost_idis invalid.400 invalid_paramifattachment_idandattachment_idsare used together or have an invalid format.400 invalid_paramiftokenandtokensare used together, iftokenis used with more than one file, or if a token is not a non-empty string.400 upload_failedif PHP reports an upload error on the file.404 not_foundifpost_idis set but the post does not exist.404 not_foundif a referencedattachment_iddoes not exist.500 upload_failedif WordPress refuses to save the file.500 request_failedif the WordPress attachment is not created or updated correctly.
Imports one or more remote files by URL (or links attachments already in the Media Library by attachment_id). It saves/verifies them in the Media Library, attaches them to an existing post through post_parent, and updates the ACF field whose name matches the key inside files.
Content-Type:
application/jsonBody:
{
"post_id": 321,
"files": {
"image": "https://cdn.example.com/image-1.jpg",
"image_2": "https://cdn.example.com/image-2.jpg"
}
}Behaviour:
post_idis required and must refer to an existing post.- It updates that post only, not its WPML translations. To set a file on every language, send it in
filesonPOST /posts. filesis required and must be a non-empty JSON object.- Each key of
filesis the name of the ACF field to update on the post. It must be an ACF field of the post's type: any other key returns400 invalid_param(Media :: ACF field '<name>' not found for post type '<type>'), checked before anything is downloaded. Only the existence of the field is checked, not its type: any ACF field is accepted. - The entries are processed one by one, in payload order. If an entry fails, the entries before it stay saved.
- Each value of
filesmust be a valid URL reachable by WordPress, theattachment_id(JSON integer) of a file already in the Media Library (e.g. uploaded withPOST /media), ornull. - Each remote file is downloaded and imported into the Media Library through the WordPress sideload flow. An
attachment_idis instead verified and re-attached to the requestedpost_id, without download. nullclears the ACF field. Nothing is downloaded or deleted.- If the same URL was already imported by the plugin, the same attachment is reused and re-attached to the requested
post_id. - After the import, reuse or direct link, the controller saves the resulting
attachment_idin the matching ACF field. - For URLs, the controller saves the source URL in the
_onpage_source_urlmeta. - On Page® storage URLs (
https://storage.onpage.it/<token>[.<format>]/<name>, orhttps://<host>/api/storage/<token>[.<format>]/<name>where<host>isonpage.itor a subdomain, such ashttps://app.onpage.it/api/storage/…):- the controller extracts the segment and also saves it in the
_onpage_file_tokenmeta, the same index used byPOST /media; - reuse looks up the segment first, then the exact URL. A file renamed on On Page® changes URL but not segment, so it is neither downloaded again nor duplicated;
- URLs from other hosts produce no segment, even when their path looks the same, and are reused by exact URL only.
- the controller extracts the segment and also saves it in the
- The allowed file types are listed in Remote file imports.
- The response returns one item per entry in
files, in payload order.
Example:
curl -X POST \
"https://example.com/wp-json/onpage/v1/media/link" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"post_id": 321,
"files": {
"image": "https://cdn.example.com/image-1.jpg",
"image_2": "https://cdn.example.com/image-2.jpg"
}
}'Response 200:
[
{
"field": "image",
"action": "created",
"attachment": {
"action": "created",
"attachment_id": 601,
"filename": "image-1.jpg",
"title": "image-1",
"url": "https://example.com/wp-content/uploads/2026/04/image-1.jpg",
"mime_type": "image/jpeg",
"post_id": 321,
"source_url": "https://cdn.example.com/image-1.jpg"
}
},
{
"field": "image_2",
"action": "linked",
"attachment": {
"action": "linked",
"attachment_id": 455,
"filename": "image-2.jpg",
"title": "image-2",
"url": "https://example.com/wp-content/uploads/2026/03/image-2.jpg",
"mime_type": "image/jpeg",
"post_id": 321,
"source_url": "https://cdn.example.com/image-2.jpg"
}
}
]Response notes:
fieldis the key offiles, i.e. the ACF field name.actionis:createdwhen the URL was downloaded into a new attachment;linkedwhen an existing attachment was reused: a URL already imported, or anattachment_id;clearedwhen the value wasnull.
attachmentdescribes the attachment now stored in the field. It has the sameaction.source_urlis present only for URL values.- For
cleared,attachmentisnull:{"field": "image", "action": "cleared", "attachment": null}.
Main errors:
400 invalid_paramif the body is not a valid JSON object.400 invalid_paramifpost_idis not a positive integer.400 invalid_paramiffilesis not a non-empty object.400 invalid_paramif a key offilesis not an ACF field of the post type (Media :: ACF field '<name>' not found for post type '<type>').400 invalid_paramif a value infilesis neither an existingattachment_id, a valid URL nornull.404 not_foundifpost_iddoes not exist.400 invalid_paramif the URL's host does not resolve to a public address (see Remote file imports).413 file_too_largeif the remote file is larger than the size limit.500 request_failedif downloading or importing the remote file fails, including a file type WordPress refuses or a redirect to a non-public address (see Remote file imports).500 acf_errorif ACF cannot save the field.
Deletes one or more media attachments by numeric ID.
Body:
[501, 502, 503]Optional query:
?ignore=1
Behaviour:
- The body must be a JSON array (see Request bodies).
[]does nothing and returns200withnull. - Each element must be a positive numeric ID (see Numeric IDs). The lenient rule applies:
2.0and" 12"are accepted.true,"1e3"or1.9are rejected, not cast. - Only posts of type
attachmentare deleted. - Deletion uses
wp_delete_attachment($id, true), so it is forced. It also removes the physical file and all associated metadata, including the On Page® index (_onpage_file_tokenand_onpage_source_url). After deletion the media can no longer be re-adopted by token. - If an element fails, the request stops immediately.
- With
?ignore(any value, see?ignoreon DELETE), attachments that are not found are skipped without error. For those IDs, any orphaned On Page® index left behind (attachment deleted outside WordPress) is still removed.
Example:
curl -X DELETE \
"https://example.com/wp-json/onpage/v1/media?ignore=1" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '[501, 502, 503]'Response 200:
nullMain errors:
400 invalid_paramif the body is not a JSON array (Media :: Request body must be a JSON array) or contains invalid IDs.404 not_foundif an attachment does not exist andignoreis not set.500 delete_failedif WordPress fails to delete an attachment.
Returns all ACF taxonomies.
Pagination: none. The response always contains all taxonomies; there is no per_page/page.
Response 200:
[
{
"id": 67,
"key": "brand",
"singular_label": {
"it": "Brand",
"en": "Brand"
},
"plural_label": {
"it": "Brands",
"en": "Brands"
},
"description": "Product brand",
"hierarchical": false
}
]singular_labelandplural_labelcan be plain strings orlanguage => labelmaps, depending on the static data saved by the plugin.
Creates or updates one or more ACF taxonomies (upsert by key).
Example payload:
[
{
"key": "brand",
"singular_label": {
"it": "Brand",
"en": "Brand"
},
"plural_label": {
"it": "Brands",
"en": "Brands"
},
"description": "Product brand",
"hierarchical": false
}
]| Field | Required | Notes |
|---|---|---|
key |
yes | The taxonomy slug: at most 32 lowercase letters, digits, _ or - (the form sanitize_key() keeps). Any other value, such as Brand or my type, returns 400 invalid_param. |
singular_label |
yes | A missing or empty label returns 400 invalid_param. |
plural_label |
yes | A missing or empty label returns 400 invalid_param. |
description |
no | |
hierarchical |
no | Default false. |
object_type |
no | List of post types the taxonomy is attached to. Default []. |
capabilities |
no | Object with manage_terms, edit_terms, delete_terms and assign_terms. Defaults: manage_categories for the first three, edit_posts for assign_terms. The legacy key deleteTerms is still read when delete_terms is absent. |
Other ACF taxonomy settings are also accepted with their ACF names, for example public, show_ui, show_in_rest, show_admin_column, rewrite and default_term.
Behaviour:
- Idempotent upsert. If a taxonomy with the same
keyexists, it is updated in place (same ACF ID); otherwise it is created. Sending the same payload again converges on the same record, with no duplicate errors. - The
keyis the stable identifier of the taxonomy (also used as its slug). There is no separatelocal_key. - A missing or empty
keyreturns400 invalid_paramwith the messageTaxonomy :: Element N :: Parameter 'key' is required. - Labels can be plain strings or per-language maps.
- If labels are per-language maps but WPML is not installed or active, the request returns
500 wpml_required. - If labels are multilingual, the service saves a custom map in an option (
onpage_taxonomy_label_translations) and also tries to register them in ACFML. - The taxonomy is flagged as translatable in WPML, when available.
flush_rewrite_rules()runs at the end.
Response 200:
[67]Main errors:
400 invalid_paramif the body is not a JSON array, an element is not a non-empty object,keyis missing, empty or not a valid slug, or a label is missing or empty500 acf_error500 wpml_requiredif the payload contains multilingual values but WPML is not installed or active
Deletes taxonomies by ACF ID or by slug.
Body:
[67, "brand"]Optional query:
?ignore=1
Steps performed, in order:
- Deletes all terms of the taxonomy.
- Deletes the ACF taxonomy.
- Deletes the ACF field groups with location
taxonomy == <slug>. - Removes the saved translated labels.
- Removes the taxonomy from WPML's list of translatable taxonomies.
- Runs
flush_rewrite_rules().
Notes:
- An integer is an ACF taxonomy ID. A string is a taxonomy slug. It is matched as sent first, so a taxonomy saved with a key such as
Brandbefore keys were validated can still be deleted, then sanitized withsanitize_key. - The terms of a taxonomy saved with
active: false(not registered) are deleted too. 0or a negative integer is looked up as an ID and matches nothing:404 not_found, or skipped with?ignore. An empty string returns404 not_foundwith the messageTaxonomy :: Taxonomy '' not found, or is skipped with?ignore.- Any other element returns
400 input_invalidwith the messageTaxonomy :: Element N :: Invalid delete value; expected taxonomy ID (int) or slug (string). - Only ACF taxonomies are deleted. With
?ignore, a slug that is not an ACF taxonomy (for exampleproduct_cat) is skipped: its terms, field groups and WPML settings stay untouched.
Response 200:
nullMain errors:
400 invalid_paramif the body is not a JSON array400 input_invalidfor an element that is neither an integer nor a string404 not_foundif an ID or slug is not an ACF taxonomy andignoreis not set500 delete_failedif a term, a field group or the taxonomy cannot be deleted; a WordPress error on a term is appended to the message500 request_failedif the terms of the taxonomy cannot be listed
All term operations go through /terms: list/search (GET), create/update (POST) and delete (DELETE).
/terms (GET: list/search, POST: create/update, DELETE)
The taxonomy is passed as ?taxonomy= (query string, optional on GET/DELETE) or as the taxonomy body field (required on POST). It accepts either the slug (e.g. product_cat, brand, pa_color) or the numeric ACF ID. The slug takes precedence and is recommended (stable across environments, and valid for non-ACF taxonomies too). The numeric ID is still supported for backward compatibility. Example: /terms?taxonomy=product_cat.
There are no /taxonomies/{id}/terms routes: the taxonomy is never part of the path.
Lists or searches terms. The taxonomy goes in the query string (?taxonomy=), like GET /posts?type=.
Pagination: none. The response always contains all terms matching the filters; there is no per_page/page.
Optional query:
?taxonomy=brand
?taxonomy=product_cat&name=Chairs
?taxonomy=brand&name[it]=Acme&name[en]=Acme
?name=Acme
?name[it]=Rosso&name[en]=Red
?taxonomy=product_cat&parent_id=575
?taxonomy=product_cat&parent_lk=6000
| Parameter | Description |
|---|---|
taxonomy |
Optional. Slug or numeric ACF ID. If sent but not resolved → 404 not_found. If omitted, listing and name search cover all taxonomies (each term's taxonomy is taken from the term itself). |
name |
Exact-name search: a string (?name=Chairs, current request language) or a language map (?name[it]=Sedie&name[en]=Chairs, each name searched in its WPML language, union of the results). |
parent_id |
WP term ID of the parent: returns its direct children. Works without taxonomy. |
parent_lk |
local_key of the parent: returns its direct children. Expanded to all WPML translations of the parent (children are searched under each). Requires taxonomy to resolve the local_key (400 if missing). |
parent_idandparent_lkare mutually exclusive (400if both are sent). Aparent_lkthat resolves to no term returns an empty list. Both have lower precedence thanname.- Without
nameorparent_*, the full list of terms is returned (of the given taxonomy, or of all taxonomies iftaxonomyis omitted). - The response is always a list. With
name, it holds one object per distinct translation group (the default-language term is the representative when available), carrying thetranslationsmap. Names that are translations of the same term → one object. Names from different groups → several objects. This mirrorsGET /woocommerce/products?name=. - Without
name, translations are not grouped: every term is a separate item.
Note: without
taxonomyand withoutname, the endpoint returns all terms of all taxonomies. On large installations, always passtaxonomyand/orname.
Response 200:
[
{
"id": 10,
"name": "Acme",
"slug": "acme",
"local_key": 4001,
"description": "",
"parent": 0,
"count": 4,
"taxonomy": "brand",
"translations": {
"it": 10,
"en": 11
},
"acf_fields": {
"logo": 123
}
}
]translationsmaps each language code to the term ID in that language (WPML translation group). Without WPML it is an empty map. It is present in every term response (single, list and search).
Errors:
400 invalid_paramif bothparent_idandparent_lkare sent (Term :: Use only one of 'parent_id' and 'parent_lk')400 invalid_paramifparent_lkis sent withouttaxonomy(Term :: 'parent_lk' requires 'taxonomy')404 not_foundiftaxonomyis sent but cannot be resolved500 request_failedif WordPress fails to list the terms
Creates or updates terms in batch. Each payload element states its own taxonomy, so a single request can write terms of different taxonomies.
Simple payload:
[
{
"taxonomy": "brand",
"name": "Acme",
"slug": "acme",
"description": "Main brand",
"local_key": 4001,
"parent": 0,
"acf_fields": {
"headline": "Official reseller",
"logo": "https://example.com/uploads/acme-logo.png"
}
}
]| Field | Required | Notes |
|---|---|---|
taxonomy |
yes | Slug (e.g. product_cat, brand, pa_color) or numeric ACF ID. Missing → 400 invalid_param; not resolved → 404 not_found. |
id |
no | If present, the term is updated. A malformed id (not a positive ID) is silently ignored: the term is then resolved by local_key or slug, and a new term may be created. |
name |
yes | Required on every save, update included. |
slug |
no | |
description |
no | Omitted on update: the stored description is kept. |
local_key |
no | Stable external identifier. If sent, it must be a positive integer or a non-empty string (400 invalid_param). |
parent |
no | WordPress term ID of the parent. Default 0 (top level). A term that does not exist in the taxonomy returns 404 not_found; a value that is not a term ID, 0 or null returns 400 invalid_param. Unlike id, a malformed parent is never ignored. |
acf_fields |
no | Associative object field_name => value. |
acf_fields behaviour (see also Handling acf_fields):
- Text and scalar fields are saved directly on the term.
- An
imageACF field receiving a valid URL: the file is imported or reused from the Media Library, and theattachment_idis saved. - Remote SVGs are accepted only after validation/sanitization, and are imported as
image/svg+xml. - An
imageACF field receivingnull, an empty string,0or"0"is cleared.
Resolving the term to update:
idfirst, if present (it must exist, otherwise404 not_found).- Otherwise, a term with the same
local_key. - Otherwise, a term with the payload
slugthat has nolocal_keyor the same one; otherwise a new term.
Slugs owned by another element:
- When the term is already resolved (by
idorlocal_key), a term found by the payloadslugreplaces it only if it belongs to the same element: the same term, a member of the same WPML translation group, or a term with the samelocal_key. - If the slug belongs to another element, the element's own term is updated and keeps its current slug. The other term is not touched. The request still returns
200. - A new translation adopts an existing same-language term with its slug only if no other
local_keyowns that term.
local_key behaviour:
- Saved as the
onpage_local_keyterm meta. - Written on the whole WPML translation group of the resolved term. When a term is updated (even by
idonly), thelocal_keyis also applied to translations not included in the payload. 409 duplicate_local_keyis returned only when the request sends an explicitidand thelocal_keyalready exists on another translation group of the same taxonomy. Withoutid, thelocal_keyidentifies the term to update.
name, slug, description and the values inside acf_fields can be sent as per-language maps:
[
{
"taxonomy": "brand",
"local_key": 4001,
"name": {
"it": "Acme Italia",
"en": "Acme"
},
"slug": {
"it": "acme-italia",
"en": "acme"
},
"description": {
"it": "Descrizione italiana",
"en": "English description"
},
"acf_fields": {
"headline": {
"it": "Titolo IT",
"en": "EN title"
},
"logo": {
"it": "https://example.com/uploads/logo-it.png",
"en": "https://example.com/uploads/logo-en.png"
}
}
}
]Slugs of translations:
- If
nameis multilingual butslugis a single string, the slug is applied only to the base term. For translations without an explicitslug, WordPress generates a slug from the translated name. - If a translation's name equals the base-language name (because
nameis a shared string, or because the language map repeats the same value, e.g.{"it":"Legno","en":"Legno"}), the endpoint generates a distinct technical slug per language (<slug-base>-<language>). This is needed because WordPress rejects same-name terms under the same parent. - To control translated slugs, send
slugas a language-value map. - An explicit translated slug equal to the base term's slug is not used. The translation then gets a slug from its translated name, or
<slug-base>-<language>when that name also gives the base slug.
Rules:
- If multilingual values are present and WPML is not installed or active, the request returns
500 wpml_required. - The base language used to create the term is the WPML default language, or else the first language in the payload that actually has a name.
nullvalues per language. Inside anamemap (orslug/description), a language can benullto mean "no translation" (e.g.{"it": "Sigillante Ibrido", "en": null, "es": null}).nulllanguages are ignored: they produce no translation and do not count as a name.- The base term is created in the first language that has a name, even when the WPML default language is one of the
nullones. - The request fails with
400 invalid_param(Name is required) only if no language in the map has a non-empty name.
- Languages not active in WPML. A map is recognized as a language map by its shape (every key looks like a language code). Its codes are then intersected with the active WPML languages, and inactive codes are ignored. For example, when the site only has
itactive and receives{"it": "...", "en": null, "es": null}, onlyitis used.- If none of the codes is active, the map is treated as a shared value. The plugin then uses the first language of the map that has a name.
- At least one language must have a name, otherwise
400 invalid_param.
- Translations are created or updated in the same WPML group as the base term.
- On update, a language map writes only the languages it contains, as on
POST /posts.{"name": {"en": "Chairs", "it": "Sedie"}, "description": {"en": "Comfy"}}changes the English description and leaves the Italian one as it is. The same applies toslugand to each value inacf_fields. A translation is written only whennamehas a value for its language. A new translation created by the same request still falls back to the first language that has a name, so it never starts empty. local_keyis propagated to the translated terms too, as term meta.
Response 200:
[10, 11]Main errors:
400 invalid_paramif the body is not a JSON array, an element is not a non-empty object,taxonomyis missing,local_keyorparentis invalid, or no language has a name400 invalid_paramfor anacf_fieldskey that is not an ACF field of the taxonomy (ACF field '<name>' not found for term '<taxonomy>'), or a malformed repeater, group or flexible content value400 input_invalidif animage/filevalue inacf_fieldsis neither an existingattachment_idnor a valid URL404 not_foundiftaxonomy, theidsent or the parent cannot be resolved409 duplicate_local_keyonly with an explicitid(see above)500 request_failed500 acf_error500 wpml_requiredif the payload contains multilingual values but WPML is not installed or active500 wpml_error
Deletes terms by ID or by local_key. An ID deletes that term only. A local_key deletes every term that holds it, so the whole WPML translation group.
Body:
[10, {"id": 11}, {"local_key": 4001, "taxonomy": "brand"}]Optional query:
?taxonomy=brand
?keyfield=local_key
?ignore=1
Accepted body elements (default mode):
| Element | Effect |
|---|---|
integer, or string of digits only ("10") |
Deletes the term with that ID. Its WPML translations are kept. |
{"id": …} |
Same as a plain ID. id follows the same rule. |
{"local_key": …} |
Deletes every term holding that local_key: the term in every WPML language. |
An object element can also carry taxonomy (slug or numeric ACF ID). It overrides ?taxonomy= for that element.
Notes:
- IDs are strict. A positive JSON integer, or a string of digits only.
"12abc","1.5"," 12",0andtrueare rejected, never cast, so a malformed value never deletes an unrelated term. - A plain string is always an ID. To delete by
local_keywith plain values, use?keyfield=local_key. Every element must then be a validlocal_key(or alocal_keyobject), otherwise400 input_invalid. A boolean is not a validlocal_key:[true]is rejected, it does not delete the term withlocal_key"1". - Any other
keyfield(besides the defaultidandlocal_key) returns400 invalid_keyfield. taxonomyis optional.- With an ID, the term must be in that taxonomy. Without it, the taxonomy is taken from the term, so one request can delete terms of different taxonomies.
- With a
local_key, the key is looked up in that taxonomy only. Without it, the key is looked up in every taxonomy. If it is held by terms of more than one taxonomy, the request returns409 ambiguous_local_keyand deletes nothing for that element. - A
taxonomythat cannot be resolved returns404 not_found.
- An object never falls back to another identifier. It returns
400 input_invalidinstead:- when
local_keyis present (notnull) but invalid ("","0", a boolean, an object…):Term :: Element N :: Parameter 'local_key' must be a positive integer or a non-empty string; - in the default mode, when
idis present (notnull) but not a valid ID:Term :: Element N :: Parameter 'id' must be a term ID (positive integer); - when
taxonomyis present but not a string or number; - when the object has neither
local_keynorid(inkeyfield=local_keymode, nolocal_key).
- when
- Any other element (
null, a float, a list…) returns400 input_invalidwith the messageTerm :: Element N :: Invalid delete value; …. - Missing terms. An ID that does not exist (or is outside the taxonomy) returns
404 not_foundwithTerm :: Element N :: Term <id> not found. Alocal_keyheld by no term returns404 not_foundwithTerm :: Element N :: Term with local_key '<key>' not found. With?ignore, both are skipped. - WPML. With the WPML option that deletes translations together with the original, deleting one ID can also remove its translations. That is WPML's behaviour, not the plugin's. On a
local_keydelete, members already removed that way are skipped, not reported as errors.
Response 200:
nullErrors:
400 invalid_paramif the body is not a JSON array400 input_invalidif an element is not a valid term ID orlocal_keyobject400 invalid_keyfield404 not_foundiftaxonomycannot be resolved, or if a term ID orlocal_keydoes not exist (without?ignore)409 ambiguous_local_keyif alocal_keywithout a taxonomy is held by terms of several taxonomies500 delete_failedif WordPress fails to delete an existing term. A WordPress error message is appended to the message (Term :: Unable to delete :: <WordPress error>).
Returns the WPML languages active on the site and which one is the default.
Use it to check your language codes before you send them. No write endpoint takes a list of languages. The plugin works out which translations to create from the codes it finds in the payload's language maps. A code the site does not have is dropped silently, on purpose: a payload may legitimately carry a language that is not active here. Without this endpoint, a misconfigured language code looked exactly like a missing translation. The elements in that language never appeared, and no error was returned.
Recommended client flow:
- Call this endpoint once, at the start of the import.
- Remove from every language map the codes that are not in
languages, nested maps included. - Leave out a field whose map is left empty, and log the codes you removed.
The step-by-step rules and what happens without the filter are in the integration guide.
languages always lists the default language first. The order of wpml_active_languages is the site's display order and says nothing about the default. The default matters because it is the language of the source element: every other language is a translation of it.
wpml_active tells apart the two reasons languages can hold a single entry or none:
- WPML is installed with a single language;
- the site has no WPML. There, a language map is not ignored: it is rejected with
500 wpml_required.
No payload. Pagination: no.
curl https://<host>/wp-json/onpage/v1/languages \
-H "Authorization: Bearer <token>"Response 200:
{
"wpml_active": true,
"default": "en",
"languages": ["en", "it", "es"]
}On a site without WPML:
{
"wpml_active": false,
"default": null,
"languages": []
}Errors: only the authentication errors.
Removes all On Page® local_key associations (the "indexes") from posts and terms. Call it when the source system regenerates its local_keys: once the keys are cleared at the destination, the next import can reassign them from scratch without creating duplicates.
No payload.
curl -X DELETE https://<host>/wp-json/onpage/v1/indexes \
-H "Authorization: Bearer <token>"Response 200:
{ "posts_removed": 1240, "terms_removed": 312 }It deletes the canonical onpage_local_key meta and the legacy copies local_key / _local_key, from both wp_postmeta and wp_termmeta. It is idempotent.
It does not clear the keys of WooCommerce global attributes. Those live in the onpage_wc_attribute_local_key_{attribute_id} options, not in meta, and survive the call. The terms of an attribute (pa_*) are term meta, so they are cleared like any other term.
Important operational notes:
- Terms. After clearing, terms become "unowned". The re-import re-adopts them by structural identity (name/slug + parent) and writes the new
local_key, without creating duplicates. - Top-down order. The re-import must process parents before children. Right after clearing, a category's
parentis resolved bylocal_key; if the parent has not been re-imported yet, the request returns404 not_found. - Pre-existing duplicates. Duplicate terms created by failed imports remain (orphaned, without a key). For deterministic adoption, send the real
slugin the payload or clean up the empty duplicates. - Products. Products have no structural adoption. After clearing, a product whose title already exists but has no
local_keyis rejected with409 duplicate_title. Such products must be re-imported (by existingid, or by recreating the link). Keep this in mind before clearing thepostmetatoo. - Global attributes. Their old keys are still stored. When you re-import them with regenerated keys, send the existing
slug(or the attributeid):POST /woocommerce/attributesthen finds the attribute by slug and overwrites its key. Without a slug the plugin tries to create a new attribute, and WooCommerce rejects it if the generated slug is already taken. A new key that equals the old key of a different attribute fails with409 duplicate_local_key.
Errors:
500 delete_failedif deleting the meta fails.
Runs the plugin's data migrations: one-off adjustments to data already on the site, required by a change of internal format. Call it once per site after a plugin update. It is idempotent: running it again does no harm, and it is a no-op when there is nothing to migrate.
A site that ran the earlier plugin (main file onpage.php, any version) must call it before the first import. The earlier plugin stored keys in the legacy local_key meta, which the lookups no longer read. Until the migration runs, those posts have no local_key for the plugin, and importing them fails with 409 duplicate_title.
No payload.
curl -X POST https://<host>/wp-json/onpage/v1/migration \
-H "Authorization: Bearer <token>"What it does, in order:
- Renames the
local_key(local_key→onpage_local_key). A post whose key is stored under the legacy metalocal_keygets it underonpage_local_key, the canonical meta.- A post that already has
onpage_local_keykeeps it: its legacylocal_keyrow is deleted instead of renamed, so no post ends up with two key rows. - The other legacy rows in
wp_postmetaare renamed. - The orphaned ACF reference meta
_local_keyis deleted. - The legacy copies in
wp_termmetaare deleted (terms already stored the canonical key, so those were duplicates).
- A post that already has
- Backfills the On Page® storage segment (
_onpage_file_token). Attachments imported by URL before the segment was indexed only have_onpage_source_url. Without the segment, nothing can recognize them when the same file arrives onPOST /media, so they get duplicated. The segment is extracted from the source URL and written on the attachment. Attachments that already have it, and URLs that are not On Page® storage URLs (see Remote file imports), are skipped.
Response 200:
{
"renamed": 1236,
"duplicates_removed": 4,
"acf_reference_removed": 1240,
"term_legacy_removed": 312,
"items": [
{ "post_id": 501, "local_key": "12" },
{ "post_id": 502, "local_key": "SKU-7781" }
],
"media_tokens": {
"scanned": 3480,
"written": 3452
}
}Response notes:
renamedis the number ofwp_postmetarows renamed. It counts only the renamed rows.duplicates_removedis the number of legacylocal_keyrows deleted because their post already hadonpage_local_key.acf_reference_removedandterm_legacy_removedare the number of legacy rows deleted fromwp_postmetaandwp_termmetarespectively.itemslists every post that had a legacylocal_keyrow (renamed or removed as a duplicate), with the key found, for verification. It is captured before the migration and can be long on a large site. Thelocal_keyhere is the raw meta value, so it is always a string (unlike other endpoints, which return an integer when the key is a canonical integer).media_tokens.scannedis the number of attachments examined (those with_onpage_source_urland no segment).media_tokens.writtenis the number that received a segment. The difference is media imported from non-On Page® URLs: they have no segment and remain de-duplicated by exact URL.- On a second run every counter is
0, exceptmedia_tokens.scanned. Media imported from non-On Page® URLs never get a segment, so they are examined again on every run.media_tokens.writtenis0.
Errors:
500 migration_failedif any metadata read or write fails. The migration is not transactional: steps already completed stay applied, and the call can be repeated.
curl -X GET \
"https://example.com/wp-json/onpage/v1/taxonomies" \
-H "Authorization: Bearer <token>"curl -X POST \
"https://example.com/wp-json/onpage/v1/post-types" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '[
{
"post_type": "product",
"singular_label": "Product",
"plural_label": "Products",
"rewrite_slug": "catalog/products"
}
]'curl -X POST \
"https://example.com/wp-json/onpage/v1/terms" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '[
{
"taxonomy": "brand",
"local_key": 4001,
"name": {
"it": "Acme Italia",
"en": "Acme"
},
"acf_fields": {
"headline": {
"it": "Titolo IT",
"en": "EN title"
}
}
}
]'- The API relies on ACF plugin functions such as
acf_update_field_group,acf_update_post_typeandacf_update_taxonomy. - Many endpoints assume ACF is present and correctly initialized.
- Payload semantics are driven by the current controller code, not by a formal OpenAPI schema.
- Updates are partial only in some cases. For example, posts update only the fields sent, but the taxonomies sent are fully reassigned.