David Lewin David Lewin
E-Commerce, Research & Ads

Integrating a 3PL API with WooCommerce: oversell protection, inventory sync and order routing

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

Once ShipBob holds your stock, WooCommerce's stock number is a copy, and every oversell comes from the gap between the copy and the warehouse. ShipBob's 2026-07 API has no inventory webhook, so poll GET /inventory-level and set WooCommerce stock to fulfillable quantity (not sellable, which counts inbound stock) minus paid orders ShipBob hasn't received yet, minus a small buffer. Apply the change with WooCommerce's atomic increase/decrease rather than a plain set: in our race test a plain set left 2 phantom units on sale after an order landed mid-sync, while the delta left the correct number. Right before payment, ask ShipBob again for just the cart's SKUs with a 3-second timeout, because other channels sell from the same stock; WooCommerce's own stock hold already settles two shoppers racing for the last unit. Send paid orders from Action Scheduler with reference_id set to the WooCommerce order ID (ShipBob answers a resend with 422, not a second shipment), and treat webhooks as a doorbell for a reconcile job that reads ShipBob's orders and writes tracking back. ShipBob is our pick for the warehouse side.

The day your stock moves into a third-party warehouse, WooCommerce stops being the place where inventory lives. It becomes a copy of a number that ShipBob owns, that other sales channels also draw from, and that only reaches your store when something copies it across. Overselling is what happens in the gap. This guide closes the gap from both ends: a sync that can't be fooled by orders landing mid-run, and a last check against the warehouse right before the card is charged. It also covers sending orders to the right fulfillment center and getting tracking numbers back.

How this guide was made

We read ShipBob's developer documentation and its OpenAPI specification for API version 2026-07 (October 2026). The mu-plugin was tested on a real WordPress 7.1.3 and WooCommerce 11.2.1 site against a local mock of the ShipBob API built from that specification, with checkouts placed through WooCommerce's Store API (the block checkout) and two PHP servers sharing one database so that two shoppers could really race for the last unit. All 32 checks pass with WooCommerce's order storage set to HPOS and to classic posts. We didn't have a ShipBob account, so nothing here was run against ShipBob's live API or sandbox.

1. The short answer

ProblemWhat the plugin does
Stock changes at ShipBob, but no webhook tells youPolls GET /inventory-level every 5 minutes, all pages or nothing
Paid orders ShipBob hasn't received yetSubtracts them from ShipBob's fulfillable count
An order lands while the sync is runningMoves stock by the difference with WooCommerce's atomic update, never a plain set
Amazon or a retail store sold the units since the last syncAsks ShipBob again for the cart's SKUs before payment; refuses the order if they're gone
Two shoppers want the last unitWooCommerce's own stock hold, which is atomic; we tested it
ShipBob slow or down at checkoutWaits at most 3 seconds, then sells on local stock and notes it on the order
Sending the same order twicereference_id = WooCommerce order ID; ShipBob answers 422 and the plugin re-links the existing order
Lost, repeated or out-of-order webhooksA webhook only queues a reconcile job, which reads ShipBob's orders and catches up

2. Where oversells come from

Four gaps, in order of how often they bite:

  • There's no push for inventory. ShipBob's 2026-07 webhook topics cover orders, shipments, returns, receiving and billing. None fires when a stock level changes. Its inventory guide describes polling: fetch inventory levels in bulk and write them to your platform. So there is always a window, and the job is to make it short and safe.
  • Your copy overwrites your own sales. WooCommerce takes stock off when an order is paid. ShipBob only counts that order once it has received it. A sync that copies ShipBob's number in the meantime puts the sold units back on sale.
  • Other channels share the same shelves. If ShipBob also fulfills Amazon, a retail partner or a second store, units can disappear between two syncs without WooCommerce selling them.
  • The wrong number. ShipBob's inventory levels report several quantities. total_fulfillable_quantity is on hand minus committed, which is what you can ship today. total_sellable_quantity adds stock that is still on its way to the warehouse. Syncing sellable sells inventory that hasn't arrived.

ShipBob's own advice for the last two is a buffer: stop selling a SKU while a few units are still left. The plugin keeps that (2 units by default), then adds the check before payment, so the buffer can stay small.

From order to pick, with the stock checks
From order to pick, with the stock checksShopper places the order → Check before payment → Payment taken → Action Scheduler job → ShipBob fulfillment center → order.shipped webhook / Reconcile every 15 minutes → WooCommerce orderShopper places the order: WooCommerce holds the stock for this order in one atomic queryShopper places the orderWooCommerce holds the stock for this order in one atomic queryCheck before payment: GET /inventory-level for the cart's SKUs, 3-second timeoutCheck before paymentGET /inventory-level for the cart's SKUs, 3-second timeoutPayment taken: order goes to processing and WooCommerce reduces stockPayment takenorder goes to processing and WooCommerce reduces stockAction Scheduler job: POST /order with reference_id and the chosen location_idAction Scheduler jobPOST /order with reference_id and the chosen location_idShipBob fulfillment center: picks, packs and buys the labelShipBob fulfillment centerpicks, packs and buys the labelorder.shipped webhook: queues a reconcileorder.shipped webhookqueues a reconcileReconcile every 15 minutes: catches lost webhooksReconcile every 15 minutescatches lost webhooksWooCommerce order: tracking note to the customer, marked completedWooCommerce ordertracking note to the customer, marked completed
Nothing in the shopper's request waits for ShipBob except the one stock check, and that one gives up after 3 seconds.

3. The race conditions, one by one

An order lands while the sync is running

Reading ShipBob's inventory takes a few seconds (one request per 250 SKUs, at most 150 requests a minute per user and app). If the sync computes "set stock to 26" and an order for 2 is paid while it is still reading, writing 26 erases that sale. In our test the plain-set version did exactly that: 26 units showing, against 24 really available.

The plugin reads things in a fixed order and writes a difference instead of a value:

  1. Read local stock for every SKU, straight from the database.
  2. Read the paid orders that ShipBob hasn't received yet.
  3. Read ShipBob's fulfillable quantities, all pages.
  4. For each SKU, compute the target (fulfillable minus unsent minus buffer) and move local stock by target minus the stock read in step 1, using wc_update_product_stock() with increase or decrease. That function is a single atomic SQL update.

An order paid during the run is then counted once (it reduced local stock after step 1, and the difference lands on top) or twice (it also showed up in step 2 or reached ShipBob before step 3). Twice means the store shows too little stock until the next run. It can never show too much. In the test, the order really was paid while the sync was holding ShipBob's last page, and the result was the correct 24.

Two shoppers, one unit

You don't need to build this one. Current WooCommerce (we read the 11.2.1 source) holds stock for an order before payment in both the block and the classic checkout, with a single conditional insert into wc_reserved_stock, locked in product ID order to avoid deadlocks. We placed two checkouts for the last unit at the same moment, on two separate PHP servers: one order went through, the other was refused, and stock ended at 0, not -1.

Units sold elsewhere since the last sync

This is the one that needs the check before payment. The plugin hooks woocommerce_store_api_checkout_order_processed (block checkout) and woocommerce_checkout_order_processed (classic). Both run after WooCommerce has held the stock and before the payment gateway is called. It asks ShipBob for only the cart's inventory IDs, subtracts unsent orders, and if there isn't enough, releases the hold, corrects local stock at once and throws. The shopper sees "just sold out" and nothing is charged.

If ShipBob doesn't answer within 3 seconds, the order goes through on local stock and gets a note. That's a deliberate choice: blocking every sale during a ShipBob outage costs more than the rare oversell it prevents, and the 5-minute sync and the buffer already narrow the window. If you sell one-off items, fail closed instead.

Webhooks that arrive twice, late, out of order or never

ShipBob's documentation says webhooks may be delayed, duplicated or arrive out of order, recommends reconciling with GET endpoints, and retries a failed delivery on a schedule that runs from immediately to 10 hours apart. The plugin doesn't parse webhook payloads at all. A verified webhook queues one reconcile job and gets a 200 at once. The job reads GET /order?LastUpdateStartDate=..., which uses ShipBob's documented order shape, writes new tracking numbers to the order, completes fulfilled orders, puts orders with a shipment exception on hold, and marks the shipments as uploaded. Because it works from ShipBob's current state, duplicates and ordering don't matter, and a 15-minute schedule catches anything a lost webhook missed.

One trap we hit while testing: Action Scheduler's unique flag matches on hook, group and arguments, so a webhook that queued the same hook as the recurring 15-minute job was silently dropped, because the recurring one is always pending. The webhook now uses its own hook.

4. The plugin

Drop this into wp-content/mu-plugins/ and add the constants to wp-config.php.

<?php
/**
 * Plugin Name: ShipBob stock guard
 * Description: Keeps WooCommerce stock in line with ShipBob, re-checks ShipBob before payment, sends paid orders to a chosen fulfillment center, and syncs tracking back.
 *
 * wp-config.php:
 *   define( 'SHIPBOB_TOKEN', '...' );              // Personal Access Token: ShipBob dashboard > Integrations > API Tokens
 *   define( 'SHIPBOB_CHANNEL_ID', '...' );         // GET /2026-07/channel: the channel with orders_write
 *   define( 'SHIPBOB_WEBHOOK_SECRET', 'whsec_...' );
 *   define( 'SHIPBOB_ROUTES', array( 'US:CA' => array( 9, 6 ), 'US' => array( 6, 9 ) ) ); // optional: preferred location IDs (GET /location) by country:state or country
 */

defined( 'ABSPATH' ) || exit;
defined( 'SHIPBOB_API' ) || define( 'SHIPBOB_API', 'https://api.shipbob.com/2026-07' );
defined( 'SHIPBOB_ROUTES' ) || define( 'SHIPBOB_ROUTES', array() );

const SB_BUFFER = 2; // units per SKU the store never shows: sales on other channels between two syncs

function sb_api( $method, $path, $body = null, $timeout = 10 ) {
	$res = wp_remote_request( SHIPBOB_API . $path, array(
		'method'  => $method,
		'timeout' => $timeout,
		'headers' => array(
			'Authorization'      => 'Bearer ' . SHIPBOB_TOKEN,
			'shipbob_channel_id' => SHIPBOB_CHANNEL_ID,
			'Content-Type'       => 'application/json',
		),
		'body'    => null === $body ? null : wp_json_encode( $body ),
	) );
	if ( is_wp_error( $res ) ) {
		return array( 0, null, array() );
	}
	return array( wp_remote_retrieve_response_code( $res ), json_decode( wp_remote_retrieve_body( $res ), true ), wp_remote_retrieve_headers( $res ) );
}

/** Units in paid orders that ShipBob hasn't accepted yet. Its fulfillable count can't include them, so we subtract them. */
function sb_unsent_qty() {
	$qty    = array();
	$orders = wc_get_orders( array(
		'status'       => array( 'processing', 'on-hold' ),
		'limit'        => -1,
		'meta_key'     => '_shipbob_order_id', // not meta_query: the posts order store silently ignores it
		'meta_compare' => 'NOT EXISTS',
	) );
	foreach ( $orders as $o ) {
		foreach ( $o->get_items() as $item ) {
			$sku         = $item->get_product() ? $item->get_product()->get_sku() : '';
			$qty[ $sku ] = ( $qty[ $sku ] ?? 0 ) + $item->get_quantity();
		}
	}
	return $qty;
}

/**
 * Every 5 minutes: WooCommerce stock = ShipBob fulfillable - unsent local orders - buffer.
 * Read local stock and unsent orders first, ShipBob last, then move stock by the difference with WooCommerce's own
 * atomic increase/decrease. An order placed while this runs is then counted once or twice, never zero times:
 * at worst the store shows too little stock until the next run, never too much.
 */
add_action( 'sb_sync_inventory', 'sb_sync_inventory' );
function sb_sync_inventory() {
	global $wpdb;
	$before = $wpdb->get_results( "SELECT p.ID AS id, sku.meta_value AS sku, st.meta_value AS stock FROM {$wpdb->posts} p
		JOIN {$wpdb->postmeta} sku ON sku.post_id = p.ID AND sku.meta_key = '_sku' AND sku.meta_value <> ''
		JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_stock'
		JOIN {$wpdb->postmeta} ms ON ms.post_id = p.ID AND ms.meta_key = '_manage_stock' AND ms.meta_value = 'yes'
		WHERE p.post_type IN ('product', 'product_variation')", OBJECT_K );
	$unsent = sb_unsent_qty();
	$levels = array();
	for ( $next = '/inventory-level?PageSize=250'; $next; ) {
		list( $code, $page ) = sb_api( 'GET', $next );
		if ( 200 !== $code ) {
			return; // all or nothing: a partial read would zero out SKUs on the pages we missed
		}
		foreach ( $page['items'] ?? array() as $row ) {
			$levels[ $row['sku'] ] = $row;
		}
		$next = $page['next'] ? substr( $page['next'], strpos( $page['next'], '/inventory-level' ) ) : null; // stay on SHIPBOB_API: the token goes with every call
	}
	foreach ( $before as $p ) {
		if ( ! isset( $levels[ $p->sku ] ) ) {
			continue; // not stocked at ShipBob: leave it alone
		}
		$row    = $levels[ $p->sku ];
		$target = max( 0, $row['total_fulfillable_quantity'] - ( $unsent[ $p->sku ] ?? 0 ) - SB_BUFFER );
		$delta  = $target - (int) $p->stock;
		$prod   = wc_get_product( $p->id );
		$prod->update_meta_data( '_shipbob_inventory_id', $row['inventory_id'] );
		$prod->save_meta_data();
		if ( $delta ) {
			wc_update_product_stock( $prod, abs( $delta ), $delta > 0 ? 'increase' : 'decrease' );
		}
	}
}

/** Right before payment, ask ShipBob again: another channel may have sold the last units since the last sync. */
add_action( 'woocommerce_store_api_checkout_order_processed', 'sb_check_before_payment' );
add_action( 'woocommerce_checkout_order_processed', fn( $id, $data, $order ) => sb_check_before_payment( $order ), 10, 3 );
function sb_check_before_payment( $order ) {
	$need = array();
	foreach ( $order->get_items() as $item ) {
		$p   = $item->get_product();
		$inv = $p ? (int) $p->get_meta( '_shipbob_inventory_id' ) : 0;
		if ( $inv ) {
			$need[ $inv ] = array( 'qty' => ( $need[ $inv ]['qty'] ?? 0 ) + $item->get_quantity(), 'product' => $p );
		}
	}
	if ( ! $need ) {
		return;
	}
	list( $code, $json ) = sb_api( 'GET', '/inventory-level?PageSize=250&InventoryIds=' . implode( ',', array_keys( $need ) ), null, 3 );
	if ( 200 !== $code ) {
		// ShipBob slow or down: don't block the sale. WooCommerce has already reserved its own stock for this order.
		$order->add_order_note( "ShipBob stock check unavailable ($code): accepted on WooCommerce stock." );
		return;
	}
	$unsent = sb_unsent_qty();
	foreach ( $json['items'] ?? array() as $row ) {
		$n = $need[ $row['inventory_id'] ] ?? null;
		if ( ! $n ) {
			continue;
		}
		$free = $row['total_fulfillable_quantity'] - ( $unsent[ $n['product']->get_sku() ] ?? 0 );
		if ( $free < $n['qty'] ) {
			wc_release_stock_for_order( $order );
			wc_update_product_stock( $n['product'], max( 0, $free - SB_BUFFER ), 'set' ); // show the real number now, not in 5 minutes
			$order->add_order_note( sprintf( 'Stopped before payment: ShipBob has %d of %s free, order needs %d.', $free, $n['product']->get_sku(), $n['qty'] ) );
			/* translators: %s: product name */
			$msg = sprintf( __( 'Sorry, "%s" just sold out. Please update your cart.' ), $n['product']->get_name() );
			if ( 'woocommerce_store_api_checkout_order_processed' === current_action() ) {
				throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'shipbob_out_of_stock', $msg, 409 );
			}
			throw new Exception( $msg );
		}
	}
}

/** Paid orders go to ShipBob from Action Scheduler, never inside the shopper's request. */
add_action( 'woocommerce_order_status_processing', fn( $id ) => as_enqueue_async_action( 'sb_push_order', array( $id ), 'shipbob', true ) );
add_action( 'sb_push_order', 'sb_push_order', 10, 2 );
function sb_push_order( $order_id, $attempt = 1 ) {
	$order = wc_get_order( $order_id );
	if ( ! $order || $order->get_meta( '_shipbob_order_id' ) ) {
		return;
	}
	$products = array();
	$need     = array();
	foreach ( $order->get_items() as $item ) {
		$p = $item->get_product();
		if ( ! $p || ! $p->get_sku() ) {
			$order->update_status( 'on-hold', 'Not sent to ShipBob: a line item has no SKU.' );
			return;
		}
		$products[] = array( 'reference_id' => $p->get_sku(), 'name' => $p->get_name(), 'quantity' => $item->get_quantity(), 'unit_price' => (float) $order->get_item_subtotal( $item ) );
		$need[ (int) $p->get_meta( '_shipbob_inventory_id' ) ] = $item->get_quantity();
	}
	$body = array(
		'reference_id'    => (string) $order->get_id(), // ShipBob's idempotency key: a resend gets a 422, not a second shipment
		'order_number'    => $order->get_order_number(),
		'type'            => 'DTC',
		'shipping_method' => $order->get_shipping_method() ?: 'Standard', // must match a Ship Option Mapping in ShipBob
		'purchase_date'   => $order->get_date_created()->format( DATE_ATOM ),
		'recipient'       => array(
			'name'         => $order->get_formatted_shipping_full_name(),
			'email'        => $order->get_billing_email(),
			'phone_number' => $order->get_shipping_phone() ?: $order->get_billing_phone(),
			'address'      => array(
				'address1'     => $order->get_shipping_address_1(),
				'address2'     => $order->get_shipping_address_2(),
				'company_name' => $order->get_shipping_company(),
				'city'         => $order->get_shipping_city(),
				'state'        => $order->get_shipping_state(),
				'zip_code'     => $order->get_shipping_postcode(),
				'country'      => $order->get_shipping_country(),
			),
		),
		'products'        => $products,
	);
	$location = sb_pick_location( $order, $need );
	if ( $location ) {
		$body['location_id'] = $location;
	}
	list( $code, $json, $headers ) = sb_api( 'POST', '/order', $body );
	if ( 201 === $code || 200 === $code ) {
		$order->update_meta_data( '_shipbob_order_id', $json['id'] );
		$order->add_order_note( 'Sent to ShipBob as order ' . $json['id'] . ( $location ? " at location $location" : ', ShipBob routes it' ) . '.' );
		$order->save();
		return;
	}
	if ( 422 === $code ) { // usually "already exists" after a timeout we never saw the answer to
		list( $c2, $found ) = sb_api( 'GET', '/order?ReferenceIds=' . $order->get_id() );
		if ( 200 === $c2 && ! empty( $found[0]['id'] ) ) {
			$order->update_meta_data( '_shipbob_order_id', $found[0]['id'] );
			$order->add_order_note( 'Already in ShipBob as order ' . $found[0]['id'] . '.' );
			$order->save();
			return;
		}
	}
	if ( ( 0 === $code || 429 === $code || $code >= 500 ) && $attempt < 8 ) {
		$wait = 429 === $code ? max( 1, (int) ( $headers['x-retry-after'] ?? 30 ) ) : min( HOUR_IN_SECONDS, 60 * 2 ** $attempt );
		as_schedule_single_action( time() + $wait, 'sb_push_order', array( $order_id, $attempt + 1 ), 'shipbob' );
		return;
	}
	$order->update_status( 'on-hold', "ShipBob rejected the order ($code): " . wp_json_encode( $json['errors'] ?? $json ) );
}

/** First preferred fulfillment center that can ship every line complete; otherwise null and ShipBob decides (and may split). */
function sb_pick_location( $order, $need ) {
	$routes = SHIPBOB_ROUTES;
	$prefs  = $routes[ $order->get_shipping_country() . ':' . $order->get_shipping_state() ] ?? $routes[ $order->get_shipping_country() ] ?? array();
	if ( ! $prefs || isset( $need[0] ) ) {
		return null;
	}
	list( $code, $json ) = sb_api( 'GET', '/inventory-level/locations?PageSize=250&InventoryIds=' . implode( ',', array_keys( $need ) ) );
	if ( 200 !== $code ) {
		return null;
	}
	$stock = array();
	foreach ( $json['items'] ?? array() as $item ) {
		foreach ( $item['locations'] as $l ) {
			$stock[ $l['location_id'] ][ $item['inventory_id'] ] = $l['fulfillable_quantity'];
		}
	}
	foreach ( $prefs as $loc ) {
		$ok = true;
		foreach ( $need as $inv => $qty ) {
			$ok = $ok && ( $stock[ $loc ][ $inv ] ?? 0 ) >= $qty;
		}
		if ( $ok ) {
			return $loc;
		}
	}
	return null;
}

/** Webhooks are a doorbell: verify, queue one reconcile, answer 200 at once. The reconcile reads ShipBob's own order data. */
add_action( 'rest_api_init', fn() => register_rest_route( 'shipbob/v1', '/webhook', array(
	'methods'             => 'POST',
	'callback'            => 'sb_webhook',
	'permission_callback' => '__return_true', // authenticated by the signature below
) ) );
function sb_webhook( WP_REST_Request $r ) {
	$id  = (string) $r->get_header( 'webhook-id' );
	$ts  = (string) $r->get_header( 'webhook-timestamp' );
	$key = base64_decode( substr( SHIPBOB_WEBHOOK_SECRET, strlen( 'whsec_' ) ) );
	$sig = base64_encode( hash_hmac( 'sha256', "$id.$ts." . $r->get_body(), $key, true ) );
	$ok  = false;
	foreach ( explode( ' ', (string) $r->get_header( 'webhook-signature' ) ) as $s ) {
		$ok = $ok || hash_equals( $sig, (string) substr( $s, strpos( $s, ',' ) + 1 ) ); // "v1,<base64>"
	}
	if ( ! $ok || '' === $id || abs( time() - (int) $ts ) > 5 * MINUTE_IN_SECONDS ) {
		return new WP_REST_Response( null, 401 );
	}
	// Its own hook: "unique" matches hook + group + args, so the always-pending recurring sb_reconcile would swallow it
	as_enqueue_async_action( 'sb_reconcile_now', array(), 'shipbob', true ); // duplicates and retries collapse into one pending run
	return new WP_REST_Response( null, 200 );
}

/** Every 15 minutes and after each webhook: orders ShipBob changed since the last run. Catches lost webhooks too. */
add_action( 'sb_reconcile', 'sb_reconcile' );
add_action( 'sb_reconcile_now', 'sb_reconcile' );
function sb_reconcile() {
	// One run at a time (add_option is an atomic insert); a doorbell that finds a run in progress tries again in a minute
	if ( ! add_option( 'sb_reconcile_lock', time(), '', false ) && get_option( 'sb_reconcile_lock' ) > time() - 10 * MINUTE_IN_SECONDS ) {
		as_schedule_single_action( time() + MINUTE_IN_SECONDS, 'sb_reconcile_now', array(), 'shipbob', true );
		return;
	}
	update_option( 'sb_reconcile_lock', time(), false ); // also takes over a lock left by a crashed run
	try {
		$since   = get_option( 'sb_reconcile_since', gmdate( DATE_ATOM, time() - DAY_IN_SECONDS ) );
		$started = time();
		for ( $page = 1; ; $page++ ) {
			list( $code, $orders ) = sb_api( 'GET', '/order?' . http_build_query( array( 'LastUpdateStartDate' => $since, 'Limit' => 250, 'Page' => $page ) ) );
			if ( 200 !== $code ) {
				return; // cursor stays put: the next run retries the same window
			}
			foreach ( $orders as $o ) {
				sb_apply_shipbob_order( $o );
			}
			if ( count( $orders ) < 250 ) {
				break;
			}
		}
		update_option( 'sb_reconcile_since', gmdate( DATE_ATOM, $started - 10 * MINUTE_IN_SECONDS ), false ); // overlap: late writes on ShipBob's side
	} finally {
		delete_option( 'sb_reconcile_lock' );
	}
}

function sb_apply_shipbob_order( $o ) {
	$order = wc_get_order( (int) $o['reference_id'] );
	if ( ! $order || (string) $order->get_meta( '_shipbob_order_id' ) !== (string) $o['id'] ) {
		return; // another channel's order, or not ours
	}
	$known    = (array) $order->get_meta( '_shipbob_tracking' );
	$uploaded = array();
	foreach ( $o['shipments'] ?? array() as $s ) {
		$t = $s['tracking'] ?? null;
		if ( ! empty( $t['tracking_number'] ) && ! isset( $known[ $s['id'] ] ) ) {
			$known[ $s['id'] ] = array( 'carrier' => $t['carrier'], 'number' => $t['tracking_number'], 'url' => $t['tracking_url'] ?? '' );
			$order->add_order_note( sprintf( 'Shipped with %s, tracking number %s %s', $t['carrier'], $t['tracking_number'], $t['tracking_url'] ?? '' ), true );
			$uploaded[] = $s['id'];
		}
		if ( in_array( $s['status'], array( 'Exception', 'OnHold' ), true ) && ! $order->has_status( array( 'on-hold', 'completed' ) ) ) {
			$order->update_status( 'on-hold', sprintf( 'ShipBob shipment %d is %s: %s', $s['id'], $s['status'], wp_json_encode( $s['status_details'] ?? null ) ) );
		}
	}
	$order->update_meta_data( '_shipbob_tracking', $known );
	$order->save();
	if ( 'Fulfilled' === $o['status'] && ! $order->has_status( 'completed' ) ) {
		$order->update_status( 'completed', 'All shipments shipped by ShipBob.' );
	}
	if ( $uploaded ) {
		sb_api( 'POST', '/shipment:batchUpdateTrackingUpload', array( 'is_tracking_uploaded' => true, 'shipment_ids' => $uploaded ) );
	}
}

add_action( 'init', function () {
	if ( function_exists( 'as_has_scheduled_action' ) && ! as_has_scheduled_action( 'sb_sync_inventory' ) ) {
		as_schedule_recurring_action( time(), 5 * MINUTE_IN_SECONDS, 'sb_sync_inventory', array(), 'shipbob' );
		as_schedule_recurring_action( time(), 15 * MINUTE_IN_SECONDS, 'sb_reconcile', array(), 'shipbob' );
	}
} );

Notes on the parts that are easy to get wrong:

  • Unsent orders are found with meta_key and meta_compare. Our first version used meta_query, and with WooCommerce's classic posts storage it was silently ignored, so every order counted as unsent. The test caught it.
  • A partial sync is no sync. If any page fails, nothing is written, so a timeout on page 3 can't zero out the SKUs on pages 3 and later.
  • Paging stays on your API host. The plugin takes only the path from ShipBob's next link, so your token is never sent anywhere else.
  • Orders go out from Action Scheduler, never inside the payment request. Rate limits (429) are retried after ShipBob's x-retry-after; timeouts and 5xx back off up to an hour; after 8 tries, or on a real validation error, the order goes on hold with ShipBob's message.
  • Routing is a preference, not an override. For each destination you list preferred fulfillment centers. The first one that can ship every line complete gets the order. If none can, the plugin leaves location_id out and lets ShipBob decide, which may mean splitting the shipment.
  • Webhook signatures follow ShipBob's description: HMAC-SHA256 over id.timestamp.body, keyed with the base64-decoded secret after whsec_, compared in constant time against every signature in the header. Requests older than 5 minutes are refused.

5. Setting it up

  1. Create a Personal Access Token in the ShipBob dashboard. ShipBob creates a single-merchant app and a channel for it. Call GET https://api.shipbob.com/2026-07/channel and use the channel that has orders_write as SHIPBOB_CHANNEL_ID.
  2. Make SKUs match. The plugin matches WooCommerce products to ShipBob inventory by SKU. Products without a SKU, or SKUs that ShipBob doesn't stock, are left alone by the sync and put on hold by the order push.
  3. Map shipping methods. shipping_method must match a Ship Option Mapping in ShipBob. The plugin sends WooCommerce's shipping method title, or "Standard".
  4. Subscribe to webhooks under Integrations > Webhooks, pointing at https://your-store/wp-json/shipbob/v1/webhook, for order.shipped, order.shipment.exception and order.shipment.on_hold. Put the signing secret in SHIPBOB_WEBHOOK_SECRET. ShipBob requires HTTPS and publishes the IP addresses its webhooks come from, if you need to allow them through a firewall.
  5. Set routing, if you want it. Get location IDs from GET /location and define SHIPBOB_ROUTES, keyed by country:state or country.
  6. Run Action Scheduler from a real cron. The sync, the order push and the reconcile all run there. On a quiet site WP-Cron alone means stale stock.
  7. Use ShipBob's sandbox first. It runs at sandbox-api.shipbob.com; set SHIPBOB_API to it while you test.

6. Test results

CheckResult
Sync: stock = fulfillable - buffer, 3 pages followed, token only sent to the API host, fulfillable used rather than sellablePass
Paid order not yet at ShipBob: the next sync leaves it subtracted (5, not 8)Pass
After ShipBob accepts the order: still counted oncePass
Order push: reference_id, SKU lines, channel header, ISO country codePass
Routing: single-center order goes to the preferred center that has the stock; a mixed cart no center can ship whole goes to ShipBob's own routingPass
Resend of an order ShipBob already has: 422, existing order re-linked, no second shipmentPass
429: retried after x-retry-after (25 s), order stays in processingPass
Other channel sold the units: checkout refused with 409 before payment, stock hold released, local stock correctedPass
Two simultaneous checkouts for the last unit on two servers: one order, one refusal, stock 0Pass
ShipBob hanging at checkout: order accepted about 3 s slower than normal, with a note; ShipBob 503: no measurable delayPass
Sync hitting a 500: no stock changedPass
Order paid while the sync was mid-way through ShipBob's pages: 24 shown, the correct number (a plain-set version showed 26)Pass
Webhooks: wrong signature and 10-minute-old timestamp refused; valid one answered without calling ShipBob; 3 retries leave 1 queued jobPass
Reconcile: tracking as a customer note, order completed, shipment marked uploaded, no duplicate note on a second runPass
Shipment exception: WooCommerce order on hold with ShipBob's reasonPass
All of the above with HPOS on, and again with classic post storagePass

Not tested: ShipBob's live API or sandbox, product bundles (ShipBob tracks stock per component, so a bundle's availability is the minimum across its parts; the plugin skips bundle SKUs), lot-tracked inventory, and the classic shortcode checkout (the hook is wired, but our tests used the Store API).

7. Our opinion: trade-offs

  • Start with ShipBob's own WooCommerce integration if your catalogue is simple and you only sell in one place. It imports products and orders without code. Build something like this when you need control over the parts that cause oversells: what gets subtracted, how often, what happens at checkout and which center ships what.
  • The check before payment is the cheapest insurance you can buy. One request for a handful of IDs, only at the moment of purchase, and it covers the case no sync interval can: stock sold somewhere else a minute ago.
  • Keep the buffer small but not zero. Two units covers other-channel sales between syncs for most stores; raise it for SKUs that sell fast on marketplaces, and set it to 0 only if every sale goes through this store.
  • Be careful with routing. ShipBob's default routing knows things you don't, such as carrier cut-offs and how stock is distributed between centers. Route yourself when you have a concrete reason, like a region you promise next-day delivery to, and let ShipBob handle everything else.
  • A 3PL is a pricing decision too. ShipBob quotes each merchant individually (its pricing page asks for a quote), so compare the quote against what in-house fulfillment really costs you, including staff and space.

Related: syncing Amazon prices and inventory with the SP-API and scoring fraudulent WooCommerce orders, which should run before an order reaches the warehouse.

8. Tools

Top pick

ToolRoleWhy it fits
ShipBob3PL fulfillment with a developer APIFulfillment centers in the US, Canada, the UK, Australia and Europe, a versioned REST API with an OpenAPI specification, a sandbox, signed webhooks and per-location inventory levels. Pricing is a custom quote per merchant (pricing page, October 2026).

Alternatives: free tools and references

We don't earn anything from these links.

  • In-house fulfillment with WooCommerce's own stock: if you pack orders yourself, WooCommerce's stock hold already prevents the last-unit race, and there's no copy to keep in sync. A 3PL earns its keep on volume and delivery speed, not on stock accuracy.
  • ShipBob developer documentation: the API reference, webhooks, rate limits and the inventory guide quoted here.
  • Action Scheduler: the background job queue that ships with WooCommerce.
  • Standard Webhooks: the open spec that uses the same webhook-id / webhook-timestamp / webhook-signature headers as ShipBob.

9. Pitfalls

  • Syncing total_sellable_quantity, which includes stock that hasn't arrived.
  • Setting stock to ShipBob's number without subtracting paid orders ShipBob hasn't received, or with a plain set that erases sales made during the run.
  • Writing a partial sync after a failed page.
  • Sending orders to ShipBob inside the checkout request, so a slow API means a slow or failed payment.
  • Generating a new reference_id on retry, which turns a lost response into a second shipment.
  • Trusting webhooks to arrive once and in order, or doing slow work before answering them (ShipBob counts a reply after 15 seconds as a failure).
  • Using meta_query in wc_get_orders() on a store with classic order storage, where it is ignored without an error.
  • Letting the recurring reconcile job swallow webhook-triggered runs through Action Scheduler's unique flag.
Bottom line

Treat ShipBob as the source of truth and WooCommerce's stock as a careful copy. Poll fulfillable quantity, subtract the orders ShipBob hasn't seen, keep a small buffer, and apply the result as an atomic difference. Ask ShipBob one more time before taking payment. Send orders and read tracking in background jobs that are safe to repeat. In our tests that combination never showed a unit that wasn't there, including when an order landed in the middle of a sync.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.