Extending the plugin
Five patterns for real-world addons. Class-based handlers, named functions, no closures.
Before you start
If you find yourself reaching for a fork, stop. The plugin’s built around the idea that addons should never need to fork — if there’s a surface you want to extend and no hook exists for it, that’s a bug. Open an issue. Missing hooks get added.
Two ground rules for any addon code you write:
- Never read or write the plugin’s database tables directly. Always go through
OrderUpdatesDborAttachmentsDb. The schemas can change between versions; the public API on those classes is what stays stable. - Use named functions or class methods, never anonymous closures. A closure attached with
add_actioncan’t be removed withremove_action(), can’t be overridden by a child addon, and is hard to test. The five-second saving costs you hours later.
Bootstrapping a new addon
Standard WordPress plugin structure. Bootstrap on plugins_loaded at priority 20 (or later) so the core plugin’s classes are guaranteed to be loaded. Always check the core plugin is active before doing anything — if it’s deactivated, you don’t want fatal errors.
<?php
/**
* Plugin Name: My Addon for Order Updates
* Description: Integrates Order Updates for WooCommerce with our CRM.
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
add_action( 'plugins_loaded', 'my_addon_boot', 20 );
function my_addon_boot(): void {
if ( ! class_exists( 'OrderUpdatesForWoo\\Plugin' ) ) {
// Core plugin not active. Bail silently.
return;
}
My_Addon\Bootstrap::init();
}
From there, Bootstrap::init() wires up your hooks and classes — same pattern as the core plugin’s own Plugin::boot().
Pattern 1 — Add a custom settings tab
Most addons need somewhere to store configuration — toggles, API keys, custom defaults. Don’t add a new top-level WP admin page; piggyback on the plugin’s settings instead. Your tab appears in the same row as General, Customer, Emails, etc. The example below wires up a CRM integration tab, but the pattern works for anything — Slack credentials, a feature-flag panel, a custom field-mapping editor.
Register your custom tab
// my-addon/my-addon.php
add_filter( 'order_updates_for_woo_settings_section_controllers', 'my_addon_register_settings_tab' );
function my_addon_register_settings_tab( array $controllers ): array {
$controllers[] = new My_Addon\Settings\IntegrationTabController();
return $controllers;
}
Add fields and validation
Four fields covering the common types: a password input (API key), a URL with custom validation, a checkbox, and a select. Each field declares its own sanitize_callback — WordPress runs them automatically when the form posts.
// my-addon/src/Settings/IntegrationTabController.php
declare(strict_types=1);
namespace My_Addon\Settings;
class IntegrationTabController {
private const OPTION_GROUP = 'my_addon_integration';
private const SECTION = 'my_addon_integration_main';
public function slug(): string {
return 'my-addon-integration';
}
public function label(): string {
return __( 'CRM integration', 'my-addon' );
}
public function register(): void {
register_setting( self::OPTION_GROUP, 'my_addon_crm_api_key', [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
] );
register_setting( self::OPTION_GROUP, 'my_addon_crm_endpoint', [
'type' => 'string',
'sanitize_callback' => [ $this, 'sanitize_endpoint' ],
'default' => '',
] );
register_setting( self::OPTION_GROUP, 'my_addon_crm_enabled', [
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
] );
register_setting( self::OPTION_GROUP, 'my_addon_crm_default_pipeline', [
'type' => 'string',
'sanitize_callback' => [ $this, 'sanitize_pipeline' ],
'default' => 'support',
] );
add_settings_section(
self::SECTION,
__( 'Connection', 'my-addon' ),
[ $this, 'render_section_intro' ],
self::OPTION_GROUP
);
add_settings_field( 'my_addon_crm_api_key', __( 'API key', 'my-addon' ), [ $this, 'render_api_key' ], self::OPTION_GROUP, self::SECTION );
add_settings_field( 'my_addon_crm_endpoint', __( 'Endpoint URL', 'my-addon' ), [ $this, 'render_endpoint' ], self::OPTION_GROUP, self::SECTION );
add_settings_field( 'my_addon_crm_enabled', __( 'Enabled', 'my-addon' ), [ $this, 'render_enabled' ], self::OPTION_GROUP, self::SECTION );
add_settings_field( 'my_addon_crm_default_pipeline', __( 'Pipeline', 'my-addon' ), [ $this, 'render_pipeline' ], self::OPTION_GROUP, self::SECTION );
}
/**
* Reject non-HTTPS URLs. On failure, keep the previous value and show
* an inline error notice at the top of the form.
*/
public function sanitize_endpoint( $value ): string {
$clean = esc_url_raw( (string) $value );
if ( '' !== $clean && 0 !== strpos( $clean, 'https://' ) ) {
add_settings_error(
'my_addon_crm_endpoint',
'insecure',
__( 'The endpoint URL must use HTTPS.', 'my-addon' )
);
return (string) get_option( 'my_addon_crm_endpoint', '' );
}
return $clean;
}
public function sanitize_pipeline( $value ): string {
$allowed = [ 'support', 'sales', 'success' ];
$value = sanitize_key( (string) $value );
return in_array( $value, $allowed, true ) ? $value : 'support';
}
public function render_section_intro(): void {
echo '<p>' . esc_html__( 'Configure how this site talks to your CRM.', 'my-addon' ) . '</p>';
}
public function render_api_key(): void {
printf(
'<input type="password" name="my_addon_crm_api_key" value="%s" class="regular-text" autocomplete="off">',
esc_attr( (string) get_option( 'my_addon_crm_api_key', '' ) )
);
}
public function render_endpoint(): void {
printf(
'<input type="url" name="my_addon_crm_endpoint" value="%s" class="regular-text">',
esc_attr( (string) get_option( 'my_addon_crm_endpoint', '' ) )
);
}
public function render_enabled(): void {
$checked = (bool) get_option( 'my_addon_crm_enabled', false );
printf(
'<label><input type="checkbox" name="my_addon_crm_enabled" value="1" %s> %s</label>',
checked( $checked, true, false ),
esc_html__( 'Enable mirroring to CRM', 'my-addon' )
);
}
public function render_pipeline(): void {
$current = (string) get_option( 'my_addon_crm_default_pipeline', 'support' );
$options = [
'support' => __( 'Support', 'my-addon' ),
'sales' => __( 'Sales', 'my-addon' ),
'success' => __( 'Success', 'my-addon' ),
];
echo '<select name="my_addon_crm_default_pipeline">';
foreach ( $options as $value => $label ) {
printf(
'<option value="%s" %s>%s</option>',
esc_attr( $value ),
selected( $current, $value, false ),
esc_html( $label )
);
}
echo '</select>';
}
/**
* Renders the tab body. The plugin's outer settings page handles the
* <form>, nonce, and capability gate — this just emits fields.
* The wp_die() is defence in depth in case anyone reaches render() directly.
*/
public function render(): void {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to view these settings.', 'my-addon' ) );
}
settings_fields( self::OPTION_GROUP );
do_settings_sections( self::OPTION_GROUP );
submit_button();
}
}
Field types you can use
The pattern above uses a text input, a URL, a checkbox, and a select. WordPress’s settings API supports anything you can render in HTML. The common types and their sanitize callbacks:
| Field | Input | Sanitize callback | Notes |
|---|---|---|---|
| Text | <input type="text"> | sanitize_text_field | Strips tags, normalises whitespace. |
| Password | <input type="password"> | sanitize_text_field | Same sanitiser; pair with autocomplete="off". |
| URL | <input type="url"> | esc_url_raw | Add your own callback to enforce HTTPS, allowed hosts, etc. |
<input type="email"> | sanitize_email | Returns empty string if invalid. | |
| Number | <input type="number"> | absint / intval / floatval | Pick based on whether you allow negatives or decimals. |
| Checkbox | <input type="checkbox"> | rest_sanitize_boolean | Accepts “1”, “true”, “yes”, etc. |
| Select | <select> | custom whitelist check | Reject anything outside your $allowed array. |
| Radio | <input type="radio"> | custom whitelist check | Same as Select. |
| Textarea | <textarea> | sanitize_textarea_field | Preserves line breaks, strips tags. |
| Rich text | wp_editor() | wp_kses_post | Allow safe HTML; for content fields, not credentials. |
| Colour | <input type="color"> | sanitize_hex_color | Returns null on invalid hex. |
| Date | <input type="date"> | custom DateTime check | Validate the parsed date matches the input string. |
| Multi-select | <select multiple> | array_map over a whitelist | Store as a serialised array or comma-separated string. |
| File upload | media library picker | store the attachment ID | Don’t accept raw file uploads via settings — use the media library. |
For every type, the sanitize_callback on register_setting is the security gate. Always declare one; WordPress won’t guess.
What you get for free
- Capability gating — the outer settings page already requires
manage_woocommerce; thewp_dieinsiderender()is defence in depth. - Sanitization on every save — each
register_settingdeclares its ownsanitize_callback; WordPress runs them automatically. - Inline validation errors — when
sanitize_endpointrejects a value, it callsadd_settings_error()and returns the previous value. The form re-renders with a red notice at the top, the user's old value still in place. - Output escaping everywhere — every value rendered into the form goes through
esc_attroresc_html. Standard WP VIP rule. - i18n built in — every label uses
__()with your add-on's text domain. Runwp i18n make-potin your add-on folder to ship a.pot.
For a production-grade example with grouped fields and conditional rendering, read src/Admin/Settings/Controllers/GeneralTabController.php in the core plugin source.
Pattern 2 — Add a custom button to the update card
You’ve got the CRM credentials saved. Next step: give the team a one-click way to push an update into the CRM on demand. Put the button in the card’s action bar so it sits next to Mark Solved and Delete — native, no extra navigation.
add_action( 'order_updates_for_woo_update_card_actions', 'my_addon_render_sync_button' );
function my_addon_render_sync_button( array $update ): void {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
$update_id = (int) $update['id'];
printf(
'<button type="button" class="button awts_card__action" data-my-addon-sync="%1$d" data-nonce="%2$s">%3$s</button>',
$update_id,
esc_attr( wp_create_nonce( 'my_addon_sync_' . $update_id ) ),
esc_html__( 'Sync to CRM', 'my-addon' )
);
}
The click handler lives in your addon’s JS — enqueue your own script on the order edit page, bind a delegated click on [data-my-addon-sync], POST to your own REST endpoint with the nonce. Use OrderUpdatesForWoo\API\Concerns\VerifiesAccess as a template for the endpoint’s can_access() — same nonce check, same capability check, drop-in.
Three details that matter: capability-check inside the handler (the card itself is gated, but your button may need stricter rules); use awts_card__action if you want the button to match the existing buttons’ styling; never inline the click handler in markup — use a delegated event listener so the JS stays out of every rendered card.
Pattern 3 — Listen for update lifecycle events
The on-demand button is nice, but most teams want every solve, every customer note, every rating mirrored automatically. The pattern: a class with one init() method that registers handlers, one method per event you care about.
declare(strict_types=1);
namespace My_Addon\Crm;
class EventMirror {
public static function init(): void {
add_action( 'order_updates_for_woo_after_mark_solved', [ self::class, 'on_solved' ], 10, 3 );
add_action( 'order_updates_for_woo_after_add_customer_note', [ self::class, 'on_note' ], 10, 4 );
add_action( 'order_updates_for_woo_after_customer_rating', [ self::class, 'on_rated' ], 10, 4 );
}
public static function on_solved( int $update_id, array $update, \WP_REST_Request $request ): void {
self::send( 'update.solved', [
'update_id' => $update_id,
'order_id' => (int) $update['order_id'],
'solver_id' => get_current_user_id(),
] );
}
public static function on_note( int $note_id, int $update_id, string $note, \WP_REST_Request $request ): void {
self::send( 'note.added', [
'note_id' => $note_id,
'update_id' => $update_id,
'body' => $note,
] );
}
public static function on_rated( int $update_id, int $order_id, array $rating, \WP_REST_Request $request ): void {
self::send( 'update.rated', [
'update_id' => $update_id,
'order_id' => $order_id,
'stars' => (int) $rating['stars'],
'comment' => (string) $rating['comment'],
] );
}
private static function send( string $event, array $payload ): void {
wp_remote_post( My_Addon\Config::endpoint(), [
'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . My_Addon\Config::api_key() ],
'body' => wp_json_encode( [ 'event' => $event ] + $payload ),
'timeout' => 5,
] );
}
}
EventMirror::init();
Why a class? Three reasons. You can unregister specific handlers later (remove_action( ..., [ EventMirror::class, 'on_solved' ], 10 )) without affecting the others. A child addon can extend EventMirror and override send() to add retry logic. You can unit-test the methods individually.
Keep timeout low — these handlers run inside the REST request, so a slow CRM means a slow admin page. If the CRM is sometimes flaky, push the send() call into an Action Scheduler job and return immediately.
Pattern 4 — A new tab on the update card
The button works for “sync this thing now”, but what about showing CRM activity inline? A new tab next to Internal Notes / Customer Notes / Participants / Tracking Log is the right home. You get a dedicated panel with its own content.
add_action( 'order_updates_for_woo_update_card_tabs', 'my_addon_render_crm_tab_button' );
add_action( 'order_updates_for_woo_update_card_after_details', 'my_addon_render_crm_tab_panel' );
function my_addon_render_crm_tab_button( array $update ): void {
printf(
'<li class="awts_card__tab" data-tab="my-addon-crm">%s</li>',
esc_html__( 'CRM activity', 'my-addon' )
);
}
function my_addon_render_crm_tab_panel( array $update ): void {
printf(
'<div class="awts_card__panel" data-tab-panel="my-addon-crm" hidden>'
. ' <p>%1$s</p>'
. ' <ul class="my-addon-crm-list" data-update="%2$d"></ul>'
. '</div>',
esc_html__( 'Loading CRM activity...', 'my-addon' ),
(int) $update['id']
);
}
The data-tab on the button and data-tab-panel on the content have to match exactly — that’s how the card’s JS knows what to show when the tab is clicked. Lazy-load the panel content: render an empty <ul> server-side, then fetch the actual CRM data via REST when the tab is first clicked. If the panel is never opened, you’ve saved a DB hit.
Pattern 5 — Replace polling with WebSockets
If your customers complain that updates feel laggy (the 30-second poll is the default), this is the pattern. You wire up Pusher (or Ably, or your own driver), the customer JS uses it instead of polling, and polling becomes the fallback if the WebSocket drops.
declare(strict_types=1);
namespace My_Addon\Realtime;
class PusherDriver {
public static function init(): void {
add_filter( 'order_updates_for_woo_realtime_config', [ self::class, 'inject_config' ], 10, 2 );
add_action( 'order_updates_for_woo_after_add_customer_note', [ self::class, 'publish_note_added' ], 10, 4 );
}
public static function inject_config( array $config, int $order_id ): array {
return array_merge( $config, [
'driver' => 'pusher',
'app_key' => getenv( 'PUSHER_APP_KEY' ),
'cluster' => 'eu',
'channel' => "order-{$order_id}",
] );
}
public static function publish_note_added( int $note_id, int $update_id, string $note, \WP_REST_Request $request ): void {
\My_Addon\Pusher::publish(
"order-{$update_id}",
'customer-note.added',
[ 'note_id' => $note_id ]
);
}
}
PusherDriver::init();
You also need to enqueue your driver JS that subscribes to the Pusher channel and calls the customer page’s realtime callbacks (appendNote, updateNote, reloadPage) — those are exposed on window.AWTS_COU_DRIVER_CALLBACKS for exactly this purpose.
Test the failure path. Most addons that swap in WebSockets work fine on the happy path and silently break when the connection drops. Disable your network mid-session and confirm polling actually kicks back in — that’s the harder test.
Pattern 6 — Add your own emoji shortcuts
The plugin’s EmoticonConverter handles common text emoticons (:) → 🙂, <3 → ❤️) at note save time. The conversion map is private — you can’t plug new entries into it directly. Hook the note payload filter instead and do your own pass first.
add_filter( 'order_updates_for_woo_internal_note_payload', 'my_addon_expand_emoji', 5, 3 );
add_filter( 'order_updates_for_woo_customer_note_payload', 'my_addon_expand_emoji', 5, 3 );
function my_addon_expand_emoji( string $note, int $update_id, \WP_REST_Request $request ): string {
$map = [
':shrug:' => "\u{1F937}",
':thinking:' => "\u{1F914}",
':rocket:' => "\u{1F680}",
':fire:' => "\u{1F525}",
];
return str_replace( array_keys( $map ), array_values( $map ), $note );
}
Priority 5 runs your conversion before the plugin’s own (which is at priority 10). That means your shortcuts get expanded before the plugin’s converter sees the text — useful if your custom shortcut starts with characters the plugin would otherwise mistake for one of its built-ins.
Hook both internal and customer payload filters if you want the shortcuts to work everywhere. Skip one if you want shortcuts that only apply on one side (e.g., team-only project codes that shouldn’t leak to customers).
Pattern 7 — Override the email templates
If you want to change the HTML structure of the emails — colours, header logo, footer copy — copy the template into your theme and edit it there. Same theme-override pattern WooCommerce uses for its own emails.
Full walkthrough with copy-paste shell commands lives on the Theme overrides page. Short version:
cp wp-content/plugins/order-updates-for-woo/src/Frontend/Notifications/Templates/order-update-notification.php \
wp-content/themes/your-theme/order-updates-for-woo/frontend/notifications/order-update-notification.php
Edit your copy, reload. View::render() picks up the override automatically — no filter to register, no settings to flip. Delete the override to revert.
If you only want different wording (not different HTML), skip the override entirely and use WooCommerce → Settings → Emails → <your email>. Faster and survives plugin updates.
Pattern 8 — Tune or invalidate the cache
The plugin caches every read on OrderUpdatesDb under Constants::CACHE_GROUP with a configurable TTL. Two scenarios where you’d want to intervene:
Change the TTL. Settings UI: WooCommerce → Settings → Order Updates → Cache. Programmatic: set order_updates_for_woo_cache_ttl on the WordPress option directly.
// Set TTL to 5 minutes (in seconds). Defaults to 600.
update_option( 'order_updates_for_woo_cache_ttl', 300 );
Invalidate the cache from your addon. If your addon writes directly to update-related data (e.g., a background sync that touches order meta), the plugin’s internal cache won’t know. Bust it through the WordPress object cache — the plugin uses the same group:
use OrderUpdatesForWoo\Shared\Config\Constants;
// Bust the cache for one update.
wp_cache_delete( "update_{$update_id}", Constants::CACHE_GROUP );
// Bust everything in the order's update list.
wp_cache_delete( "order_updates_{$order_id}", Constants::CACHE_GROUP );
The plugin uses cache versions internally to invalidate groups of keys (so it doesn’t have to enumerate every key). If you’ve done a bulk write and want to nuke everything for an order, increment the version counter:
$version_key = "order_updates_ver_{$order_id}";
$current = (int) wp_cache_get( $version_key, Constants::CACHE_GROUP );
wp_cache_set( $version_key, $current + 1, Constants::CACHE_GROUP );
One thing to know: if you’re on a host that uses a persistent object cache (Redis, Memcached), changes are global. On a host without persistent cache, WP’s default in-memory cache resets on every request anyway, so most of this matters less. The Settings → Cache page shows which cache backend you’re using.
Pattern 9 — Inject custom events into the tracking log
The tracking log captures every meaningful event on an update — created, assigned, status changed, reopened, rated. The DB method that writes these is public, so your addon can call it directly for custom events (CRM sync started, escalation triggered, refund issued, etc.).
use OrderUpdatesForWoo\Shared\Config\Constants;
function my_addon_log_crm_sync( int $update_id ): void {
OrderUpdatesForWoo\Plugin::db()->log_lifecycle_event(
$update_id,
'crm_synced', // your custom kind
sprintf( 'Synced to CRM (%s)', My_Addon\Crm::name() ),
get_current_user_id(),
current_time( 'mysql', true )
);
}
The event lands in the same customer_notes table the built-in events use, with your custom kind string. Heads up on a current limitation: the tracking-log SELECT query filters by a fixed list of kinds (status_change, title_change, reopen, rating). Custom kinds won’t show up in the rendered tracking log until that filter accepts your kind.
If you need custom events visible in the UI today, the workarounds are: (1) write your event as a regular internal note with a recognisable prefix, or (2) post a status-change event using one of the built-in kinds. The longer-term fix is a filter on the SELECT — open an issue if you’re hitting this in production, it’s a small change with real value.
Pattern 10 — Add your own REST endpoint
The plugin’s built-in endpoints cover most things, but you’ll often want your own — a “sync this update to my CRM” trigger, a custom analytics aggregator, a webhook receiver. Register the endpoint under your REST namespace (not the plugin’s), and mirror the plugin’s auth pattern.
The full skeleton
// my-addon/src/API/Endpoints/SyncToCrmEndpoint.php
declare(strict_types=1);
namespace My_Addon\API\Endpoints;
use OrderUpdatesForWoo\API\Concerns\VerifiesAccess;
use OrderUpdatesForWoo\Shared\Updates\OrderUpdatesDb;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
class SyncToCrmEndpoint {
use VerifiesAccess; // reuses the plugin's nonce + capability helpers
private const NAMESPACE = 'my-addon/v1';
private const ROUTE = '/updates/(?P\d+)/sync-to-crm';
public function __construct( private OrderUpdatesDb $order_updates_db ) {}
public function register(): void {
register_rest_route(
self::NAMESPACE,
self::ROUTE,
[
'methods' => WP_REST_Server::CREATABLE, // POST
'callback' => [ $this, 'handle' ],
'permission_callback' => [ $this, 'can_access' ],
'args' => [
'update_id' => [
'required' => true,
'type' => 'integer',
],
'pipeline' => [
'required' => false,
'type' => 'string',
'enum' => [ 'support', 'sales', 'success' ],
'default' => 'support',
],
],
]
);
}
/**
* Auth. Verify the nonce, then check the user can edit the order this
* update belongs to. Returns WP_Error on failure so REST returns 403.
*/
public function can_access( WP_REST_Request $request ): bool|WP_Error {
if ( $error = $this->verify_nonce( $request ) ) {
return $error;
}
$update = $this->order_updates_db->get_update( absint( $request->get_param( 'update_id' ) ) );
$order_id = (int) ( $update['order_id'] ?? 0 );
if ( ! $order_id || ! $this->is_authorized_for_order( $order_id ) ) {
return new WP_Error(
'my_addon_forbidden',
__( 'You cannot sync this update.', 'my-addon' ),
[ 'status' => 403 ]
);
}
return true;
}
/**
* The work. Inputs are already validated by the args schema above; cast
* to the right types and run.
*/
public function handle( WP_REST_Request $request ): WP_REST_Response|WP_Error {
$update_id = absint( $request->get_param( 'update_id' ) );
$pipeline = sanitize_key( (string) $request->get_param( 'pipeline' ) );
$update = $this->order_updates_db->get_update( $update_id );
if ( ! $update ) {
return new WP_Error( 'my_addon_not_found', __( 'Update not found.', 'my-addon' ), [ 'status' => 404 ] );
}
do_action( 'my_addon_before_sync_to_crm', $update_id, $pipeline );
try {
$result = My_Addon\Crm::sync_update( $update, $pipeline );
} catch ( \Throwable $e ) {
return new WP_Error( 'my_addon_sync_failed', $e->getMessage(), [ 'status' => 502 ] );
}
do_action( 'my_addon_after_sync_to_crm', $update_id, $pipeline, $result );
return rest_ensure_response( [
'update_id' => $update_id,
'pipeline' => $pipeline,
'crm_id' => $result['id'] ?? null,
'synced_at' => current_time( 'mysql', true ),
] );
}
}
// In your addon's bootstrap:
add_action( 'rest_api_init', 'my_addon_register_endpoints' );
function my_addon_register_endpoints(): void {
$endpoint = new My_Addon\API\Endpoints\SyncToCrmEndpoint(
// Grab the plugin's DB singleton.
OrderUpdatesForWoo\Plugin::db()
);
$endpoint->register();
}
What you’re inheriting from the plugin
By using the VerifiesAccess trait you get three methods for free:
verify_nonce( $request )— checks theX-WP-Nonceheader againstwp_rest. Returnsnullon success,WP_Erroron failure.is_authorized_for_order( $order_id )— checks the user hasedit_shop_orderormanage_woocommerceon that order. HPOS-safe.is_acting_as_customer( $order_id, $order_key )— for guest-customer endpoints, validates the order_key.
If your endpoint needs different auth (admin-only, role-gated, etc.), write your own permission_callback instead of using these.
Things to get right
- Use your own namespace. The example uses
my-addon/v1. Don’t register underorder-updates-for-woo/v1— that’s the plugin’s namespace and reserved for its endpoints. - Declare your args schema. The
argsarray tells WP REST to validate inputs before your callback even fires. Usetype,enum,required,default. Saves you a layer of manual validation. - Sanitise inside
handle()too. The args schema validates shape; you stillabsint()/sanitize_key()before using values. Defence in depth. - Wrap mutations in your own action hooks. Fire
my_addon_before_*andmy_addon_after_*around the work — gives extensibility to anyone consuming your addon. - Return
WP_Errorwith a meaningful status code. 400 for bad input, 403 for auth, 404 for missing resource, 500 for unexpected, 502 for downstream failures (your CRM was down).
Calling your endpoint from JS
fetch( '/wp-json/my-addon/v1/updates/123/sync-to-crm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': awtsData.nonce, // the plugin's nonce works fine for your endpoint
},
body: JSON.stringify( { pipeline: 'sales' } ),
} )
.then( function ( r ) { return r.json(); } )
.then( function ( body ) {
console.log( 'Synced:', body );
} );
The same X-WP-Nonce the plugin’s JS uses works for your endpoint too — it’s a generic wp_rest nonce, not plugin-scoped.
The rules
- Don’t access the plugin’s DB tables directly. Go through
OrderUpdatesDb/AttachmentsDb. - Register REST endpoints under your own namespace, not the plugin’s.
- Use named functions or class methods. No closures attached to
add_action/add_filter. - Sanitise, escape, nonce-verify. Match the security baseline in
src/API/Concerns/VerifiesAccess.php. - Test on both HPOS and classic order storage. The core plugin supports both; your addon should too.