Programmatic WooCommerce Order Notes: Customer Messages and Status History

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.