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.
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.
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
| Problem | What the plugin does |
|---|---|
| Stock changes at ShipBob, but no webhook tells you | Polls GET /inventory-level every 5 minutes, all pages or nothing |
| Paid orders ShipBob hasn't received yet | Subtracts them from ShipBob's fulfillable count |
| An order lands while the sync is running | Moves stock by the difference with WooCommerce's atomic update, never a plain set |
| Amazon or a retail store sold the units since the last sync | Asks ShipBob again for the cart's SKUs before payment; refuses the order if they're gone |
| Two shoppers want the last unit | WooCommerce's own stock hold, which is atomic; we tested it |
| ShipBob slow or down at checkout | Waits at most 3 seconds, then sells on local stock and notes it on the order |
| Sending the same order twice | reference_id = WooCommerce order ID; ShipBob answers 422 and the plugin re-links the existing order |
| Lost, repeated or out-of-order webhooks | A 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_quantityis on hand minus committed, which is what you can ship today.total_sellable_quantityadds 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.
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:
- Read local stock for every SKU, straight from the database.
- Read the paid orders that ShipBob hasn't received yet.
- Read ShipBob's fulfillable quantities, all pages.
- 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()withincreaseordecrease. 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_keyandmeta_compare. Our first version usedmeta_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
nextlink, 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_idout 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 afterwhsec_, compared in constant time against every signature in the header. Requests older than 5 minutes are refused.
5. Setting it up
- 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/channeland use the channel that hasorders_writeasSHIPBOB_CHANNEL_ID. - 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.
- Map shipping methods.
shipping_methodmust match a Ship Option Mapping in ShipBob. The plugin sends WooCommerce's shipping method title, or "Standard". - Subscribe to webhooks under Integrations > Webhooks, pointing at
https://your-store/wp-json/shipbob/v1/webhook, fororder.shipped,order.shipment.exceptionandorder.shipment.on_hold. Put the signing secret inSHIPBOB_WEBHOOK_SECRET. ShipBob requires HTTPS and publishes the IP addresses its webhooks come from, if you need to allow them through a firewall. - Set routing, if you want it. Get location IDs from
GET /locationand defineSHIPBOB_ROUTES, keyed bycountry:stateorcountry. - 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.
- Use ShipBob's sandbox first. It runs at
sandbox-api.shipbob.com; setSHIPBOB_APIto it while you test.
6. Test results
| Check | Result |
|---|---|
| Sync: stock = fulfillable - buffer, 3 pages followed, token only sent to the API host, fulfillable used rather than sellable | Pass |
| Paid order not yet at ShipBob: the next sync leaves it subtracted (5, not 8) | Pass |
| After ShipBob accepts the order: still counted once | Pass |
Order push: reference_id, SKU lines, channel header, ISO country code | Pass |
| 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 routing | Pass |
| Resend of an order ShipBob already has: 422, existing order re-linked, no second shipment | Pass |
429: retried after x-retry-after (25 s), order stays in processing | Pass |
| Other channel sold the units: checkout refused with 409 before payment, stock hold released, local stock corrected | Pass |
| Two simultaneous checkouts for the last unit on two servers: one order, one refusal, stock 0 | Pass |
| ShipBob hanging at checkout: order accepted about 3 s slower than normal, with a note; ShipBob 503: no measurable delay | Pass |
| Sync hitting a 500: no stock changed | Pass |
| 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 job | Pass |
| Reconcile: tracking as a customer note, order completed, shipment marked uploaded, no duplicate note on a second run | Pass |
| Shipment exception: WooCommerce order on hold with ShipBob's reason | Pass |
| All of the above with HPOS on, and again with classic post storage | Pass |
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
| Tool | Role | Why it fits |
|---|---|---|
| 3PL fulfillment with a developer API | Fulfillment 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-signatureheaders 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_idon 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_queryinwc_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
uniqueflag.
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.