{"slug":"wp-rest-api","title":"wp-rest-api","summary":"Scaffold and audit inbound custom WordPress REST API endpoints","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-09-16T14:52:30.335544Z","repo":{"url":"https://github.com/Lonsdale201/wp-agent-skills","stars":22,"forks":2,"license":"MIT","updatedAt":"2026-09-26T23:03:36Z"},"bodyHtml":"<hr>\n<h2>name: wp-rest-api\ndescription: Scaffold and audit inbound custom WordPress REST API endpoints\nregistered with register_rest_route on rest_api_init. Covers explicit\npermission_callback intent, public-route review, object-level authorization,\npublic telemetry/beacon abuse budgets, request-source precedence,\nargs/JSON Schema validation and sanitization,\nWP_REST_Controller resources, bounded pagination and filters,\nWP_REST_Response/WP_Error contracts, register_rest_field, cookie auth with\nX-WP-Nonce, and REST vs admin-ajax decisions. Use for endpoint implementation,\nsecurity review, 401/403 debugging, headless APIs, or admin-ajax migration.\nTrigger on register_rest_route, permission_callback, WP_REST_Request,\nWP_REST_Controller, register_rest_field, rest_ensure_response, or X-WP-Nonce;\ndo not trigger merely for outbound wp_remote_* integrations.\nmetadata:\nwp-skills-author: \"Soczó Kristóf\"\nwp-skills-contact: \"mailto:lonsdale201@hotmail.com\"\nwp-skills-plugin: \"wordpress\"\nwp-skills-plugin-version-tested: \"6.0 - 7.1\"\nwp-skills-wp-version-tested: \"7.1\"\nwp-skills-php-min: \"7.4\"\nwp-skills-last-updated: \"2026-08-20\"</h2>\n<h1>WordPress REST API: scaffold, review, secure</h1>\n<p>Use this skill for inbound REST endpoints. Prefer REST for new, versioned\nplugin APIs and external clients. Keep outbound HTTP integrations out of\nthe route handlers, and use <code>admin-ajax</code> only for a concrete legacy or\nWP-admin-specific reason.</p>\n<p>Read <a href=\"reference.md\">reference.md</a> for dispatch/auth debugging, controllers,\ncollections, <code>register_rest_field()</code>, and edge-case verification.</p>\n<h2>Core execution model</h2>\n<p>Apply this order when reviewing behavior:</p>\n<ol>\n<li>Core authentication handlers establish the current user or return an auth error.</li>\n<li><code>WP_REST_Server</code> matches <code>(namespace, route, method)</code>.</li>\n<li>Core checks required args, validates registered args, then sanitizes them.</li>\n<li>Core calls the endpoint's <code>permission_callback</code>.</li>\n<li>Core calls the main <code>callback</code> only when permission succeeds.</li>\n<li>Core converts <code>WP_Error</code> and other supported return values into a REST response.</li>\n</ol>\n<p>Validation and sanitization therefore run before endpoint authorization. Keep\ntheir callbacks cheap, deterministic, read-only, and safe for anonymous traffic.</p>\n<h2>Review workflow</h2>\n<ol>\n<li><p>Inventory all inbound REST surfaces:</p>\n<pre><code>rg -n \"register_rest_route|register_rest_field|rest_api_init|WP_REST_Controller\" .\n</code></pre>\n</li>\n<li><p>Build a route matrix with namespace, path, method, callback, public/private\nintent, <code>permission_callback</code>, accepted args, and response fields.</p>\n</li>\n<li><p>Trace every security-sensitive identifier from its exact request source into\nthe permission check and the write/read operation. Confirm both use the same\nvalue.</p>\n</li>\n<li><p>Trace declared and undeclared input into SQL, metadata, options, filesystem,\nHTTP, email, and object update calls. Reject mass assignment.</p>\n</li>\n<li><p>Verify output field allowlists, context, pagination bounds, and stable filters.</p>\n</li>\n<li><p>Test anonymous, low-privilege, authorized, invalid-input, not-found, and\ncross-object access cases. Confirm <code>GET</code>/<code>HEAD</code> are side-effect free; for\nwrites, test method semantics and replay/retry behavior where relevant.</p>\n</li>\n<li><p>Report each finding with severity, route/method, file and line, exploit or\nfailure path, evidence, and the smallest correct remediation. Separate\nconfirmed exposure from defense-in-depth advice.</p>\n</li>\n</ol>\n<p>Treat unauthenticated privileged writes or sensitive reads as high/critical.\nTreat missing object-level authorization, unbounded collections, mass assignment,\nand cross-source identifier confusion as security findings, not style issues.</p>\n<h2>Minimal endpoint scaffold</h2>\n<pre><code>add_action( 'rest_api_init', static function (): void {\n    register_rest_route(\n        'myplugin/v1',\n        '/items/(?P&lt;id&gt;\\d+)',\n        array(\n            'methods'             =&gt; WP_REST_Server::READABLE,\n            'callback'            =&gt; 'myplugin_get_item',\n            'permission_callback' =&gt; static function ( WP_REST_Request $request ) {\n                $url_params = $request-&gt;get_url_params();\n                $post_id    = (int) ( $url_params['id'] ?? 0 );\n\n                return current_user_can( 'read_post', $post_id );\n            },\n            'args'                =&gt; array(\n                'id' =&gt; array(\n                    'required' =&gt; true,\n                    'type'     =&gt; 'integer',\n                    'minimum'  =&gt; 1,\n                ),\n            ),\n        )\n    );\n} );\n\n/**\n * @return WP_REST_Response|WP_Error\n */\nfunction myplugin_get_item( WP_REST_Request $request ) {\n    $url_params = $request-&gt;get_url_params();\n    $post_id    = (int) ( $url_params['id'] ?? 0 );\n    $post       = get_post( $post_id );\n\n    if ( ! $post ) {\n        return new WP_Error(\n            'myplugin_not_found',\n            __( 'Item not found.', 'myplugin' ),\n            array( 'status' =&gt; 404 )\n        );\n    }\n\n    return rest_ensure_response(\n        array(\n            'id'    =&gt; $post-&gt;ID,\n            'title' =&gt; get_the_title( $post ),\n        )\n    );\n}\n</code></pre>\n<p>The exact-source lookup is intentional. Do not replace it with <code>$request['id']</code>\nor <code>get_param( 'id' )</code> for an object identifier; merged body/query values have\nhigher priority than the URL value.</p>\n<h2>Security and correctness rules</h2>\n<h3>Require explicit permission intent</h3>\n<p>Specify <code>permission_callback</code> for every endpoint. Since WordPress 5.5, omitting\nit emits <code>_doing_it_wrong()</code>, but registration and dispatch continue. Missing or\nempty permission callbacks are skipped, so the route is open at the endpoint\npermission layer unless another layer or the main callback denies it.</p>\n<ul>\n<li><p>Use <code>__return_true</code> for a deliberately public route. It is not a vulnerability\nby itself.</p>\n</li>\n<li><p>Never use unconditional public permission for a privileged write or sensitive\nread.</p>\n</li>\n<li><p>Check object-level meta capabilities with the target ID:</p>\n<pre><code>current_user_can( 'edit_post', $post_id );\ncurrent_user_can( 'edit_user', $user_id );\n</code></pre>\n</li>\n<li><p>Return <code>true</code>, <code>false</code>, <code>null</code>, or <code>WP_Error</code>. Core denies only exact <code>false</code>,\n<code>null</code>, or <code>WP_Error</code>; falsey values such as <code>0</code>, <code>''</code>, or <code>array()</code> can grant\naccess. Prefer explicit <code>true</code> or a namespaced <code>WP_Error</code>.</p>\n</li>\n<li><p>Keep permission callbacks read-only and idempotent. Core may call them again\nwhile generating the <code>Allow</code> header.</p>\n</li>\n<li><p>Treat authentication and authorization separately. A valid REST nonce proves\nthe cookie-authenticated request; it does not grant a capability.</p>\n</li>\n</ul>\n<p>For a public form, login, webhook, or callback route, verify the complete abuse\npolicy: bounded input, rate/resource limits, signature or token rules where\napplicable, replay handling, and non-enumerating responses.</p>\n<h3>Audit public telemetry and ingestion routes as resource APIs</h3>\n<p>An analytics beacon can be intentionally public and still expose an IDOR or\ndenial-of-service primitive. Review the complete per-request work budget, not\nonly <code>permission_callback</code>.</p>\n<ul>\n<li>Bound raw body bytes before expensive decoding where the application can do\nso; also enforce infrastructure/WAF limits because PHP receives the request\nafter the web server.</li>\n<li>Give every nested string/number/array a schema. Use <code>maxLength</code>, numeric\nbounds, <code>maxItems</code>, accepted keys, and a custom depth/node budget when core's\nschema cannot express it. A 1–2 MiB JSON cap is usually far too generous for\na beacon that should contain a few metrics.</li>\n<li>Count fan-out through hooks: dimension get-or-create queries, inserts per\narray element, goal evaluation, email, and outbound HTTP all belong to the\nanonymous request's cost. Queue slow or retriable remote delivery.</li>\n<li>Do not accept a sequential record ID as proof that an anonymous client owns\nthe record. Return an opaque random/signed token or bind the record to a\nserver-resolved session, then update with both resource and owner predicates\nsuch as <code>WHERE id = ? AND session_id = ?</code>.</li>\n<li>Rate-limit and quota by a proxy-safe identity, but keep storage and fan-out\nbounded even when attackers rotate IPs/cookies. Rate limiting is not a\nsubstitute for ownership or idempotency.</li>\n<li>Return deterministic <code>400</code>, <code>413</code>, <code>422</code>, and <code>429</code> errors. Malformed JSON or\na scalar root must not fall through into PHP warnings/5xx responses.</li>\n</ul>\n<p>Test cross-session record updates, replayed tokens, maximum and maximum+1 array\nsizes, oversized/deep bodies, concurrent first beacons, and repeated requests\nwith outbound integrations enabled. Assert a documented upper bound on local\nqueries/writes and zero synchronous third-party calls on the public hot path.</p>\n<h3>Declare and enforce the input contract</h3>\n<p>Declare every accepted URL, query, and body parameter in <code>args</code>. Undeclared\nparameters are not stripped and remain readable from the request, so never pass\n<code>get_params()</code> or an arbitrary JSON object directly into a model/update API.</p>\n<pre><code>'args' =&gt; array(\n    'email' =&gt; array(\n        'required'          =&gt; true,\n        'type'              =&gt; 'string',\n        'format'            =&gt; 'email',\n        'validate_callback' =&gt; 'rest_validate_request_arg',\n        'sanitize_callback' =&gt; 'sanitize_email',\n    ),\n    'role' =&gt; array(\n        'type'    =&gt; 'string',\n        'enum'    =&gt; array( 'subscriber', 'contributor', 'author' ),\n        'default' =&gt; 'subscriber',\n    ),\n    'count' =&gt; array(\n        'type'    =&gt; 'integer',\n        'minimum' =&gt; 1,\n        'maximum' =&gt; 100,\n    ),\n),\n</code></pre>\n<p>When <code>type</code> exists and no custom <code>sanitize_callback</code> is set, core defaults to\n<code>rest_parse_request_arg()</code>, which validates the registered schema and sanitizes\nthe value. A custom sanitizer replaces that fallback. Pair it with\n<code>validate_callback =&gt; rest_validate_request_arg</code> or a custom validator, or\nconstraints such as <code>minimum</code>, <code>maximum</code>, <code>enum</code>, and <code>format</code> may not run.</p>\n<p>Validation proves shape; sanitization normalizes data. Neither replaces\n<code>$wpdb-&gt;prepare()</code>, capability checks, output policy, or business validation.</p>\n<h3>Read from the intended parameter source</h3>\n<p>Use source-specific accessors for identifiers and security decisions:</p>\n<ul>\n<li>route capture: <code>$request-&gt;get_url_params()</code></li>\n<li>query string: <code>$request-&gt;get_query_params()</code></li>\n<li>JSON body: <code>$request-&gt;get_json_params()</code></li>\n<li>form body: <code>$request-&gt;get_body_params()</code></li>\n<li>uploaded files: <code>$request-&gt;get_file_params()</code></li>\n</ul>\n<p><code>get_param()</code> and array access merge sources in this priority: JSON, form body,\nquery string, URL, defaults. Never authorize one source and mutate another.</p>\n<h3>Return REST-native responses and errors</h3>\n<p>Return supported data or <code>WP_REST_Response</code> on success and <code>WP_Error</code> on\nexpected failure. Prefer explicit response objects when setting status, headers,\nor links.</p>\n<pre><code>return rest_ensure_response( $data );\nreturn new WP_REST_Response( $data, 201, array( 'Location' =&gt; $location ) );\nreturn new WP_Error( 'myplugin_invalid', '...', array( 'status' =&gt; 422 ) );\n</code></pre>\n<p>Do not call <code>wp_send_json_*()</code> in REST callbacks; it terminates execution and\nbypasses normal REST response handling. Do not expose exception messages,\nstack traces, SQL, paths, secrets, or internal class names in 5xx responses.</p>\n<h3>Shape output explicitly</h3>\n<p>Do not expose unreviewed database rows, model objects, or metadata blobs.\nAllowlist response fields and evaluate personal/sensitive data per route and\ncontext. An email address is not safe merely because it was intentionally\nselected. Escape values when a client renders them into HTML; do not HTML-escape\nordinary JSON data indiscriminately on the server.</p>\n<h3>Use cookie authentication correctly</h3>\n<p>Cookie-authenticated browser requests need <code>_wpnonce</code> or <code>X-WP-Nonce</code> generated\nfor <code>wp_rest</code>. Without a nonce, core treats cookie auth as anonymous; an invalid\nnonce returns <code>rest_cookie_invalid_nonce</code> with 403.</p>\n<p>When WordPress enqueues its registered <code>wp-api-fetch</code> script, core installs the\nREST nonce middleware automatically, including on the front end. A decoupled\nbundle importing <code>@wordpress/api-fetch</code> from npm must configure nonce middleware\nitself or use another authentication scheme. Application Passwords authenticate\nexternal HTTPS requests but still require endpoint authorization; never ship\napplication credentials in public browser code.</p>\n<h3>Use controllers for resource APIs</h3>\n<p>For several related collection/item routes, extend <code>WP_REST_Controller</code> instead\nof duplicating registration, permission, schema, and response methods. Core\nprovides parameter helpers, not the actual query/filter/pagination behavior.\nSee <a href=\"reference.md#controllers-collections-and-pagination\">reference.md</a>.</p>\n<p>Use a unique versioned namespace such as <code>myplugin/v1</code>; add <code>v2</code> instead of\nbreaking an existing public contract in place.</p>\n<h2>False-positive guards</h2>\n<ul>\n<li>Do not report <code>__return_true</code> as a vulnerability without proving the route\nshould be private or the public operation lacks necessary abuse controls.</li>\n<li>Do not treat a nonce as a substitute for capability/object authorization.</li>\n<li>Do not call a missing <code>permission_callback</code> exploitable until tracing global\nfilters and callback-internal checks; still report the fail-open registration\npattern because tooling cannot enforce the intended policy.</li>\n<li>Do not report validation errors returned before permission as an auth bypass;\nassess separately whether they leak sensitive schema/state or enable expensive\nanonymous work.</li>\n<li>Do not assume <code>401</code> versus <code>403</code> inconsistency: core normally returns 401 for\nunauthenticated denial and 403 for an authenticated but unauthorized user.</li>\n<li>Do not label an explicitly mapped database row unsafe without identifying a\nsensitive or unintended field. Report unreviewed broad exposure and its data.</li>\n</ul>\n<h2>Cross-references</h2>\n<ul>\n<li>Run <code>wp-security-audit</code> for the surrounding nonce, capability, input, SQL,\nfilesystem, redirect, and output checks.</li>\n<li>Run <code>wp-client-side-media-processing</code> for WordPress 7.1 media endpoints whose\nbrowser and server paths use different multi-request lifecycles.</li>\n<li>Run <code>wp-abilities-api</code> when the desired contract is a discoverable typed\noperation rather than an HTTP resource.</li>\n</ul>\n<h2>Out of scope</h2>\n<p>Do not design custom JWT/OAuth/signature protocols, complete CORS/WAF/proxy\npolicy, distributed rate limiting, or OpenAPI generation here. Do not audit\ncore-owned <code>wp/v2</code> contracts unless plugin code changes them.</p>\n<h2>References</h2>\n<ul>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/rest-api/extending-the-rest-api/adding-custom-endpoints/\">https://developer.wordpress.org/rest-api/extending-the-rest-api/adding-custom-endpoints/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/register_rest_route/\">https://developer.wordpress.org/reference/functions/register_rest_route/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/rest-api/extending-the-rest-api/controller-classes/\">https://developer.wordpress.org/rest-api/extending-the-rest-api/controller-classes/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/\">https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/\">https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/</a></li>\n<li>Verified source paths:\n<ul>\n<li><code>wp-includes/rest-api.php</code></li>\n<li><code>wp-includes/rest-api/class-wp-rest-server.php</code></li>\n<li><code>wp-includes/rest-api/class-wp-rest-request.php</code></li>\n<li><code>wp-includes/rest-api/endpoints/class-wp-rest-controller.php</code></li>\n<li><code>wp-includes/script-loader.php</code></li>\n<li><code>wp-includes/capabilities.php</code></li>\n</ul>\n</li>\n</ul>\n","files":[{"path":"reference.md","sizeBytes":13938,"isText":true},{"path":"SKILL.md","sizeBytes":15098,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-09-16T14:59:34.022019Z","sha256":"0EAEF36EBB1F1273869FA75CBA3E7E901F19EDF2334FBBC82CA316DEA91C672B","sizeBytes":11790},"review":null,"source":{"repositoryUrl":"https://github.com/Lonsdale201/wp-agent-skills","path":"wordpress/wp-rest-api","license":"MIT","commit":"c51b571a259f0c4b5f5c0a3bc50ed580c6851f98","subtreeSha":"8288717D5EB3A3705D3C4532FE64D8E8420E8A91902F65804C7882EA190E5D23","lastSyncedAt":"2026-09-29T23:33:03.303675Z"},"reviewedAt":"2026-09-16T15:21:28.955592Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/wordpress/wp-rest-api"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart"},{"target":"git","command":"git clone https://github.com/Lonsdale201/wp-agent-skills.git"}]}