What you are building
By the end of this tutorial you will have a book custom post type whose ISBN and star rating appear at the top level of the REST response, described by a JSON Schema, validated on write, and readable in the block editor through block bindings. You will also have curl and WP-CLI commands you can paste into a terminal to prove it works.
The important detail most tutorials skip: the schema argument on register_rest_field() is descriptive. WordPress uses it for the OPTIONS response and for client-side validation in the block editor, but it does not automatically validate the value arriving at your update_callback. You have to call rest_validate_value_from_schema() yourself. We will do exactly that.
Prerequisites
- WordPress 6.5 or later (block bindings land in 6.5; the REST helpers are far older).
- PHP 7.4+ and a theme or mu-plugin where you can add code. A small site-specific plugin is cleaner than
functions.php. - WP-CLI installed locally or over SSH, plus
curlandjqfor the terminal tests. - HTTPS on the site if you intend to authenticate with Application Passwords — WordPress refuses them over plain HTTP by default.
- Familiarity with
register_post_type()andregister_post_meta(). If those are new, read the register_post_meta() reference first.
Step 1 — Register the post type and its meta
Start with the storage layer. Two things matter here: show_in_rest => true on the post type (so the route exists at all) and a sanitize_callback on each meta key (so nothing unvalidated ever reaches the database, regardless of entry point).
<?php
add_action( 'init', function () {
register_post_type( 'book', [
'label' => 'Books',
'public' => true,
'show_in_rest' => true,
'rest_base' => 'books',
'supports' => [ 'title', 'editor', 'custom-fields' ],
] );
register_post_meta( 'book', 'isbn', [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => static fn() => current_user_can( 'edit_posts' ),
] );
register_post_meta( 'book', 'rating', [
'type' => 'integer',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'absint',
'auth_callback' => static fn() => current_user_can( 'edit_posts' ),
] );
} );
Why bother with register_rest_field() if the meta is already in REST? Because show_in_rest on meta nests the values under a meta object, keyed by raw meta key, with no per-field descriptions and no grouping. A registered REST field gives you a named, documented object at the top level of the response — far easier for a headless front end, a mobile client, or a block binding source to consume. It also lets you compute or reshape values rather than dumping raw storage.
Rule of thumb: use
register_post_meta()withshow_in_restfor plain storage you want editable in the block editor’s custom fields panel. Addregister_rest_field()when you need a stable public contract — a named shape, a schema, or derived data.
Step 2 — Define the schema once, in a function
Put the schema in its own function so the registration call and the write validator share one source of truth. Drift between the two is the most common cause of “it validates in the editor but not on the server”.
<?php
function wpth_book_meta_schema(): array {
return [
'description' => __( 'ISBN and star rating for a book.', 'wpth' ),
'type' => 'object',
'context' => [ 'view', 'edit', 'embed' ],
'properties' => [
'isbn' => [
'description' => __( 'ISBN-13, digits only.', 'wpth' ),
'type' => 'string',
'pattern' => '^[0-9]{13}$',
'context' => [ 'view', 'edit', 'embed' ],
],
'rating' => [
'description' => __( 'Star rating, 0 to 5.', 'wpth' ),
'type' => 'integer',
'minimum' => 0,
'maximum' => 5,
'context' => [ 'view', 'edit' ],
],
],
];
}
add_action( 'rest_api_init', function () {
register_rest_field(
'book',
'book_meta',
[
'get_callback' => 'wpth_get_book_meta',
'update_callback' => 'wpth_update_book_meta',
'schema' => wpth_book_meta_schema(),
]
);
} );
Three details worth noting. First, the first argument is the post type slug (book), not the rest_base (books) — mixing those up silently registers nothing. Second, pattern is a bare regular expression with no delimiters, matching JSON Schema conventions. Third, context controls which requests include the field: rating is omitted from embed responses because it is not needed in oEmbed payloads.
Step 3 — Write callbacks that actually validate
The get_callback receives the prepared post array, the field name, the request and the object type. Return a value whose shape matches the schema — an array here, because the schema declares type => object. Returning a JSON string instead will break client-side validation in the editor.
The update_callback is where the real work happens. Check capability, check the shape, then run the schema validator before touching the database.
<?php
function wpth_get_book_meta( array $post_arr, string $field_name, WP_REST_Request $request, string $object_type ): array {
$post_id = (int) $post_arr['id'];
return [
'isbn' => (string) get_post_meta( $post_id, 'isbn', true ),
'rating' => (int) get_post_meta( $post_id, 'rating', true ),
];
}
function wpth_update_book_meta( $value, WP_Post $post, string $field_name, WP_REST_Request $request, string $object_type ) {
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
return new WP_Error(
'wpth_forbidden',
__( 'You cannot edit this book.', 'wpth' ),
[ 'status' => rest_authorization_required_code() ]
);
}
if ( ! is_array( $value ) ) {
return new WP_Error(
'wpth_invalid_book_meta',
__( 'book_meta must be an object.', 'wpth' ),
[ 'status' => 400 ]
);
}
$valid = rest_validate_value_from_schema( $value, wpth_book_meta_schema(), 'book_meta' );
if ( is_wp_error( $valid ) ) {
return $valid;
}
if ( array_key_exists( 'isbn', $value ) ) {
update_post_meta( $post->ID, 'isbn', sanitize_text_field( $value['isbn'] ) );
}
if ( array_key_exists( 'rating', $value ) ) {
update_post_meta( $post->ID, 'rating', absint( $value['rating'] ) );
}
return true;
}
Returning true tells WordPress the update succeeded; returning a WP_Error aborts the request with the status you specify. Note the belt-and-braces approach: the schema validator rejects a nine-digit ISBN or a rating of 11, and the sanitize_callback on the meta key catches anything that slips in through a different route, such as the classic editor or a direct update_post_meta() call.
One caveat: register_rest_field() has no permission_callback of its own. The parent route’s permission callback governs whether the request reaches you at all, so the current_user_can() check above is a second line of defence, not the first.
Step 4 — Test the read path with curl
Create a book, note its ID, then fetch it with _fields to keep the output readable. _fields is a core REST parameter that trims the response to the keys you name — invaluable when you are debugging a single field.
# Public read: no authentication needed for published books.
curl -s "https://example.com/wp-json/wp/v2/books/42?_fields=id,title,book_meta" | jq .
# Inspect the schema WordPress advertises for the route.
curl -s -X OPTIONS "https://example.com/wp-json/wp/v2/books" | jq '.schema.properties.book_meta'
You should see book_meta as a sibling of id and title, not buried under meta. If it is missing entirely, jump to the troubleshooting section before changing anything else.
Step 5 — Test the write path with curl and WP-CLI
Writes need authentication. Create an Application Password under Users → Profile, then send it as basic auth. The space-separated password works as-is inside the credentials string.
# Valid write.
curl -s -X POST "https://example.com/wp-json/wp/v2/books/42" \
-H "Content-Type: application/json" \
-u "editor:abcd efgh ijkl mnop qrst uvwx" \
-d '{"book_meta":{"isbn":"9780262033848","rating":5}}' | jq .book_meta
# Invalid write: rating out of range. Expect HTTP 400 and a rest_invalid_param code.
curl -s -o /dev/null -w "%{http_code}\n" -X POST "https://example.com/wp-json/wp/v2/books/42" \
-H "Content-Type: application/json" \
-u "editor:abcd efgh ijkl mnop qrst uvwx" \
-d '{"book_meta":{"rating":9}}'
WP-CLI is faster when you are iterating on a local install and want to see the raw error body:
wp rest get wp/v2/books/42 --user=admin --fields=id,book_meta --format=json
wp rest post wp/v2/books/42 --user=admin \
--body='{"book_meta":{"isbn":"9780262033848","rating":5}}'
# Deliberately break it to confirm validation fires.
wp rest post wp/v2/books/42 --user=admin \
--body='{"book_meta":{"isbn":"123","rating":5}}' --debug
The --debug flag prints the full HTTP exchange, including the JSON error payload, which is the quickest way to confirm your WP_Error codes are reaching the client intact.
Step 6 — Consume the field in the editor with block bindings
Do not reach for innerHTML and hand-rolled fetch() calls. Since WordPress 6.5 you can register a block bindings source and let core blocks pull the value directly, with no custom JavaScript at all.
<?php
add_action( 'init', function () {
register_block_bindings_source( 'wpth/book-meta', [
'label' => __( 'Book meta', 'wpth' ),
'uses_context' => [ 'postId' ],
'get_value_callback' => static function ( array $source_args, $block_instance ) {
$post_id = $block_instance->context['postId'] ?? get_the_ID();
$key = $source_args['key'] ?? 'isbn';
return get_post_meta( $post_id, $key, true );
},
] );
} );
Then bind a paragraph inside your block template or pattern:
<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"wpth/book-meta","args":{"key":"isbn"}}}}} -->
<p>ISBN</p>
<!-- /wp:paragraph -->
Because the binding reads post meta directly on the server, the REST field is not strictly required for rendering — but it is required for any headless consumer, and it gives the editor a documented contract for validation. Keep both; they serve different audiences.
Troubleshooting
The field never appears in the response
- Confirm the first argument to
register_rest_field()is the post type slug, notrest_base. - Confirm the post type has
'show_in_rest' => true. Without it, no route is registered and the field has nowhere to attach. - Check that
rest_api_initis firing — if you hooked too late (for example insideadmin_init), the field is never registered. - If you are requesting with
context=embed, verify the field’s schema includesembedin itscontextarray.
The field returns null or an empty object
Your get_callback is probably returning a value that does not match the declared type. An object schema expects a PHP associative array; a string schema expects a scalar. Cast explicitly rather than trusting get_post_meta(), which returns '' for missing keys.
Writes return 401 or 403
Application Passwords require HTTPS. If you are on a local HTTP install, either add define( 'WP_ENVIRONMENT_TYPE', 'local' ); or use a nonce-authenticated request from within the admin. Also check that the user has edit_post on that specific post — a capability check against edit_posts alone will fail for another author’s draft.
The editor rejects the value before it reaches the server
The block editor validates against the schema you registered, client-side. If the schema says integer and your get_callback returns the string "5", the editor flags a mismatch. Fix the callback, not the schema.
Meta saves but the REST field does not update
You are likely writing to a different meta key than the one the getter reads, or the update_callback is returning true before the update_post_meta() calls run. Trace it with wp post meta list 42 immediately after the request.
Next steps
- Read Modifying responses in the REST API handbook to compare
register_rest_field()with therest_prepare_bookfilter — the filter is better when you need to remove or rename existing keys. - Add a
prepare_callbackto your field if you need per-context shaping, for example hiding the ISBN from unauthenticated requests. - Expose the same field through block bindings in a
theme.json-driven block template so the value renders server-side with no JavaScript. - Write a PHPUnit test using
WP_Test_REST_TestCasethat asserts a 400 for an out-of-range rating. Schema validation is only as good as the test that proves it fires.
Leave a Reply