WooCommerce Order Fulfillment Automation with Action Scheduler

Manual order fulfillment steps — sending to a 3PL API, updating tracking numbers, notifying customers — belong in the background, not in the HTTP request that processes the payment. Action Scheduler (the WooCommerce-bundled job queue) provides reliable async task execution with retries, logging, and concurrency control, replacing fragile WP-Cron one-off events for fulfillment workflows.

The code below schedules a fulfillment job when an order moves to "processing", implements the async fulfillment callback with retry logic, updates the order with tracking data, and shows how to monitor the Action Scheduler queue from the command line.

<?php
// 1. Schedule fulfillment job when order reaches "processing" status
add_action( 'woocommerce_order_status_processing', function ( int $order_id ) {
    // Avoid double-scheduling if already queued
    if ( as_has_scheduled_action( 'my_plugin_fulfill_order', [ 'order_id' => $order_id ] ) ) {
        return;
    }
    as_schedule_single_action(
        time() + 30,                                    // 30-second delay
        'my_plugin_fulfill_order',
        [ 'order_id' => $order_id, 'attempt' => 1 ],
        'order-fulfillment'                             // group name
    );
} );

// 2. Fulfillment callback with retry logic
add_action( 'my_plugin_fulfill_order', function ( int $order_id, int $attempt = 1 ) {
    $order = wc_get_order( $order_id );
    if ( ! $order || $order->get_status() !== 'processing' ) {
        return;   // order no longer in the right state — skip
    }

    // Call 3PL API
    $response = wp_remote_post( 'https://3pl.example.com/api/orders', [
        'headers' => [ 'Authorization' => 'Bearer ' . get_option( '3pl_api_key' ) ],
        'body'    => wp_json_encode( build_3pl_payload( $order ) ),
        'timeout' => 15,
    ] );

    if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
        if ( $attempt < 5 ) {
            // Exponential backoff: 2^attempt minutes
            $delay = pow( 2, $attempt ) * 60;
            as_schedule_single_action(
                time() + $delay,
                'my_plugin_fulfill_order',
                [ 'order_id' => $order_id, 'attempt' => $attempt + 1 ],
                'order-fulfillment'
            );
            $order->add_order_note( "Fulfillment attempt $attempt failed. Retry in {$delay}s." );
        } else {
            $order->update_status( 'failed', 'Fulfillment failed after 5 attempts.' );
        }
        return;
    }

    // Success: update order with tracking info
    $body        = json_decode( wp_remote_retrieve_body( $response ), true );
    $tracking_no = sanitize_text_field( $body['tracking_number'] ?? '' );

    $order->update_meta_data( '_tracking_number', $tracking_no );
    $order->update_status( 'completed', "Fulfilled. Tracking: $tracking_no" );
    $order->save();

    // Trigger customer notification
    do_action( 'my_plugin_order_fulfilled', $order_id, $tracking_no );
}, 10, 2 );

function build_3pl_payload( WC_Order $order ): array {
    return [
        'order_ref' => $order->get_id(),
        'items'     => array_map( fn( WC_Order_Item_Product $item ) => [
            'sku' => $item->get_product()->get_sku(),
            'qty' => $item->get_quantity(),
        ], array_values( $order->get_items() ) ),
        'address'   => [
            'name'    => $order->get_shipping_first_name() . ' ' . $order->get_shipping_last_name(),
            'line1'   => $order->get_shipping_address_1(),
            'city'    => $order->get_shipping_city(),
            'postcode'=> $order->get_shipping_postcode(),
            'country' => $order->get_shipping_country(),
        ],
    ];
}

NOTE: Action Scheduler stores its queue in wp_actionscheduler_actions — on high-volume stores this table can grow large; run wp action-scheduler clean weekly (or via a scheduled Action Scheduler action itself) to purge completed and failed actions older than 30 days and keep the table performant.