WooCommerce order notes are timestamped entries stored in the wp_comments table with comment_type = ‘order_note’ and comment_post_ID pointing to the order ID. They serve three distinct purposes depending on the customer_note comment meta value: private internal notes visible only in the admin (customer_note = 0), customer-visible notes that appear in the “Order Updates” section of the My Account order detail page and trigger a notification email (customer_note = 1), and system status entries added automatically by WooCommerce when the order status changes (added via WC_Order::add_order_note() with no customer notification). Programmatically adding an order note uses $order->add_order_note( $note, $is_customer_note, $added_by_user ) — the second parameter controls customer visibility and, when true, triggers the WooCommerce “Customer note” email to the billing address. Order notes are retrieved as an array of WP_Comment objects via wc_get_order_notes( [ ‘order_id’ => $order_id, ‘type’ => ‘customer’ ] ) — the type argument accepts ‘all’, ‘customer’ (customer-visible only), or ‘internal’ (private only). The woocommerce_new_order_note_data filter runs before a note is inserted and receives the comment data array — it can be used to strip HTML from notes, enforce maximum length, or route notes to an external logging system. Custom note types beyond the built-in customer/internal split can be implemented by adding a custom comment_type and rendering them in the order detail admin screen via the woocommerce_order_note_class filter. Bulk operations on order notes — cleaning up thousands of auto-generated notes, migrating notes to a separate table — are most efficient using direct $wpdb queries against wp_comments and wp_commentmeta rather than iterating via the WC_Order API. The Custom WooCommerce Email post covered building dedicated notification emails; order notes with $is_customer_note = true use WooCommerce’s built-in customer note email template and are a simpler alternative when a full custom email class is not needed.
Problem: A WooCommerce store integrates with a third-party fulfilment warehouse that sends webhook callbacks when shipments are picked, packed, and dispatched. Each event should add a private internal note to the WooCommerce order for staff, and the “dispatched” event should also send the customer a tracking message. Additionally, the admin order list needs a column showing whether the order has any unread customer replies in the notes.
Solution: Build a webhook receiver that parses the fulfilment payload, adds appropriately typed order notes, and sends a customer notification for the dispatch event — then add a custom admin column that queries for orders with recent customer-visible notes.
// ── Webhook endpoint: register a REST route for the fulfilment webhook ─────────
add_action( 'rest_api_init', function(): void {
register_rest_route( 'myplugin/v1', '/fulfilment-webhook', [
'methods' => 'POST',
'callback' => 'myplugin_handle_fulfilment_webhook',
'permission_callback' => 'myplugin_verify_webhook_secret',
] );
} );
function myplugin_verify_webhook_secret( WP_REST_Request $request ): bool {
$incoming = $request->get_header( 'x-webhook-secret' );
$expected = get_option( 'myplugin_webhook_secret' );
// Use hash_equals to prevent timing attacks on string comparison
return hash_equals( (string) $expected, (string) $incoming );
}
function myplugin_handle_fulfilment_webhook( WP_REST_Request $request ): WP_REST_Response {
$payload = $request->get_json_params();
$event = sanitize_key( $payload['event'] ?? '' );
$order_id = absint( $payload['order_id'] ?? 0 );
$tracking = sanitize_text_field( $payload['tracking_number'] ?? '' );
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
return new WP_REST_Response( [ 'error' => 'Order not found' ], 404 );
}
switch ( $event ) {
case 'picked':
// Internal note — staff only
$order->add_order_note(
__( 'Warehouse: order items picked and awaiting packing.', 'myplugin' ),
false // $is_customer_note = false
);
break;
case 'packed':
$order->add_order_note(
__( 'Warehouse: order packed and queued for dispatch.', 'myplugin' ),
false
);
break;
case 'dispatched':
// Internal note for staff
$order->add_order_note(
sprintf(
/* translators: %s: tracking number */
__( 'Warehouse: order dispatched. Tracking: %s', 'myplugin' ),
$tracking
),
false
);
// Customer-visible note — triggers "Customer note" email
$order->add_order_note(
sprintf(
/* translators: %s: tracking number */
__( 'Your order has been dispatched! Track it with: %s', 'myplugin' ),
esc_html( $tracking )
),
true // $is_customer_note = true → sends email to customer
);
// Store tracking number as order meta for display elsewhere
$order->update_meta_data( '_tracking_number', $tracking );
$order->save();
break;
default:
return new WP_REST_Response( [ 'error' => 'Unknown event' ], 400 );
}
return new WP_REST_Response( [ 'ok' => true ], 200 );
}
// ── Read order notes programmatically ─────────────────────────────────────────
function myplugin_get_order_tracking_history( int $order_id ): array {
// Retrieve only customer-visible notes
$notes = wc_get_order_notes( [
'order_id' => $order_id,
'type' => 'customer',
'orderby' => 'date_created',
'order' => 'ASC',
] );
return array_map( static function( WP_Comment $note ): array {
return [
'date' => $note->comment_date,
'message' => wp_strip_all_tags( $note->comment_content ),
];
}, $notes );
}
// ── Delete auto-generated system notes older than 90 days (bulk cleanup) ───────
function myplugin_purge_old_system_notes(): void {
global $wpdb;
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-90 days' ) );
// System notes have customer_note = 0 and no user_id (added_by = 'WooCommerce')
$deleted = $wpdb->query( $wpdb->prepare(
"DELETE c FROM {$wpdb->comments} c
WHERE c.comment_type = 'order_note'
AND c.comment_date_gmt < %s
AND c.comment_author = 'WooCommerce'
AND c.user_id = 0",
$cutoff
) );
if ( $deleted ) {
error_log( sprintf( 'Purged %d old system order notes', $deleted ) );
}
}
NOTE: WooCommerce order notes are stored in wp_comments alongside post comments, pingbacks, and trackbacks — the comment_type = ‘order_note’ discriminator is what separates them. This means that any plugin or query that runs SELECT COUNT(*) FROM wp_comments without a WHERE comment_type filter reports inflated comment counts on WooCommerce sites with many orders. It also means that wp_count_comments() and the admin dashboard comment widget include order notes in their totals — use wc_get_order_notes() instead of raw wp_comments queries when working specifically with order notes. When programmatically adding customer notes in large batches (e.g., bulk import), call $order->add_order_note( $note, false ) (no customer notification) to avoid sending hundreds of emails — use the WooCommerce email API to send a single summary email instead.