wp_ajax_{$action} and wp_ajax_nopriv_{$action}: Writing a Handler That Answers
admin-ajax.php picks one of two hooks based on whether the visitor is logged in, never both, and answers 400 with a bare 0 when nothing is listening. Most broken AJAX handlers fail on one of those two facts.
wp_ajax_{$action} runs for logged-in users and wp_ajax_nopriv_{$action} for logged-out visitors, and admin-ajax.php fires exactly one of them based on the request's action parameter. If nothing is hooked to the one it picks, the response is a bare 0 with status 400. Nearly every "AJAX returns 0" or "400 Bad Request" report comes down to the request's action, the visitor's login state, or a handler that never ended the request.
What admin-ajax.php does, in order
Reading wp-admin/admin-ajax.php top to bottom:
- Defines
DOING_AJAXandWP_ADMIN, loads WordPress, sendsX-Robots-Tag: noindex. - If
$_REQUEST['action']is empty or not scalar:wp_die( '0', 400 ). - Fires
admin_init— on every AJAX request, front end included. - Registers core's own handlers for recognised core actions.
- If
is_user_logged_in(): checkshas_action( "wp_ajax_{$action}" ), answers0/ 400 if false, otherwise fires it. If not logged in: the same withwp_ajax_nopriv_{$action}. - After the action:
wp_die( '0' ).
Step 3 and step 6 explain the two least obvious failures, below.
A handler that works
add_action( 'wp_ajax_ti_save_note', 'ti_save_note' );
function ti_save_note() {
check_ajax_referer( 'ti_save_note' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( array( 'message' => 'Not allowed' ), 403 );
}
$note = sanitize_textarea_field( wp_unslash( $_POST['note'] ?? '' ) );
update_user_meta( get_current_user_id(), 'ti_note', $note );
wp_send_json_success( array( 'saved' => strlen( $note ) ) );
}
And the script side, with the URL and nonce passed from PHP — ajaxurl is defined only on admin screens, so front-end code must be given the URL:
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_script( 'ti-note', plugins_url( 'note.js', __FILE__ ), array(), '1.0', true );
wp_localize_script( 'ti-note', 'tiNote', array(
'url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'ti_save_note' ),
) );
} );
const body = new URLSearchParams( {
action: 'ti_save_note',
_ajax_nonce: tiNote.nonce,
note: document.querySelector( '#note' ).value,
} );
fetch( tiNote.url, { method: 'POST', body, credentials: 'same-origin' } )
.then( ( r ) => r.json() )
.then( ( res ) => console.log( res.success, res.data ) );
Script loading itself is covered in wp_enqueue_scripts and, for admin screens, admin_enqueue_scripts.
The nonce
check_ajax_referer( $action, $query_arg = false, $stop = true ) looks for the nonce in the named field, then _ajax_nonce, then _wpnonce. It fires the check_ajax_referer action and, on failure, ends with wp_die( -1, 403 ). So -1 with status 403 is a nonce failure, distinct from 0 with 400. Nonces are tied to the user session; a page cached for logged-out visitors serves the same nonce to everyone for hours, and after it expires every request returns -1. Generate front-end nonces on a request that is not page-cached, or exclude the page from cache.
Why you get 0 and 400
The action never arrived. fetch() with a JSON.stringify() body sends JSON or plain text, which PHP does not parse into $_POST. $_REQUEST['action'] is empty and step 2 answers 400. Send URLSearchParams or FormData, as above, or use the REST API, which reads JSON.
Wrong login state. A handler registered only with wp_ajax_ does nothing for logged-out visitors; one registered only with wp_ajax_nopriv_ does nothing for logged-in ones. The second is the classic "works in a private window, fails when I'm logged in" report. Register both for public features:
add_action( 'wp_ajax_ti_vote', 'ti_vote' );
add_action( 'wp_ajax_nopriv_ti_vote', 'ti_vote' );
The hook is registered too late or conditionally. admin-ajax.php loads plugins and the theme, then checks. Registering inside a shortcode callback, on template_redirect, on wp, or behind ! is_admin() means it never runs during the AJAX request — is_admin() is true there because WP_ADMIN is defined.
The trailing 0
If the handler echoes output and returns, step 6 still runs wp_die( '0' ) and appends 0. JSON becomes {"ok":true}0 and fails to parse. wp_send_json() and its success and error variants end the request themselves — inside AJAX they call wp_die() with no extra output — so the 0 never appears. If you echo by hand, finish with wp_die().
The admin_init trap
Because step 3 fires admin_init for every AJAX call, a common snippet that keeps subscribers out of the dashboard —
add_action( 'admin_init', function () {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_safe_redirect( home_url() );
exit;
}
} );
— also redirects every AJAX request from a logged-in subscriber. Live search, form submissions and voting widgets break only for those users. Guard it with if ( wp_doing_ajax() ) return; before the redirect.
Debugging a handler from the shell
Take the browser out of the picture. A logged-out request needs no cookies, so curl shows exactly what admin-ajax.php answers:
curl -s -o - -w '\n%{http_code}\n' \
-d 'action=ti_vote' -d 'post_id=12' \
https://example.com/wp-admin/admin-ajax.php
0 and 400 mean no wp_ajax_nopriv_ti_vote callback was registered by the time the request was dispatched. -1 and 403 mean the handler ran and rejected the nonce. A JSON body followed by 0 means the handler did not end the request. Anything else — HTML, a PHP warning, a security plugin's block page — came from code that ran before your handler, and the response body says which.
Common mistakes
- Skipping the capability check because a nonce is present. A nonce proves intent from this session, not permission.
- Reading
$_POSTwithoutwp_unslash()and sanitising. - Using admin-ajax for heavy front-end traffic. Each call boots WordPress with the admin flag; a public, cacheable read belongs in a REST endpoint.
- Printing PHP notices with debug display on, which corrupt the JSON exactly like the trailing
0.
Verify
- In DevTools › Network, the request to
admin-ajax.phpshows Form Data with youraction, status 200 and a JSON body. - Repeat logged out and logged in; both work if both hooks are registered.
- Tamper with the nonce: the response is
-1with status 403. debug.logshows nothing from the handler.
Frequently asked
- Either no handler is registered for the action that was sent, in which case the response is 0 with status 400, or the handler ran but did not end the request, in which case admin-ajax.php appends its own final 0. End every handler with wp_send_json_success(), wp_send_json_error() or wp_die().
- For anything a logged-out visitor can trigger, yes. admin-ajax.php fires wp_ajax_{$action} for logged-in users and wp_ajax_nopriv_{$action} for everyone else, never both. A front-end feature registered only with nopriv breaks for logged-in users, including administrators testing it.
- The REST API is the better fit for new endpoints: proper HTTP methods, JSON input, schema validation and per-route permission callbacks. admin-ajax.php remains appropriate for small admin-screen actions and for compatibility with code that already uses it, and it is not deprecated.