Developer guidesWorking with the REST API

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.

These routes exist to serve the Icons panel. They are not part of the public surface the readme promises: it names a short list of functions, hooks and constants and says that everything else "is an internal detail that may be renamed or removed". Treat every route on this page as internal. Pin the plugin version you tested against, check the response shape before you rely on it, and prefer the public PHP functions (easy_svg_rename_icon(), easy_svg_update_icon_markup()) when your code runs inside WordPress.

The routes at a glance

RouteMethodWhat it doesReference
/wp-json/easy-svg/v1/settingsGET, POSTReads or saves the feature switches and the size limitrest-settings
/wp-json/easy-svg/v1/libraryGETLists the stored icons, optionally searchedrest-library
/wp-json/easy-svg/v1/library/addPOSTAdds one icon from SVG markuprest-library-add
/wp-json/easy-svg/v1/library/mediaPOSTTurns SVGs from the media library into iconsrest-library-media
/wp-json/easy-svg/v1/library/renamePOSTChanges an icon's visible namerest-library-rename
/wp-json/easy-svg/v1/library/replacePOSTGives an icon a new drawingrest-library-replace
/wp-json/easy-svg/v1/library/deletePOSTDeletes an icon permanentlyrest-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.

What a failed login looks like:

SituationStatusCode
No credentials, or a cookie without a nonce401rest_forbidden
Logged in, but without manage_options403rest_forbidden
Cookie with a wrong or expired nonce403rest_cookie_invalid_nonce
Wrong application password401an 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, not edit_theme_options. The icon post type itself asks for EASY_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 check manage_options regardless.
  • Even the read routes are admin only. GET /settings and GET /library are 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.

Messages come back in the site language. To get them in the language of the user's profile instead, add _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.

itemsarray

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.

totalinteger

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.

truncatedboolean

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.

body
svg_uploadboolean

Whether SVG files may be uploaded to the media library. Accepts true, false, and the forms WordPress coerces (1, 0, "true", "false").

body
iconsboolean

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.

body
max_mbinteger

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}'
StatusCodeWhen
400rest_invalid_parammax_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.php loads, so the request that saves icons: true still runs without the icon library. The response already shows the new value.
  • Unknown keys are dropped. The sanitizer only returns svg_upload, icons and max_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.

curl 'https://example.com/wp-json/easy-svg/v1/library?search=arrow' \
	-u admin:'abcd efgh ijkl mnop qrst uvwx'

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.

body
markupstring
Required

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.

body
labelstring

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

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()):

ReasonMessage
The sanitizer library is not loadedThe sanitizer is unavailable, so nothing was stored. Reinstall the plugin to restore it.
No label, or one that makes no valid slugThat name cannot be turned into an icon name. Use letters and numbers.
Markup empty or whitespaceThat 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 leftThat is not an SVG the sanitizer accepts.
The database write failed, or the stored post has no svgThat 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.

body
idsinteger[]
Required

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]}'

A request is either refused as a whole or answers 200 with one report row per ID. One bad file never stops the others.

StatusCodeWhen
400rest_missing_callback_paramNo ids anywhere in the request
400rest_invalid_paramids empty, more than 25, or an item that is not an integer
400easy_svg_no_idsids 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:

StateMeaning
addedStored. The row also has slug and wanted, and its id is now the new icon's ID.
not_foundNo post with that ID (or an ID of 0 or less). label is then empty.
not_svgThe attachment's MIME type is not image/svg+xml, or the sanitizer refused the file.
no_fileThe 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_sanitizerThe 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.

body
idinteger
Required

The icon's post ID, as items[].id reports it. Minimum 1.

body
labelstring
Required

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)"}'
StatusCodeMessage
400rest_missing_callback_param / rest_invalid_paramMissing id or label, or id below 1
404easy_svg_no_iconIcon not found. (no post, or a post that is not an esw_icon)
422easy_svg_bad_labelAn icon needs a name.
500easy_svg_rename_failedThe 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.

body
idinteger
Required

The icon's post ID. Minimum 1.

body
markupstring
Required

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 @-
StatusCodeMessage
400rest_missing_callback_param / rest_invalid_paramMissing id or markup, or id below 1
404easy_svg_no_iconIcon not found.
422easy_svg_replace_refusedThat 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.

The bounds check runs against the icon's current title, because the shared check derives a slug from it. If an icon was renamed to something that cannot become a slug (only punctuation, or a non-Latin script), every replace for it is refused with 422, whatever markup you send. Rename it to a Latin name first, then replace.

Delete an icon: POST /library/delete

Deletes one icon for good. Full reference: rest-library-delete.

body
idinteger
Required

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}'
StatusCodeMessage
400rest_missing_callback_param / rest_invalid_paramMissing id or id below 1
404easy_svg_no_iconIcon not found.
There is no undo. The post is deleted with 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. Without Content-Type: application/json, the fields are invisible to the handler even though they passed validation: /settings answers 200 and changes nothing, /library/add refuses with "bad_name", /library/rename, /library/replace and /library/delete answer 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_icon posts and show up once the switch is turned on. The 409 easy_svg_no_store answer 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/rename and /library/replace clear the cache themselves, /library/add and /library/delete do not. The REST list is not affected (it reads the database), but easy_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_failed and easy_svg_replace_refused carry the reason only in the translated message. 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 as get_the_title() returns it, after the the_title filters (curly quotes become &#8217; 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.