InventoryConnect API

A small REST API for reading your inventory, sales, and customers, and for pushing new customers + leads into your account from your own website forms. Built for store owners who want their custom forms (Shopify, etc.) — directly or through Zapier — to auto-save clients instead of being re-keyed by hand.

Authentication

Every request is authenticated with a store-scoped API key. The store owner creates one in Settings → API keys, picks the scopes it needs, and copies the secret (shown once). Send it as a Bearer token:

Authorization: Bearer twc_live_xxxxxxxxxxxxxxxxxxxxx

Check the key first. Before anything else, call GET /api/v1/auth/check with it. A 200 names the store, the key and its scopes; anything else tells you exactly what arrived:

curl -sS "https://www.inventoryconnect.io/api/v1/auth/check" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxxxxxxx"

OpenAPI specification

A machine-readable OpenAPI 3.0 spec is served at https://www.inventoryconnect.io/api/v1/openapi.json. Import it into Swagger UI, Postman, Insomnia, or a code generator to scaffold a client.

AI agents (MCP)

The same API is available to AI agents as a Model Context Protocol server, so Claude, Muse and any other MCP client can work a store with tools instead of hand-written HTTP calls. One endpoint, the same API key:

https://www.inventoryconnect.io/api/v1/mcp
Authorization: Bearer twc_live_xxxxxxxxxxxxxxxxxxxxx

Conventions

Endpoints

GET/api/v1/auth/checkscope: none (any valid key)

Check a key — make this your first call. A key that works gets 200 with the store it belongs to, its name, its first 12 characters and its scopes. A key that is refused gets a 401 whose hint says exactly what arrived (how many characters, whether it was altered on the way). Reads and changes no store data.

Example
curl -sS "https://www.inventoryconnect.io/api/v1/auth/check" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
// 200 — the key works
{ "ok": true,
  "store": { "id": "ckstore…", "name": "Your Store" },
  "key": { "name": "Website form", "prefix": "twc_live_7k2", "scopes": ["read:inventory", "write:customers"] } }

// 401 — and exactly why
{ "error": "Invalid API key — that isn't a complete key.",
  "hint": "32 characters arrived. A complete key is 33 characters if it mixes capital and small letters, or 41 if it is all lowercase.",
  "detail": { "reason": "incomplete", "length": 32, "expectedLengths": [33, 41], … } }
POST/api/v1/customersscope: write:customers

Create or update a customer (client). Dedupes within your store by email, then phone — found = update only the fields you send; not found = create. Ideal for website-form intake (e.g. via Zapier).

Request body
{
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane@example.com",      // email OR phone required
  "phone": "+1 555 123 4567",
  "company": "Acme",
  "addressLine1": "12 Main St",
  "city": "Austin",
  "state": "TX",
  "postalCode": "78701",
  "country": "US",
  "type": "RETAIL",                  // RETAIL | WHOLESALE (default RETAIL)
  "note": "Submitted the contact form",
  "customFields": { "shirt_size": "L" }  // keys = your custom-field slugs
}
Example
curl -X POST https://www.inventoryconnect.io/api/v1/customers \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com", "firstName": "Jane" }'
Response
{ "customerId": "ckxyz…", "isNew": true }
POST/api/v1/leadsscope: write:leads

Create a lead: upserts the customer AND drops a card into a pipeline. Use this (instead of /customers) when a form should become a deal to work (e.g. sell/trade or inquiry forms).

Request body
{
  "email": "jane@example.com",       // email OR phone required
  "phone": "+1 555 123 4567",
  "firstName": "Jane",
  "lastName": "Doe",
  "pipelineSlug": "retail-buyers",   // required
  "stageSlug": "new-lead",           // optional (defaults to first stage)
  "title": "Submariner inquiry",
  "brand": "Rolex",
  "model": "Submariner",
  "estimatedValue": 12500,
  "source": "website_form",
  "notes": "Saw the listing, wants matte black"
}
Example
curl -X POST https://www.inventoryconnect.io/api/v1/leads \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com", "pipelineSlug": "retail-buyers" }'
Response
{ "opportunityId": "ckabc…", "customerId": "ckxyz…", "customer": { "isNew": true } }
POST/api/v1/inventory/{type}scope: write:inventory

Create an inventory item. Every product type is supported: watch, jewelry, handbag, sneaker, apparel, accessory, preciousMetal, watchPart, art, vehicle. The store is taken from your key; SKU is auto-generated when omitted (a SKU you send must be unique in your store — a repeat is a 409). For watches, manufacturer + reference number are required; other types accept a useful field subset (brand/model/color/condition/status/prices + type specifics). Every type takes customFields, keyed by the field keys under Settings → Store setup → Product fields, and the intake keys: vendorCustomerId or vendorName, locationId, isConsignment + consignorId, acquisitionChannel, acquisitionPlatform (+ acquisitionPlatformOther for OTHER / FACEBOOK_GROUP), isTradeIn + tradeInValue, dateOfIntake, and intakeFees (itemised intake costs; not on sneakers or watch parts). A value that doesn't fit, or a contradiction (a consignor on a piece that isn't consigned), is a 400 naming the key, and nothing is saved. On a watch, keys the API doesn't save (an unknown key, stockPhotoUrl, includeOnBezel, includeOnFacebookMarketplace) don't fail the create: they're listed back in ignoredFields (plus a Warning header) and everything else is saved. The other types, and every PATCH, refuse an unknown key with a 400.

Request body
{
  "manufacturer": "Rolex",            // required
  "referenceNo": "124060",            // required
  "model": "Submariner",
  "year": 2022,
  "serial": "9F8…",
  "condition": "PRE_OWNED",           // NEW | NEW_OLD_STOCK | PRE_OWNED
  "status": "AVAILABLE",
  "purchasePrice": 9500,
  "wholesalePrice": 11800,
  "retailListPrice": 13500,
  "hasBox": true,
  "hasPapers": true,
  "retailDescription": "Full set, 2022…",
  "vendorName": "Acme Watches",       // or vendorCustomerId (your store's customer)
  "locationId": "ckloc123",           // from GET /api/v1/locations
  "acquisitionChannel": "WHOLESALE",
  "acquisitionPlatform": "CHRONO24",  // optional
  "dateOfIntake": "2026-09-20",
  "intakeFees": [                     // sets repairCost to their sum
    { "category": "SHIPPING", "label": "Inbound FedEx", "amount": 42.5 }
  ],
  "customFields": {                    // keys = your product-field keys
    "invoice_number": "INV-1042",
    "invoice_date": "2026-09-20"
  }
}
Example
curl -X POST https://www.inventoryconnect.io/api/v1/inventory/watch \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "manufacturer": "Rolex", "referenceNo": "124060", "retailListPrice": 13500 }'
Response
{ "id": "ckwatch123", "type": "watch", "sku": "TWC-0001" }   // + "ignoredFields": [...] when a key wasn't saved
// A watch whose serial is already on another watch in your store is still created, with:
// "warnings": [{ "code": "serial_previously_sold", "watchId": "ckold456", "sku": "TWC-0874",
//   "status": "SOLD", "soldAt": "2026-04-02", "buybackPath": "/api/v1/inventory/watch/ckold456/buyback", … }]
// ("serial_in_inventory" when that watch is still in stock; the buyer only for a key with read:customers)
POST/api/v1/inventory/watch/{id}/buybackscope: write:inventory

Buy a sold watch back from the client — exactly what Buy this back does on a sold watch in the app. {id} is the SOLD watch. A new watch is created with its own SKU, carrying the sold one's serial, identity, specs, descriptions, custom fields and asking prices; the two are linked both ways; the acquisition channel is Client buyback; the vendor is the client on the original sale unless you send vendorCustomerId (null for no contact). The sold watch and its sale are never changed. Repair costs, intake fees, the location, the condition notes and (unless copyPhotos is true) the photos don't carry. A New purchase inspection started on the new watch opens with the sold watch's warranty card dates. 404 for a watch that isn't in your store, 409 for one that isn't sold; any key besides the ones below is a 400. Store keys only.

Request body
{
  "purchasePrice": 9200,             // required: what you paid to buy it back
  "purchaseDate": "2026-10-28",      // optional; defaults to today in your store's timezone
  "status": "AVAILABLE",             // AVAILABLE (default) | INCOMING
  "vendorCustomerId": "ckcust456",   // optional; omit for the client on the original sale, null for none
  "purchasedFrom": "Walk-in",        // optional free text
  "retailListPrice": 14500,          // optional new asking price; omitted, the old one carries
  "internalNote": "Came back with the original box",
  "copyPhotos": false
}
Example
curl -X POST https://www.inventoryconnect.io/api/v1/inventory/watch/ckwatch123/buyback \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "purchasePrice": 9200 }'
Response
{ "id": "cknew789", "type": "watch", "sku": "TWC-1201", "buybackOf": { "id": "ckwatch123", "sku": "TWC-0874" }, "vendorCustomerId": "ckcust456" }
PATCH/api/v1/inventory/{type}/{id}scope: write:inventory

Update an inventory item — only the fields you send change. Works for every product type. customFields merges into the saved answers: keys you leave out are untouched, null clears one. The intake keys work here too; intakeFees replaces the item's cost lines ([] clears them). 404 if the item isn't in your store.

Request body
{
  "status": "HOLD",
  "retailListPrice": 12900,
  "customFields": { "invoice_number": "INV-1042", "wire_sent": null }
}
Example
curl -X PATCH https://www.inventoryconnect.io/api/v1/inventory/watch/ckwatch123 \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "status": "HOLD" }'
Response
{ "id": "ckwatch123", "type": "watch", "sku": "TWC-0001" }
POST/api/v1/invoicesscope: write:invoices

Create a DRAFT invoice. taxRate is a fraction (0.0825 = 8.25%), at most 0.5 (50%). A customerId, if given, must belong to your store. The response item (and GET) include a payUrl — the hosted link you can send the customer.

Request body
{
  "customerId": "ckcust123",         // optional; must be your store's
  "taxRate": 0.0825,                 // fraction (8.25%), max 0.5
  "dueDate": "2026-07-15",
  "notes": "Thanks for your business",
  "lineItems": [
    { "description": "Rolex Submariner 124060", "quantity": 1, "unitPrice": 13500 }
  ]
}
Example
curl -X POST https://www.inventoryconnect.io/api/v1/invoices \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "lineItems": [{ "description": "Repair", "quantity": 1, "unitPrice": 250 }] }'
Response
{ "id": "ckinv123", "invoiceNumber": "INV-2026-0007", "status": "DRAFT" }
GET/api/v1/invoicesscope: read:invoices

List invoices (filter with ?status=DRAFT|SENT|PAID|VOID). Each item includes its payUrl.

Example
curl "https://www.inventoryconnect.io/api/v1/invoices?status=SENT" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 12, "items": [ { "id": "…", "invoiceNumber": "…", "total": 13500, "payUrl": "…", … } ] }
POST/api/v1/invoices/{id}/statusscope: write:invoices

Set an invoice's status: DRAFT, SENT, PAID, or VOID. Marking PAID fires the invoice.paid webhook.

Request body
{ "status": "PAID" }
Example
curl -X POST https://www.inventoryconnect.io/api/v1/invoices/ckinv123/status \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "status": "SENT" }'
Response
{ "id": "ckinv123", "status": "SENT" }
POST/api/v1/invoices/{id}/sendscope: write:invoices

Ask the store to send the invoice. Nothing is sent from the API: the store's owners and admins get a notification in the app linking to the invoice, and it goes to the customer only when one of them checks it and presses Email invoice or Text invoice. Answers 202 with status "awaiting_approval" and the invoice page's address. channel "email" (default) or "sms" says how you'd like it sent. Refused (and nobody notified) for a voided invoice, a customer with no email or textable phone on file, a sale a dealer collected (409 dealer_collected), or a customer whose email or phone was entered or changed through the API (409 contact_from_api) — someone has to check that customer in the app.

Request body
{ "channel": "sms" }   // optional; "email" (default) or "sms"
Example
curl -X POST https://www.inventoryconnect.io/api/v1/invoices/ckinv123/send \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{
  "id": "ckinv123",
  "status": "awaiting_approval",
  "message": "Nothing was sent. Invoice INV-2026-00091 is waiting for a person: …",
  "invoiceUrl": "https://www.inventoryconnect.io/invoices/ckinv123"
}
GET/api/v1/imports/fieldsscope: read:imports

The field names a bulk import's rows may use, which of them a new row needs, and how existing records are matched. kind is inventory, customers or sales; type is the product type (default watch).

Example
curl "https://www.inventoryconnect.io/api/v1/imports/fields?kind=inventory&type=watch" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "kind": "inventory", "type": "watch", "fields": ["sku", "manufacturer", "..."], "required": ["manufacturer", "referenceNo"], "rowsPerCall": 500 }
POST/api/v1/imports/inventoryscope: write:imports

Bulk import: add or update pieces of one product type from up to 500 rows. A row whose sku matches a piece you already have updates it; any other row adds a new piece. Contacts go to /api/v1/imports/customers and past sales to /api/v1/imports/sales the same way. Every import is double-checked afterwards (Import reviews in the app) and can be undone with POST /api/v1/imports/{batchId}/undo. No webhooks are sent for imported rows.

Request body
{
  "type": "watch",
  "rows": [
    { "Stock #": "W-1001", "Brand": "Rolex", "Ref": "126610LN", "Cost": 11200 }
  ],
  "mapping": {                       // optional: your column names -> fields
    "Stock #": "sku", "Brand": "manufacturer", "Ref": "referenceNo", "Cost": "purchasePrice"
  }
}
Example
curl -X POST https://www.inventoryconnect.io/api/v1/imports/inventory \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d @rows.json
Response
{ "batchId": "impapi_…", "total": 1, "created": 1, "updated": 0, "errors": [], "review": { "path": "/import/review/impapi_…", "kind": "full" } }
POST/api/v1/salesscope: write:sales

Record a sale of any product type. The item must be in your store and not already sold (watch parts and accessories must have stock). Optionally creates a draft invoice. Watch parts and accessories sell by quantity — pass quantity (default 1); the sale draws down stock and the row goes SOLD only at zero. Watch/handbag/part sales are single-store and no-split; platform/pending sales aren't supported via the API. A productId that isn't a piece in your store answers 404; a customerId that isn't your store's, or a soldById that isn't an active member of your team, answers 400.

Request body
{
  "kind": "watch",                   // jewelry|sneaker|apparel|accessory|preciousMetal|watch|handbag|watchPart
  "productId": "ckwatch123",
  "salePrice": 4200,                 // total for all units
  "paymentMethod": "CREDIT_CARD",    // WIRE|ZELLE|CREDIT_CARD|CASH|CHECK|ACH|… (full list in the OpenAPI spec)
  "quantity": 1,                     // watchPart + accessory only (sell by quantity); default 1
  "customerId": "ckcust123",         // optional; must be your store's
  "soldById": "ckuser123",           // optional; an active teammate — omit to credit the store's owner
  "createInvoice": true,             // optional; makes a DRAFT invoice
  "applyTax": true,
  "saleDate": "2026-06-24",          // optional; defaults to now
  "notes": "Paid in full"
}
Example
curl -X POST https://www.inventoryconnect.io/api/v1/sales \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "jewelry", "productId": "ckjwl123", "salePrice": 4200, "paymentMethod": "WIRE" }'
Response
{ "saleId": "cksale123" }
POST/api/v1/inventory/{type}/{id}/photos/presignscope: write:inventory

Step 1 of photo upload: get a presigned URL (valid 15 minutes). Then PUT your image bytes to uploadUrl with a Content-Type header set to exactly the returned contentType — it is part of the signature, so a PUT with no Content-Type, or a different one, gets a 403 — and no Authorization header. Then commit the key in step 2, which is where you choose the gallery. Works for every product type; {id} is the item's id — to start from a stock number, look it up with GET /api/v1/inventory?q=<stock number> (the worked example under step 2 does).

Request body
{ "contentType": "image/jpeg", "filename": "front.jpg" }
Example
curl -X POST https://www.inventoryconnect.io/api/v1/inventory/jewelry/ckjwl123/photos/presign \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "contentType": "image/jpeg", "filename": "front.jpg" }'

# Then PUT the bytes to the returned uploadUrl. The Content-Type header must be
# EXACTLY the returned contentType ("image/jpeg" here) — missing or different = 403.
curl -X PUT "<uploadUrl from the response>" \
  -H "Content-Type: image/jpeg" \
  --data-binary @front.jpg
Response
{ "uploadUrl": "https://…", "key": "jewelry/ckjwl123/1727280000000-a1b2c3d4.jpg", "publicUrl": "https://…", "contentType": "image/jpeg" }
POST/api/v1/inventory/{type}/{id}/photosscope: write:inventory

Step 2: after PUTting the bytes to uploadUrl, commit the key(s) from step 1 to attach the photo(s) to the item (up to 50 per call). Each photo can name its gallery: "gallery": "retail" (Retail photos) or "gallery": "in_hand" (In-hand photos). Leave it out and the photo goes to Retail photos, as every upload did before the field existed. Any other value is a 400 that names the photo, and nothing in that call is saved. Vehicles have Retail photos only, so "in_hand" on a vehicle is a 400 as well. Photos are saved in the order you list them, after the photos already in that gallery (each gallery keeps its own order). Commit one call at a time and the order is exactly the order you sent. The piece's History shows them as added by your API key, by its name ("10 photos added by API key Photo upload"), marked via API. Adding photos changes nothing else on the piece, exactly as when you add them by hand in the app: no channel switch is touched, nothing is pushed to eBay or a website shop (a listing already there keeps its photos until the piece's next save or push), and no webhook fires. The photos are then simply on the piece, and they show wherever that gallery is already shown for it. A photo is also one of the things a piece needs before it is listed at all, so a piece that was otherwise ready can start to appear once it has one: on a storefront or channel feed it is switched on for, and on the Dealer Marketplace, which has no switch on the piece and lists every Available piece that has a wholesale price and a photo when the store shares its inventory. Only a key returned by this item's presign is accepted; url is optional and, if sent, must be that key's publicUrl. Size and type are checked from the stored file (JPEG, PNG, WebP or GIF, up to 25 MB); a key that's already attached returns 409. The example below is the whole job: add 10 in-hand photos to stock number 260913.

Request body
{
  "photos": [
    { "key": "watches/ckwatch123/1727280000000-a1b2c3d4.jpg", "gallery": "in_hand" },
    { "key": "watches/ckwatch123/1727280000100-e5f6g7h8.jpg", "gallery": "in_hand" },
    { "key": "watches/ckwatch123/1727280000200-i9j0k1l2.jpg" }   // no gallery = "retail"
  ]
}
Example
BASE="https://www.inventoryconnect.io"            # your InventoryConnect domain
KEY="twc_live_xxxxxxxxxxxxxxxx"            # a key with read:inventory and write:inventory

# 1. Stock number -> id. q is a "contains" search across several fields, so keep
#    the piece whose sku IS the stock number. (Another product type: add &type=handbag
#    here and use that type in the paths below.)
ID=$(curl -s "$BASE/api/v1/inventory?q=260913" -H "Authorization: Bearer $KEY" \
  | jq -r '.items[] | select(.sku == "260913") | .id')

# 2. Each file, in the order you want them shown: ask for an upload URL, then PUT
#    the bytes to it. The PUT carries exactly the contentType that came back and
#    no Authorization header.
PHOTOS='[]'
for FILE in 01.jpg 02.jpg 03.jpg 04.jpg 05.jpg 06.jpg 07.jpg 08.jpg 09.jpg 10.jpg; do
  P=$(curl -s -X POST "$BASE/api/v1/inventory/watch/$ID/photos/presign" \
    -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
    -d "{ \"contentType\": \"image/jpeg\", \"filename\": \"$FILE\" }")
  curl -s -X PUT "$(echo "$P" | jq -r .uploadUrl)" \
    -H "Content-Type: $(echo "$P" | jq -r .contentType)" --data-binary "@$FILE"
  PHOTOS=$(echo "$PHOTOS" | jq --arg key "$(echo "$P" | jq -r .key)" '. + [{ key: $key, gallery: "in_hand" }]')
done

# 3. Commit all ten in ONE call. They keep this order, after any in-hand photos
#    already on the piece.
curl -s -X POST "$BASE/api/v1/inventory/watch/$ID/photos" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "{ \"photos\": $PHOTOS }"
# -> { "added": 10, "galleries": { "in_hand": 10 } }
Response
{ "added": 10, "galleries": { "in_hand": 10 } }   // galleries: how many went where
POST/api/v1/inventory/watch/{id}/timegrapher-photo/presignscope: write:inventory

Step 1 of filing the photo on a watch's Timegrapher card: get a presigned URL (valid 15 minutes). Then PUT your image bytes to uploadUrl with a Content-Type header set to exactly the returned contentType — it is part of the signature, so a PUT with no Content-Type, or a different one, gets a 403 — and no Authorization header. Then commit the key in step 2. Watches only (any other product type is a 400). JPEG, PNG, WebP or GIF (never SVG or HEIC), up to 25 MB — the stored file is checked on commit, and anything else is discarded and refused. Only a key this watch's timegrapher-photo/presign returned is accepted; a key from the gallery photo presign, or from another watch, is refused.

Request body
{ "contentType": "image/jpeg", "filename": "timegrapher.jpg" }
Example
curl -X POST https://www.inventoryconnect.io/api/v1/inventory/watch/ckwatch123/timegrapher-photo/presign \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "contentType": "image/jpeg", "filename": "timegrapher.jpg" }'

# Then PUT the bytes to the returned uploadUrl. The Content-Type header must be
# EXACTLY the returned contentType ("image/jpeg" here) — missing or different = 403.
curl -X PUT "<uploadUrl from the response>" \
  -H "Content-Type: image/jpeg" \
  --data-binary @timegrapher.jpg
Response
{ "uploadUrl": "https://…", "key": "watches/ckwatch123/timegrapher-1727280000000-a1b2c3d4.jpg", "publicUrl": "https://…", "contentType": "image/jpeg" }
POST/api/v1/inventory/watch/{id}/timegrapher-photoscope: write:inventory

Step 2: after PUTting the bytes to uploadUrl, commit the key from step 1 as the photo on the watch's Timegrapher card. A watch's Timegrapher card holds one photo. Commit onto a card that already has one and the answer is a 409: nothing is changed, and your upload is kept so the same key can be sent again. Send "replace": true to put the new photo in its place — the old photo is then deleted for good, so ask the person first. The watch's History shows the Timegrapher photo as changed by your API key, by its name, marked via API. It is the photo on the watch's Timegrapher card in the app, not one of the piece's gallery photos: it is not shown on a storefront, a channel feed or the Dealer Marketplace, no channel switch is touched and no webhook fires. An inspection form line that waits for a Timegrapher photo ticks itself once the photo is on the card. The example below is the whole job: file the Timegrapher card photo on stock number 260913.

Request body
{
  "key": "watches/ckwatch123/timegrapher-1727280000000-a1b2c3d4.jpg",
  "replace": true   // only to replace a photo already on the card; the old one is deleted
}
Example
BASE="https://www.inventoryconnect.io"            # your InventoryConnect domain
KEY="twc_live_xxxxxxxxxxxxxxxx"            # a key with read:inventory and write:inventory

# 1. Stock number -> id. q is a "contains" search across several fields, so keep
#    the watch whose sku IS the stock number.
ID=$(curl -s "$BASE/api/v1/inventory?q=260913" -H "Authorization: Bearer $KEY" \
  | jq -r '.items[] | select(.sku == "260913") | .id')

# 2. Ask for an upload URL, then PUT the bytes to it. The PUT carries exactly the
#    contentType that came back and no Authorization header.
P=$(curl -s -X POST "$BASE/api/v1/inventory/watch/$ID/timegrapher-photo/presign" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "contentType": "image/jpeg", "filename": "timegrapher.jpg" }')
curl -s -X PUT "$(echo "$P" | jq -r .uploadUrl)" \
  -H "Content-Type: $(echo "$P" | jq -r .contentType)" --data-binary "@timegrapher.jpg"

# 3. Commit the key. A 409 means the card already has a photo and nothing was
#    changed: send the same key again with "replace": true only when the old
#    photo should be deleted.
curl -s -X POST "$BASE/api/v1/inventory/watch/$ID/timegrapher-photo" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "{ \"key\": \"$(echo "$P" | jq -r .key)\" }"
# -> { "timegrapherPhotoUrl": "https://…", "replaced": false }
Response
{ "timegrapherPhotoUrl": "https://…", "replaced": false }   // replaced: true when a photo was already there
POST/api/v1/inventory/{type}/{id}/documents/presignscope: write:inventory

Step 1 of attaching a document (a vendor's purchase invoice, a certificate, a service record) to an item's Documents: get an upload URL (valid 15 minutes). Then PUT the file to uploadUrl with a Content-Type header set to exactly the returned contentType — it is part of the signature, so a PUT with no Content-Type, or a different one, gets a 403 — and no Authorization header. PDF, photo, Word or Excel; up to 15 MB each, 20 per item. {id} is the item's id — to start from a stock number, look it up with GET /api/v1/inventory?q=<stock number> and use the id of the item whose sku matches.

Request body
{ "contentType": "application/pdf", "filename": "INV-1042.pdf" }
Example
curl -X POST https://www.inventoryconnect.io/api/v1/inventory/watch/ckwatch123/documents/presign \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "contentType": "application/pdf", "filename": "INV-1042.pdf" }'

# Then PUT the file to the returned uploadUrl. The Content-Type header must be
# EXACTLY the returned contentType ("application/pdf" here) — missing or different = 403.
curl -X PUT "<uploadUrl from the response>" \
  -H "Content-Type: application/pdf" \
  --data-binary @INV-1042.pdf
Response
{ "uploadUrl": "https://…", "key": "item-documents/watches/ckwatch123/api-…-INV-1042.pdf", "expiresInSeconds": 900, "contentType": "application/pdf" }
POST/api/v1/inventory/{type}/{id}/documentsscope: write:inventory

Step 2: commit the uploaded key(s) (up to 8 per call). Size and type are checked from the stored file. kind is optional — one of the app's document types, e.g. "Purchase invoice". Documents stay private to your store and show on the item's Documents card, credited to your API key.

Request body
{ "documents": [ { "key": "item-documents/watches/ckwatch123/api-…-INV-1042.pdf", "kind": "Purchase invoice", "label": "INV-1042" } ] }
Example
curl -X POST https://www.inventoryconnect.io/api/v1/inventory/watch/ckwatch123/documents \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "documents": [{ "key": "item-documents/watches/ckwatch123/api-…-INV-1042.pdf", "kind": "Purchase invoice" }] }'
Response
{ "added": [ { "id": "ckdoc…", "filename": "INV-1042.pdf", "kind": "Purchase invoice" } ] }
GET/api/v1/locationsscope: read:inventory

Your store's active locations — the ids locationId takes on inventory create/update. Restricted safes aren't listed and can't be used through the API.

Example
curl https://www.inventoryconnect.io/api/v1/locations \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 2, "items": [ { "id": "ckloc…", "name": "Tray 2", "type": "TRAY", "parentId": "ckloc…", "path": "Safe A / Tray 2" } ] }
GET/api/v1/customersscope: read:customers

List the customers in your store.

Example
curl https://www.inventoryconnect.io/api/v1/customers \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 128, "items": [ { "id": "…", "email": "…", "firstName": "…", … } ] }
GET/api/v1/customers/{id}scope: read:customers

Fetch one customer by id (scoped to your store; 404 otherwise).

Example
curl https://www.inventoryconnect.io/api/v1/customers/ckcust123 \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "id": "ckcust123", "type": "WHOLESALE", "firstName": "…", "email": "…", … }
PATCH/api/v1/customers/{id}scope: write:customers

Update a customer by id — only the fields you send change. The id is resolved within your store; a foreign id returns 404. Fires customer.updated.

Request body
{ "phone": "+1 555 0100", "company": "Acme Watches" }
Example
curl -X PATCH https://www.inventoryconnect.io/api/v1/customers/ckcust123 \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+1 555 0100" }'
Response
{ "id": "ckcust123", "phone": "+1 555 0100", … }
GET/api/v1/customers/{id}/activitiesscope: read:crm

The customer's unified activity timeline — sales, invoices, messages, calls, tasks, appointments, email-broadcast engagement, deal-stage changes, notes, and field changes — newest first. Optional ?categories= (comma-separated: sales, messages, calls, tasks, appointments, deals, marketing, changes) and ?limit=. Only shared inbox messages are exposed, and email synced from a connected Gmail or Outlook mailbox appears only for a key that also holds read:email. A person on an event (a note's author, who made a change, who sent a form) is named only when they are on your team; anyone else reads "Platform support".

Example
curl "https://www.inventoryconnect.io/api/v1/customers/ckcust123/activities?categories=messages,calls" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 42, "limit": 60, "items": [ { "kind": "messages_day", "at": "…", "channel": "SMS", "inboundCount": 3, "outboundCount": 1, "preview": "…" }, { "kind": "call", "at": "…", "direction": "inbound", "answered": true, "durationSec": 92 }, … ] }
GET/api/v1/customers/{id}/messagesscope: read:conversations

Message history with the customer across SMS + email + social, newest first. Optional ?channel= (SMS, EMAIL, FB_MESSENGER, IG_DM, WHATSAPP), ?limit=, ?offset=. Only shared messages are returned — personal-mailbox mail is never exposed via the API. Email synced from a connected Gmail or Outlook mailbox is returned only when the key ALSO holds read:email ("Read synced email (Gmail and Outlook)"), a permission an admin ticks on its own; without it those messages are left out of items and total and the response carries omitted: { syncedEmail: true, needsScope: "read:email" }. Email at the store's built-in address needs read:conversations only.

Example
curl "https://www.inventoryconnect.io/api/v1/customers/ckcust123/messages?channel=SMS" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 128, "limit": 100, "offset": 0, "items": [ { "id": "…", "channel": "SMS", "direction": "inbound", "body": "Is the Daytona still available?", "at": "…" }, … ] }
GET/api/v1/customers/{id}/callsscope: read:conversations

Call history with the customer (direction, duration, answered, recording, transcription), newest first. Supports ?limit=, ?offset=.

Example
curl "https://www.inventoryconnect.io/api/v1/customers/ckcust123/calls" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 6, "limit": 100, "offset": 0, "items": [ { "id": "…", "direction": "outbound", "answered": true, "durationSec": 210, "transcription": "…", "at": "…" } ] }
GET/api/v1/customers/{id}/notesscope: read:crm

The customer's CRM notes, newest first. Supports ?limit=, ?offset=. author is a member of your team by name; a note written by anyone not currently on your team (InventoryConnect support staff helping your store, or someone since removed from it) reads "Platform support", and a note your key or an import added has none.

Example
curl "https://www.inventoryconnect.io/api/v1/customers/ckcust123/notes" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 4, "limit": 100, "offset": 0, "items": [ { "id": "…", "body": "Prefers to be called after 5pm", "author": "Justin Brown", "createdAt": "…" } ] }
GET/api/v1/customers/{id}/addressesscope: read:customers

The customer's saved shipping addresses, default first. Each has a label, a type (RESIDENTIAL, BUSINESS or FEDEX_HOLD_AT_LOCATION) and isDefault. Customer reads include the same list as `addresses`; the customer's shipping* fields are the default one.

Example
curl https://www.inventoryconnect.io/api/v1/customers/ckcust123/addresses \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 2, "items": [ { "id": "…", "label": "Office", "type": "BUSINESS", "isDefault": true, "addressLine1": "…", "city": "…", … } ] }
POST/api/v1/customers/{id}/addressesscope: write:customers

Add a saved shipping address. addressLine1 is required; label, type, shipToName, addressLine2, city, state, postalCode, country, phone, fedexLocationId and makeDefault are optional. A customer's first address becomes the default.

Request body
{ "label": "Office", "type": "BUSINESS", "addressLine1": "310 Market Street", "city": "Springfield", "state": "IL", "postalCode": "62701" }
Example
curl -X POST https://www.inventoryconnect.io/api/v1/customers/ckcust123/addresses \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "label": "Office", "addressLine1": "310 Market Street" }'
Response
{ "address": { "id": "…", "label": "Office", "isDefault": false, … } }
PATCH/api/v1/customers/{id}/addresses/{addressId}scope: write:customers

Edit a saved address — only the fields you send change — and/or send makeDefault: true to make it the default.

Request body
{ "makeDefault": true }
Example
curl -X PATCH https://www.inventoryconnect.io/api/v1/customers/ckcust123/addresses/ckaddr1 \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "makeDefault": true }'
Response
{ "address": { "id": "ckaddr1", "isDefault": true, … } }
DELETE/api/v1/customers/{id}/addresses/{addressId}scope: write:customers

Archive a saved address. It leaves the pickers; invoices and shipments that already used it keep showing it. Archiving the default promotes another.

Example
curl -X DELETE https://www.inventoryconnect.io/api/v1/customers/ckcust123/addresses/ckaddr1 \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "archived": true, "newDefaultId": "…" }
GET/api/v1/tasksscope: read:crm

List tasks in your store, newest first. Filters: ?status=OPEN|DONE|CANCELLED, ?customerId=. assignedToId and assignedTo name a member of your team; a task assigned to anyone not currently on your team (InventoryConnect support staff helping your store, or someone since removed from it) reads assignedToId null and assignedTo "Platform support".

Example
curl "https://www.inventoryconnect.io/api/v1/tasks?status=OPEN" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 12, "limit": 100, "offset": 0, "items": [ { "id": "…", "title": "Follow up on Daytona", "status": "OPEN", "dueAt": "…", "customerId": "…" } ] }
GET/api/v1/appointmentsscope: read:crm

List appointments. Filters: ?status=, ?customerId=, ?from=/?to= (ISO bounds on the scheduled time).

Example
curl "https://www.inventoryconnect.io/api/v1/appointments?from=2026-07-01" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 3, "limit": 100, "offset": 0, "items": [ { "id": "…", "type": "In-store viewing", "scheduledAt": "…", "status": "BOOKED", "customerId": "…" } ] }
GET/api/v1/opportunitiesscope: read:crm

List deals. Filters: ?status=OPEN|WON|LOST, ?pipelineId=, ?customerId=.

Example
curl "https://www.inventoryconnect.io/api/v1/opportunities?status=OPEN" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 8, "limit": 100, "offset": 0, "items": [ { "id": "…", "title": "Trade + upgrade", "status": "OPEN", "pipeline": "Sales", "stage": "Negotiating", "estimatedValue": 22000, "customerId": "…" } ] }
GET/api/v1/teamscope: read:crm

The active members of your store's team — the user ids assignedToId (tasks) and soldById (sales) accept. Id and name only.

Example
curl https://www.inventoryconnect.io/api/v1/team \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 3, "items": [ { "id": "ckuser123", "name": "Dana Reyes" }, … ] }
GET/api/v1/pipelinesscope: read:crm

Your pipelines with their stages — the pipelineSlug and stageSlug POST /api/v1/leads takes, and the pipelineId that filters /opportunities. Archived pipelines and hidden stages are left out. When a shared board and a teammate's personal board carry the same slug, a lead goes to the shared one (receivesLeadsForSlug marks it).

Example
curl https://www.inventoryconnect.io/api/v1/pipelines \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 2, "items": [ { "id": "ckpipe1", "name": "Retail buyers", "slug": "retail-buyers", "shared": true, "receivesLeadsForSlug": true, "stages": [ { "id": "ckst1", "name": "New lead", "slug": "new-lead" }, … ] }, … ] }
GET/api/v1/inventoryscope: read:inventory

List inventory in your store. Defaults to watches; add ?type= to list any product type: watch, handbag, sneaker, apparel, accessory, jewelry, preciousMetal, watchPart. Supports ?status=, ?q=, ?limit=, ?offset=. Watches also take ?manufacturer= and ?model=, which match any watch whose brand or model CONTAINS the value, ignoring case and accents (?manufacturer=audemars finds Audemars Piguet; ?manufacturer=Glashutte finds Glashütte Original), plus ?condition=, ?sort= and ?dir=. To find a piece by its stock number, send ?q=<stock number> and take the item whose sku is that number (q also matches brand, model, reference and serial, so it can return more than one) — its id is what the item, photo and document endpoints take.

Example
curl "https://www.inventoryconnect.io/api/v1/inventory?type=jewelry" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 18, "limit": 100, "offset": 0, "items": [ { "id": "…", "type": "jewelry", "sku": "…", "brand": "…", "model": "…", "retailListPrice": 4200, … } ] }
GET/api/v1/inventory/{type}/{id}scope: read:inventory

Get a single inventory item of any type (watch, handbag, sneaker, apparel, accessory, jewelry, preciousMetal, watchPart, art, vehicle). Includes customFields keyed by your product-field keys — the same shape create and update accept.

Example
curl https://www.inventoryconnect.io/api/v1/inventory/jewelry/ckjwl123 \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "id": "ckjwl123", "type": "jewelry", "sku": "…", "brand": "…", …, "customFields": { "invoice_number": "INV-1042" } }
GET/api/v1/inventory/fieldsscope: read:inventory

The exact fields a create and an update accept for one product type (?type=watch, handbag, vehicle, …) — the ten types share almost no field names. Generated from the validation the writes run, so it is always current. A key with write:inventory may call it too.

Example
curl "https://www.inventoryconnect.io/api/v1/inventory/fields?type=handbag" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "type": "handbag", "create": { "required": [], "fields": { "brand": { "type": ["string","null"] }, … } }, "update": { "sameAsCreateExcept": ["sku"] }, "alsoAccepted": { "intake": { … }, "channels": { … }, "customFields": "…" }, "notes": [ … ] }
GET/api/v1/inventory/statusesscope: read:inventory

Every status a piece can be in, per product type (?type=watch for one), in the store's own order: the key every other endpoint takes, the store's label for it, whether the store hid it from its pickers, whether an update may set it, and how many pieces are in it now. Includes the photo steps — Awaiting photos, then Awaiting photo edits, then Available. Filter by any key with GET /api/v1/inventory?type=&status=.

Example
curl "https://www.inventoryconnect.io/api/v1/inventory/statuses?type=watch" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "types": { "watch": [ { "key": "AWAITING_PHOTO_EDITS", "label": "Awaiting photo edits", "defaultLabel": "Awaiting photo edits", "hidden": false, "settable": true, "count": 3, "onHand": true }, … ] }, "photoWorkflow": ["AWAITING_PHOTOS", "AWAITING_PHOTO_EDITS", "AVAILABLE"], "notes": [ … ] }
GET/api/v1/watches/{id}scope: read:inventory

Get a single watch by id (richer watch-specific shape).

Example
curl https://www.inventoryconnect.io/api/v1/watches/ckwatch123 \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "id": "ckwatch123", "sku": "…", "manufacturer": "…", … }
GET/api/v1/salesscope: read:sales

List completed sales in your store.

Example
curl https://www.inventoryconnect.io/api/v1/sales \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 87, "items": [ { "id": "…", "salePrice": 11800, "saleDate": "…", … } ] }
GET/api/v1/reports/{kind}scope: read:reports

Reporting rollups as JSON, always scoped to your store. kinds: sales, inventory, sales-tax, payments, margin-by-brand, fees, purchases, receivables, payables. Common filters ?from= & ?to= (dates); sales also ?paymentMethod= ?type=watches|handbags ?manufacturer= ?soldBy=; margin-by-brand ?type= ?sort=revenue|gross-margin|net-margin|count (the same sales as the in-app report: dealer-share mirror copies left out since 2026-10-14; each row also gives returnLoss — returns dated in the window on its sales — and netProfitAfterReturns, the default order; a brand with only a return in the window has unitsSold 0); fees ?scope=sold|all; purchases ?type= ?channel= ?platform= ?tradeIn=yes|no ?group=vendor|channel|platform|month and ?q=, which searches the pieces themselves and matches the vendor a piece was bought from only when the key also holds read:customers.

Example
curl "https://www.inventoryconnect.io/api/v1/reports/margin-by-brand?sort=net-margin" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "kind": "margin-by-brand", "count": 7, "rows": [ { "brand": "Rolex", "productType": "Watch", "unitsSold": 12, "revenue": 142500, "grossMarginPct": 16.7, "netProfit": 19100, "netMarginPct": 13.4, "returnLoss": 0, "netProfitAfterReturns": 19100 } ] }
POST/api/v1/market-logic/checksscope: write:market-logic

Price one watch with Market Logic: the wholesale and retail ranges, the result read off them and every price behind it. Send the reference and what else you know (brand, year, condition, completeness, dial, markers, bezel, bracelet, currency), plus purchase — what you would pay — to have it read against the ranges. A watch your keys already checked is read back for 15 days (search.newSearch false). Otherwise a new search runs, which can take a minute or more: a store gets 10 new searches per calendar day across all its keys and connected apps, then 429 daily_search_limit with Retry-After. A search is used once it has run. Rolex and Tudor need the dial and bracelet — a request without them gets 422 needs_input with the question to ask, and nothing is counted. These checks are separate from the ones your team runs in the app.

Example
curl -X POST https://www.inventoryconnect.io/api/v1/market-logic/checks \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "reference": "126610LN", "year": 2023, "condition": "used", "dial": "black", "bracelet": "oyster", "purchase": 11500 }'
Response
{ "result": { "reference": "126610LN", "verdict": "buy", "quickList": 13400, "targetSell": { "low": 13900, "high": 14600 }, … }, "bands": { … }, "comps": [ … ], "search": { "newSearch": true, "savedCheckId": "…", "nextNewSearchInHours": 360, "marketplacesRead": true }, "newSearches": { "perDay": 10, "usedToday": 1, "leftToday": 9, "resetsAt": "2026-10-08T07:00:00.000Z" } }
GET/api/v1/market-logic/checksscope: read:market-logic

The Market Logic checks your keys and connected apps have run, newest first, and what is left of today's new searches. ?reference= keeps references that start with it; GET /api/v1/market-logic/checks/{id} returns one in full. Reads only — nothing is searched or counted.

Example
curl "https://www.inventoryconnect.io/api/v1/market-logic/checks?reference=126610LN" \
  -H "Authorization: Bearer twc_live_xxxxxxxxxxxxxxxx"
Response
{ "total": 1, "items": [ { "id": "…", "reference": "126610LN", "checkedOn": "2026-10-07", "nextNewSearchInHours": 348, "result": { "verdict": "list_now", … } } ], "newSearches": { "perDay": 10, "usedToday": 1, "leftToday": 9, "resetsAt": "…" } }

Webhooks

Add an HTTPS endpoint under Settings → Webhooks and we'll POST a JSON event to it when something happens in your store. Subscribe to specific events or all of them. Current events: customer.created, customer.updated, sale.created, invoice.created, invoice.paid, inventory.created, inventory.updated, message.received and more (the full list is on the Webhooks page). A message.received event carries the text of the message as body — except for email synced from a connected Gmail or Outlook mailbox, where it sends "body": null and "bodyWithheld": "synced_email"; read that email through the API with a key holding read:email.

Payload
{
  "id": "evt_9f2c…",
  "type": "sale.created",
  "createdAt": "2026-06-24T18:05:00.000Z",
  "storeId": "ckstore…",
  "data": { "id": "cksale…", "salePrice": 11800, "customerId": "ckcust…", … }
}

Inventory events carry the whole record. inventory.created and inventory.updated add an item object holding every field we store for that product type — identity, specs, costs and prices, storefront and channel flags, photos, location, vendor/consignor, your itemized intake costs as intakeFees, and your custom fields keyed by their stable key. Money is a JSON number, dates are ISO 8601 strings, and unset fields are null rather than missing, so you can map the object straight into your own system without a follow-up API call.

Inventory payload
{
  "id": "evt_4b71…",
  "type": "inventory.created",
  "createdAt": "2026-07-25T15:02:11.000Z",
  "storeId": "ckstore…",
  "data": {
    "id": "ckwatch…",
    "type": "watch",
    "sku": "TWC-1042",
    "item": {
      "id": "ckwatch…", "sku": "TWC-1042", "status": "AVAILABLE",
      "manufacturer": "Rolex", "model": "Submariner", "referenceNo": "126610LN",
      "serial": "A1B2C3", "year": 2023, "condition": "PRE_OWNED",
      "purchasePrice": 8000, "repairCost": 250, "wholesalePrice": 10500,
      "retailListPrice": 11800, "retailMinPrice": 11200,
      "caseMaterial": "Steel", "caseDiameterMm": 41, "caliber": "3235",
      "includeOnChrono24": true, "includeOnBezel": false,
      "dateOfIntake": "2026-07-20T00:00:00.000Z",
      "createdAt": "2026-07-20T14:11:02.000Z",
      "updatedAt": "2026-07-25T15:02:11.000Z",
      "photos": [{ "id": "ckphoto…", "url": "https://…/front.jpg", "order": 0,
                   "kind": "RETAIL", "width": 2000, "height": 2000,
                   "createdAt": "2026-07-20T14:12:40.000Z" }],
      "location": { "id": "ckloc…", "name": "Safe A · Tray 2" },
      "enteredBy": { "id": "ckuser…", "name": "Justin H" },
      "vendor": { "id": "ckcust…", "name": "Acme Watches", "company": "Acme Watches" },
      "consignor": null,
      "intakeFees": [
        { "id": "ckfee…", "category": "SHIPPING", "label": "Inbound FedEx",
          "amount": 42.5, "createdAt": "2026-07-20T14:11:02.000Z" },
        { "id": "ckfee…", "category": "SERVICE", "label": "Movement service",
          "amount": 207.5, "createdAt": "2026-07-20T14:11:02.000Z" }
      ],
      "intakeFeesTotal": 250,
      "customFields": { "warranty_expiry": "2028-01-01" }
    }
  }
}

Each request includes X-InventoryConnect-Event and a signature header X-InventoryConnect-Signature: t=<unix>,v1=<hmac>. The HMAC is SHA256(secret, "<t>.<raw body>") using your endpoint's signing secret (shown once when you create it). Verify it and reject timestamps older than ~5 minutes:

import crypto from "crypto";

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=").map((s) => s.trim()))
  );
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const ok =
    expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return ok && fresh;
}

Respond 2xx to acknowledge. Non-2xx (or a timeout) is retried with backoff for several attempts; deliveries and their status are visible under Settings → Webhooks, where you can also re-send.

Restricted safes. In a store with restricted safes, an endpoint carries no more of them than the person it follows can see (the Follows column on the Webhooks page: whoever set it up, or the admin who last chose Follow me) — the rule API keys follow. For a watch in a safe that person can’t open, the endpoint gets no inventory.* event at all. Every other event (sales, invoices, tasks, deals, shipments) goes out in full. The check runs again on every attempt, retries and re-sends included; a delivery it stops is marked “Withheld (restricted safe)”. An endpoint with no one on record gets no inventory events about a restricted safe’s watches.

Wiring a website form (Zapier)

  1. Trigger on your form (Shopify form submission, a Catch Hook, etc.).
  2. Action: Webhooks by Zapier → POST to https://www.inventoryconnect.io/api/v1/customers (payload type json).
  3. Add headers Authorization: Bearer twc_live_… and Content-Type: application/json.
  4. Map your form fields to the body fields above, then test.