shipbob-node-sdk
v0.0.28
Published
ShipBob API node SDK
Readme
ShipBob Node SDK
The ShipBob API is in a state of flux as their legacy 1.0 and 2.0 API versions are
officially deprecated
end of support for
1.0and2.0was July 31, 2026, and requests are progressively blocked through August 2026 until all1.0requests return410 Gone(August 29, 2026).
First of all there are no official SDKs for ShipBob. I'm just dropping this here, in case it will speed up somebody else getting started using their API.
This SDK supports multiple API versions, but being generated it is lower-level than a hand-written wrapper would be. You
will need to provide for "POST" a body and "GET" will use query. path will be needed when a parameter is part of
the url. So, you'll need to refer to
their API reference to be
able to see how to pass parameters (the typings/intellisense shows them, too). Also, headers may be required as when you
place orders (even with sendChannel set to true) to satisfy the typings.
Refering to the /test folder should provide enough examples to get started - there are inventory, create/update
products and place order examples.
install
npm i shipbob-node-sdk
# or
pnpm add shipbob-node-sdk
# or
yarn add shipbob-node-sdkimport { createAPI } from 'shipbob-node-sdk';
// generated functions live under their version - 2026-07 is newest, and what createAPI defaults to
import { get202607Product } from 'shipbob-node-sdk/dist/client/2026-07';
// create your client like this (return object has extra functions/objects)
const client = await createAPI('<your-token-here>', 'https://sandbox-api.shipbob.com');
// the generated functions are now authenticated - nothing to pass around
const productSearch = await get202607Product();API implementation
Everything is implemented except for the /simulate/* endpoints, which are not in OpenAPI (see "Follow URIs and
simulate" below).
Generated clients ship for 2025-07, 2026-01 and 2026-07 (import from shipbob-node-sdk/dist/client/<version>):
createAPI(...)applies your token/base URL/headers and the interceptors to every version's HTTP client, so generated functions from any shipped version just work (ie:get202601Product(),get202607Product()). Their API version is baked into their URL.options.apiVersionselects which version'sGET /channelretrieves the channels (unknown versions likeexperimentalfall back to the newest). It's also the default path prefix for theget/post/put/patch/deletehelpers (see "Follow URIs and simulate").options.channelLoadRetryretries a failedGET /channel-{ retries?: number; delaySeconds?: number; maxRateLimitWaitSeconds?: number }. Their gateway returns the occasional ephemeral401(empty body) on a token that succeeds seconds either side, which without a retry failscreateAPIoutright. A403is never retried - that one is a real missingchannels_readscope. Skip the call entirely by injectingoptions.channel. Each option is documented in full onCreateOptions.retries(default1, max10) - retries, not attempts, so the default is up to 2 calls.0disables.delaySeconds(default0.5, max60) - the wait before retrying a connection failure,401or5xx. Aretry-afteron those is logged but not obeyed - it is not about your request rate.maxRateLimitWaitSeconds(default5, max60) - the longest429wait to sit through. A429is slept for its fullx-retry-afteror not retried at all, since retrying into a window that has not cleared cannot succeed. Raise it to wait longer ones out, at the cost ofcreateAPIblocking that long.
- One active
createAPIat a time. Each version's generated client is a module-level singleton, so a secondcreateAPIre-pointsbaseUrl/authand replaces the interceptors - the newest call wins outright, and the earlier API object's requests go out with the newer token. Sandbox and production side by side in one process are not supported.
Known OpenAPI spec deficiencies
The vendored openapi-*.json files are kept exactly as ShipBob publishes them, so their defects flow into the generated
types. Cataloged here instead of patching the spec (a re-download would silently revert any patch). Sometimes their
specs even have issues that prevent them being used to generate code:
Untyped schemas generate
unknown. 22 of/product's 26 query param schemas (ie:Products.Get.Api.V5.Product.PageSize.Int) are literally{"additionalProperties":false}- notype- in2025-07,2026-01and2026-07, soPageSize,SKU,Barcode,HasVariantsetc. compile with any value (250,'250', an object). OnlySearch,SortBy,SortOrder(andIsInventorySyncEnabled, below) have a type. The inventory endpoints type theirs properly ({"type":"integer"}), which is why the compiler checks those but not these. Worse for2025-07as of the 2026-09-25 download: its older export typed these paramsstring, so they at least rejected numbers and objects.IsInventorySyncEnabledcannot be sent. The/productfilter is declared{"type":"object"}with no properties, which generates{ [key: string]: never }- only an empty object type-checks, and a boolean does not. Cast it, or use thegethelper. In2026-01and2026-07, and in2025-07as of the 2026-09-25 download (wasstring).List query params must be comma-separated. The spec implies the OpenAPI default serialization (
explode: true, repeated keys ie:InventoryIds=1&InventoryIds=2), but the API silently honours only one key and returns partial results instead of failing.createAPIforcesexplode: false(InventoryIds=1,2) on every client.Reserved characters must be sent unescaped. Query values only match with literal
,and:- the request interceptor un-escapes%2C/%3Aon every URL.Paging hrefs are missing the version prefix (and use different casing, ie:
/Product?cursor=...for/product- their routing is case-insensitive). See "Follow URIs and simulate".Announced endpoints can be missing from the spec. Their changelog lists the sandbox simulations with the
2026-01release (POST /2026-01/simulate/shipment,GET /2026-01/simulate/status/{simulationId}), but none of our spec downloads has ever contained them - "simulat" appears in noopenapi-*.jsonin this repo's history, including the 2026-09-25 downloads. Call them with theget/posthelpers (see "Follow URIs and simulate").POST /inventory/history:queryfrom the same changelog entry did make it into2026-01and2026-07.Webhook payloads have no published schema at all. The spec only covers webhook management (subscribe/list/delete) - the delivered payloads are typed nowhere. They approximately mirror the resource view models (ie:
OrdersShipmentViewModel); the differences below were verified against live2026-01captures and are corrected inWebhookPayloadsByTopicV2:- shipment payloads carry
order_typeandzone(absent fromOrdersShipmentViewModel);zone.idisnullon a cancelled shipment, andtrackingisnulluntil a label exists. retailer_program_datanamed its dates camelCase in the spec (doNotShipBeforeDate,shipByDate) but the wire sends snake_case (do_not_ship_before_date,ship_by_date). The spec now uses snake_case -2026-01as of the 2026-08-19 download (the 2026-06-22 one was still camelCase),2026-07from its first download (2026-08-19), and2025-07as of the 2026-09-25 download. 2026-09-25 payloads still send snake_case, so spec and wire now agree andWebhookPayloadsByTopicV2no longer corrects them.- several fields arrive
nullwhere the spec says optional-only:status_details[].extra_information,shipping_terms.carrier_type/payment_term, and shipmentmeasurements(on theorder.shipment.on_holdandorder.shipment.exceptioncaptures, both 2026-09; the cancelled and delivered captures carried measurements).purchase_order_numberandretailer_program_typearrivenulltoo, where the spec now says required (below). - The same spec fix tightened those two types. The old specs'
requiredlistedpurchaseOrderNumber/retailerProgramType- camelCase names matching no property, so it was silently ignored and both generated optional. The fixedrequirednames the real properties, sopurchase_order_numberandretailer_program_typenow generate as required, non-nullstring(same downloads as the date fix). 2026-09-25 payloads still sendnullfor both, so the generated types are now wrong where they used to be merely loose -WebhookPayloadsByTopicV2overrides them asstring | null. - no model exists for the
order.shipment.tracking.updatedpayload (theTracking*schemas are the public tracking API - a different shape). The topic itself was missing from the spec's generatedWebhooksTopicsenum for2026-01until the 2026-08-19 download - the enumWebhookPayloadsByTopicV2(hand-written) checks its topics against.
Verified 2026-09-25 by type-checking a live capture of every subscribed topic (
order.shipped,order.shipment.delivered/cancelled/on_hold/exception/tracking.updated) againstWebhookPayloadsByTopicV2: all pass, and the shipment and order captures fail against the raw generated types. The scrubbed captures live intest/webhook-payloads-v2.samples.tsand are type-checked on everyyarn build.- shipment payloads carry
The webhook create response is under-declared.
200is the spec's only success code; the201that means the subscription was actually created appears in noyyyy-mmspec, and no spec has ever declared both (1.0/2.0declare only201). See "Undocumented behaviour" below.Assorted wrong response types - see the hand-written corrections in
src/types.ts(ie:SetExternalSyncResponse).
Undocumented behaviour (and where the docs are wrong)
The spec deficiencies above are at least machine-readable. These are things their published documentation states incorrectly, or does not state at all - each one below cost a support ticket or a live capture to establish, so they are recorded here rather than rediscovered.
Webhook subscriptions survive an API version's end-of-life. Their team confirmed it on request; nothing documents it. With a 6-month release cadence, this is what decides whether you need a migration job - you don't. A subscription stops only when deleted. See "Webhooks".
POST /webhookreports success for a subscription it did not create.201means created;200means the topic+url is already registered and nothing happened, so events never flow. Only200is documented and it is the spec's sole success code, so the status that means "it worked" is undeclared and a generated client treats the no-op as the happy path. Their old v2.0 page had201right; the current page does not. Treat anything but201as a failure.The
V2webhook generation is named everywhere except the documentation.2025-07is where their webhook schemas first carry a version marker at all -Webhooks.V2.*, against a plainly unversionedWebhooks.Create.WebhookViewModelin1.0/2.0(there is no V1). It is the same boundary where topics went underscore to dot, the delivery header wentshipbob-topictox-webhook-topic, and the declared create status changed. Four wire changes, one version bump, one name, and no page connecting them. It spans everyyyyy-mmversion, which is whyWebhookPayloadsByTopicV2has no per-version twin.ShipBob silently migrated existing subscriptions when V2 landed. Unconfirmed by them - this is inferred from our own API testing, but the behaviour is consistent. A subscription registered before
2025-07should speak one generation; a migrated one is a single "frankenstein" record living in both, and that explains every oddity below:- it publishes both
shipbob-topic: order_shippedandx-webhook-topic: order.shipped, so one event arrives twice - handle both, or deduplicate. - the management endpoints list it in dot notation with nothing marking its generation, so they cannot be used to tell migrated from native.
- a create for a topic+url it already holds returns
200(see above) even though nothing in the current view looks like a conflict. - deleting works normally and from either side: one
DELETEagainst the legacy or the current endpoint unsubscribes both, confirming it really is one record.
Delivery is run through a third party (SVIX). Might explain why Shipbob's own support seem unable to answer questions on how webhooks work.
- it publishes both
The 150/min rate limit is a default - limits are raised per account on request, so read
x-remaining-callsrather than the documented number. Bewarex-remaining-calls-ip, an unmentioned per-IP counter that reads like the real one but isn't. They cannot or will not create a second account, so you can separate writes vs. reads. ie: placing orders and syncing inventory must share the same rate limit, but they will bump your rate limit! I suggest throttling your calls and using processing queues with managed concurrency.Ephemeral
401s on a valid token are not acknowledged anywhere. Their gateway intermittently returns a401with an empty body on a token that succeeds seconds either side, with rate-limit headers showing plenty of budget remaining.options.channelLoadRetryexists because of this.Some constants exist only in a PDF.
packaging_material_type_idis a barenumberin the spec and the id-to-name mapping is published only as a document (with a gap where value4should be), soPackagingMaterialinsrc/types.tsis transcribed by hand.GET /packaging-requirementis the only runtime source of truth.
Webhooks
As a personal note. I regret implementing webhooks at all, since I needed to implement polling anyway. There are various gaps in the webhooks. Using their table is perhaps the best way to explain:
| Shipment Status | Status Detail Name | Comment | | --------------- | ------------------ | ----------------------------------------------------------------------------------------------- | | Processing | Labeled | I think this maps to "order.shipped" webhook, doesn't always have tracking (or invoice amounts) | | Completed | Delivered | Does not always fire when delivered (seems to be carrier dependent) | | Completed | Delivery Exception | No Webhook (need to poll for RTS, etc.) | | Exception | * | These do fire, but are not useful for delivery |
ie: No webhooks for delivery exceptions ie: Completed -> DeliveryException when carrier needs more details. The
shipment_exception webhook only applies to before the shipment leaves their FC (ie: inventory related issue).
Partial rows of table from here: (https://developer.shipbob.com/#shipment-statuses)
Webhook payload typings are exported as WebhookPayloadsByTopicV2 for narrowing an incoming payload by its topic. It's
basically an order and the status is known. WRO and other webhooks were added sometime in 2026.
The topics are not versioned the way the REST endpoints are - there is no ...For202607 twin, and you subscribe through
whichever versioned /webhook endpoint you like. ShipBob have confirmed that a subscription outlives the API version
that created them: one made via /2026-01/webhook keeps delivering after 2026-01 reaches end of support, so you do not
re-create subscriptions on their 6-month release cadence. A subscription stops only when you delete it.
That cuts both ways: subscriptions predating the 2025-07 rename are still delivering the older underscore topics, typed
as WebhookPayloadsByTopicLegacy. Narrow on the header rather than the topic - legacy posts
shipbob-topic: order_shipped, newer ones post x-webhook-topic: order.shipped - because get202601Webhook and the
dashboard list every subscription in dot notation regardless of its generation. Subscriptions migrated when 2025-07
landed are one record in both systems and fire both headers, so handle both or deduplicate.
Follow URIs and simulate
The returned get/post/put/patch/delete helpers cover endpoints outside the OpenAPI spec (ie: /simulate/* -
see /test/shipbob-api.simulate.spec.ts) and paging URIs (next/prev/first/last in the JSON responses - see the
paging tests). The paging HATEOAS is not a full href - the API version prefix is missing (ie: /Product?cursor=...) -
so the helpers prepend options.apiVersion (defaults to 2026-07, the newest - longest support before the version
sunsets), with a per-call override (ie: api.get('/product', 'experimental')):
import { createAPI } from 'shipbob-node-sdk';
import { get202601Product } from 'shipbob-node-sdk/dist/client/2026-01';
import {
type Get202601ProductResponses,
type Get202601ProductErrors,
} from 'shipbob-node-sdk/dist/client/2026-01/types.gen';
const client = await createAPI('<your-token-here>', 'https://sandbox-api.shipbob.com', {
logTraffic: true,
// version prefix for the get/post helpers (and the channel load)
apiVersion: '2026-01',
});
const productSearch = await get202601Product();
assert.ok(productSearch.data, 'should have data');
const { next } = productSearch.data;
assert.ok(next !== null && next !== undefined, 'should have more pages');
// you can get these types by clicking ie: `get202601Product` function and look at typings parameters.
const pagedResult = await client.get<Get202601ProductResponses, Get202601ProductErrors>(next);
assert.ok(pagedResult.data, 'should have paged data');Building locally
For making changes to this library locally - use yarn link to test out the changes easily. This is useful if you would
like to contribute. If you don't want an NPM dependency, it's easy to generate this yourself from OpenAPI spec and then
just copy the index.ts file to your project.
NOTE: I did not notice until I had written a custom implementation that ShipBob had published an Open API spec :facepunch:.
# this is how the clients are generated (see scripts in package.json)
$ yarn generate:2026-01
$ yarn generate:2026-07Testing
Running the tests in this repo
yarn test (mocha + tsx). There are two kinds of spec in /test:
- Offline -
channel-load-retryandsend-channel-idreplacefetchwith a stub, need no credentials and always run. - Live - everything else calls the real ShipBob API and asserts on data belonging to one account (particular orders, channels and webhook subscriptions), so they cannot pass against anyone else's. They skip when their credentials are absent, printing which key was missing, so a clean checkout gets a green run rather than a confusing failure.
To actually run the live ones, uncomment the keys you need in test/.env (test/.env.sample is the template). Doing
so points them at whatever SHIPBOB_API_URL holds and they will issue real requests - several create and delete
orders and webhook subscriptions.
# for the PAT (Personal Access Token) login (recommended)
SHIPBOB_API_TOKEN=<redacted>
SHIPBOB_API_URL=https://sandbox-api.shipbob.com
# for the oAuth login
SHIPBOB_CLIENT_ID=<redacted>
SHIPBOB_CLIENT_SECRET=<redacted>
# for the Web UI scraper (web.shipbob.com) login
[email protected]
SHIPBOB_WEB_UI_PASSWORD=<redacted>To run a subset, use --grep - .mocharc.json sets a spec glob that both positional arguments and --spec merge
with rather than replace, so naming a file does not narrow anything:
npx mocha --grep "channelLoadRetry" # just the offline retry suiteYou can also run and debug any of these in VS Code.
Mocking this library in your own tests
You can fake out this library itself, or otherwise mocking the ShipBob API http calls are quite easy to simulate with
nock. Here's a way to test creating an order verifying idempotent operation.
// NOTE: nock > 14 with undici is needed to mock underlying "fetch" calls
const CHANNELS_RESPONSE = {
items: [{
id: 1,
application_name: 'SMA',
name: 'test',
scopes: []
}]
};
const nockScope = nock('https://sandbox-api.shipbob.com')
.defaultReplyHeaders({ 'content-type': 'application/json' })
// createAPI loads channels following `options.apiVersion` (defaults to the newest API version)
.get('/2026-07/channel')
.once()
.reply(200, JSON.stringify(CHANNELS_RESPONSE))
.post('/2026-01/order')
.once()
.reply(422, JSON.stringify({
"": [
"Cannot insert order with existing ReferenceId"
]
}))
.get('/2026-01/order?ReferenceIds=123')
.once()
.reply(200, JSON.stringify([{
id: 1,
order_number: '18743683',
}]))
;
...
assert.ok(nockScope.isDone(), 'should have completed nock requests');Adding more events
To replace what could be considered "missing" webhooks, such as WRO completed (Receiving originally had no webhooks!).
You can follow the section How to sync WROs for a more robust solution. Read on if you are interested in how this
works, but also why it won't work!
If you want something more event driven, you can use the emails they send out with an inbound email processor: ie:
// this is done using Mandrill/Mailchimp Inbound mail processor:
for (const event of events) {
const { subject } = event.msg;
switch (subject) {
case 'Your WRO is now complete':
// ie: Your WRO 756713 is now complete and all associated inventory is ready to ship! ...
// https://web.shipbob.com/app/Merchant/#/inventory/receiving/756713/summary ...
const match = /Your WRO (?<wro>\d+) is now complete/i.exec(event.msg.text);
if (match === null || match.groups === undefined || !('wro' in match.groups)) {
throw new Error(`cannot find wro in email '${taskStorageId}'`);
}
const wro = match.groups.wro;
console.log(` Got it! Received WRO# '${wro}'`);
break;
case 'Your box/pallet is now complete!':
console.log(`Ignoring subject: '${subject}'`);
break;
default:
console.log(`Unsupported subject: '${subject}'.`);
break;
}
}You can publish that as an event or push to a queue and it will act as a "webhook".
NOTE: I discovered after writing the above inbound mail handler that a WRO you create may be split. ie: we created 1 WRO and it was split into 6 more WROs by the ShipBob team, so it's not really possible to link back to your system when that occurs. Also, they have indicated to me there's no link on these WROs or UROs (Unidentified Receiving Orders) that they create. There's no hierarchical relationship with the split WROs they are creating. In other words, you will need to implement polling anyway, so adding this is probably not worthwhile.
UPDATE: 2026-07 (in the spec since our first download, 2026-08-19) links UROs to WROs, one way only:
- URO -> WRO: per the spec, every URO carries
linked_wro_id(null/0when unlinked) andlinked_wro_date- not yet checked against a live response.GET /2026-07/unidentified-receiving-order?isCompleted=truelists linked UROs (false, the default, lists unlinked), andPOST /2026-07/unidentified-receiving-order/{uroId}:associateWROlinks one yourself. Not in2026-01or2025-07. - WRO -> URO: nothing. The WRO has no URO field and no receiving endpoint filters by URO, so to find a WRO's UROs, page
the linked UROs and match
linked_wro_idyourself. - Split WROs are still unrelated to each other.
Broken in practice (2026-09-25, production): GET /2026-07/unidentified-receiving-order returns 200 with an empty
array for both isCompleted=false and isCompleted=true - page 1 of both unlinked and linked is [], so the list
endpoint returns no UROs either way. The link fields above are therefore unverified against any real URO. See the URO
test in test/shipbob-api-2026-07.spec.ts. Their website also seems to be broken "on hold receiving" has none either.
OAuth
There is no S2S (server-to-server) oAuth. User intervention is required. There are only helper methods to help with that. You could bypass that with a webscraper (see next section).
oAuthGetConnectUrl()direct an end user to follow the generated URL. Useoffline_accessas part of scope to get a refresh token.oAuthGetAccessToken()call this with thecodequery string fragmenthttps://yourname.ngrok.io/#code=...&id_token=...the redirect_uri (and your client Id and secret from ShipBob App)oAuthRefreshAccessToken()call this with the lastrefresh_tokenyou received, otherwise the same asoAuthGetAccessToken()withoutcode.
The method to get/refresh access token both return access_token, so you provide that to createAPI(...) with your
application name (via options.channelPredicateOrApplicationName).
If you create products with your API, you will not be able to see them with an oAuth app. I went down a big rabbit hole here - something to do with different channels. Try not to waste as much time as I did here and avoid using oAuth unless you are building actually an app IMO.
Access APIs available to web.shipbob.com
The Web UI has a much broader set of unsupported APIs (ordersapi.shipbob.com, shippingservice.shipbob.com,
merchantpricingapi.shipbob.com, etc.). For example, you cannot "patch" a WRO on the public receiving API, but you can
somehow with a web login, so it unlocks extra functionality and has a separate rate limit from the API. You would need
to create a ShipBob account and then use those credentials to login with a scraper (see /src/WebScraper.ts). The account
can only be logged with one session at a time. Look at AuthScopesWeb type and you can probably work backwards the
scopes needed for each API. If you go this route then the peer dependency on puppeteer is not optional.
There is no documentation for these extra APIs - you can look through network tracing in the browser. See unit tests for full example:
// scrape web.shipbob.com website:
// NOTE: not exported from the package root (that would make puppeteer a hard dependency)
import { getAccessTokenUI } from 'shipbob-node-sdk/dist/WebScraper';
import { createAPI } from 'shipbob-node-sdk';
import { get202601InventoryLevelLocations } from 'shipbob-node-sdk/dist/client/2026-01';
const authResponse = await getAccessTokenUI({ email, password }, 60 /* timeout seconds */);
const missingChannelScope = authResponse.scopes.indexOf('channels_read') === -1;
const extraHeaders = {
origin: 'https://web.shipbob.com',
referer: 'https://web.shipbob.com/',
};
// options are a discriminated union: load + select ('SMA'), inject a persisted `channel`, or skip entirely
await createAPI(
authResponse.accessToken,
'https://api.shipbob.com',
missingChannelScope
? { skipChannelLoad: true, extraHeaders }
: { channelPredicateOrApplicationName: 'SMA', extraHeaders }
);
const inventoryListResponse = await get202601InventoryLevelLocations({
query: { InventoryIds: [20104984] },
});Polling Orders for tracking
This is a suggested way to track orders from ShipBob. This may be better than using the webhook, sometimes the
order_shipped webhook fires with no tracking information.
- Poll GET
/2026-01/order?HasTracking=true&IsTrackingUploaded=false&startDate=03-25-2025
// right now for me I get error "An unexpected database error occurred. Please try again later", but it worked on 1.0 API
const result = await get202601Order({
query: {
HasTracking: true,
IsTrackingUploaded: false,
StartDate: '2025-03-25T14:15:22Z',
},
});- Iterate through each order (and each shipment)
- Sync the tracking back to your platform
- Mark the order as shipped using this endpoint (https://developer.shipbob.com/api-docs/#tag/Orders/paths/~11.0~1shipment~1%7BshipmentId%7D/put). Or, you can mark it as shipped using the bulk mark as shipped endpoint (https://developer.shipbob.com/api-docs/#tag/Orders/paths/~11.0~1shipment~1:bulkUpdateTrackingUpload/post).
How to sync WROs
Syncing WROs (Warehouse Receiving Orders) back to your system has 2 options.
Option #1 - Simple (wait until the WRO status is Completed)
- Poll our GET WRO endpoint for WROs in Completed status: GET https://sandbox-api.shipbob.com/2026-01/receiving?Statuses=Completed&ExternalSync=false
/**
* this was /2.0/receiving-extended
*/
const response = await get202601Receiving({
query: {
Statuses: ['Completed'],
ExternalSync: false,
},
});- Iterate through each inventory id in the inventory_quantities array and sync the stowed_quantity back to your system
- Mark the WRO as synced (see below for the endpoint to use and sample request)
- Poll the GET WRO endpoint again and it will no longer show up as you already synced this and ExternalSync is now set to true
Mark the WRO as synced (this is optional and a alternative option to continuously polling completed WROs that you may or may not know you have already synced)
// use Ids from "getReceivingExtended" with ExternalSync: false.
const response = await post202601ReceivingSetExternalSync({
body: {
ids: [918363],
is_external_sync: true,
},
});Option #2 - Advanced (partial receiving)
- Poll GET WRO endpoint for WROs with statuses "processing" and "completed": https://sandbox-api.shipbob.com/2026-01/receiving?Statuses=Processing,Completed&ExternalSync=false.
const response = await get202601Receiving({
query: {
Statuses: ['Processing'],
ExternalSync: false,
},
});Note: When initially testing you can remove the statuses from the query params, and you will see the default status of "AwaitingArrival" whenever a WRO is created. There is no sandbox simulation to move these forward.
- For each WRO, make a request to the Get Warehouse Receiving Order Boxes endpoint: https://sandbox-api.shipbob.com/2026-01/receiving/442997/boxes
- Iterate through each box
- Iterate through each item in the box_items array
- Sync the stowed_quantity for each item back to your system
Synchronizing inventory levels
There are no webhooks or easy way to sync. Plus once you place an order there is a delay (~1 minute) before the inventory levels are impacted.
So, if you have one FC, you can use total_sellable_quantity for each inventory item.
For multiple FCs - their recommendation is to use for each FC the fulfillable_quantity and subtract the total_exception_quantity (since it could be assigned to either FC).
You can't do this anymore in 2026-01 - it requires 2 separate API calls (inventory-level and
inventory-level/locations). See the get inventory for a SKU test in test/shipbob-api-2026-01.spec.ts for a working
example of combining them.
{
"id": 1234,
"name": "...",
"total_fulfillable_quantity": 1688,
"total_onhand_quantity": 1688,
"total_committed_quantity": 0,
"total_sellable_quantity": 1688,
"total_exception_quantity": 0,
"fulfillable_quantity_by_fulfillment_center": [
{
"id": 211,
"name": "Fairburn (GA)",
"fulfillable_quantity": 1688,
"onhand_quantity": 1688,
"committed_quantity": 0,
"awaiting_quantity": 0,
"internal_transfer_quantity": 0
}
]
}Background
Originally I wrote a library that had endpoints not available in OpenAPI spec ie:
/2.0/receiving-extended/experimental/product:skull:/experimental/receiving:skull:
It was originally cumbersome to keep up-to-date and their dated API versions expire on a schedule (currently supported for 24 months, new release every 6 months), so it only made sense to use only their OpenAPI specs to generate version specific clients.
