# T.REX ARMS Partner API ## Overview T.REX ARMS makes custom holsters. Our holster lines, combined with the pistols, weapon lights, hand, barrel and color options we support, come to hundreds of thousands of possible configurations. Stocking them all isn't realistic, so we make every holster to order. The partner program puts our holster configurator on your product pages and drop-ships the orders. The customer configures and buys on your site; we make the holster and ship it to them in our box. This API is everything that takes, from listing the products to returns. [![A partner's product page, with the T.REX configurator in it](https://partner.trex-arms.com/product_page_mockup.webp)](https://partner.trex-arms.com/demo) [Configurator demo](https://partner.trex-arms.com/demo) ### What the customer sees Your product page, with our configurator in an iframe. The iframe takes the place of the product gallery and description, everything beside your add-to-cart block, and is styled to match your site. Our only branding is a small T.REX skull in the corner of the main image. The review count opens our reviews in a popup over the configurator, and the warranty link opens our warranty page in a new tab. The price shown in your buy box comes from the iframe, through the [`selection_changed`](#messages-from-the-iframe-to-your-page) message. In your cart, the configuration is one line: the holster and the attachments chosen with it, with a render of exactly what was configured, one price and one quantity. See it working in the [demo](https://partner.trex-arms.com/demo): a stand-in product page with the configurator in its iframe, a buy box and a cart driven by its messages, and a log of every message in both directions. ### Process assumptions - **Every holster is made to order.** Each product and cart item carries its lead time: how soon it ships out. - **We make, pack and ship every order** straight to your customer, with a T.REX ARMS packing slip in the box listing the customer's configuration. - **We ship to the address exactly as sent** and don't verify it; your checkout already has. Our holsters ship anywhere, but some attachments made by others ship to the US only ([Shipping restrictions](#shipping-restrictions)). - **Our prices are our storefront's.** Every price you show or charge comes from us: the customer gets whatever public sale we're running, with color upcharges and their discounts handled correctly. A **promo** is our term for any discount, scheduled or unscheduled; it's the `promo` in `promo_discounts` and `promo_explanation`. - **Coupons on your site are yours.** We don't do anything with them: a discount you give at your checkout never reaches us, and the order we make carries our own prices. - **Sales tax is yours.** You're the merchant of record: you collect and remit the tax, and our orders carry none. - **Shipping charges are yours.** We bill you the cost of the shipping label. We use USPS Ground Advantage, and we recommend charging a flat rate for shipping. Upgradeable shipping is not available yet. - **You own every conversation with the customer.** We never email, call or message your customer. When we need something from them (a fit problem found in production, a carrier that can't deliver), we put the order on hold and get in touch with you, and you take it from there. - **Until the first box ships, an order can change:** a new address, lines deleted, or cancelled outright, even once we've started making it. We absorb the cost of a cancellation, as we do for our own customers: a cancelled order is never invoiced, however far production got. A configured holster can't be edited; the customer deletes the line and orders a new configuration ([Changing an order](#changing-an-order)). - **Returns come back to us.** You open a return (an RMA) for what the customer is sending back, and we receive the box at our warehouse. Accepted returns are credited toward your next invoice. - **Our lifetime warranty applies.** The configurator links to our warranty page, and a warranty or support contact is tied back to its order by your order id. ### Products and attachments Each of our holster lines is one product on your site, with one product page. [`GET /products`](#get-products) is the live list. **Attachments are not products of their own.** The customer chooses them inside the holster's configurator, on that holster's product page, and they're sold only as part of a holster configuration. No attachment gets its own product page, listing or search entry. In the cart and on the order, a holster and the attachments configured with it are one line, with one price and one quantity ([Cart](#cart)). Which attachments a holster offers is decided by its configurator, and can change without any change to this API. ## Technical details For the developers. ### Guiding principles 1. **As much complexity as possible is handled by T.REX.** Pistol and light compatibility, pricing, stock and shipping restrictions are all complicated, and flattening them into a simple attribute model doesn't work. So the configurator works them out, every price you show or charge comes from us, computed the way our own storefront computes it, and we check each cart against the shipping address for you ([Shipping restrictions](#shipping-restrictions)). 2. **That includes cart items.** The customer's browser hands you only cart item refs, and a ref reads nothing without your bearer token. When you place an order, you send only cart item refs and an address. Line contents, quantities and prices come from our records. 3. **Speed.** There are no rate limits on partner traffic, and we aim to answer every request in under 100ms of server time. ### Data flow overview Your systems and ours meet in two places: the customer's browser, where your product page and our configurator iframe share nothing but `postMessage`, and this API, between your server and ours. 1. You sync our products ([`GET /products`](#get-products)) and keep one product of your own for each. 2. Each of those product pages loads our configurator in an iframe from the product's `embed_url`. 3. The iframe gets fits, availability and prices from our servers directly. 4. As the customer configures, the iframe reports the selection and its price to your page (`selection_changed`). 5. When the customer clicks your "Add to cart", your page tells the iframe, with the quantity (`add_to_cart_clicked`). 6. The iframe registers the configuration with us. We create the **cart item** at that quantity and return its `cart_item_ref`. 7. The iframe hands the ref to your page (`cart_item_created`). This is the only place a cart item crosses from our side to yours. 8. Your page sends the ref to your cart. 9. Your server reads the cart item from us ([`GET /cart_items/{cart_item_ref}`](#get-cart_items-cart_item_ref)) to build its cart line, and validates the cart with us ([`POST /cart/validate`](#post-cart-validate)) whenever it's viewed and, with the shipping address, immediately before creating the order. 10. When the customer changes a quantity, you send it to us ([`PATCH /cart_items/{cart_item_ref}`](#patch-cart_items-cart_item_ref)), and we refuse a quantity we can't supply. 11. The customer checks out on your site. 12. You create the order with the cart item refs, your own order id and line ids, and the address ([`POST /orders`](#post-orders)). We take it at once and create it moments later, and an `order_created` event says it's there. From then on, you name the order and its lines by your own ids, so you have none of ours to store. 13. Ordering uses the cart items up: we delete them, so they can't be ordered again. 14. Until the order ships, you can change the address, delete lines, put the order on hold, or cancel it. You can read the order at any time ([`GET /orders/{partner_order_id}`](#get-orders-partner_order_id)). 15. We post an event to your webhook when the order's status changes or a box ships. [![How your systems and ours fit together. The numbers are the steps below.](https://partner.trex-arms.com/integration_flow.svg)](https://partner.trex-arms.com/integration_flow.svg) ### Base URL and versioning ``` https://www.trex-arms.com/drop_manufacturing_api/v1 ``` All paths below are relative to this base. Breaking changes get a new version prefix. Additive changes (new fields, new optional parameters, new error codes) ship under `v1` without notice, so ignore any field you don't recognize. Sandbox and live traffic use the same base URL; only the bearer token differs. ### Authentication Every endpoint takes a bearer token: ``` Authorization: Bearer ``` We set you up with two **accounts**, one live and one sandbox. Each has its own bearer token and its own `embed_key`. A missing, unknown or revoked token gets `401`. - **Live or sandbox.** Orders created with the sandbox token are never made, shipped or invoiced, and never reach our warehouse. In every other way they behave like live orders, so you can exercise the whole flow (products, the embed, cart items, orders, edits, holds, cancellation, webhooks, returns) against production data before going live. A sandbox order ships itself about five minutes after it's created (or, if it's on hold then, soon after it's released), in one box with a fake tracking number, and becomes `Completed`, so you can test the shipment webhook end to end. - **An account sees only what it created.** Cart items, orders and returns belong to the account that created them. Anything created by another account answers `404`, as if it didn't exist. So the sandbox account can't see live orders, the live account can't see sandbox ones, and neither can see anything of another partner's. - **The embed names the account by its `embed_key`.** The key isn't a secret: it's in the `embed_url` your product pages load. Cart items created through an embed belong to the account the key names, and only that account's token can read them. To rotate a token, we issue a new one for the same account. Everything the account created stays with it, and the old token keeps working for a grace period, so you can switch over without a coordinated cutover. ```javascript import assert from 'node:assert/strict' const base_url = 'https://www.trex-arms.com/drop_manufacturing_api/v1' // Keep the token on your server; it never goes to the browser. const token = process.env.TREX_API_TOKEN // Set a cart item's quantity. const response = await fetch(`${ base_url }/cart_items/${ cart_item_ref }`, { method: 'PATCH', headers: { Authorization: `Bearer ${ token }`, 'Content-Type': 'application/json', }, body: JSON.stringify({ quantity: 2 }), }) // What comes back: a CartItem, here with some of its fields. assert.partialDeepStrictEqual(await response.json(), { cart_item_ref: 202958, product_ref: 1066740, name: 'T.REX Ironside Hybrid IWB Holster', quantity: 2, regular_price_usd: '110.00', unit_price_usd: '99.00', discount_usd: '11.00', line_total_usd: '198.00', promo_explanation: 'Labor Day Sale', available: true, availability_issue: null, max_available_quantity: 14, created_datetime: '2026-10-08T14:14:06Z', }) ``` ### Requests and responses - JSON both ways, UTF-8, `Content-Type: application/json`. - Field names are `snake_case` and, wherever possible, match the column names in our own database. - `*_ref` fields are our integer ids. They're stable and safe to store. - Money is a **string** in USD with two decimals (`"135.00"`), never a JSON number. That matches our database exactly and avoids floating point. - Datetimes are ISO 8601 in UTC with a `Z` suffix (`"2026-09-22T14:14:06Z"`). Dates are `YYYY-MM-DD`. - Booleans are JSON booleans. ### Errors Any response other than a 2xx has an `ErrorResponse` body. | Status | Meaning | | ------ | ------- | | `400` | The request is malformed: a missing or mistyped field. Sending it again unchanged won't help. | | `401` | The bearer token is missing, unknown or revoked. | | `404` | The cart item, order, line item or return doesn't exist, or belongs to another account. A cart item that has been ordered no longer exists. | | `409` | The request is valid, but the state of what it names doesn't allow it: a quantity we can't supply right now, an order already shipped or cancelled, a status change we don't allow, or a return of something that hasn't shipped or has already come back. | | `418` | I'm a teapot. | | `422` | An address change on an order would send a line somewhere it can't ship (`shipping_restricted`). The error names the line. | | `500` | Our fault. Safe to retry; creating an order is idempotent. | ```ts type ErrorResponse = { errors: ErrorDetail[] // Quote this when reporting a problem to us; it points at our server logs. request_id: string } type ErrorDetail = { code: ErrorCode message: string // The cart item or line the error is about, when there is one. cart_item_ref?: number partner_line_item_id?: string // Set with quantity_unavailable and return_quantity_exceeded: the most units // we can supply, or that can still be returned. available_quantity?: number } type ErrorCode = | 'invalid_request' | 'unauthorized' | 'not_found' | 'cart_item_unavailable' | 'quantity_unavailable' | 'order_already_shipped' | 'order_already_cancelled' | 'order_edit_not_allowed' | 'status_transition_not_allowed' | 'shipping_restricted' | 'order_not_shipped' | 'return_quantity_exceeded' | 'partner_return_id_in_use' | 'internal_error' ``` ## Products The products we offer you. Most product information rarely changes, but prices, sales, review counts and lead times do, and these endpoints keep your listings current. ### GET /products Every product offered to you, sorted by name, as a `ProductsResponse`. - Sales apply per product, so `promo_discounts` differs from product to product. - Only public sales appear: what any visitor to our site gets. Response: ```ts type ProductsResponse = { products: Product[] } ``` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `200` | | Success. | ```ts type Product = { // Our product id: what the customer buys, and what cart items and order lines name. product_ref: number // Marketing name, e.g. "T.REX Titan Level 2 OWB Holster". name: string tagline: string subtitle: string // Plain-text summary suitable for a listing card. summary: string // Primary image, plus the gallery in display order. Absolute URLs. image_url: string | null white_background_image_url: string | null extra_media: { image_url: string, sort_order: number }[] // Review summary. stars_avg is a 0-5 average with one decimal, null with no reviews. stars_avg: string | null review_count: number // Our own product page, and the page to load in the configurator iframe. product_page_url: string embed_url: string // Base price of the product before any color upcharge or attachment. Every // configuration's real price comes from its CartItem. price: string // Cheapest to dearest over the product's options, color upcharges included: // from the product alone in a standard color up to the product in its // dearest color with the attachments it comes with selected, in theirs. price_range: { min_usd: string, max_usd: string } msrp: string | null // If set, a discounted price below this must not be advertised, though it // may still be charged. Applies to the product's own listing; cart item // prices already respect it. minimum_advertised_price: string | null // The best sale on the product's base price right now, or null. current_discount: PromoDiscount | null // The base price with current_discount applied. Equals price when // current_discount is null. discounted_price: string // Every public sale that targets this product: the current one, and any // scheduled to start later. Sorted by public_access_start_datetime. promo_discounts: PromoDiscount[] // Production lead time, in business days from order to ship. lead_time_class: LeadTimeClass | null } type PromoDiscount = { promo_ref: number promo_discount_ref: number // Public promo name, e.g. "Labor Day Sale". name: string // Exactly one of discount_usd and discount_rate is set. discount_usd: string | null // A fraction, four decimals: "0.2000" is 20% off. discount_rate: string | null // When the sale is (or becomes) public. A sale whose window hasn't opened yet // is scheduled; end_datetime null means open-ended. public_access_start_datetime: string end_datetime: string | null // True while the window is open. active: boolean } type LeadTimeClass = { lead_time_class_ref: number // e.g. "Kydex" name: string min_days: number max_days: number // The line our storefront shows, e.g. "Ships out in 1-5 business days". lead_time_text: string } ``` ### GET /products/{product_ref} One product. Response: `Product` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `200` | | Success. | | `404` | `not_found` | The product isn't a part of the program. | ## The configurator iframe The configurator section of each product page is an iframe loading the product's `embed_url`. It's our own configurator page without the site around it: no header, no cart and no price. It's styled to sit on your page, in a skin we set up to match your site. It offers only what can be ordered right now: a pistol or light fit we don't make yet, or a color that's out of stock or not yet released, isn't shown at all. The iframe talks to our servers directly and reports its state to your page with `window.postMessage()`. In order, as the diagram numbers them: 1. The iframe gets the product from our servers: its fits, availability and prices. 2. Our servers answer with the product. 3. The iframe posts `ready` to your page, with the `product_ref`. 4. The iframe posts `resize` with its height, and again whenever the height changes; size the frame to it. 5. As the customer configures, the iframe posts `selection_changed`: whether the selection is complete, its `state`, its price and lead time, and the most we can supply. Show the price, cap your quantity selector, and keep the `state`. 6. When the customer clicks your "Add to cart", outside the iframe, your page posts `add_to_cart_clicked` with the quantity. 7. The iframe sends our servers the selection, the quantity and a snapshot of the configuration, and we create the cart item. 8. Our servers answer with the cart item. 9. The iframe posts `cart_item_created` to your page with the `cart_item_ref` and the cart item, or `add_to_cart_failed` with a code and a message. 10. Your page sends the `cart_item_ref` to your server with its add-to-cart request. Your server reads the cart item from us ([`GET /cart_items/{cart_item_ref}`](#get-cart_items-cart_item_ref)) and builds its cart line from it, trusting nothing else that came from the browser. The [demo](https://partner.trex-arms.com/demo) hosts the iframe from a site other than ours, the way your product page does, and logs every message in both directions as it happens. It can also simulate our site erroring (500) or being unreachable (522), to show how your page should handle it. Its add to cart is simulated: it shows the cart item you'd get, but stores nothing. The [sample](https://partner.trex-arms.com/sample.html) is a complete product page in plain JavaScript, with no framework, to start from: it frames the configurator, sizes it, drives a buy box, adds to the cart, and takes the frame down when the configurator can't load. It shows its own code under it, so copy it from there. [![The configurator iframe's messages, in order, up to your server reading the cart item](https://partner.trex-arms.com/iframe_flow.svg)](https://partner.trex-arms.com/iframe_flow.svg) ### URL ``` https://www.trex-arms.com/embed/product/{product_ref}?partner=[&state=] ``` - `partner` is your account's `embed_key`; the `embed_url` from [`GET /products`](#get-products) already carries it. It decides which account owns the cart items the embed creates (the sandbox account's embed creates sandbox cart items), and which sites may frame the iframe and receive its messages: only the origins registered for that account. - `state` restores an earlier selection. Pass back, unchanged, the `state` string from the last `selection_changed` message. ### Messages from the iframe to your page Every message is a JSON object with a `type` and `source: "trex"`, one of `IframeToContainerMessage`. If the iframe says `unavailable`, or hasn't said `ready` within about 15 seconds (it may never load at all), take it off the page, disable your "Add to cart", and show a short message instead of a blank box. The demo and the [sample](https://partner.trex-arms.com/sample.html) both do this. ```ts type IframeToContainerMessage = | { // The configurator has loaded and is interactive. type: 'ready' source: 'trex' product_ref: number } | { // The content height changed; resize the iframe to avoid a nested scrollbar. type: 'resize' source: 'trex' height_px: number } | { // Fires on every change to the customer's selection. type: 'selection_changed' source: 'trex' // True once every required choice has been made and the configuration is // orderable; false disables your "Add to cart" button. complete: boolean // Opaque state to keep and pass back in the `state` query parameter, so a // reload of your page keeps the customer's selection. state: string // Price of one unit of the whole selection (holster plus attachments), // computed the same way the cart will compute it. Null until complete. regular_price_usd: string | null unit_price_usd: string | null discount_usd: string | null // A label for the discount, e.g. "Labor Day Sale", or null. promo_explanation: string | null // The selection's lead time: its slowest line's, as on the cart item. lead_time_class: LeadTimeClass | null // How many units of the selection we can supply right now, by the same rule // as the cart item's max_available_quantity. Null until complete. max_available_quantity: number | null } | { // The answer to add_to_cart_clicked: the selection has been registered with // our server as one cart item, the holster and its attachments together. type: 'cart_item_created' source: 'trex' cart_item_ref: number // The same contents your server will read from us, for immediate display // without a second round trip. Not to be trusted server-side. cart_item: CartItem } | { // add_to_cart_clicked failed: the configuration is incomplete, became // unavailable, asks for more than we can supply, or our server errored. type: 'add_to_cart_failed' source: 'trex' code: 'incomplete' | 'unavailable' | 'error' message: string // When we couldn't supply the quantity: the most we can right now. A // selection_changed with it as max_available_quantity follows. available_quantity?: number } | { // The configurator couldn't load: our site errored, or couldn't be reached. type: 'unavailable' source: 'trex' // The HTTP status we answered with, e.g. 500 or 502. status: number } ``` ### Messages from your page to the iframe Post a `ContainerToIframeMessage` with `iframe.contentWindow.postMessage(message, 'https://www.trex-arms.com')`. The quantity is checked against stock the same way a quantity change is ([`PATCH /cart_items/{cart_item_ref}`](#patch-cart_items-cart_item_ref)). Cap your quantity selector at the latest `selection_changed`'s `max_available_quantity` and a refusal will be rare, though stock can still move between the two. If we can't supply the quantity, the iframe answers `add_to_cart_failed` with code `unavailable` and `available_quantity`, the most we can supply right now. It then sends a `selection_changed` whose `max_available_quantity` is that number, so a selector capped from `selection_changed` catches up on its own. When adding fails for any reason, nothing is added: show the message from `add_to_cart_failed` and keep the customer on the page. When it succeeds, send the `cart_item_ref` from `cart_item_created` to your server ([Cart](#cart)). Every add to cart creates a new cart item. Adding the same configuration twice gives two cart items, not one with a larger quantity. ```ts type ContainerToIframeMessage = { // The customer clicked your "Add to cart". The iframe answers with // cart_item_created or add_to_cart_failed. type: 'add_to_cart_clicked' // Units to add, from your page's quantity selector. A positive whole // number; defaults to 1. quantity?: number } ``` ## Cart A **cart item** is one configuration: a holster and the attachments configured with it. To you it's one cart line, with one price, one quantity, one discount and one image. The holster and each attachment are listed inside it (`lines`) so you can show the customer what they're buying, but they have no price or quantity of their own to manage. We keep our own record of every cart item in your carts, quantity included, and its `cart_item_ref` is your handle to it. The iframe hands your page the ref when the customer adds to the cart ([The configurator iframe](#the-configurator-iframe)), and your server reads the cart item from us ([`GET /cart_items/{cart_item_ref}`](#get-cart_items-cart_item_ref)) to build its cart line, trusting nothing else that came from the browser. Quantity lives on our side: when the customer changes it, send the change to us, and apply it in your cart only once we accept it. Removing a line from your cart needs no call to us: a cart item you never order is simply never ordered. Cart items never expire; ordering is what uses them up. A cart item's contents are recomputed on every read, so its price, availability and lead time are current, not what they were when it was created. ```ts type CartItem = Configuration & { cart_item_ref: number // A render of exactly what the customer configured, attachments included, // snapshotted when they added it to the cart. Stable for the cart item's lifetime. image_url: string | null // Re-opens the configurator in this state (embed_url with `state` filled in). embed_url: string // How many units we can supply right now: the scarcest of the stock the // configuration uses. max_available_quantity: number // Per unit, summed over the lines: what the customer pays. Equals // regular_price_usd when no sale applies. unit_price_usd: string // Per unit, summed over the lines. Null when no line is discounted. discount_usd: string | null // quantity * unit_price_usd line_total_usd: string // False when the cart item can't be ordered as it stands; availability_issue // says why, and names the line it's about. available: boolean availability_issue: AvailabilityIssue | null // The slowest line's. lead_time_class: LeadTimeClass | null created_datetime: string } type Configuration = { // The configured product (the holster); attachments are in `lines`. product_ref: number // The product's name, e.g. "T.REX Sidecar IWB Holster". name: string // The holster's choices, in display order: make, family, model, caliber, // weapon light, threaded barrel, dominant hand, barrel length, kydex color, // and whatever else the line offers (belt width, clip material). attribute_picks: AttributePick[] // The holster and each attachment, holster first. For display only. lines: ConfigurationLine[] quantity: number // Per unit, summed over the lines, before any sale. regular_price_usd: string // The labels of the sales that apply, e.g. "Labor Day Sale", joined when // more than one does. Null when none does. promo_explanation: string | null } type ConfigurationLine = { product_ref: number // e.g. "T.REX Sidecar IWB Pistol Mag Attachment". name: string attribute_picks: AttributePick[] } type AttributePick = { // Attribute label, e.g. "Weapon Light", "Kydex Color", "Belt width". name: string // Chosen value, e.g. "Surefire X300U-A", "Wolverine", "1.5\"". value: string sort_order: number } type AvailabilityIssue = { kind: AvailabilityIssueKind // The line the issue is about. product_ref: number // For quantity_exceeded: how many units can be ordered now. Null otherwise. available_quantity: number | null // True when the same product still has orderable alternatives (another // color, another fit), so "reconfigure" is a useful suggestion. other_variations_available: boolean } type AvailabilityIssueKind = // The kydex color is out of stock, or we've temporarily disabled this // pistol or light fit to work on it. Other colors or fits may still be fine. | 'holster_unavailable' // An attachment's stock is gone. | 'oos' // Stock ran short after the quantity was accepted: some is left, but less // than the cart item's quantity. | 'quantity_exceeded' // A product was retired. | 'not_for_sale' // The choices no longer make a configuration we can build. Reconfigure it. | 'incomplete_configuration' type CartProblem = { cart_item_ref: number code: 'not_found' | 'cart_item_unavailable' | 'quantity_exceeded' | 'shipping_restricted' // For the customer, e.g. "T.REX Titan Level 2 OWB Holster can't ship to this address: ..." message: string } ``` ### GET /cart_items/{cart_item_ref} One cart item. Response: `CartItem` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `200` | | Success. | | `404` | `not_found` | No such cart item, it's another account's, or it's been ordered. | ### PATCH /cart_items/{cart_item_ref} Sets a cart item's quantity. We refuse a quantity we can't supply right now, so your cart never holds more than we can fill. A refused change leaves the quantity as it was. Lowering a quantity is always accepted, since it never asks for more than before. Cap your quantity selector at `max_available_quantity` and these refusals will be rare. Stock can still run short after a quantity is accepted, when other orders take it. That shows up as a `quantity_exceeded` availability issue ([`POST /cart/validate`](#post-cart-validate)). Request: ```ts type SetCartItemQuantityRequest = { // A positive whole number. quantity: number } ``` Response: `CartItem` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `200` | | Success. | | `400` | `invalid_request` | `quantity` isn't a positive whole number. | | `404` | `not_found` | No such cart item, it's another account's, or it's been ordered. | | `409` | `cart_item_unavailable` | The cart item is unavailable (`available: false`) for a reason other than its quantity, so its quantity can't go up. | | `409` | `quantity_unavailable` | More than `max_available_quantity`; the error's `available_quantity` is the most we can supply. | ### POST /cart/validate A cart item can become unavailable after it's in a cart. Usually a color has gone out of stock. Rarely, we've found a problem with how a model or light fits a holster and disabled that combination while we fix it. Prices change too, when a sale starts or ends, and nothing locks them. So validate the cart whenever it's viewed, and again with the shipping address immediately before creating the order. Send all of the address or none of it; it's checked by the same rules as an order's. Reads every cart item as [`GET /cart_items/{cart_item_ref}`](#get-cart_items-cart_item_ref) does, and says whether an order of them, to that address, would go through as is. If it wouldn't, `problems` names each cart item that stops it and why, so you can tell the customer while they can still act: - `cart_item_unavailable`: the cart item can't be ordered right now (its `availability_issue` says why). - `quantity_exceeded`: stock ran short; lowering the quantity to the issue's `available_quantity` or below makes it orderable again. - `shipping_restricted`: the cart item can't ship to that address. - `not_found`: no such cart item, or it's already been ordered. We take an order regardless ([`POST /orders`](#post-orders)), so this check is yours. A cart item whose `unit_price_usd` has changed has a new price; show it. The order is charged the price current when it's created, so the last read before you create it is what to charge the customer. Request: ```ts type CartValidationRequest = Partial & { // Up to 100 per request. cart_item_refs: number[] } ``` Response: ```ts type CartValidationResponse = { // True when an order of these cart items, to this address, would go through // as is. Without an address, nothing is checked against one. orderable: boolean // In the order requested. A ref that doesn't exist or belongs to another // account is an error entry rather than failing the whole request. cart_items: (CartItem | { cart_item_ref: number, error: 'not_found' })[] // Empty when orderable. problems: CartProblem[] } ``` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `200` | | Success. | | `400` | `invalid_request` | A malformed body, more than 100 refs, or a partial or malformed address. | ## Orders ### POST /orders **An order always goes through.** We take it at our edge, so it's accepted even if the rest of our site is slow or down: we check only your bearer token (`401`) and the request's shape (`400`), and answer `202` at once. The order is created moments later, and we post an `order_created` event ([Webhooks](#webhooks)); until then [`GET /orders/{partner_order_id}`](#get-orders-partner_order_id) answers `404`. **It's idempotent on `partner_order_id`:** sending the same order twice creates it once. So when you're unsure whether a request got through (a timeout, a `500`), send it again. Nothing about the order is refused after that, since your validation just before creating it ([`POST /cart/validate`](#post-cart-validate)) has already had its say. A cart item that has gone unavailable is still ordered, and made when it can be. If something can't be resolved (a cart item that doesn't exist, belongs to another account or was already ordered, or a line that can't ship to the address), the order is still created, without any line we couldn't build, and put `On Hold`, and we get in touch with you to sort it out. Nothing holds stock while the customer checks out. The request carries no prices, quantities, names, attributes or SKUs. We build the order from our own record of each cart item, at its quantity on our side and our own price at that moment. The order shows the prices we charged, so you can reconcile them against what you collected. The order has no sales tax and no shipping charge. Ordering uses the cart items up: once the order is created they're deleted, and reading one again answers `404`. The order's `line_items` come in the order sent, each with your `partner_line_item_id`. Request: ```ts type CreateOrderRequest = ShippingAddress & Contact & { // Your own order id. Unique across your orders; the idempotency key for this // request. Up to 20 characters. partner_order_id: string // One entry per cart item. line_items: { // Your own id for the order line. Unique within the order; every later // call and every shipment names the line by it. Up to 50 characters. partner_line_item_id: string cart_item_ref: number }[] // A note from the customer to print on the packing slip, if you collect one. note_from_customer?: string | null } ``` Response: ```ts type CreateOrderResponse = { partner_order_id: string // Quote this when reporting a problem to us. request_id: string } ``` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `202` | | Success; the order is created moments later. A repeat with the same `partner_order_id` is answered the same, and changes nothing. | | `400` | `invalid_request` | A malformed body or address, a repeated line id or cart item, or a line item with any field but its id and cart item ref (a quantity included). | ### GET /orders/{partner_order_id} The order as it stands now, looked up by your order id. Response: `Order` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `200` | | Success. | | `404` | `not_found` | No such order of yours, or it hasn't been created yet. | ```ts type Order = ShippingAddress & Contact & { // Our order number, for when you talk to us about the order. The API itself // is keyed on partner_order_id. sales_order_ref: number partner_order_id: string created_datetime: string // "Processing" while we make and pack it; "On Hold" while either side has // paused it; "Completed" once every box has shipped; "Cancelled". sales_order_status: OrderStatus // True once any box has shipped. shipped: boolean // The date we've committed to ship by, from the lead time at order time. promised_ship_by_date: string | null // One per cart item ordered, in the order sent. A removed line leaves the list. line_items: OrderLineItem[] shipments: Shipment[] // The returns you've opened, with their status. returns: Return[] // The sum of the line totals. total_usd: string } type OrderStatus = 'Processing' | 'On Hold' | 'Completed' | 'Cancelled' type OrderLineItem = Configuration & { // Your id for the line, as sent. partner_line_item_id: string // Our line id, for when you talk to us about the line. sales_order_line_item_ref: number // The render from the cart item. image_url: string // Per unit, summed over the lines, as charged. price_usd: string // quantity * price_usd total_usd: string } type Shipment = { shipping_label_ref: number tracking_number: string // Carrier and service codes, e.g. "ups" and "ups_ground". carrier_code: string service_code: string shipped_datetime: string // What's in the box. An order can ship in more than one box. line_items: { partner_line_item_id: string, sales_order_line_item_ref: number, quantity: number }[] // True if the label was cancelled before the box left; a replacement label // arrives as its own Shipment. voided: boolean } // On an order you read back, every field is there, empty or null when the // address has none. type ShippingAddress = { shipping_name: string shipping_company?: string | null shipping_street_1: string shipping_street_2?: string | null shipping_city: string // For a US address, the two-letter USPS code, required. Elsewhere, the // state or province if the country has one. shipping_state?: string | null // For a US address, the ZIP code, required. Elsewhere, the postal code if // the country has one. shipping_postal_code?: string | null // ISO 3166-1 alpha-2, e.g. "US", "CA", "GB". shipping_country: string } type Contact = { // The customer's email address. It's how we tie a warranty or support // contact back to the order. email_address: string // Passed to the carrier for delivery issues only. Never used for marketing. phone_number?: string | null } ``` ### Shipping restrictions Some parts can't ship everywhere: some attachments made by others ship to the US only, for example. [`POST /cart/validate`](#post-cart-validate) checks the cart against the shipping address and names any cart item that can't go there; that's the place to catch it, before the customer pays. An address change on an existing order is checked the same way ([`PATCH /orders/{partner_order_id}`](#patch-orders-partner_order_id)). Our holsters themselves ship anywhere. ### Changing an order When a customer calls to change an order, you can change the shipping address, delete line items, put the order on hold or release it, or cancel it. All of these work only until the first box ships. An order on hold still accepts address changes and line deletions. A line can't be edited. The valid edits to a configured holster are hard to describe and hard to pass between our systems, so the customer deletes the line and orders a new configuration. ### PATCH /orders/{partner_order_id} Changes the order's contact and shipping details (send any of them; the rest are unchanged). A new address is stored as sent and checked against the lines' shipping restrictions. The rules for a US address apply to the address as it will be, so changing only `shipping_country` to `US` is refused unless the state and postal code already on the order are a USPS state and a ZIP. Request: ```ts // Any of the contact and address fields; the ones you leave out are unchanged. type UpdateOrderRequest = Partial ``` Response: `Order` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `200` | | Success. | | `400` | `invalid_request` | A field that can't be changed, a malformed address, or nothing to change. | | `404` | `not_found` | No such order of yours. | | `409` | `order_already_shipped` | A box has shipped. | | `409` | `order_already_cancelled` | The order is cancelled. | | `422` | `shipping_restricted` | A line can't ship to the new address; `partner_line_item_id` names it. | ### DELETE /orders/{partner_order_id}/line_items/{partner_line_item_id} Removes the line: the holster and its attachments together. The line drops out of `line_items`, and the order's `total_usd` is recomputed. Response: `Order` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `200` | | Success. | | `404` | `not_found` | No such order of yours, or no such line on it (a repeat of this request included). | | `409` | `order_already_shipped` | A box has shipped. | | `409` | `order_already_cancelled` | The order is cancelled. | | `409` | `order_edit_not_allowed` | It's the order's last line; cancel the order instead. | ### POST /orders/{partner_order_id}/status Puts the order on hold or releases it. The only allowed changes are `Processing` to `On Hold` and back. Setting the status the order already has does nothing. While an order is `On Hold` we don't pick, make or ship it, and its promised ship date moves out by the time it spent on hold. Use it while the customer decides whether to change or cancel, or while you confirm something with them. Releasing it puts the order back in the production queue. We put orders `On Hold` too, for problems only the customer can resolve, and get in touch with you about them. We never contact the customer, so you do. Once the customer answers, fix the order (change the address, delete a line or cancel) or release it. Request: ```ts type SetOrderStatusRequest = { sales_order_status: 'Processing' | 'On Hold' } ``` Response: `Order` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `200` | | Success, also when the order already had that status. | | `400` | `invalid_request` | `sales_order_status` isn't Processing or On Hold. | | `404` | `not_found` | No such order of yours. | | `409` | `order_already_shipped` | A box has shipped. | | `409` | `order_already_cancelled` | The order is cancelled. | | `409` | `status_transition_not_allowed` | Anything but `Processing` to `On Hold` and back. | ### POST /orders/{partner_order_id}/cancel Any order that hasn't shipped can be cancelled, even if we've started making it ([Process assumptions](#process-assumptions)). Response: `Order` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `200` | | Success, also when the order was already cancelled. | | `404` | `not_found` | No such order of yours. | | `409` | `order_already_shipped` | A box has shipped; the recourse is a return. | | `409` | `order_edit_not_allowed` | The order can't be cancelled right now. | ## Webhooks When something happens to an order, we POST an event to a URL you give us, with a bearer token you give us. Webhooks are optional: an account with no webhook URL is sent nothing. Every event describes state that [`GET /orders/{partner_order_id}`](#get-orders-partner_order_id) also returns, so you can take the webhook, poll, or both, and polling is always enough to catch up on a missed event. ### POST {your_webhook_url} - A JSON body with `Content-Type: application/json` and `Authorization: Bearer `, with the token you give us. - At least once. A `2xx` within 10 seconds acknowledges the event. Anything else, a timeout included, is retried a minute or so later, a limited number of times; if an event never gets through, `GET /orders` catches you up. A retry carries the same `event_id`, so de-duplicate on it. - Events can arrive out of order, an order's included. When two events disagree, `GET /orders` is the truth. - Each account has its own webhook URL, so sandbox events can go to your test system. The sandbox account gets the same events for its orders, the simulated shipment included. - `shipment_shipped` comes once per box, at its first label: an order that ships in two boxes sends two. A label voided and replaced sends no second event; reading the order shows the replacement. Request: ```ts type WebhookEvent = { event_id: string event_datetime: string sales_order_ref: number partner_order_id: string } & ( | { // A box left our warehouse. event_type: 'shipment_shipped' shipment: Shipment // True when this shipment completes the order. order_complete: boolean } | { // The order went On Hold or came off it, on either side, or was cancelled. event_type: 'order_status_changed' sales_order_status: 'Processing' | 'On Hold' | 'Cancelled' } | { // An order you sent has been created, "On Hold" if something needs // sorting out with you. event_type: 'order_created' sales_order_status: 'Processing' | 'On Hold' } ) ``` ## Returns When a customer wants to send something back, open a return (an RMA) for it. It starts `initiated`. When the box reaches us we receive it, recording what actually came back, and it becomes `received`. Accepted returns are credited toward your next invoice ([Process assumptions](#process-assumptions)). ### POST /orders/{partner_order_id}/returns Opens a return. Only what has shipped can come back, and a line can't come back more times than it was ordered, counting every earlier return. Every return you open is also listed on the order, in `returns`. A sandbox return stays `initiated`, since no box ever arrives. Request: ```ts type CreateReturnRequest = { // Your own id for the return. Unique across all your returns, as your order // ids are; the idempotency key for this request. Up to 50 characters. partner_return_id: string // Why it's coming back. return_reason_ref: ReturnReason // Anything the customer said about it. Up to 1000 characters. note?: string | null // What's coming back. A line comes back whole: the holster and its // attachments together. line_items: ReturnLine[] } ``` Response: ```ts type CreateReturnResponse = Return & { // True when partner_return_id was already used on this order, and this is // that earlier return. deduplicated: boolean } ``` Statuses: | Status | Code | Meaning | | ------ | ---- | ------- | | `201` | | Success: a new return. | | `200` | | Success: a repeat on the same order, answered with that earlier return and `deduplicated: true`. | | `400` | `invalid_request` | A malformed body, or a `return_reason_ref` that isn't a `ReturnReason`. | | `404` | `not_found` | No such order of yours, or no such line on it. | | `409` | `order_not_shipped` | Nothing on the order has shipped, or this line hasn't. | | `409` | `return_quantity_exceeded` | More than is left to return; `available_quantity` says how many are. | | `409` | `partner_return_id_in_use` | The id is already a return on another of your orders. | ```ts type Return = { // Our return number, for when you talk to us about it. return_order_ref: number partner_return_id: string // "initiated" until the box reaches us; "received" once we've checked it in. status: 'initiated' | 'received' return_reason_ref: ReturnReason created_datetime: string received_datetime: string | null // What the return holds. Once received, what actually arrived. line_items: ReturnLine[] } type ReturnLine = { partner_line_item_id: string quantity: number } type ReturnReason = | 1 // Ordered wrong item | 2 // Ordered wrong size | 3 // Item not as described | 4 // No longer needed | 5 // Defective product | 6 // Broken / damaged | 7 // Spent too much / buyer's remorse | 8 // Received the same product from someone else | 9 // Didn't like the product | 10 // Other / no explanation | 11 // A family member ordered the wrong item ```