@quaillogistics/adapters
v0.10.0
Published
TMS adapter library — fetch and map data from 3PL Systems, Turvo, and other TMS platforms
Readme
@quaillogistics/adapters
TMS adapter library for fetching and mapping data from external TMS systems. Used internally by QUAIL Logistics.
Install
npm install @quaillogistics/adaptersUsage
Each TMS adapter is a separate subpath export. Import only what you need:
import { ThreePLClient, mapThreePLLoad } from '@quaillogistics/adapters/3pl'
import { TurvoClient } from '@quaillogistics/adapters/turvo'
import { chunkDateRange } from '@quaillogistics/adapters/common'3PL Systems
const client = new ThreePLClient({
apiHost: '3pl.hyperiontms.com',
clientId: '...',
clientSecret: '...',
globalClientId: '...',
globalClientSecret: '...',
})
// Layer 1: typed raw API responses
const rawLoads = await client.getLoads({ startDate, endDate })
// Layer 2: opt-in mapped intermediaries
const loads = rawLoads.map(mapThreePLLoad)Writing loads to 3PL
There is no 3PL sandbox, so the API's own dry run is the safety net: dryRun sends test: true,
which makes the server validate the real body, return the placeholder id 10000 and persist
nothing. Preflight every live write with it.
Live writes need pushEnabled: true — without it they throw ThreePLPushDisabledError. Dry
runs do not: they persist nothing, so you can validate an entire create flow against the live API
with writes still switched off. Flipping pushEnabled is then the only thing that ever makes a
write real.
import { ThreePLClient, buildCreateShipment } from '@quaillogistics/adapters/3pl'
const client = new ThreePLClient({ ...credentials, pushEnabled: true })
const body = buildCreateShipment({
customerId: 1624635,
equipmentType: 'DRY_VAN', // canonical — mapped to the write literal "Van"
mode: 'LTL',
status: 'QUOTED',
items: [{ description: 'General Freight', freightClass: '70', pieces: 2, weight: 500 }],
stops: [
{ stopType: 'PICKUP', sequence: 0, zipCode: '90755', appointmentEarly, appointmentLate },
{ stopType: 'DELIVERY', sequence: 1, zipCode: '90802', appointmentEarly, appointmentLate },
],
})
const preflight = await client.createShipment(body, { dryRun: true })
// { loadId: null, dryRun: true, returnedId: 10000 } — validated, nothing written
const created = await client.createShipment(body)
// { loadId: 95674, dryRun: false, returnedId: 95674 }
await client.updateShipment({ loadid: created.loadId!, poReference: 'PO-1' }) // merges
await client.cancelShipment(created.loadId!) // restatus to "Canceled"; nothing deletes a loadWhat the dry run actually checks, from a negative-controlled sweep: equipmentType, shipmentMode
and the zips are validated (a bogus value 400s, so a 200 is real evidence), and shipmentStatus
is not — a nonsense status came back 200. Preflight a body and you have proven its lane and
equipment, not its status.
Six things the API does that are worth knowing before you build on it:
- The write body is flat and two-stop.
shipperX/consigneeXscalars, not the read path'sstops[]. A multi-stop load cannot be expressed at all —buildCreateShipmentrefuses one rather than silently dropping the middle stops. UpdateShipmentmerges, exceptitemsandaccessorials, which it replaces. Send only what changed; the rest of the load survives, and the mappers omit keys you did not set rather than sending nulls. A body withitemsmust carry every line with every field it should keep. That includespcf, the line's density, which 3PL does not recompute: leave it out and the line reads back 0 (3PL 95584, #31).buildWriteItemwritesPushItemInput.densityaspcf, so pass it on every line. A body withaccessorialsmust carry the shipment's existing lines too.- Accessorials take a different shape on create and on update. A create takes a list of codes,
and each lands at 0. An update takes
ThreePLWriteAccessoriallines, and the amount billed isretail, notbill. 3PL stores any code, real or not (3PL 95955, #41). - A create requires four keys the rest of the body doesn't hint at.
items[].class,items[].isHazardous,shipperAddressandconsigneeAddressmust be present — a missing one 400s (The Class field is required.,Shipper Address is required.), and the dry run does check them. An empty value passes, sobuildCreateShipmentsends"0",falseand""when the caller has none — the same values a rep's hand-keyed truckload carries. Updates never get these defaults. (quail-tms#1689, 2026-09-22.) - Cancel is a restatus, not a delete. There is no Global cancel endpoint, and nothing on any
surface deletes a shipment — a cancelled load keeps coming back from
GetLoads. - The write enum is not the read vocabulary. Reads emit
Dry Van,Step Deck,Hot Shot; the write API rejects every literal with a space and matches its own, narrower enum. Two canonical types — BOX_TRUCK and POWER_ONLY — have no write literal at all, andbuildCreateShipmentrefuses them rather than sending the nearest wrong one.
Paths, verbs and semantics were verified live against the MLT tenant on 2026-09-08
(quaillogistics/quail-integrations → evals/2026-09-08-threepl-writes.md). Writes return no
rate-limit headers, so pace them blind, and never blind-retry a create — it mints duplicate loads.
Rating an LTL ask
rateByCustomer prices freight on one customer's tariff across every LTL carrier the broker has
connected (POST /api/clientv1/RatingByCustomerId, Global scope). It is a read, so it needs no
pushEnabled:
const options = await client.rateByCustomer({
customerId: 1623682,
shipperZip: '44125',
consigneeZip: '48026',
shipmentMode: 'LTL',
equipmentType: 'Van',
accessorials: ['LGDE'],
items: [{ class: '70', pieces: 1, weight: 240, packaging: 'Pallets', isHazardous: false }],
})
// [{ name: 'XPO Logistics', scac: 'CNWY', billed: 252.63, serviceType: 'Standard', transitTime: 2, … }, …]billed is the customer's sell, not the carrier buy, which this call does not carry. A customer
with no tariff gets every option back at 0. That means "no tariff", not a price, and the client
passes it through as 0.
Turvo
const client = new TurvoClient({
apiHost: 'app.turvo.com',
username: '...',
password: '...',
apiKey: '...',
clientId: '...',
clientSecret: '...',
})
const shipment = await client.getShipment(123)Mock mode
Every adapter supports mock: true for testing without hitting real APIs:
const client = new ThreePLClient({
...credentials,
mock: true,
})
const loads = await client.getLoads({ startDate, endDate }) // deterministic test dataObservability
Pass logging callbacks to see HTTP activity:
const client = new ThreePLClient({
...credentials,
onRequest: (method, path) => console.log(`>> ${method} ${path}`),
onResponse: (method, path, status, ms) => console.log(`<< ${status} ${path} (${ms}ms)`),
})Adapters
| Adapter | Import | Capabilities |
|---------|--------|-------------|
| 3PL Systems | @quaillogistics/adapters/3pl | Pull: loads, carriers, customers, contacts, commissions, AP invoices, vendor payments, tracking, documents. Push: create / update / cancel shipment, with a live-API dry run. Rating: an LTL ask on a customer's tariff |
| Turvo | @quaillogistics/adapters/turvo | Push: shipment CRUD. Read: shipments, locations |
Development
npm install
npm run test # run tests
npm run test:watch # watch mode
npm run typecheck # type check
npm run build # build to dist/
npm run check-deps # verify zero production dependenciesPublishing
Releases are tag-driven. From a clean, green main:
- Bump:
npm version X.Y.Z --no-git-tag-version— movespackage.jsonand bothpackage-lock.jsonsites together - Commit:
git commit -am "vX.Y.Z" - Tag:
git tag vX.Y.Z - Push:
git push origin main && git push origin vX.Y.Z
publish.yml re-runs the checks, verifies the tag matches package.json, and publishes to npm. Feature PRs don't bump the version — the release commit does. /release (in .claude/skills/) runs the whole sequence, sizing the bump from the commits since the last tag.
