{"id":42,"date":"2026-09-09T04:25:57","date_gmt":"2026-09-09T04:25:57","guid":{"rendered":"https:\/\/nirohelp.com\/docs\/42\/rest-api-reference\/"},"modified":"2026-09-09T04:26:12","modified_gmt":"2026-09-09T04:26:12","slug":"rest-api-reference","status":"publish","type":"nirohelp_doc","link":"https:\/\/nirohelp.com\/docs\/developers\/rest-api-reference\/","title":{"rendered":"REST API reference"},"content":{"rendered":"<p>Every browser-to-server call in NiroHelp goes through one namespace,<br \/>\n<code>nirohelp\/v1<\/code>. The base URL is<br \/>\n<code>https:\/\/your-site.example\/wp-json\/nirohelp\/v1\/<\/code>. Routes are registered<br \/>\ncentrally in <code>Controllers\\Common\\API<\/code>; the handlers live in <code>app\/API\/<\/code>, one<br \/>\nclass per resource.<\/p>\n<h2>Authentication \u2014 read this first<\/h2>\n<p><strong>Application Passwords do not work on these routes.<\/strong> Almost every<br \/>\n<code>permission_callback<\/code> in the plugin calls <code>Traits\\Auth::has_nonce()<\/code>, which<br \/>\nrequires a valid <code>wp_rest<\/code> nonce in the <code>X-WP-Nonce<\/code> header. Application<br \/>\nPassword and Basic auth requests carry no such header, so they authenticate<br \/>\nsuccessfully and are then refused with <code>403 rest_forbidden<\/code>:<\/p>\n<pre><code class=\"language-none\">$ curl -u &quot;admin:xxxx xxxx xxxx xxxx&quot; ...\/wp-json\/wp\/v2\/users\/me            # 200\n$ curl -u &quot;admin:xxxx xxxx xxxx xxxx&quot; ...\/wp-json\/nirohelp\/v1\/settings\/schema # 403\n<\/code><\/pre>\n<p>The supported callers are:<\/p>\n<ul>\n<li><strong>The browser, as a logged-in user.<\/strong> A cookie plus the nonce. The admin SPA<br \/>\n  gets both from <code>apiFetch<\/code> in <code>spa\/lib\/api.ts<\/code>; front-end scripts get the<br \/>\n  nonce from the <code>NIROHELP<\/code> \/ <code>NIROHELP_DOCS<\/code> localized objects.<\/li>\n<li><strong>Anyone, on the seven <code>is_public<\/code> routes.<\/strong> The three doc reads, the three<br \/>\n  ticket taxonomy lists and ticket creation accept anonymous requests, because<br \/>\n  the embed widget in <code>assets\/tickets\/js\/embed.js<\/code> runs on other people&#8217;s sites<br \/>\n  and cannot carry a nonce. Those callbacks are hardened internally instead \u2014<br \/>\n  <code>POST \/tickets\/<\/code> strips a caller-supplied <code>user_id<\/code>, and <code>GET \/docs\/<\/code> only<br \/>\n  honours <code>status<\/code> for someone who can edit docs.<\/li>\n<\/ul>\n<p>There is one escape hatch, for local development only:<\/p>\n<pre><code class=\"language-php\">\/\/ wp-config.php \u2014 NEVER on a production site.\ndefine( 'NIROHELP_SANDBOX', true );\n<\/code><\/pre>\n<p><code>is_sandbox_mode()<\/code> short-circuits <strong>every<\/strong> permission callback in<br \/>\n<code>app\/Traits\/Auth.php<\/code>, including the admin-only AI, settings, migration and<br \/>\ndashboard routes. It makes the whole API world-readable and world-writable.<\/p>\n<h2>Permission callbacks<\/h2>\n<p>Route tables below cite these by name.<\/p>\n<table>\n<thead>\n<tr>\n<th>Callback<\/th>\n<th>Passes when<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>is_public<\/code><\/td>\n<td>Always. No nonce, no login.<\/td>\n<\/tr>\n<tr>\n<td><code>has_nonce<\/code><\/td>\n<td>A valid <code>wp_rest<\/code> nonce is present. Login not required.<\/td>\n<\/tr>\n<tr>\n<td><code>is_guest<\/code><\/td>\n<td>Not logged in, and sandbox mode is off.<\/td>\n<\/tr>\n<tr>\n<td><code>is_member<\/code><\/td>\n<td>Logged in + nonce.<\/td>\n<\/tr>\n<tr>\n<td><code>is_admin<\/code><\/td>\n<td><code>manage_options<\/code> + nonce.<\/td>\n<\/tr>\n<tr>\n<td><code>is_agent<\/code><\/td>\n<td><code>edit_others_tickets<\/code> or <code>manage_options<\/code>, + nonce.<\/td>\n<\/tr>\n<tr>\n<td><code>can_edit_docs<\/code><\/td>\n<td>The doc CPT&#8217;s <code>edit_pages<\/code>, or a NiroHelp role holding bare <code>edit_posts<\/code>, + nonce.<\/td>\n<\/tr>\n<tr>\n<td><code>can_delete_docs<\/code><\/td>\n<td>Same, for <code>delete_posts<\/code> \/ <code>delete_pages<\/code>.<\/td>\n<\/tr>\n<tr>\n<td><code>can_delete_tickets<\/code><\/td>\n<td><code>delete_others_tickets<\/code> or <code>manage_options<\/code>, + nonce.<\/td>\n<\/tr>\n<tr>\n<td><code>can_read_ticket<\/code><\/td>\n<td><code>edit_others_tickets<\/code>, <code>manage_options<\/code>, or being the ticket&#8217;s own client, + nonce.<\/td>\n<\/tr>\n<tr>\n<td><code>can_comment_on_ticket<\/code><\/td>\n<td>Same as <code>can_read_ticket<\/code>.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p><code>can_edit_docs<\/code> resolves to administrators, Editors and <code>nirohelp_manager<\/code>;<br \/>\n<code>nirohelp_agent<\/code> and <code>nirohelp_user<\/code> both declare <code>edit_posts =&gt; false<\/code>. See<br \/>\n<a href=\"https:\/\/nirohelp.com\/docs\/setup-configuration\/roles-and-permissions\/\">Roles and permissions<\/a>.<\/p>\n<h2>Docs<\/h2>\n<table>\n<thead>\n<tr>\n<th>Method<\/th>\n<th>Path<\/th>\n<th>Permission<\/th>\n<th>Notes<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>GET<\/td>\n<td><code>\/docs\/<\/code><\/td>\n<td><code>is_public<\/code><\/td>\n<td><code>status<\/code>, <code>s<\/code>, <code>count<\/code>, <code>formatted<\/code>. <code>status<\/code> is honoured only for users who can edit docs; everyone else gets published docs. <code>count<\/code> is clamped by the <code>nirohelp_docs_api_max_per_page<\/code> filter (100).<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/docs\/product\/{product}<\/code><\/td>\n<td><code>is_public<\/code><\/td>\n<td><code>{product}<\/code> is a <strong>term slug<\/strong>. Same <code>s<\/code> \/ <code>count<\/code> \/ <code>formatted<\/code> args.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/docs\/<\/code><\/td>\n<td><code>can_edit_docs<\/code><\/td>\n<td><code>title<\/code>, <code>description<\/code> required; <code>slug<\/code>, <code>status<\/code>, <code>meta<\/code> optional.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/docs\/{id}<\/code><\/td>\n<td><code>is_public<\/code><\/td>\n<td><\/td>\n<\/tr>\n<tr>\n<td>PUT<\/td>\n<td><code>\/docs\/{id}<\/code><\/td>\n<td><code>can_edit_docs<\/code><\/td>\n<td>All fields optional except <code>id<\/code>.<\/td>\n<\/tr>\n<tr>\n<td>DELETE<\/td>\n<td><code>\/docs\/{id}\/delete<\/code><\/td>\n<td><code>can_delete_docs<\/code><\/td>\n<td><\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/docs\/{id}\/vote<\/code><\/td>\n<td><code>has_nonce<\/code><\/td>\n<td>Body <code>{\"type\":\"upvote\"}<\/code> or <code>{\"type\":\"downvote\"}<\/code>. Returns the new count.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2>Tickets<\/h2>\n<table>\n<thead>\n<tr>\n<th>Method<\/th>\n<th>Path<\/th>\n<th>Permission<\/th>\n<th>Notes<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>GET<\/td>\n<td><code>\/tickets\/<\/code><\/td>\n<td><code>is_member<\/code><\/td>\n<td><code>status<\/code>, <code>client<\/code>, <code>agent_id<\/code>, <code>product<\/code>, <code>reason<\/code>, <code>urgency<\/code>, <code>date_from<\/code>, <code>date_to<\/code>, <code>search<\/code>, <code>date_query<\/code>, <code>per_page<\/code> (20), <code>page<\/code> (1).<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/tickets\/<\/code><\/td>\n<td><code>is_public<\/code><\/td>\n<td><code>title<\/code> and <code>description<\/code> required. <code>user_id<\/code> is stripped unless the caller holds <code>edit_others_tickets<\/code> or <code>manage_options<\/code>.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/tickets\/{id}<\/code><\/td>\n<td><code>can_read_ticket<\/code><\/td>\n<td><\/td>\n<\/tr>\n<tr>\n<td>PUT<\/td>\n<td><code>\/tickets\/{id}<\/code><\/td>\n<td><code>is_agent<\/code><\/td>\n<td><code>title<\/code>, <code>slug<\/code>, <code>description<\/code>, <code>status<\/code>, <code>meta<\/code>.<\/td>\n<\/tr>\n<tr>\n<td>DELETE<\/td>\n<td><code>\/tickets\/{id}\/delete<\/code><\/td>\n<td><code>can_delete_tickets<\/code><\/td>\n<td><\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/tickets\/{id}\/comments<\/code><\/td>\n<td><code>can_read_ticket<\/code><\/td>\n<td>The comments are the private conversation, so this is gated per ticket, not merely on being logged in.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/tickets\/{id}\/comments<\/code><\/td>\n<td><code>can_comment_on_ticket<\/code><\/td>\n<td><code>message<\/code> required. Auto-transitions ticket status.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/tickets\/products\/<\/code><\/td>\n<td><code>is_public<\/code><\/td>\n<td><\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/tickets\/reasons\/<\/code><\/td>\n<td><code>is_public<\/code><\/td>\n<td><\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/tickets\/urgencies\/<\/code><\/td>\n<td><code>is_public<\/code><\/td>\n<td><\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The agent on a ticket is changed through <code>meta<\/code> on <code>PUT \/tickets\/{id}<\/code>, which<br \/>\nfires <code>nirohelp-tickets-agent_changed<\/code>.<\/p>\n<h2>Authentication routes<\/h2>\n<table>\n<thead>\n<tr>\n<th>Method<\/th>\n<th>Path<\/th>\n<th>Permission<\/th>\n<th>Notes<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>POST<\/td>\n<td><code>\/login\/<\/code><\/td>\n<td><code>is_guest<\/code><\/td>\n<td>Drives whichever login method is configured. <code>email<\/code> required; <code>code<\/code> on the second step of the OTP flow; <code>name<\/code> and <code>redirect<\/code> optional.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/wp-login\/<\/code><\/td>\n<td><code>is_guest<\/code><\/td>\n<td><code>email<\/code> + <code>password<\/code>. Runs <code>wp_authenticate()<\/code>, so the whole core <code>authenticate<\/code> filter chain applies.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/wp-signup\/<\/code><\/td>\n<td><code>is_guest<\/code><\/td>\n<td><code>name<\/code> + <code>email<\/code> + <code>password<\/code>. Gated on <code>nirohelp_can_register_users()<\/code>.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>See <a href=\"https:\/\/nirohelp.com\/docs\/ticket-management\/client-login\/\">Client login<\/a> for the three methods and<br \/>\nhow they differ.<\/p>\n<h2>Options<\/h2>\n<table>\n<thead>\n<tr>\n<th>Method<\/th>\n<th>Path<\/th>\n<th>Permission<\/th>\n<th>Notes<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>GET<\/td>\n<td><code>\/option<\/code><\/td>\n<td><code>is_admin<\/code><\/td>\n<td><code>key<\/code> required.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/option<\/code><\/td>\n<td><code>is_admin<\/code><\/td>\n<td><code>key<\/code> + <code>value<\/code>.<\/td>\n<\/tr>\n<tr>\n<td>DELETE<\/td>\n<td><code>\/option<\/code><\/td>\n<td><code>is_admin<\/code><\/td>\n<td><code>key<\/code> required.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p><strong>Do not route settings through <code>\/option<\/code>.<\/strong> It writes a raw option value by<br \/>\nkey and so skips <code>Settings::sanitize()<\/code> \u2014 including the two repeater passes<br \/>\nthat zero unchecked row checkboxes and drop deleted rows. Use <code>\/settings<\/code>.<\/p>\n<h2>Settings<\/h2>\n<table>\n<thead>\n<tr>\n<th>Method<\/th>\n<th>Path<\/th>\n<th>Permission<\/th>\n<th>Notes<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>GET<\/td>\n<td><code>\/settings\/schema<\/code><\/td>\n<td><code>is_admin<\/code><\/td>\n<td>The schema the admin screen renders from.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/settings<\/code><\/td>\n<td><code>is_admin<\/code><\/td>\n<td><code>settings<\/code>, an object keyed <code>[tab][section][field]<\/code>.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2>Migration<\/h2>\n<p>Five routes, all <code>is_admin<\/code>. They start and delete content in bulk and rely on<br \/>\nthe REST nonce alone.<\/p>\n<table>\n<thead>\n<tr>\n<th>Method<\/th>\n<th>Path<\/th>\n<th>Notes<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>GET<\/td>\n<td><code>\/migration\/sources<\/code><\/td>\n<td>The importers, grouped, with availability and migrated counts.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/migration\/status<\/code><\/td>\n<td>Where the current run has got to. Returns the importer&#8217;s own summary text verbatim.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/migration\/start<\/code><\/td>\n<td>Begin an import.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/migration\/clean<\/code><\/td>\n<td>Delete everything one source produced.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/migration\/reset<\/code><\/td>\n<td>Clear the run state.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2>AI<\/h2>\n<p>All <code>is_admin<\/code>.<\/p>\n<table>\n<thead>\n<tr>\n<th>Method<\/th>\n<th>Path<\/th>\n<th>Notes<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>POST<\/td>\n<td><code>\/ai\/register<\/code><\/td>\n<td><code>name<\/code>, <code>email<\/code>. Forwards to the service; it mails back a site key.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/ai\/verify<\/code><\/td>\n<td><code>site_key<\/code> required.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/ai\/resend<\/code><\/td>\n<td>Re-runs <code>register<\/code> with the stored name and email.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/ai\/reset<\/code><\/td>\n<td>Unverifies remotely and clears local credentials.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/ai\/site-key<\/code><\/td>\n<td>The stored site key. 403 until verified.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/ai\/sync<\/code><\/td>\n<td>Push published docs to the vector store. 300s timeout.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/ai\/state<\/code><\/td>\n<td>What the AI screens render from: verified, has_site_key, name, email, tickets_enabled. Never returns the key itself.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/ai\/usage<\/code><\/td>\n<td>Proxies the service&#8217;s usage figures.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/ai\/auto-responder<\/code><\/td>\n<td>Current auto-responder settings.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/ai\/auto-responder<\/code><\/td>\n<td><code>delay_amount<\/code>, <code>delay_unit<\/code>, <code>threshold<\/code> required; <code>enabled<\/code>, <code>author_name<\/code>, <code>change_status<\/code> optional.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/ai\/custom-prompt<\/code><\/td>\n<td>The stored instructions plus the default.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/ai\/custom-prompt<\/code><\/td>\n<td><code>custom_prompt<\/code>. Refuses on an unverified site.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/ai\/chatbot<\/code><\/td>\n<td>Stored chatbot configuration.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/ai\/chatbot<\/code><\/td>\n<td>Save it.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/ai\/chatbot\/preview<\/code><\/td>\n<td>Build the embed from posted values <strong>without<\/strong> storing them. Backs Preview, View Code and Download.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/ai\/chatbot\/plugin<\/code><\/td>\n<td>The generated single-file plugin.<\/td>\n<\/tr>\n<tr>\n<td>POST<\/td>\n<td><code>\/ai\/copilot<\/code><\/td>\n<td>Ask NiroHelp. <code>message<\/code> required, <code>session_id<\/code> optional.<\/td>\n<\/tr>\n<tr>\n<td>GET<\/td>\n<td><code>\/ai\/logs\/{type}<\/code><\/td>\n<td><code>{type}<\/code> is <code>chatbot<\/code> or <code>responder<\/code>.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p><code>GET \/ai\/usage<\/code> and <code>GET \/ai\/logs\/{type}<\/code> proxy<br \/>\n<code>my.nirosuite.com\/wp-json\/nirohelp-cloud\/v1\/{usage,logs}<\/code>, <strong>which do not exist<br \/>\nyet<\/strong> \u2014 both 404 today, so the usage panel and the two log screens report the<br \/>\nservice&#8217;s error rather than data. That is tracked on the service side<br \/>\n(<code>nirohelp-cloud#30<\/code>), not here.<\/p>\n<h2>Dashboard<\/h2>\n<p>All <code>is_admin<\/code>, all local counts \u2014 no remote call, so a slow service cannot<br \/>\nstall the screen.<\/p>\n<table>\n<thead>\n<tr>\n<th>Path<\/th>\n<th>Returns<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>\/dashboard\/stats<\/code><\/td>\n<td>Ticket and doc counts, sentiment split, service levels. <code>range<\/code> in days.<\/td>\n<\/tr>\n<tr>\n<td><code>\/dashboard\/queues<\/code><\/td>\n<td>Three triage queues: needs attention, needs reply, new.<\/td>\n<\/tr>\n<tr>\n<td><code>\/dashboard\/ai<\/code><\/td>\n<td>Auto-responder outcomes and handovers.<\/td>\n<\/tr>\n<tr>\n<td><code>\/dashboard\/chatbot<\/code><\/td>\n<td>Chatbot activity for the window.<\/td>\n<\/tr>\n<tr>\n<td><code>\/dashboard\/docs<\/code><\/td>\n<td>The four docs panels.<\/td>\n<\/tr>\n<tr>\n<td><code>\/dashboard\/activity<\/code><\/td>\n<td>Merged ticket, doc and AI-reply events.<\/td>\n<\/tr>\n<tr>\n<td><code>\/dashboard\/agents<\/code><\/td>\n<td>Per-agent load and medians.<\/td>\n<\/tr>\n<tr>\n<td><code>\/dashboard\/hot-spots<\/code><\/td>\n<td>Three heatmaps.<\/td>\n<\/tr>\n<tr>\n<td><code>\/dashboard\/briefing<\/code><\/td>\n<td>The cached daily briefing, written by cron at 06:00.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2>A note on <code>args<\/code><\/h2>\n<p><code>register_rest_route<\/code>&#8216;s <code>args<\/code> declaration documents a route; it does <strong>not<\/strong><br \/>\nfilter the request. Unregistered parameters still reach the callback, and a<br \/>\nmissing <code>required<\/code> one is rejected but an unexpected type is not. Validate<br \/>\ninside the handler.<\/p>\n<h2>Worked examples<\/h2>\n<p>Filing a ticket anonymously \u2014 the one write that needs no credentials:<\/p>\n<pre><code class=\"language-bash\">curl -H 'Content-Type: application\/json' \\\n  -d '{&quot;title&quot;:&quot;Login fails&quot;,&quot;description&quot;:&quot;Steps to reproduce...&quot;,&quot;email&quot;:&quot;alice@example.com&quot;,&quot;name&quot;:&quot;Alice&quot;}' \\\n  https:\/\/your-site.example\/wp-json\/nirohelp\/v1\/tickets\/\n<\/code><\/pre>\n<p>From the front end, where the nonce is already localized:<\/p>\n<pre><code class=\"language-js\">fetch( '\/wp-json\/nirohelp\/v1\/tickets\/', {\n    method: 'POST',\n    headers: {\n        'Content-Type': 'application\/json',\n        'X-WP-Nonce': NIROHELP.nonce,\n    },\n    body: JSON.stringify( { title: 'Login fails', description: 'Steps...' } ),\n} );\n<\/code><\/pre>\n<p>From inside the admin SPA, use <code>apiFetch<\/code> from <code>spa\/lib\/api.ts<\/code> \u2014 it wires the<br \/>\nnonce and the root URL for you:<\/p>\n<pre><code class=\"language-ts\">import { apiFetch } from '@nirohelp\/lib\/api';\n\nconst stats = await apiFetch&lt; DashboardStats &gt;( { path: '\/dashboard\/stats' } );\n<\/code><\/pre>\n<h2>Extending it<\/h2>\n<p>New browser-to-server calls belong here, not on <code>admin-ajax<\/code>. Register the<br \/>\nroute in <code>Controllers\\Common\\API<\/code> through <code>Traits\\Rest::register_route()<\/code>, put<br \/>\nthe callback in <code>app\/API\/<\/code>, and give it a real <code>permission_callback<\/code>.<\/p>\n<p>A callback placed in <code>Controllers\/Admin\/<\/code> is never loaded during a REST<br \/>\nrequest \u2014 <code>Bootstrap\\Initializer<\/code> skips that whole group \u2014 so the route fails<br \/>\nwith no obvious cause.<\/p>\n<p>One <code>wp_ajax_*<\/code> holdout remains, <code>Controllers\\Admin\\AJAX<\/code> for doc and topic<br \/>\nreordering. It is tracked for removal, not a precedent.<\/p>\n","protected":false},"author":1,"featured_media":0,"template":"","meta":{"site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","ast-disable-related-posts":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"default","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}}},"nirohelp_product":[],"nirohelp_topic":[12],"class_list":["post-42","nirohelp_doc","type-nirohelp_doc","status-publish","hentry","nirohelp_topic-developers"],"_links":{"self":[{"href":"https:\/\/nirohelp.com\/docs\/wp-json\/wp\/v2\/nirohelp_doc\/42","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nirohelp.com\/docs\/wp-json\/wp\/v2\/nirohelp_doc"}],"about":[{"href":"https:\/\/nirohelp.com\/docs\/wp-json\/wp\/v2\/types\/nirohelp_doc"}],"author":[{"embeddable":true,"href":"https:\/\/nirohelp.com\/docs\/wp-json\/wp\/v2\/users\/1"}],"wp:attachment":[{"href":"https:\/\/nirohelp.com\/docs\/wp-json\/wp\/v2\/media?parent=42"}],"wp:term":[{"taxonomy":"nirohelp_product","embeddable":true,"href":"https:\/\/nirohelp.com\/docs\/wp-json\/wp\/v2\/nirohelp_product?post=42"},{"taxonomy":"nirohelp_topic","embeddable":true,"href":"https:\/\/nirohelp.com\/docs\/wp-json\/wp\/v2\/nirohelp_topic?post=42"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}