ShipHero Integration
Summary
ShipHero is our 3PL (third-party logistics) provider — they physically hold our inventory and ship customer orders. The Easy OTC API talks to ShipHero over their GraphQL API to keep inventory counts in sync, push orders for fulfillment, and (eventually) receive tracking updates back via webhook.
The integration is bi-directional but lopsided: ShipHero is the source of truth for inventory and fulfillment, while Easy OTC remains the source of truth for the product catalog (categories, manufacturer, NDC codes, pricing tiers, regulatory data — none of which ShipHero can store natively). See SHIPHERO.md at the project root for the field-level mapping spreadsheet.
All API calls go through App\Services\ShipHeroService (app/Services/ShipHeroService.php), which handles OAuth, token caching, and GraphQL transport.
Inbound: ShipHero → Easy OTC
What gets synced from ShipHero into our database.
shiphero:sync-products — inventory and basic product fields
Implemented in app/Console/Commands/SyncProductsFromShipHeroCommand.php.
Command signature (SyncProductsFromShipHeroCommand.php:18-21):
shiphero:sync-products
{--limit=100 : Number of products to fetch per page (max 100)}
{--dry-run : Run without making changes to the database}
{--sku= : Sync a specific product by SKU}How it matches products: by sku. Lookup is Product::where('sku', $sku)->first() (SyncProductsFromShipHeroCommand.php:228). If the SKU does not already exist in Easy OTC, the product is skipped, not created (SyncProductsFromShipHeroCommand.php:246-252). This is intentional — the CSV/Filament importer owns product creation; ShipHero only updates existing rows.
Fields updated on the products table (SyncProductsFromShipHeroCommand.php:230-236):
| ShipHero field | products column |
|---|---|
name | name (falls back to existing value if null) |
sum of warehouse_products[].on_hand | inventory_count |
derived: inventory_count > 0 | is_available |
Inventory is summed across all warehouses by ShipHeroService::calculateTotalOnHand() (ShipHeroService.php:354-359). If the sum is zero or negative, calculateInventory() substitutes a fake random number between 20–200 (SyncProductsFromShipHeroCommand.php:273-278) — this is a leftover from the seeded sandbox environment and should be reviewed before production.
Note on the other assign* methods in this file (assignManufacturer, assignIdentifiers, assignPackagingDetails, assignPrices, assignRegulatoryDetails, assignImages, assignTags, assignCategories, indexInSearch): they exist in the file but are not called from processProduct(). The current implementation only writes name, inventory_count, and is_available. Everything else (UPC, dimensions, images, categories, manufacturer, price) is dead code in the inbound sync today.
When does it run? Not on a schedule. routes/console.php (the project uses the Laravel 11 routes/console.php style, not Console\Kernel.php) lists only:
Schedule::command('health:check --notify-on-success')->hourly()->environments(['local', 'stage', 'staging']);
Schedule::command('subscriptions:process-due')->dailyAt('06:00');
Schedule::command('subscriptions:send-reminders')->dailyAt('09:00');shiphero:sync-products is manual-only. Run it from CLI when you want to refresh inventory.
Real-time inventory decrements
In addition to the bulk sync, individual stock changes are pushed from us to ShipHero at order time (see Outbound below). Easy OTC does not currently poll for per-SKU inventory changes from ShipHero between syncs — if a unit ships outside our flow (e.g. someone manually adjusts in the ShipHero dashboard), our inventory_count will be stale until the next shiphero:sync-products run.
Outbound: Easy OTC → ShipHero
What gets pushed from us to ShipHero.
Order creation on checkout
When a customer completes checkout, OrderService::createOrder() calls $this->createShipHeroOrder($order) synchronously inside the order-creation flow (app/Services/OrderService.php:134-135).
createShipHeroOrder() (OrderService.php:273-335) builds an order payload and calls ShipHeroService::createOrder(), which issues a GraphQL order_create mutation (ShipHeroService.php:477-581).
Payload shape (OrderService.php:279-324):
[
'order_number' => $order->order_number,
'order_date' => $order->created_at->toIso8601String(),
'line_items' => [/* sku, quantity, price (dollars), product_name, partner_line_item_id */],
'shipping_address' => [/* first_name, last_name, address1, address2, city, state, zip, country, email, phone */],
'billing_address' => [/* same shape, optional */],
'subtotal' => '<dollars>',
'total_price' => '<dollars>',
'total_tax' => '<dollars>',
'total_discounts' => '<dollars>',
]The mutation in ShipHero hard-codes fulfillment_status: "pending" and uses shop_name from config('services.shiphero.shop_name') (default EasyOTC) (ShipHeroService.php:479, 499). If SHIPHERO_WAREHOUSE_ID is set, every line item is tagged with that warehouse (ShipHeroService.php:551-556).
This is NOT queued as a job. It runs inline inside the checkout DB transaction. Failures are caught and logged but do not roll back the order (OrderService.php:327-334):
} catch (\Exception $e) {
// Don't fail the order if ShipHero call fails
Log::error('Failed to create ShipHero order', [
'order_id' => $order->id,
'order_number' => $order->order_number,
'error' => $e->getMessage(),
]);
}That means an order can be successfully created in Easy OTC but silently fail to reach ShipHero. There is no retry, no failed-jobs entry, and no admin alert.
Subscription order creation
Recurring subscription orders also hit ShipHero. SubscriptionOrderService::generateOrderFromSubscription() calls $this->shipHeroService->removeInventory(...) to decrement stock for the subscription's SKU (app/Services/SubscriptionOrderService.php:134-136). Note: the subscription flow currently decrements inventory but does not call createOrder() on ShipHero — only OrderService does that. This is likely a gap.
Inventory decrement at order time
When order items are saved, OrderService::deductInventory() decrements products.inventory_count locally and then mirrors the decrement to ShipHero (OrderService.php:244-268):
// Sync with ShipHero
if ($product->sku) {
$this->shipHeroService->removeInventory($product->sku, $quantity);
}Implementation: ShipHeroService::removeInventory() (ShipHeroService.php:365-409) issues an inventory_remove GraphQL mutation against SHIPHERO_WAREHOUSE_ID.
Inventory increment (returns / re-stock)
ShipHeroService::addInventory() (ShipHeroService.php:415-459) issues the inventory_add mutation. Grepping the codebase shows it is called from OrderService.php:628 — context suggests this is a refund/cancel path that returns stock to ShipHero.
Product master push: shiphero:sync-metadata
Implemented in app/Console/Commands/SyncMetadataToShipHeroCommand.php. Described as "One-time sync of Easy OTC product metadata to ShipHero" (SyncMetadataToShipHeroCommand.php:17).
Command signature (SyncMetadataToShipHeroCommand.php:12-15):
shiphero:sync-metadata
{--limit=200 : Number of products to push}
{--dry-run : Run without making changes to ShipHero}
{--sku= : Push a specific product by SKU}For each product it calls ShipHeroService::createOrUpdateProduct() (ShipHeroService.php:902-918), which does a fetch-by-SKU and routes to either product_create or product_update (ShipHeroService.php:825-988).
Payload, built by buildPayload() (SyncMetadataToShipHeroCommand.php:222-284):
name— product nametags— category names, padded withNA1/NA2/NA3to satisfy ShipHero's 3-tag minimum (ensureMinimumTags(),SyncMetadataToShipHeroCommand.php:293-302)customs_description— a JSON blob containing every Easy OTC-specific field that ShipHero has nowhere else to put:active_ingredient,drug_class,expiry_date,upc,ndc_10,ndc_11,case_upc,inner_pack_upc,nb_upc,quantity,quantity_label,inner_pack,case_pack,cases_per_pallet,item_dimensions,cube,case_weight,nbe_compare_to,regulatory_classification,medical_device,country_of_origin,sds_required,added_to_product_line_at,contains_fd_c_red_dye_3dimensions—{length, width, height}frompackagingDetailimages— sorted bysort_order, mapped to{src, position}
Price is pushed via a separate mutation, warehouse_product_update, because ShipHero stores price per-warehouse not per-product (ShipHeroService.php:766-817, called from SyncMetadataToShipHeroCommand.php:182-184). It only fires if both a price and SHIPHERO_WAREHOUSE_ID are present.
Manufacturer / vendor sync: shiphero:sync-manufacturers
Implemented in app/Console/Commands/SyncManufacturersToShipHeroCommand.php. One-shot command that:
- Fetches existing ShipHero vendors via
ShipHeroService::fetchVendors()and builds a name→id map (SyncManufacturersToShipHeroCommand.php:109-124). - Iterates every
Manufacturerrecord and creates any missing ones in ShipHero viaShipHeroService::createVendor()(auto-generates emails likeacme-pharma@vendor.easyotc.comviaShipHeroService::generateVendorEmail(),ShipHeroService.php:737-743). - Links every
Productwith a manufacturer to the corresponding ShipHero vendor viaShipHeroService::addVendorToProduct()(SyncManufacturersToShipHeroCommand.php:175-209).
Cancellation / refund
There is no order cancellation mutation plumbed in. ShipHeroService does not expose a cancelOrder() method. If an order is canceled in Easy OTC after createShipHeroOrder() has fired, ShipHero will not be notified and may still ship. The addInventory() call at OrderService.php:628 returns stock but does not cancel the upstream order.
Webhooks
Implemented: shipment-update
ShipHero is configured to POST shipment updates (tracking numbers, carrier, package details) to:
POST /api/webhooks/shiphero/shipment-updateRoute: routes/api.php:233-236
Route::prefix('webhooks/shiphero')->group(function () {
Route::post('/shipment-update', [ShipHeroWebhookController::class, 'shipmentUpdate']);
});Handler: app/Http/Controllers/Webhooks/ShipHeroWebhookController.php:17-115.
Current behavior: the handler parses the payload (fulfillment block, shipping address, line items, packages with tracking numbers), logs it, and returns the expected {"code":"200","Message":"Success"} response. It does not persist anything to the database. The code contains an explicit TODO (ShipHeroWebhookController.php:101-112):
// TODO: When shipments table is created, save the shipment data hereSo tracking numbers and carrier info arrive but are not stored — they only live in the Laravel logs today. There is no shipments table yet. There is also no signature verification or auth on this endpoint.
Not implemented
ShipHero supports several other webhook types (inventory updates, exceptions, order canceled, return created, etc.) — none of these are wired up. The only registered ShipHero webhook route is /shipment-update.
Config / secrets
All ShipHero configuration is read from config/services.php:61-69:
'shiphero' => [
'username' => env('SHIPHERO_USERNAME'),
'password' => env('SHIPHERO_PASSWORD'),
'warehouse_id' => env('SHIPHERO_WAREHOUSE_ID'),
'shop_name' => env('SHIPHERO_SHOP_NAME', 'EasyOTC'),
'auth_url' => 'https://public-api.shiphero.com/auth/token',
'refresh_url' => 'https://public-api.shiphero.com/auth/refresh',
'graphql_url' => 'https://public-api.shiphero.com/graphql',
],Env keys (.env.example:85-88):
| Key | Required | Purpose |
|---|---|---|
SHIPHERO_USERNAME | yes | Account email for OAuth password-grant auth |
SHIPHERO_PASSWORD | yes | Account password |
SHIPHERO_WAREHOUSE_ID | yes (for inventory/price mutations and order line items) | Warehouse to write inventory adjustments against |
SHIPHERO_SHOP_NAME | no | shop_name field on created orders; defaults to EasyOTC |
API URLs (auth_url, refresh_url, graphql_url) are hardcoded in config/services.php — there is no env override for environment-switching (dev vs prod). The current .env points at chris+sandbox@easyotc.com with a sandbox account.
Token caching (ShipHeroService.php:11-12, 50-51):
- Access token is cached in Laravel cache under key
shiphero_access_tokenfor(expires_in - 3600)seconds (defaults to 28 days minus 1 hour). - Refresh token is cached under
shiphero_refresh_tokenfor 60 days. - Call
ShipHeroService::clearTokenCache()(ShipHeroService.php:748-755) to force re-auth on next request.
Failure modes
| Failure | What happens | Recovery |
|---|---|---|
| ShipHero auth fails on a sync command | Command prints Failed to authenticate with ShipHero and returns FAILURE (SyncProductsFromShipHeroCommand.php:58-62). Logs to Log::error('ShipHero authentication failed', ...) (ShipHeroService.php:64-68). | Check SHIPHERO_USERNAME / SHIPHERO_PASSWORD in .env. |
ShipHero down or 5xx during shiphero:sync-products | Command continues per page; a failed page logs Failed to fetch products from ShipHero and returns early from syncAllProducts() (SyncProductsFromShipHeroCommand.php:146-148). Already-processed pages are kept. | Re-run the command. There is no resume cursor — it starts from page 1. |
ShipHero down during checkout createShipHeroOrder() | Exception is swallowed. Order is created in Easy OTC, log line Failed to create ShipHero order is written, no retry, no failed job, no alert (OrderService.php:327-334). | Manual — someone has to spot it in the logs and re-create the order in the ShipHero dashboard. |
ShipHero down during removeInventory() at checkout | ShipHeroService::removeInventory() catches the HTTP error internally, logs it, and returns null (ShipHeroService.php:174-186). Easy OTC's inventory_count still gets decremented, so the two systems drift. | Re-sync inventory via php artisan shiphero:sync-products. |
GraphQL returns an errors array | syncAllProducts() checks isset($response['errors']) and bails (SyncProductsFromShipHeroCommand.php:151-155). Other call sites generally log 'errors' => $result['errors'] ?? 'Unknown error' and return null. | |
| Rate limit hit (ShipHero allows ~4,004 credits, 60/sec restore) | Will surface as a GraphQL error. The sync uses a default page size of 100 to stay within budget. | Reduce --limit. |
| Webhook payload arrives malformed | Handler defensively uses ?? null everywhere; will still return 200 even with missing fields (ShipHeroWebhookController.php:36-99). | Logs will show empty fields. |
Failed jobs
None of the ShipHero work uses Laravel queue jobs. app/Jobs/ contains only GenerateImageVariantsJob and GenerateProductImagesJob — nothing ShipHero-related. So there is nothing to inspect in failed_jobs if a ShipHero call fails. All recovery is log-driven.
Operational commands
All commands live under the shiphero: artisan namespace.
Inbound (ShipHero → us)
# Sync all products from ShipHero (updates inventory_count, name, is_available on existing SKUs)
php artisan shiphero:sync-products
# Preview without writing
php artisan shiphero:sync-products --dry-run
# Sync a single SKU
php artisan shiphero:sync-products --sku=ABC123
# Tune page size (max 100)
php artisan shiphero:sync-products --limit=50Source: app/Console/Commands/SyncProductsFromShipHeroCommand.php.
Outbound (us → ShipHero)
# Push product master data (name, tags=categories, customs_description JSON, dimensions, images, price)
php artisan shiphero:sync-metadata
php artisan shiphero:sync-metadata --dry-run
php artisan shiphero:sync-metadata --sku=ABC123
php artisan shiphero:sync-metadata --limit=500
# Push manufacturers as ShipHero vendors and link them to products
php artisan shiphero:sync-manufacturers
php artisan shiphero:sync-manufacturers --dry-runSources:
app/Console/Commands/SyncMetadataToShipHeroCommand.phpapp/Console/Commands/SyncManufacturersToShipHeroCommand.php
Recommended runbook
- First-time setup of a fresh ShipHero account:
php artisan shiphero:sync-manufacturers --dry-runthen without--dry-runphp artisan shiphero:sync-metadata --dry-runthen without--dry-run
- Regular inventory refresh:
php artisan shiphero:sync-products— currently manual; consider adding toroutes/console.phpon an hourly schedule once the integration is past the sandbox phase. - Investigating a tracking-number issue: grep the Laravel log for
ShipHero Shipment Update Webhook received— the data is logged but not stored.