Skip to content
ThemesIonic — home
WordPress Tutorials

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.

5 min read intermediate

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:

  1. Defines DOING_AJAX and WP_ADMIN, loads WordPress, sends X-Robots-Tag: noindex.
  2. If $_REQUEST['action'] is empty or not scalar: wp_die( '0', 400 ).
  3. Fires admin_init — on every AJAX request, front end included.
  4. Registers core's own handlers for recognised core actions.
  5. If is_user_logged_in(): checks has_action( "wp_ajax_{$action}" ), answers 0 / 400 if false, otherwise fires it. If not logged in: the same with wp_ajax_nopriv_{$action}.
  6. 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 $_POST without wp_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.php shows Form Data with your action, 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 -1 with status 403.
  • debug.log shows 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.

Related guides