Working with the REST API
Call the easy-svg/v1 routes behind the Icons panel: authentication, permissions, every route with its fields, errors and working examples.
Tutorial · Easy SVG
Easy SVG registers seven REST routes under the easy-svg/v1 namespace. The Icons panel in wp-admin uses them to read and save the settings and to add, rename, redraw and delete icons. This tutorial shows how to call the same routes yourself, from a terminal, a deployment script or your own admin JavaScript, and what each one accepts and answers.
easy_svg_rename_icon(), easy_svg_update_icon_markup()) when your code runs inside WordPress.The routes at a glance
| Route | Method | What it does | Reference |
|---|---|---|---|
/wp-json/easy-svg/v1/settings | GET, POST | Reads or saves the feature switches and the size limit | rest-settings |
/wp-json/easy-svg/v1/library | GET | Lists the stored icons, optionally searched | rest-library |
/wp-json/easy-svg/v1/library/add | POST | Adds one icon from SVG markup | rest-library-add |
/wp-json/easy-svg/v1/library/media | POST | Turns SVGs from the media library into icons | rest-library-media |
/wp-json/easy-svg/v1/library/rename | POST | Changes an icon's visible name | rest-library-rename |
/wp-json/easy-svg/v1/library/replace | POST | Gives an icon a new drawing | rest-library-replace |
/wp-json/easy-svg/v1/library/delete | POST | Deletes an icon permanently | rest-library-delete |
All of them are registered on rest_api_init by easy_svg_panel_rest() in includes/panel.php. The routes are always registered, whether or not the icon library switch is on (more on that under Pitfalls).
Authentication
Every route needs a logged-in user. There are two ways to be one.
WordPress has application passwords built in since 5.6. They are meant for exactly this: a script that talks to one site without knowing the account's real password.
Create the password
In wp-admin, go to Users > Profile (or edit the user you want the script to act as) and scroll to Application Passwords. Type a name such as "Icon deploy", click Add New Application Password and copy the password it shows. It is shown once.
Use an administrator
The user needs the manage_options capability (see Permissions). An Editor's application password gets a 403 on every route.
Send it as Basic auth
Pass the user name and the password, spaces included, to curl with -u. Keep the quotes, because of the spaces.
curl https://example.com/wp-json/easy-svg/v1/settings \
-u admin:'abcd efgh ijkl mnop qrst uvwx'
Application passwords only work over HTTPS, unless the site's environment type is local. On plain HTTP WordPress does not offer them at all.
Code that runs in a logged-in admin's browser already carries the login cookie. WordPress accepts that cookie for the REST API only when the request also carries a nonce for the action wp_rest, sent in the X-WP-Nonce header. Without the nonce the request is treated as logged out.
You create the nonce in PHP with wp_create_nonce( 'wp_rest' ) and hand it to your script. On the Icons screen itself Easy SVG already does this: window.EasySvgPanelData.nonce and window.EasySvgPanelData.restRoot are the values the panel uses.
The simplest route is wp.apiFetch: when a script depends on wp-api-fetch, WordPress sets up the root URL and the nonce for it, so you never handle the header yourself.
add_action( 'admin_enqueue_scripts', 'myaddon_enqueue_icon_tools' );
function myaddon_enqueue_icon_tools( $hook_suffix ) {
if ( 'tools_page_myaddon-icons' !== $hook_suffix ) {
return;
}
wp_enqueue_script(
'myaddon-icon-tools',
plugins_url( 'js/icon-tools.js', __FILE__ ),
array( 'wp-api-fetch' ),
'1.0.0',
true
);
}
If you use plain fetch() instead, pass the nonce yourself:
wp_add_inline_script(
'myaddon-icon-tools',
'window.myaddonRest = ' . wp_json_encode(
array(
'root' => esc_url_raw( rest_url( 'easy-svg/v1/' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
)
) . ';',
'before'
);
What a failed login looks like:
| Situation | Status | Code |
|---|---|---|
| No credentials, or a cookie without a nonce | 401 | rest_forbidden |
Logged in, but without manage_options | 403 | rest_forbidden |
| Cookie with a wrong or expired nonce | 403 | rest_cookie_invalid_nonce |
| Wrong application password | 401 | an authentication error from WordPress core |
Permissions
Every route, read or write, uses the same permission callback, easy_svg_panel_rest_permission(). It is one line:
function easy_svg_panel_rest_permission(): bool {
return current_user_can( 'manage_options' );
}
Three things follow from that:
- It is
manage_options, notedit_theme_options. The icon post type itself asks forEASY_SVG_ICON_CAP(edit_theme_options), but the REST routes ask for more. A user who may edit theme options but not manage options cannot use them. - Panel tab capabilities do not apply. An add-on can give a panel tab its own capability (see
easy_svg_panel_tab_capabilities()). That only decides which tabs and menu entries a user sees. The REST routes checkmanage_optionsregardless. - Even the read routes are admin only.
GET /settingsandGET /libraryare not public, because the library response carries every icon's markup.
On a multisite network, manage_options is checked on the site the request goes to, so a site administrator can use the routes on their own site.
How a request is processed
It helps to know the order, because the error you get depends on which step refused.
WordPress validates the arguments before it checks the permission. A request with a missing required field answers 400 even when nobody is logged in. Do not read a 400 as "my credentials are fine".
Every POST handler reads the JSON body and nothing else. Each one starts with $request->get_json_params(). WordPress fills that only when the request says Content-Type: application/json. This has a nasty consequence: the schema check in step C looks at every place a parameter can come from (JSON body, form body, query string), but the handler only looks at the JSON body. So a request can pass validation and still reach the handler empty:
# Passes the schema (id=42 is found in the query string),
# but the handler sees no JSON body, reads id as 0 and answers 404.
curl -X POST 'https://example.com/wp-json/easy-svg/v1/library/delete?id=42' \
-u admin:'abcd efgh ijkl mnop qrst uvwx'
The fix is always the same: send the fields as JSON, with the header.
curl -X POST https://example.com/wp-json/easy-svg/v1/library/delete \
-u admin:'abcd efgh ijkl mnop qrst uvwx' \
-H 'Content-Type: application/json' \
-d '{"id":42}'
The one exception is GET /library, which reads search from the query string, like any GET.
WordPress coerces types before the handler runs. Because the routes declare their arguments, WordPress sanitizes the JSON values to the declared type. "id":"42" becomes the integer 42, and "icons":"false" becomes the boolean false. A value that cannot be coerced ("id":"abc", "icons":"maybe", "max_mb":2.5) is refused with 400 rest_invalid_param.
Error responses
Every error, whether it comes from WordPress or from Easy SVG, has the standard WordPress shape:
{
"code": "easy_svg_add_failed",
"message": "That name cannot be turned into an icon name. Use letters and numbers.",
"data": {
"status": 422
}
}
Schema errors from WordPress add a params object that names the field:
{
"code": "rest_invalid_param",
"message": "Invalid parameter(s): id",
"data": {
"status": 400,
"params": {
"id": "id must be greater than or equal to 1"
}
}
}
Branch on code and data.status. Show message to people, but never parse it: it is translated.
_locale=user to the query string, for example /wp-json/easy-svg/v1/library/add?_locale=user. The Icons panel does this on every call.The library payload
Six of the routes answer with the same object: the current icon list. It is built by easy_svg_panel_library_payload(), and it is always read straight from the database, never from the icon cache, so it reflects the write you just made.
The icons, oldest first (ordered by post ID), at most EASY_SVG_PANEL_LIBRARY_MAX (500). Each entry has id (integer, the esw_icon post ID), slug (string, the name the Icon block and easy_svg_icon() use), label (string, the visible name) and content (string, the stored markup, already sanitized and hardened). Always a list, never an object.
Without a search: how many published icons the site has, which can be more than items holds. With a search: the number of matches returned, which is never more than 500.
Whether the list was cut at 500. Without a search it is true when total is larger than the number of items. With a search it is true when the search filled all 500 places, so a search with exactly 500 matches also reports true.
There is no paging. Icons past the 500th cannot be reached through these routes; a narrower search is the only way to see them.
Settings: GET and POST /settings
Reads or saves the three values on the panel's Settings tab. Full reference: rest-settings.
GET takes no arguments and returns easy_svg_settings(). POST merges the JSON body onto what is stored, runs the result through easy_svg_sanitize_settings(), saves it and returns the settings as they are now. A key you leave out keeps its stored value; an explicit false switches a feature off.
Whether SVG files may be uploaded to the media library. Accepts true, false, and the forms WordPress coerces (1, 0, "true", "false").
Whether the icon library is switched on: the esw_icon post type, the Easy SVG collection for the Icon block and the [easy_svg_icon] shortcode.
The largest SVG the sanitizer parses, in megabytes. The schema refuses anything below 1 (400 rest_invalid_param) and anything that is not a whole number. There is no upper bound in the schema on purpose: a value above the ceiling from easy_svg_max_mb_ceiling() (20 unless the easy_svg_max_mb_ceiling filter raises it) is clamped to the ceiling and saved, not refused.
# Read
curl https://example.com/wp-json/easy-svg/v1/settings \
-u admin:'abcd efgh ijkl mnop qrst uvwx'
# Switch the icon library on, leave the rest alone
curl -X POST https://example.com/wp-json/easy-svg/v1/settings \
-u admin:'abcd efgh ijkl mnop qrst uvwx' \
-H 'Content-Type: application/json' \
-d '{"icons":true}'
const settings = await wp.apiFetch( { path: '/easy-svg/v1/settings' } );
// Ask for 50 MB. With the default ceiling of 20 the answer says 20.
const saved = await wp.apiFetch( {
path: '/easy-svg/v1/settings',
method: 'POST',
data: { max_mb: 50 },
} );
if ( saved.max_mb < 50 ) {
console.info( `Clamped to the ceiling: ${ saved.max_mb } MB` );
}
| Status | Code | When |
|---|---|---|
| 400 | rest_invalid_param | max_mb below 1 or not an integer, a switch that is not a boolean |
Things to know:
- Changes apply from the next request. The upload and icon features are wired when
easy-svg.phploads, so the request that savesicons: truestill runs without the icon library. The response already shows the new value. - Unknown keys are dropped. The sanitizer only returns
svg_upload,iconsandmax_mb. - A form-encoded POST is a silent no-op. The handler sees an empty JSON body, merges nothing and answers 200 with the unchanged settings.
List icons: GET /library
Full reference: rest-library.
Query string parameter. Narrows the list to icons whose visible name or slug contains the term. The markup is never searched. The term is cleaned with sanitize_text_field() and trimmed; an empty term returns the whole library.
curl 'https://example.com/wp-json/easy-svg/v1/library?search=arrow' \
-u admin:'abcd efgh ijkl mnop qrst uvwx'
const term = 'arrow';
const library = await wp.apiFetch( {
path: `/easy-svg/v1/library?search=${ encodeURIComponent( term ) }`,
} );
library.items.forEach( ( icon ) => {
console.log( icon.slug, icon.label );
} );
if ( library.truncated ) {
console.warn( 'Only the first 500 icons were returned. Narrow the search.' );
}
This route has no error of its own beyond authentication. The title part of the search uses WordPress's normal post search, the slug part a separate LIKE query (easy_svg_icon_ids_by_slug_like()), and the two result sets are merged before the 500 cap applies.
Add an icon: POST /library/add
Stores one icon from SVG markup. Full reference: rest-library-add.
The SVG markup as text. It goes through the site's sanitizer, the size and complexity bounds an upload meets, and then a hardening pass that removes anything active, because the Icon block inlines the result. Missing entirely: 400 rest_missing_callback_param. Present but empty or only whitespace: 422 with the "empty" message.
The visible name. Optional in the schema, but required in practice: without it, or with one that cannot become a slug, the add fails with 422 and the "bad_name" message. The label is cleaned with sanitize_text_field(), so tags are stripped. The slug is derived from it with sanitize_title() and must match ^[a-z0-9] followed by letters, digits, _ or -, ending in a letter or digit. A label written only in a script WordPress cannot turn into ASCII (Cyrillic, Japanese and so on) produces no valid slug and is refused.
If the slug is already taken, WordPress makes it unique (heart becomes heart-2). The answer tells you: added.slug is what the icon got, added.wanted is what the label asked for.
curl -X POST https://example.com/wp-json/easy-svg/v1/library/add \
-u admin:'abcd efgh ijkl mnop qrst uvwx' \
-H 'Content-Type: application/json' \
-d '{"label":"Heart","markup":"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 21l-8-8a5 5 0 0 1 8-6 5 5 0 0 1 8 6z"/></svg>"}'
To send a file from disk, let jq build the JSON so the quotes are escaped for you:
jq -n --arg label 'Heart' --rawfile markup heart.svg \
'{label: $label, markup: $markup}' |
curl -X POST https://example.com/wp-json/easy-svg/v1/library/add \
-u admin:'abcd efgh ijkl mnop qrst uvwx' \
-H 'Content-Type: application/json' \
--data-binary @-
async function myaddonAddIcon( label, markup ) {
try {
const result = await wp.apiFetch( {
path: '/easy-svg/v1/library/add?_locale=user',
method: 'POST',
data: { label, markup },
} );
if ( result.added.slug !== result.added.wanted ) {
console.info(
`"${ result.added.wanted }" was taken, the icon is called "${ result.added.slug }".`
);
}
return result.added;
} catch ( error ) {
// error is the JSON body: { code, message, data: { status } }
if ( 'easy_svg_add_failed' === error.code ) {
window.alert( error.message );
return null;
}
throw error;
}
}
Every refusal from the store answers 422 easy_svg_add_failed. Only the message tells the reasons apart; there is no machine-readable reason field. The checks run in this order, and the first one that fails decides the message (from easy_svg_panel_add_message()):
| Reason | Message |
|---|---|
| The sanitizer library is not loaded | The sanitizer is unavailable, so nothing was stored. Reinstall the plugin to restore it. |
| No label, or one that makes no valid slug | That name cannot be turned into an icon name. Use letters and numbers. |
| Markup empty or whitespace | That SVG is empty, so there was nothing to store. |
Markup longer than easy_svg_max_bytes() | That SVG is larger than the size allowed in Settings. |
Too many processing instructions or use elements (easy_svg_svg_too_complex()) | That SVG has too many parts to be read safely. |
| The sanitizer refused it, or nothing drawable was left | That is not an SVG the sanitizer accepts. |
The database write failed, or the stored post has no svg | That SVG could not be stored: it arrived without a drawing, or the write was refused. |
The handler also has a 409 easy_svg_no_store branch for when the icon store is missing. It cannot fire: it checks function_exists( 'easy_svg_add_icon' ), and includes/icon-manager.php, which defines that function, is loaded on every request. Do not build logic around it.
Add from the media library: POST /library/media
Turns SVG files that are already attachments into icons. You send attachment IDs, never file contents: the server reads each file from disk itself. Full reference: rest-library-media.
Attachment IDs, at least 1 and at most EASY_SVG_MEDIA_MAX_IDS (25). The limit is in the route schema, so a client can read it with an OPTIONS request. Each item must be an integer (or a numeric string WordPress coerces). IDs are processed in the order sent, and duplicates are not removed: the same ID twice adds two icons (star and star-2).
Each attachment's title becomes the icon's label. Each file goes through the same sanitizer, bounds and hardening as /library/add, because an SVG uploaded before Easy SVG was active may never have been cleaned.
curl -X POST https://example.com/wp-json/easy-svg/v1/library/media \
-u admin:'abcd efgh ijkl mnop qrst uvwx' \
-H 'Content-Type: application/json' \
-d '{"ids":[101,102,103]}'
// The server takes 25 IDs per request. Send more in chunks, like the panel does.
async function myaddonImportMedia( ids ) {
const chunkSize = 25;
const report = [];
for ( let start = 0; start < ids.length; start += chunkSize ) {
const result = await wp.apiFetch( {
path: '/easy-svg/v1/library/media',
method: 'POST',
data: { ids: ids.slice( start, start + chunkSize ) },
} );
report.push( ...result.report );
}
return report.filter( ( row ) => 'added' !== row.state );
}
A request is either refused as a whole or answers 200 with one report row per ID. One bad file never stops the others.
| Status | Code | When |
|---|---|---|
| 400 | rest_missing_callback_param | No ids anywhere in the request |
| 400 | rest_invalid_param | ids empty, more than 25, or an item that is not an integer |
| 400 | easy_svg_no_ids | ids passed the schema but is not in the JSON body (sent as a query string or form field) |
The handler's own 400 easy_svg_too_many ("Choose at most 25 files at a time.") is not reachable over REST: the schema's maxItems refuses a longer list first. The 409 easy_svg_no_store branch is unreachable for the same reason as on /library/add.
Per row, state is one of:
| State | Meaning |
|---|---|
added | Stored. The row also has slug and wanted, and its id is now the new icon's ID. |
not_found | No post with that ID (or an ID of 0 or less). label is then empty. |
not_svg | The attachment's MIME type is not image/svg+xml, or the sanitizer refused the file. |
no_file | The file is not readable on this server, typically because the media library is offloaded to a cloud bucket. |
bad_name, empty, too_large, too_complex, not_saved, no_sanitizer | The same refusals as /library/add, with the same messages. A title that cannot become a slug gives bad_name. |
Rename an icon: POST /library/rename
Changes the visible name and nothing else. The slug stays, because themes, shortcodes and saved Icon blocks refer to it. Full reference: rest-library-rename.
The icon's post ID, as items[].id reports it. Minimum 1.
The new name. Cleaned with sanitize_text_field(); if nothing is left (only spaces, or only tags such as <br>), the rename is refused. Unlike /library/add, the new name does not have to be usable as a slug.
curl -X POST https://example.com/wp-json/easy-svg/v1/library/rename \
-u admin:'abcd efgh ijkl mnop qrst uvwx' \
-H 'Content-Type: application/json' \
-d '{"id":42,"label":"Heart (filled)"}'
const library = await wp.apiFetch( {
path: '/easy-svg/v1/library/rename',
method: 'POST',
data: { id: 42, label: 'Heart (filled)' },
} );
| Status | Code | Message |
|---|---|---|
| 400 | rest_missing_callback_param / rest_invalid_param | Missing id or label, or id below 1 |
| 404 | easy_svg_no_icon | Icon not found. (no post, or a post that is not an esw_icon) |
| 422 | easy_svg_bad_label | An icon needs a name. |
| 500 | easy_svg_rename_failed | The icon could not be renamed. |
The write goes through the public function easy_svg_rename_icon(). It switches kses off for the single update (otherwise the SVG content would be stripped for an administrator without unfiltered_html) and clears the cached icon list, so the editor shows the new name at once.
Replace an icon's drawing: POST /library/replace
Gives an existing icon new markup, keeping its name and slug, so every page that uses it shows the new drawing. Full reference: rest-library-replace.
The icon's post ID. Minimum 1.
The new SVG markup. Checked exactly like /library/add: sanitizer, size bound, complexity bound, hardening.
jq -n --argjson id 42 --rawfile markup heart-v2.svg \
'{id: $id, markup: $markup}' |
curl -X POST https://example.com/wp-json/easy-svg/v1/library/replace \
-u admin:'abcd efgh ijkl mnop qrst uvwx' \
-H 'Content-Type: application/json' \
--data-binary @-
const library = await wp.apiFetch( {
path: '/easy-svg/v1/library/replace',
method: 'POST',
data: {
id: 42,
markup: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 2h20v20H2z"/></svg>',
},
} );
| Status | Code | Message |
|---|---|---|
| 400 | rest_missing_callback_param / rest_invalid_param | Missing id or markup, or id below 1 |
| 404 | easy_svg_no_icon | Icon not found. |
| 422 | easy_svg_replace_refused | That file is not an SVG this library can use. |
The 422 covers every other outcome of easy_svg_update_icon_markup(): empty markup, a refusal by the sanitizer or the bounds, a missing sanitizer, and a failed database write. The message talks about the file in all of these cases, even when the file was fine and the write failed.
Delete an icon: POST /library/delete
Deletes one icon for good. Full reference: rest-library-delete.
The icon's post ID. Minimum 1. Only a post of type esw_icon can be deleted; any other ID, a page or an attachment for example, answers 404 and nothing is touched.
curl -X POST https://example.com/wp-json/easy-svg/v1/library/delete \
-u admin:'abcd efgh ijkl mnop qrst uvwx' \
-H 'Content-Type: application/json' \
-d '{"id":42}'
async function myaddonDeleteIcon( icon ) {
const sure = window.confirm(
`Delete "${ icon.label }"? Every page using easy-svg/${ icon.slug } will show nothing.`
);
if ( ! sure ) {
return null;
}
return wp.apiFetch( {
path: '/easy-svg/v1/library/delete',
method: 'POST',
data: { id: icon.id },
} );
}
| Status | Code | Message |
|---|---|---|
| 400 | rest_missing_callback_param / rest_invalid_param | Missing id or id below 1 |
| 404 | easy_svg_no_icon | Icon not found. |
wp_delete_post( $id, true ), which skips the trash. Pages, templates and Icon blocks that use the slug render nothing afterwards, without an error. The handler also does not check what wp_delete_post() returned, so a 200 means "this was an icon and deletion was attempted". Check items in the response if you need certainty.Recipe: replace instead of creating a "-2" twin
When you add an icon whose name is taken, you get heart-2 next to heart. If what you really wanted was a new drawing for heart, the Icons panel offers to swap it, and you can do the same in three calls: add, notice the different slug, replace the original and delete the copy.
async function myaddonUpsertIcon( label, markup ) {
const result = await wp.apiFetch( {
path: '/easy-svg/v1/library/add',
method: 'POST',
data: { label, markup },
} );
const { id: newId, slug, wanted } = result.added;
if ( slug === wanted ) {
return result; // The name was free, nothing else to do.
}
const original = result.items.find( ( icon ) => icon.slug === wanted );
if ( ! original ) {
return result; // Original is beyond the first 500 icons.
}
await wp.apiFetch( {
path: '/easy-svg/v1/library/replace',
method: 'POST',
data: { id: original.id, markup },
} );
return wp.apiFetch( {
path: '/easy-svg/v1/library/delete',
method: 'POST',
data: { id: newId },
} );
}
The markup is checked twice (once on add, once on replace). That is fine: whatever passed the first time passes the second, unless the original icon's title cannot make a slug (see the callout under replace).
Pitfalls
- JSON or nothing. Every POST handler reads
get_json_params()only. WithoutContent-Type: application/json, the fields are invisible to the handler even though they passed validation:/settingsanswers 200 and changes nothing,/library/addrefuses with "bad_name",/library/rename,/library/replaceand/library/deleteanswer 404. - 400 before 401. WordPress validates arguments before it checks the capability. A malformed request without credentials gets a 400, not a 401.
- The icon switch does not gate the routes. All seven routes work while the icon library is switched off. Icons added then are real
esw_iconposts and show up once the switch is turned on. The 409easy_svg_no_storeanswer in the add and media handlers is unreachable. - The cached icon list can lag behind writes made while the switch is off. The hooks that clear the cache on save and delete (
save_post_esw_icon,deleted_post) are only registered while the icon library is on./library/renameand/library/replaceclear the cache themselves,/library/addand/library/deletedo not. The REST list is not affected (it reads the database), buteasy_svg_icon()and the Icon block read the cache, which lives for up to a day. - Settings take effect on the next request, not in the one that saved them.
- 500 icons is the ceiling of every response. No paging. Watch
truncated. - One error code, many reasons.
easy_svg_add_failedandeasy_svg_replace_refusedcarry the reason only in the translatedmessage. Do not parse it; if you need the reason, run your own checks first or call the PHP functions, which answer with a state word. - Labels can contain HTML entities. On
/library/media, the label is the attachment title asget_the_title()returns it, after thethe_titlefilters (curly quotes become’and similar). Escape labels when you output them and do not compare them byte for byte with what you typed in the media library. - Delete is final. No trash, no revision, no warning about pages that still use the slug.
- Internal means internal. Route names, fields and codes can change in any release. Wrap your calls in one small client function, so a change is one fix.
Related
easy-svg/v1/settingseasy-svg/v1/libraryeasy-svg/v1/library/addeasy-svg/v1/library/mediaeasy-svg/v1/library/renameeasy-svg/v1/library/replaceeasy-svg/v1/library/deleteeasy_svg_panel_rest_permission()easy_svg_panel_library_payload()easy_svg_rename_icon()easy_svg_update_icon_markup()EASY_SVG_PANEL_LIBRARY_MAXEASY_SVG_MEDIA_MAX_IDS- Build an add-on
- Developer overview