Setting Usermeta via the WordPress REST API

So, you need to talk to your WordPress server from some other service. Specifically, you need to look up a user by their email and set some user metadata. You’d think this would be straightforward. You would be wrong.

The examples here are using Ruby Net::HTTP, but that’s not really the important part. The code samples are not complete, and this is not a from-scratch tutorial. A bunch of prior experience with WordPress is assumed.

First, know how to authenticate

You’ll need an application password from the user admin screen. You might be tempted to throw this into an Authorization: Bearer ... header like a sensible person, but jokes on you: that doesn’t work in WordPress. You have to use HTTP Basic Auth. (Ignore all the outdated blog posts that say you have to install a special plugin for API Basic Auth.)

In Ruby Net::HTTP that looks like this:

# ...
request = Net::HTTP::Get.new("/wp-json/wp/v2/users?search=#{query}")
request.basic_auth(wp_login, wp_password)
# ...

Note we’re using the long-form where we construct a request object. That’s the only way to use Basic Auth in Net::HTTP. It is old and funky but useful, like a great-uncle who smells like cabbage but who helps you with your cabinetry.

Second, find your user

Now it’s time to find a user by email. Simple, right? Hahahahahahaha

Actually it’s pretty easy to find every user account in which a given email address appears somewhere in the fields. Just do a GET to /wp-json/wp/v2/users?search=QUERY, where QUERY is the email address.

If you’re feeling lucky, take the first result and hope for the best. I don’t feel lucky. I want to look through the results for the one with user_email equal to the email I’m searching for.

Fun fact: out of the box, the WordPress REST API does not return the user_email field for users. And you can’t force it to from the client side. Weird, I know. So now you have to…

Expose the user email field

Exposing the user_email field on either lists of users or individual user entities requires explicitly registering it as a REST field in your WordPress config.

function gracefuldev_register_rest_fields() {
	register_rest_field( 'user', 'user_email', [ 
		'get_callback' => function ($user) {
			return get_userdata( $user['id'] )->user_email;
		},
		'update_callback' => null,
		'schema' => null
	] );
}
add_action( 'rest_api_init', 'gracefuldev_register_rest_fields' );

Stick something like that wherever you keep your WordPress configuration. I like to keep mine in a revision-controlled and automatically-deployed site-specific plugin.

Stop leaking people’s emails

Just one little problem… a second fun fact is that out of box you can use the WordPress user search API endpoint anonymously, and it will return whatever fields are registered. Like that user_email field we just exposed. So anyone can go trawling through user emails on our site. This is bad.

(Side note: I would love to know the reasoning behind making this endpoint public to unauthenticated clients 🧐)

There are probably more fine-grained ways to control this, but personally I can’t see any good reason for any of the API to be accessible to anonymous users. The way to deny all access to anonymous users is clunky and it looks like this:

function gracefuldev_deny_anonymous_api_access( $result ) {
	// If a previous authentication check was applied,
	// pass that result along without modification.
	if ( true === $result || is_wp_error( $result ) ) {
		return $result;
	}

	// No authentication has been performed yet.
	// Return an error if user is not logged in.
	if ( ! is_user_logged_in() ) {
		return new WP_Error(
			'rest_not_logged_in',
			__( 'You are not currently logged in.' ),
			array( 'status' => 401 )
		);
	}

	// Our custom authentication check should have no effect
	// on logged-in requests
	return $result;
}
add_action( 'rest_authentication_errors', 'gracefuldev_deny_anonymous_api_access', 10, 1 );

Don’t blame me, this is shamelessly swiped from the official docs.

OK now that we aren’t leaking PII like a sieve, let’s get back to…

Find the goshdarn user

Now that user_email is available, retrieving definitely-correct user ID by email looks something like this in Ruby Net::HTTP:

query   = URI.encode_www_form(search: customer_email, _fields: "id,user_email,name")
request = Net::HTTP::Get.new("/wp-json/wp/v2/users?#{query}")
request.basic_auth(wp_login, wp_password)
response = http.request(request)
abort "Can't handle pagination" if Integer(response["X-WP-TotalPages"]) > 1
matches    = JSON.parse(response.body)
wp_user    = matches.find { _1["user_email"] == customer_email }
wp_user_id = wp_user.fetch("id")

Notice the assertion in the middle; we’re leaving pagination as a problem for another day.

Update the usermeta

Now it’s just a matter of POSTing or PUTing an update to the user resource. And if you actually believe it’s that simple, you haven’t been paying attention!

Here’s the thing: similar to the pesky user_email field, WordPress REST also will not read or write usermeta that hasn’t been specifically registered for such use.

Expose the usermeta field

So back to our WordPress config we go, to register a user meta key as REST-accessible:

register_meta('user', 'rubytapas_migrate_level', [
	'type' => 'string',
	'single' => true,
	'show_in_rest' => true,
	'description' => "Bookeeping field for legacy account migrations"
]);

With that spelled out, we can return to…

Update the dingety-dang user metadata

In Ruby we can write out the update something like this:

request = Net::HTTP::Post.new("/wp-json/wp/v2/users/#{wp_user_id}")
request.basic_auth(wp_login, wp_password)

request.body         = JSON.dump({ "meta" => { "rubytapas_migrate_level" => level_to_set } })
request.content_type = "application/json"
response             = http.request(request)
response.is_a?(Net::HTTPSuccess) or abort "Failed: #{response}"

updated_user = JSON.parse(response.body)

Confirm the update

A wee little gotcha here is that WordPress REST isn’t exactly eager to report problems in input. It will happily return 200 statuses while throwing your requests away un-serviced. So you might want to add some verification:

case new_value = updated_user.dig("meta", "rubytapas_migrate_level")
when level_to_set then warn "Success"
else abort "Fail: new value is #{new_value}"
end

In my case, I spent about two hours with a typo in the Content-Type header before I figured out what was what.

Leave a Reply

Your email address will not be published. Required fields are marked *