Expose Custom Post Meta in the WordPress REST API the Right Way
You register a custom field, confirm the value exists in wp_postmeta, request /wp-json/wp/v2/parcel/123, and the field is missing. Or worse: you solve the missing field by creating a custom REST endpoint when WordPress already provides the exact read/write mechanism you need.
For current WordPress versions, the decision is usually straightforward: use register_post_meta() when the data is genuinely post metadata, register_rest_field() when the value is a computed or arbitrary API field, and register_rest_route() when you need a separate resource or business operation.
The most common REST meta failure is not a missing database value. It is a mismatch between the post type, metadata registration, REST schema, permissions, and the endpoint being queried.
Start With the Native REST Endpoint
Suppose a WordPress installation has a custom post type named parcel with these metadata fields:
pickup_pincodedrop_pincodedelivery_typeestimated_delivery_days
If the application only needs to read and update those values as part of a parcel resource, creating something like /custom/v1/parcel-meta/123 adds unnecessary API surface. The native endpoint can expose the metadata under its meta property.
GET /wp-json/wp/v2/parcel/123The target response is conceptually:
{
"id": 123,
"title": {
"rendered": "Parcel #123"
},
"meta": {
"pickup_pincode": "10001",
"drop_pincode": "94105",
"delivery_type": "express",
"estimated_delivery_days": 2
}
}That is the first architectural checkpoint: if your data belongs to the post and behaves like metadata, register it as metadata before writing a custom endpoint.
The Four Conditions Required for CPT Meta to Appear
A custom post type integration normally has four separate pieces. Missing any one of them can produce a response that looks correct everywhere except the REST API.
- 1The post type must be exposed to REST.
show_in_restmust be enabled on the custom post type. - 2The post type must support custom fields. Include
custom-fieldsin the post type’s supported features. - 3The meta key must be registered. Use
register_post_meta()for post-type-specific metadata. - 4The metadata must opt into REST exposure. Set
show_in_restappropriately and define its type/schema.
These settings solve different problems. show_in_rest on the post type exposes the resource itself. custom-fields tells WordPress that the post type supports metadata. show_in_rest on the meta registration exposes the individual key.
Register the CPT and Meta as One Coherent Contract
A clean implementation might look like this:
<?php
add_action( 'init', function () {
register_post_type(
'parcel',
array(
'label' => 'Parcels',
'public' => true,
'show_in_rest' => true,
'supports' => array(
'title',
'editor',
'custom-fields',
),
)
);
register_post_meta(
'parcel',
'pickup_pincode',
array(
'single' => true,
'type' => 'string',
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
)
);
register_post_meta(
'parcel',
'drop_pincode',
array(
'single' => true,
'type' => 'string',
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
)
);
register_post_meta(
'parcel',
'delivery_type',
array(
'single' => true,
'type' => 'string',
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
)
);
register_post_meta(
'parcel',
'estimated_delivery_days',
array(
'single' => true,
'type' => 'integer',
'show_in_rest' => true,
'sanitize_callback' => 'absint',
)
);
} );Registering these keys on init is a normal current WordPress pattern. The important point is consistency: the post type and its metadata registration need to exist before the REST layer prepares the resource schema.
Notice that register_post_meta() is used instead of manually calling register_meta( 'post', ... ). The former scopes registration to the parcel post type.
Do Not Confuse show_in_rest With Public Read Access
This is where simple tutorials often become dangerous.
show_in_rest means the field participates in the REST API metadata system. It should not be treated as a substitute for designing the security boundary around sensitive information.
Consider two metadata fields:
delivery_type— safe for a public parcel response.internal_carrier_account_id— potentially restricted operational data.
They should not automatically receive identical exposure rules simply because both are stored in wp_postmeta.
For fields that clients can update, use an explicit authorization strategy and make the capability requirement match the business rule.
register_post_meta(
'parcel',
'delivery_type',
array(
'single' => true,
'type' => 'string',
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => function ( $allowed, $meta_key, $post_id ) {
return current_user_can( 'edit_post', $post_id );
},
)
);The exact capability should reflect the application’s authorization model. Do not copy current_user_can( 'edit_posts' ) blindly into every project; editing a specific parcel and creating a post are different permissions.
Typed Metadata Matters More Than Most Tutorials Admit
WordPress metadata is historically permissive, but the REST API has a typed schema. If your application expects an integer and the server describes it as a string, clients eventually pay for that ambiguity.
For example, this:
'type' => 'integer',
'single' => true,tells the REST API that estimated_delivery_days is one integer value rather than an array of repeated metadata values.
A response should therefore contain:
"estimated_delivery_days": 2rather than:
"estimated_delivery_days": ["2"]This distinction becomes even more important when the metadata is an array or JSON object.
Use JSON Schema for Array and Object Meta
An array or object cannot be adequately described by merely setting type to array or object. The REST API needs to know what the members or properties look like.
Consider a parcel’s delivery checkpoints:
{
"checkpoints": [
{
"location": "Chicago",
"status": "departed",
"timestamp": "2026-10-06T09:30:00Z"
}
]
}Register it with an explicit schema:
register_post_meta(
'parcel',
'checkpoints',
array(
'single' => true,
'type' => 'array',
'show_in_rest' => array(
'schema' => array(
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'location' => array(
'type' => 'string',
),
'status' => array(
'type' => 'string',
),
'timestamp' => array(
'type' => 'string',
'format' => 'date-time',
),
),
'additionalProperties' => false,
),
),
),
)
);This is substantially safer than storing an arbitrary serialized array and hoping every client understands its structure.
For API contracts that matter, treat the metadata schema as part of the interface between WordPress and the consuming application.
A Schema Change Can Break Existing Metadata
One operational edge case deserves special attention: old data can violate a new schema.
Imagine a legacy field called estimated_delivery_days containing values such as:
"2"
"5"
"unknown"
"three"You later register it as an integer. The registration itself does not magically repair the historical rows.
If a stored value fails the registered REST schema, WordPress can return a null value and report a rest_invalid_stored_value error when an invalid value is sent back through an update request.
For a live system, schema changes should therefore be treated like database migrations:
- 1Inventory existing values.
- 2Identify values that do not satisfy the new schema.
- 3Normalize or migrate those records.
- 4Deploy the stricter registration.
- 5Run REST contract tests against representative records.
Creating and Updating Posts With Meta
Once metadata is registered correctly, it can be sent through the native post endpoint.
POST /wp-json/wp/v2/parcel{
"title": "Parcel 10482",
"status": "publish",
"meta": {
"pickup_pincode": "10001",
"drop_pincode": "94105",
"delivery_type": "express",
"estimated_delivery_days": 2
}
}Updating an existing record follows the same model:
POST /wp-json/wp/v2/parcel/123
{
"meta": {
"delivery_type": "overnight",
"estimated_delivery_days": 1
}
}The critical detail is that WordPress needs the meta key to be registered. Simply writing arbitrary keys into a request’s meta object does not create an unrestricted metadata write interface.
Stored Metadata
Use register_post_meta() when the value belongs to the post and is stored as metadata.
Computed Fields
Use register_rest_field() when the API value is calculated dynamically.
Business Operations
Use register_rest_route() when the client needs a separate operation or resource.
When register_rest_field() Is the Better Choice
Not every API field is post meta.
Suppose the client needs:
"delivery_eta": "2026-10-08T14:00:00Z"but the value is calculated from multiple metadata fields, carrier data, current time, and business rules. Persisting the calculated result as post meta may create stale state.
A REST field is a better fit when the API value is computed or assembled dynamically.
add_action( 'rest_api_init', function () {
register_rest_field(
'parcel',
'delivery_eta',
array(
'get_callback' => function ( $post ) {
$days = (int) get_post_meta(
$post['id'],
'estimated_delivery_days',
true
);
if ( $days <= 0 ) {
return null;
}
return gmdate(
'c',
time() + ( $days * DAY_IN_SECONDS )
);
},
'schema' => array(
'type' => array( 'string', 'null' ),
'format' => 'date-time',
'context' => array( 'view' ),
),
)
);
} );The distinction is simple:
| Requirement | Preferred mechanism |
|---|---|
| Stored post metadata | register_post_meta() |
| Computed API field | register_rest_field() |
| Custom business operation | register_rest_route() |
| Separate resource model | Custom REST controller/route |
When You Actually Need a Custom REST Endpoint
Build a custom route when the operation is not naturally a CRUD operation on the post.
For example, an application may need:
POST /wp-json/logistics/v1/parcel/123/recalculate-rateThat request might validate the parcel, call a carrier service, persist a new quote, and return several pieces of calculated information. Treating that operation as a simple metadata update would hide important business semantics.
add_action( 'rest_api_init', function () {
register_rest_route(
'logistics/v1',
'/parcel/(?P<id>\d+)/recalculate-rate',
array(
'methods' => 'POST',
'callback' => 'logistics_recalculate_rate',
'permission_callback' => function ( $request ) {
return current_user_can(
'edit_post',
(int) $request['id']
);
},
'args' => array(
'id' => array(
'required' => true,
'type' => 'integer',
),
),
)
);
} );
function logistics_recalculate_rate( WP_REST_Request $request ) {
$parcel_id = (int) $request['id'];
if ( 'parcel' !== get_post_type( $parcel_id ) ) {
return new WP_Error(
'invalid_parcel',
'The requested resource is not a parcel.',
array( 'status' => 404 )
);
}
// Perform validation, carrier lookup and rate calculation here.
return rest_ensure_response(
array(
'parcel_id' => $parcel_id,
'status' => 'recalculated',
)
);
}Notice the explicit permission_callback. A custom route should not use __return_true merely because it is convenient. Public access is an intentional security decision, not a placeholder to leave in place.
Do Not Build a Custom Endpoint Just to Return More Meta
A common anti-pattern looks like this:
/wp-json/custom/v1/parcel/123/metafollowed by:
get_post_meta( $post_id );This may work technically, but it creates a second API contract around data that WordPress already knows how to expose.
It also risks returning internal metadata that was never intended for external consumers. A registered whitelist is generally safer than dumping every metadata row associated with a post.
Performance: Post Meta Is Not a General-Purpose Data Warehouse
The REST API question eventually becomes a data-model question.
wp_postmeta is useful for relatively small pieces of structured data attached to posts. It becomes less attractive when the application needs frequent filtering, aggregation, joins, or range queries across very large datasets.
For example, an API request that needs to find parcels where all of the following are true:
- carrier is FedEx;
- delivery status is delayed;
- estimated delivery date is within the next 48 hours;
- destination is in California;
- weight is greater than 50 kg.
may produce multiple metadata conditions and expensive query plans as the dataset grows.
If this becomes a central operational query rather than occasional content metadata, evaluate a dedicated table with typed columns and indexes.
A useful boundary is: store descriptive attributes in post meta; consider a custom data model when the attributes themselves become the primary query workload.
Use _fields Instead of Rebuilding Core Responses
If the consuming application only needs a small portion of the response, do not immediately create another endpoint just to reduce payload size.
The REST API supports field selection. A client can request a constrained representation, including nested metadata, with the _fields query parameter.
GET /wp-json/wp/v2/parcel/123?_fields=id,title,metaThis is especially useful for headless applications where a collection endpoint may otherwise return substantially more post data than the client needs.
Metadata Revisions Are a Separate Design Decision
If a metadata field represents business state that must participate in post revisions, consider the revisions_enabled registration argument.
register_post_meta(
'parcel',
'delivery_type',
array(
'single' => true,
'type' => 'string',
'show_in_rest' => true,
'revisions_enabled' => true,
)
);Do not enable revisions automatically for every metadata key. A field such as a transient synchronization timestamp may have little value in revision history, while a manually maintained editorial or business-state field may need it.
The Debugging Sequence When Meta Is Missing
When a field is absent from a REST response, check the system in this order rather than immediately adding another endpoint.
- 1Confirm the post type exists. Run
get_post_type( $post_id )or inspect the registered post type. - 2Confirm
show_in_rest. The CPT itself must be available through REST. - 3Confirm
custom-fields. This is a frequent omission with custom post types. - 4Confirm registration. Verify the exact meta key, including underscores and capitalization.
- 5Confirm
show_in_reston the meta registration. - 6Confirm the type and
singlesettings. A value registered as an array will not behave like a scalar string. - 7Inspect the raw database value. Check whether the expected post actually has that metadata.
- 8Check permissions. Authenticated and unauthenticated requests can have different visibility and update behavior.
- 9Check
_fields. A client may simply be filtering the property out of the response. - 10Check schema validity. Particularly for arrays and objects, malformed legacy values can fail REST preparation.
A Five-Minute REST Smoke Test
For a parcel endpoint, test the actual contract rather than relying only on PHP inspection.
Confirm the endpoint
curl -i \
"https://example.com/wp-json/wp/v2/parcel/123"Look specifically for metadata
curl -s \
"https://example.com/wp-json/wp/v2/parcel/123" \
| jq '.meta'Test field selection
curl -s \
"https://example.com/wp-json/wp/v2/parcel/123?_fields=id,meta" \
| jqTest an authenticated write
curl -i -X POST \
"https://example.com/wp-json/wp/v2/parcel/123" \
-H "Content-Type: application/json" \
-H "Authorization: Basic BASE64_CREDENTIALS" \
--data '{
"meta": {
"delivery_type": "express",
"estimated_delivery_days": 2
}
}'In real deployments, use an appropriate WordPress authentication mechanism and never place credentials directly into source control, shell history, or application logs.
Legacy Meta Endpoint Plugins Are Not the Modern Fix
If you find an old implementation based on the WP-API meta-endpoints project, do not treat it as the recommended current architecture. That project was archived and explicitly states that core metadata registration superseded the plugin.
A migration should normally replace legacy meta endpoints with:
register_post_meta()for stored post metadata;register_rest_field()for computed or custom response fields;register_rest_route()for genuine custom operations.
This reduces custom code and aligns the API contract with WordPress’s current REST metadata implementation.
Architecture Decision Matrix
| Question | Use | Why |
|---|---|---|
| Is it a value stored against a post? | register_post_meta() | Native metadata contract and meta response tree. |
| Is it calculated at request time? | register_rest_field() | Read data without persisting derived state. |
| Does the request trigger business logic? | register_rest_route() | Explicit operation with its own validation and authorization. |
| Does the data require relational querying at high volume? | Custom table + REST layer | Typed columns and purpose-built indexes can outperform repeated meta queries. |
Final Implementation Checklist
- Use
register_post_meta()for post-type-specific metadata. - Set
show_in_reston the CPT. - Include
custom-fieldsin CPT support. - Set
show_in_reston every metadata key that should enter the API contract. - Set
singledeliberately rather than accepting the default. - Set the correct metadata
type. - Use sanitization appropriate to the actual data.
- Use an explicit authorization rule for sensitive or writable fields.
- Define JSON Schema for arrays and objects.
- Test existing data before tightening a schema.
- Use
register_rest_field()for calculated fields. - Use
register_rest_route()for operations and separate API resources. - Use
_fieldswhen payload reduction is the only reason for a custom response. - Evaluate a custom table when post-meta queries become a primary workload.
- Run authenticated and unauthenticated REST tests before deployment.
Conclusion
The fastest way to expose custom WordPress metadata through REST is usually not to create another endpoint. Register the metadata correctly, expose the post type, enable custom-fields, define the schema, and let the native /wp/v2/{post_type} controller handle the metadata contract.
Move to register_rest_field() when the API needs a calculated field, and move to register_rest_route() when the client is invoking a business operation rather than reading or updating post state.
That distinction keeps the API smaller, makes permissions easier to reason about, and avoids maintaining custom endpoints whose only job is to return rows already represented by WordPress metadata.
Need to Diagnose a WordPress REST Meta Integration?
If a custom post type is returning incomplete or incorrectly typed metadata, audit the registration, CPT support, REST schema, capability checks, existing database values, and response contract before adding another endpoint. A short contract-level audit can usually identify whether the problem belongs in metadata registration, authorization, the data model, or the API layer.
Review the REST Meta SetupFrequently Asked Questions
Why is my custom post meta missing from the WordPress REST API?
Check that the custom post type has show_in_rest enabled, supports custom-fields, and that the specific metadata key is registered with show_in_rest. Also check its type, permissions, stored value, schema, and whether the client is using _fields to filter the response.
Should I create a custom REST endpoint for post meta?
Usually not when the data is ordinary post metadata. Use register_post_meta() and the native post endpoint. A custom route makes more sense for a separate business operation, custom resource, or workflow that is not simply reading or updating post state.
When should I use register_rest_field()?
Use register_rest_field() when the API field is calculated or assembled dynamically rather than being stored directly as post metadata.
Why does metadata type matter?
The REST API uses a typed schema. Registering a value as an integer, string, array, or object affects how WordPress validates and represents that value in REST responses and requests.
Should I use post meta for large operational datasets?
Post meta can work well for relatively small pieces of structured data attached to posts. If filtering, aggregation, joins, or range queries across large datasets become a primary workload, evaluate a dedicated table with typed columns and indexes.