API reference

Entry point and index for ChannelWeave API routes and contracts.

Last updated:

Use this page as the API index, then open each focused page from the API sidebar menu.

Route groups

Quick access pinned views API routes

  • GET /api/ui/pinned-views
    • session-authenticated (requireAuth("json"))
    • tenant + user scoped (customer_id, user_id)
    • returns the current user’s pinned quick-access views with canonical href values
  • POST /api/ui/pinned-views
    • session-authenticated (requireAuth("json"))
    • tenant + user scoped (customer_id, user_id)
    • JSON body:
      • path (required supported route path)
      • search (optional query string for allowed filters)
      • label (optional user label)
    • supported paths:
      • /listings
      • /listings/drafts
      • /sales/orders
      • /sales/orders/:id (sales order detail; :id must be a UUID)
      • /inbox/messages/:thread_id (conversation detail; :thread_id must be a UUID)
      • /listings/:channel/:listing_id (listing detail for ebay|amazon|shopify|website)
      • /inventory/stock/items/:stock_id (stock item detail; :stock_id must be a UUID)
      • /sales/buyers/:id (buyer detail; :id must be a UUID)
      • /inbox/messages
      • /inbox/offers
    • stores typed route data per route key (not arbitrary free-form URLs)
  • DELETE /api/ui/pinned-views/:id
    • session-authenticated (requireAuth("json"))
    • tenant + user scoped (customer_id, user_id)
    • removes one pinned view by UUID id

Media library API routes

  • GET /api/media/library

    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • query:
      • page (default 1)
      • page_size (allowed 25|50|100|250; default 100)
      • search (optional file-name contains filter)
      • sort (optional; default -updated_at)
        • allowed:
          • file_name, -file_name
          • image_url, -image_url
          • updated_at, -updated_at
          • size_bytes, -size_bytes
    • returns:
      • ok
      • base_url (tenant media base URL)
      • total (matching file count)
      • page
      • page_size
      • sort
      • items[]:
        • file_name
        • image_url
        • thumb_url (generated WebP thumbnail URL when available; falls back to image_url)
        • preview_url (generated WebP preview URL when available; falls back to image_url)
        • size_bytes
        • updated_at
        • content_type
  • POST /api/media/library/upload

    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • multipart form-data body:
      • files (repeated) or file (single)
    • constraints:
      • up to 25 files per request
      • max 20 MB per file
      • allowed content types/extensions:
        • .jpg, .jpeg, .png, .webp, .gif, .avif, .svg
    • response:
      • ok
      • base_url
      • items[] (uploaded file metadata including thumb_url / preview_url)
    • variant generation:
      • creates WebP thumb and preview files for non-SVG uploads
      • keeps original aspect ratio (no forced square resize)
      • size bounds:
        • thumb: max 240x240
        • preview: max 960x960
  • POST /api/media/library/bulk-delete

    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • JSON body:
      • file_names: string[] (required)
    • constraints:
      • max 1000 file names per request
      • file names are sanitised server-side before delete
    • response:
      • ok
      • requested (unique validated file names from request)
      • deleted
      • not_found
  • DELETE /api/media/library/:file_name

    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • deletes one media file in the tenant products folder
    • validates/sanitises file name server-side before delete

Warehouse execution API routes

All Warehouse API routes are session-authenticated (requireAuth("json")) and tenant-scoped by the current session customer_id.

  • POST /api/inventory/warehouse/scan/resolve

    • body:
      • scan_text (required)
    • resolves one scan in this order:
      1. internal barcode
      2. manufacturer barcode
      3. SKU fallback
      4. bin code
      5. location code
    • internal barcodes are unique per customer, so one internal-barcode scan resolves to one stock item only
    • manufacturer barcodes can repeat across stock records; ambiguous matches are rejected with 409 until the stock identity is made explicit
    • response:
      • ok
      • match:
        • stock result with balances[]
        • bin result with contents[]
        • location result with totals
  • POST /api/inventory/warehouse/movements

    • body:
      • stock_id
      • movement_kind: putaway|move|replenishment|quarantine
      • quantity
      • from_location_id, from_bin_id
      • to_location_id, to_bin_id
      • optional: lot_code, expiry_date, reason_code, note, occurred_at
    • response:
      • ok
      • movement
  • POST /api/inventory/warehouse/adjustments

    • body:
      • stock_id
      • location_id, bin_id
      • quantity_delta
      • reason_code
      • optional: lot_code, expiry_date, note, occurred_at
    • response:
      • ok
      • movement
  • POST /api/inventory/warehouse/receiving

    • body:
      • stock_id
      • received_quantity
      • destination_location_id, destination_bin_id
      • optional: supplier_name, external_ref, condition_code, lot_code, expiry_date, note, occurred_at
    • response:
      • ok
      • receipt
  • POST /api/inventory/warehouse/returns

    • body:
      • stock_id
      • returned_quantity
      • disposition_code: restock|quarantine|damaged|inspect
      • destination_location_id, destination_bin_id
      • optional: external_ref, condition_code, lot_code, expiry_date, note, occurred_at
    • response:
      • ok
      • receipt
  • POST /api/inventory/warehouse/counts

    • body:
      • stock_id
      • count_mode: cycle_count|spot_check
      • location_id, bin_id
      • counted_quantity
      • optional: lot_code, expiry_date, note, occurred_at
    • response:
      • ok
      • count
  • POST /api/inventory/warehouse/issues

    • body:
      • issue_type
      • severity_code
      • note
      • optional: stock_id, location_id, bin_id, photo_url
    • response:
      • ok
      • issue
  • POST /api/inventory/warehouse/labels

    • body:
      • label_type: stock_barcode|bin_label
      • print_quantity
      • for stock labels:
        • stock_id
      • for bin labels:
        • bin_id
      • optional: note
    • response:
      • ok
      • job
      • job.print_href for the printable sheet
    • notes:
      • stock labels encode stock.internal_barcode as Code 128
      • bin labels encode stock_bin.bin_code as Code 128
      • manufacturer barcode is not used as the warehouse label identity
      • label values must stay within printable ASCII so the sheet can render as Code 128
  • GET /api/inventory/warehouse/labels/:id

    • returns one label print job by UUID id

Listings bulk end route (Website)

  • POST /api/listings/website/end
    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • used by Website listings list bulk action and Website listing detail End listing action
    • body:
      • listing_ids: string[] (required, Website channel_listing_id values)
    • behaviour:
      • ends Website listings in bulk by setting canonical listing.status to ended
      • only Website channel rows are affected
    • response:
      • ok
      • requested (ids supplied)
      • matched (Website rows found)
      • ended (rows moved from published to ended)
      • already_ended
      • not_found

Listings archive routes (eBay + Website)

  • POST /api/listings/:channel/archive

    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • supported channels:
      • ebay
      • website
    • body:
      • listing_ids: string[] (required)
    • behaviour:
      • archives canonical listing rows by setting listing.status = 'archived'
      • eBay: rows in published|ended can be archived
      • Website: only rows in ended can be archived
      • Website rows still in published are blocked and must be ended first
    • response:
      • ok
      • channel
      • requested
      • matched
      • archived
      • already_archived
      • blocked_live (Website rows blocked because still live)
      • not_found
  • POST /api/listings/:channel/:listing_id/archive

    • single-listing variant of the same archive action
    • same channel support and lifecycle rules as bulk archive
    • for Website, returns 409 when the listing is still live (published)

Overview dashboard API route

  • GET /api/dashboard

    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • returns operational dashboard snapshot for /dashboard
    • response shape:
      • ok, generated_at
      • cards:
        • dispatch SLA leading indicators:
          • dispatch_sla_at_risk
          • dispatch_sla_overdue
          • dispatch_sla_due_today
          • dispatch_sla_due_next_48h
          • dispatch_sla_missing_ship_by
        • late-shipment-rate (LSR) lagging indicators:
          • lsr_10d_rate_pct, lsr_10d_late_count, lsr_10d_eligible_count
          • lsr_30d_rate_pct, lsr_30d_late_count, lsr_30d_eligible_count
        • unread_messages, unactioned_offers
        • orders_in_progress, low_stock, sync_errors_24h
      • links:
        • deep-links for each card area (/sales/orders dispatch-SLA filters, including late_10d and late_30d, plus Inbox/low-stock/events)
      • queues[]:
        • to_allocate, to_pick, ready_to_dispatch, overdue_dispatch
        • each queue item includes label, hint, count, href
      • channels[]:
        • orders-in-progress counts grouped by channel_code/channel_label
        • each channel row now also includes unread_messages for current unread inbound channel conversations
        • each channel row includes active_order_value_by_currency[] with per-channel in-progress value totals (after refund-ledger deductions) grouped by currency_code
      • active_order_value_by_currency[]:
        • in-progress order value totals grouped by currency_code
        • each row includes currency_code and active_order_value (decimal text)
        • value formula per order/currency: max(0, sales_order.total_gross_amount - sum(sales_order_refund.amount /* gross */))
        • values are not FX-converted; when more than one currency is in play, UI should present this as mixed-currency totals
      • inventory_status:
        • excess_inventory_skus, excess_inventory_value
        • stockout_risk_skus (projected stockout inside 14 days from 30-day velocity)
        • never_sold_skus
        • stagnant_90d_skus (on-hand stock with no sale in 90 days+)
      • reorder_horizon[]:
        • horizon summary rows for 7, 14, and 30 days
        • each row includes horizon_days, sku_count, projected_reorder_cost (decimal text)
      • cashflow_impact:
        • excess_inventory_value
        • projected_reorder_cost_14d
        • stockout_revenue_risk_14d
      • urgent_replenishment[]:
        • top demand-risk SKU requiring reorder in the next 14 days
        • includes stock_id, sku, stock_name, quantity_available, quantity_on_order, avg_daily_qty, days_left, required_order_qty, required_order_value
      • fastest_moving[]:
        • SKU ranked by 30-day demand
        • includes stock_id, sku, stock_name, quantity_available, quantity_on_order, qty_30d, avg_daily_qty, days_left
      • stagnant_stock[]:
        • on-hand SKU ranked by oldest/no sale history
        • includes stock_id, sku, stock_name, quantity_on_hand, last_sale_at, days_since_sale, stock_value, never_sold
      • amazon_account_metrics:
        • account pulse snapshot for the connected Amazon channel
        • fields:
          • connected (boolean)
          • account_name (string|null)
          • marketplace_name (string|null)
          • published_listings_count (number|null)
          • orders_30d_count (number|null)
          • buyer_messages_30d_count (number|null)
          • checked_at (ISO timestamp|null)
          • unavailable_reason (string|null)
        • source:
          • current tenant channel_connection row for account / marketplace identity when available
          • tenant-local listing count for published Amazon listings
          • tenant-local sales_order count for last-30-day Amazon orders
          • tenant-local message_thread / message_message count for last-30-day inbound Amazon buyer messages
        • current UI use:
          • Control tower Amazon account pulse card on /dashboard
      • ebay_seller_metrics:
        • seller-level reputation snapshot for connected eBay accounts
        • fields:
          • connected (boolean)
          • seller_username (string|null)
          • feedback_score (number|null)
          • positive_feedback_percent (number|null)
          • total_sold_count (number|null; current windowed value)
          • sold_window_days (number|null; currently 60 when present)
          • follower_count (number|null)
          • captured_at (ISO timestamp|null)
          • stale (boolean)
          • followers_supported (boolean; currently false in v1)
          • unavailable_reason (string|null)
        • source:
          • Trading API GetUser + GetMyeBaySelling (SellingSummary + SoldList summary window)
        • refresh behaviour:
          • Control tower reads latest tenant snapshot from channel_seller_metrics_snapshot
          • stale/missing snapshots trigger a refresh attempt and then persist the latest successful result
      • shopify_store_metrics:
        • store pulse snapshot for the connected Shopify store
        • fields:
          • connected (boolean)
          • store_name (string|null)
          • shop_url (string|null; stored preferred host/domain)
          • published_listings_count (number|null)
          • orders_30d_count (number|null)
          • buyer_messages_30d_count (number|null)
          • checked_at (ISO timestamp|null)
          • unavailable_reason (string|null)
        • source:
          • current tenant channel_connection row for store identity
          • tenant-local listing count for published Shopify listings
          • tenant-local sales_order count for last-30-day Shopify orders
          • tenant-local message_thread / message_message count for last-30-day inbound Shopify buyer messages
        • current UI use:
          • Control tower Shopify store pulse card on /dashboard
      • pos_store_metrics:
        • order pulse snapshot for the POS channel (manual/internal orders)
        • fields:
          • enabled (boolean)
          • orders_30d_count (number)
          • open_orders_count (number)
          • awaiting_payment_count (number)
          • last_order_at (ISO timestamp|null)
        • source:
          • tenant-local sales_order rows with channel_code = 'pos'
        • current UI use:
          • Control tower POS order pulse card on /dashboard
      • website_store_metrics:
        • store pulse snapshot for the Website channel connection
        • fields:
          • connected (boolean)
          • website_name (string|null)
          • website_host (string|null; host/domain without protocol)
          • published_listings_count (number|null)
          • orders_30d_count (number|null)
          • buyer_messages_30d_count (number|null)
          • checked_at (ISO timestamp|null)
          • unavailable_reason (string|null)
        • source:
          • current tenant channel_connection row for Website identity/host
          • tenant-local listing count for published Website listings
          • tenant-local sales_order count for last-30-day Website orders
          • tenant-local message_thread / message_message count for last-30-day inbound Website buyer messages
        • current UI use:
          • Control tower Website store pulse card on /dashboard
  • GET /api/dashboard/order-health

    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • returns the dedicated sales/dispatch health snapshot for /dashboard/order-health
    • response shape:
      • ok, generated_at
      • cards:
        • dispatch SLA leading indicators:
          • dispatch_sla_at_risk
          • dispatch_sla_overdue
          • dispatch_sla_due_today
          • dispatch_sla_due_next_48h
          • dispatch_sla_missing_ship_by
        • late-shipment-rate (LSR) lagging indicators:
          • lsr_10d_rate_pct, lsr_10d_late_count, lsr_10d_eligible_count
          • lsr_30d_rate_pct, lsr_30d_late_count, lsr_30d_eligible_count
      • links:
        • deep-links for dispatch-SLA and LSR order filters
      • queues[]:
        • to_allocate, to_pick, ready_to_dispatch, overdue_dispatch
        • each queue item includes label, hint, count, href
      • channels[]:
        • orders-in-progress counts grouped by channel_code/channel_label
        • each channel row includes active_order_value_by_currency[] with per-channel in-progress value totals (after refund-ledger deductions) grouped by currency_code
      • active_order_value_by_currency[]:
        • overall in-progress order value totals grouped by currency_code
        • each row includes currency_code and active_order_value (decimal text)
    • current UI use:
      • Sales Order health dashboard

Insights summary API route

  • GET /api/insights/summary
    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • query:
      • since_hours (0 = all time; default 24 when omitted/invalid)
    • response shape:
      • since_hours
      • counts (info, warn, fail)
      • items[]
        • severity, title, message
        • optional presentational summary fields:
          • metric
          • supporting_text
        • optional action link:
          • action_label
          • action_href
        • current detector rows are always included so users can see explicit zero-count/healthy states in the selected view.
    • current detectors:
      • low stock (current-state stock position)
        • warn when one or more SKU are at/below reorder level
        • info when no low stock risk exists
      • recent error-like events in event_log (windowed by since_hours)
        • warn/fail when error-like events are present
        • info when no error-like events are found
      • manual actions on stock records (event_type=manual_field_edit, entity_type=stock, source=user) in event_log (windowed by since_hours)
        • severity thresholds:
          • info: 0-7 edits
          • warn: 8-19 edits
          • fail: 20+ edits
      • refund-ledger signal from sales_order_refund (windowed by since_hours)
        • fail when refunded orders are not marked payment_status in ('partially_refunded','refunded')
        • warn when refund-entry volume is high in the selected window
        • info when no refund mismatch/high-volume signal exists
      • cancelled-order signal from sales_order (windowed by since_hours against coalesce(order_date, created_at))
        • fail when cancelled orders are not marked fulfilment_status = 'cancelled'
        • warn when cancelled-order volume is high in the selected window (10+ cancelled orders)
        • info when cancellation signals are stable

Website connection details route

  • POST /api/channels/website/details
    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • creates/updates the tenant Website channel_connection row
    • body:
      • display_name
      • website_url (stored without protocol)
      • orders_enabled (boolean, optional)
      • stock_enabled (boolean, optional)
    • response includes current persisted Website connection values for those fields.

Website listings feed route

  • GET /api/website/listings
    • API-key authenticated (Authorization: Bearer <website_api_key>)
    • tenant-scoped by the resolved Website channel connection customer_id
    • query params:
      • page (default 1)
      • page_size (default 50, max 200)
      • q (optional title/SKU/id search)
      • sort (title, sku, or latest updated default)
    • response fields:
      • ok, page, page_size, total, items[]
    • row fields:
      • id (Website channel_listing_id when present; fallback internal UUID)
      • sku, title, description, listing_type, currency, price, quantity
      • view_url
      • image_url (primary stock image URL for linked stock; null when missing)
      • updated_at, published_at
    • description resolves as:
      • listing.description when non-empty
      • fallback stock_product.description for linked stock rows
    • image_url is resolved tenant-safely from stock_product_image through listing.stock_id -> stock.stock_product_id.

Website webhook routes

  • POST /webhooks/website/orders/created
  • POST /webhooks/website/orders/cancelled
    • public webhook routes (API key + signature verified)
    • validates:
      • Authorization: Bearer <website_api_key>
      • X-CW-Timestamp
      • X-CW-Signature (v1=<hmac_sha256_hex>)
    • upserts tenant-scoped sales_order rows for channel website
    • orders/created requires line_items[] and upserts sales_order_line immediately (SKU must exist in tenant stock)
    • orders/created now also adjusts Website published-listing availability:
      • for each upserted order line, ChannelWeave computes the per-line quantity delta vs the existing sales_order_line row
      • applies aggregated stock-linked deltas to listing.quantity for matching Website published listings (channel_code='website', status='published', listing.stock_id)
      • duplicate/retry webhooks with unchanged line quantities are idempotent (delta = 0, no further listing decrement)
      • listing status is not auto-ended by this webhook; quantity can reach 0 while listing remains published until ended explicitly
    • orders/created now also resolves Website buyers to Sales buyer master records when a buyer email is present:
      • identity key: (channel_code='website', identity_type='email', identity_value=lower(buyer_email))
      • reuses existing mapped buyer when found; otherwise auto-creates buyer + identity link and sets sales_order.buyer_id
      • order snapshot fields (buyer_name, buyer_email, buyer_phone) are still stored on sales_order
    • orders/cancelled marks order + lines as cancelled
  • POST /webhooks/website/messages/contact
    • public signed webhook route for website contact submissions
    • validates:
      • Authorization: Bearer <website_api_key>
      • X-CW-Timestamp
      • X-CW-Signature (v1=<hmac_sha256_hex>)
    • expects JSON payload with name, email, message, and idempotency key (submission_id or X-CW-Idempotency-Key)
    • creates/updates an inbound website buyer-question conversation in Inbox → Messages

Shopify webhook routes

  • POST /webhooks/shopify/orders/paid
    • public webhook route (Shopify HMAC-verified)
    • validates X-Shopify-Hmac-Sha256 using the configured Shopify app secret
    • resolves tenant by X-Shopify-Shop-Domain
    • upserts tenant-scoped Shopify sales_order payment state and writes channel_payment_event ledger rows
  • POST /webhooks/shopify/customers/create
  • POST /webhooks/shopify/customers/update
    • public webhook routes (Shopify HMAC-verified)
    • validates X-Shopify-Hmac-Sha256 using the configured Shopify app secret
    • resolves tenant by X-Shopify-Shop-Domain
    • ingests inbound Inbox messages only when payload note is non-empty
    • stores inbound messages under channel_code='shopify'
  • POST /webhooks/shopify/proxy/contact
    • public Shopify app-proxy route for storefront custom contact forms
    • validates signed app-proxy query parameters (shop, timestamp, signature) using the configured Shopify app secret
    • rejects stale signed requests (>15 minutes from current server time)
    • accepts URL-encoded form payloads and JSON payloads with message content (contact[body] / message)
    • stores inbound messages under channel_code='shopify' with message type buyer_question

Sales pick-list PDF route

  • GET /sales/orders/pick-list/pdf
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • query: order_ids=<uuid[,uuid...]>
    • returns application/pdf
    • supports one or many orders (current max: 200 per request)
    • PDF sections:
      • consolidated pick lines grouped by location/bin/SKU
      • per-order outstanding line breakdown

Sales order partial-refund route

  • POST /sales/orders/:id/refund-partial
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by current customer_id
    • eBay orders only (channel_code = 'ebay')
    • submitted form contract:
      • refund_amount (gross, required, positive, max 2dp, partial-only)
      • refund_tax_amount (VAT split amount)
        • required when order VAT total is greater than zero
        • must be 0.00 for zero-rated orders
      • refund_reason_code (required; eBay reason enum)
      • refund_comments (optional, max 100 chars)
    • writes tenant-scoped sales_order_refund ledger row with amount/net_amount/tax_amount split and tax_breakdown_status
    • refund status in that row reflects immediate issue_refund response (often PENDING) and is later reconciled by eBay order sync (paymentSummary.refunds[] status)
    • outbound eBay API request still sends gross only in orderLevelRefundAmount

Sales order edit-save route

  • POST /sales/orders/:id
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by current customer_id
    • status guard contract:
      • manual Save accepts only open|pending|processing|confirmed
      • dispatched and cancelled are action-driven only (ship/cancel actions)
      • once order status is dispatched or cancelled, edit-save cannot change it
    • payment-status contract:
      • payment_status is read-only on the form and is not updated by this route
      • payment status changes are system-driven (channel payment/refund flows)
    • currency-code contract:
      • currency_code is editable only on new-order create and is read-only on existing order detail
      • this route does not update currency_code
      • crafted attempts to change currency are rejected
    • fulfilment-status contract:
      • fulfilment_status is read-only on the form and is not updated by this route
      • fulfilment status changes are system-driven (allocation/ship/cancel and channel sync flows)
      • canonical values are: open|partial|allocated|dispatched|cancelled

Sales order POS payment-capture route

  • POST /sales/orders/:id/payment/pos/capture
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by current customer_id
    • POS orders only (channel_code = 'pos')
    • submitted form contract:
      • payment_capture_amount (required, positive, max 2dp)
      • payment_method (cash|card|bank_transfer|other)
      • payment_external_ref (optional)
      • payment_event_at (optional local date/time input)
    • writes tenant-scoped sales_order_payment_event ledger row with event_type='capture'
    • enforces cumulative guardrail: sum(capture amounts) + new amount <= sales_order.total_gross_amount
    • updates sales_order.payment_status to:
      • paid when captured total reaches gross total
      • partially_paid otherwise

Sales buyers HTML routes

  • GET /sales/buyers
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • query:
      • q (search by buyer code/name/group/contact fields)
      • status=active|inactive (optional)
      • sort=buyer_name_asc|buyer_name_desc|buyer_code_asc|buyer_code_desc|updated_desc|updated_asc (optional)
      • page, page_size
  • GET /sales/buyers/new
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • renders buyer create form
  • POST /sales/buyers/new
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • creates one buyer row (requires buyer_code, buyer_name)
  • GET /sales/buyers/:id
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • renders buyer detail/edit form
  • POST /sales/buyers/:id
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • updates one buyer row

Sales buyer-groups HTML routes

  • GET /sales/buyer-groups
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • query:
      • q (search by group code/name/parent fields)
      • sort=group_name_asc|group_name_desc|group_code_asc|group_code_desc|buyer_count_desc|buyer_count_asc|updated_desc|updated_asc (optional)
      • page, page_size
  • GET /sales/buyer-groups/new
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • renders buyer-group create form
  • POST /sales/buyer-groups/new
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • creates one buyer_group row (requires group_code, group_name)
  • GET /sales/buyer-groups/:id
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • renders buyer-group detail/edit form
  • POST /sales/buyer-groups/:id
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • updates one buyer_group row
  • POST /sales/buyer-groups/:id/delete
    • session-authenticated (requireAuth("html"))
    • tenant-scoped by customer_id
    • deletes one buyer_group row

eBay orders sync route

  • POST /api/ebay/orders/sync
    • session-authenticated (requireAuth("json"))
    • tenant-scoped by current session customer_id
    • optional query:
      • lookback_hours (default 24, min 1, max 720)
      • max_pages (default 10, min 1, max 100)
    • returns JSON:
      • ok: true
      • summary with customer-level ingest counts (pages_fetched, fetched_orders, processed_orders, inserted_orders, updated_orders, inserted_lines, updated_lines, skipped_orders, failed_orders, errors)

Theme preference endpoint

  • POST /settings/theme
    • session-authenticated (requireAuth("json"))
    • tenant-scoped and user-scoped by (customer_id, user_base.id) from the active session
    • request body (application/x-www-form-urlencoded):
      • theme=default|neon-noir|rose-noir|aurora|linen|eden|emerald-noir|lavender-noir
    • writes user_base.ui_theme and updates the in-session user object
    • response JSON:
      • success: { "ok": true, "theme": "<value>" }
      • validation failure: 400
      • unauthorised: 401

ChannelWeave now uses draft-first stock-link editing in UI:

  • Create/link stock from draft editors:

    • POST /api/listings/website/drafts/:id/create-and-link-stock
      • body:
        • { "sku": "<text>", "stock_group_id": "<uuid|null>" }
        • optional deferred group create: { "stock_group_create": { "group_code": "<text>", "group_name": "<text>" } }
    • POST /api/listings/ebay/drafts/:id/create-and-link-stock
      • body: { "sku": "<text>" }
    • POST /api/listings/ebay/drafts/:id/link-stock
      • body: { "stock_id": "<uuid>" }
      • supports replacing an existing stock link.
      • replacement is blocked (409) when eBay policy-risk validation fails (for example active best-offer activity) or when variation rows exist.
    • POST /api/listings/ebay/drafts/:id/link-stock/:stock_id
      • body-free variant of draft link/replace.
      • same policy validation and 409 behaviour as .../link-stock.
    • both routes are session-authenticated, tenant-scoped by customer_id.
    • Website draft stock-link routes (create-and-link-stock, link-stock, and draft save with stock_id) now include stock_image_prefill_count in responses when stock images are auto-seeded into an empty Website draft from linked stock product images.
    • Website draft stock-link creation flows now also default empty draft fields from linked stock:
      • descriptionstock_product.description when draft description is empty
      • category_pathstock_group.group_name when draft category path is empty
      • category_idstock_group.group_code when draft category ID is empty
      • quantity:
        • stock.quantity_available when stock quantity signal exists
        • fallback 1 only when no stock quantity signal exists
      • price:
        • enforced policy price for non-manual pricing modes
        • manual mode fallback to stock.sale_price when draft price is empty
  • :listing_id now resolves from listing.channel_listing_id:

    • eBay: eBay Item ID (for example 327001255236)
    • Website: ChannelWeave website listing ID (for example WL-000003)
  • Published listing link routes remain available for create/link operations:

    • POST /api/listings/:channel/:listing_id/create-and-link-stock
      • body:
        • { "sku": "<text>", "stock_group_id": "<uuid|null>" }
        • optional deferred group create: { "stock_group_create": { "group_code": "<text>", "group_name": "<text>" } }
      • channels: ebay, website
      • applies SKU policy before create/link.
      • rejects stock_group_id when the target group is disabled.
      • rejects requests that provide both stock_group_id and stock_group_create.
      • when stock_group_create is supplied, group creation is deferred and performed only inside the create-and-link transaction.
    • POST /api/listings/:channel/:listing_id/link-stock
      • body: { "stock_id": "<uuid>" }
      • channels: ebay, website
    • POST /api/listings/:channel/:listing_id/unlink-stock
      • body: {}
      • channels: ebay, website
      • returns 409 (direct unlink blocked; use Revise via draft then Change stock item).
    • POST /api/listings/website/drafts/:id/unlink-stock
      • body: {}
      • returns 409 (direct unlink blocked; use Change stock item).
    • POST /api/listings/:channel/archive
      • body: { "listing_ids": ["<listing_id>", ...] }
      • channels: ebay, website
      • eBay archives from published|ended.
      • Website archives only from ended (live Website listings must be ended first).
    • POST /api/listings/:channel/:listing_id/archive
      • body: {}
      • channels: ebay, website
      • single-listing archive variant.

All routes above are session-authenticated and tenant-scoped by customer_id.

  • draft routes operate on listing_draft rows.
  • Website draft image route:
    • GET /api/listings/website/drafts/:id/images
      • when the draft image list is empty and the draft is linked to stock, images are auto-seeded from stock_product_image before returning items.
    • POST /api/listings/website/drafts/:id/images
      • body: { "image_url": "<absolute-url>" }
      • accepts tenant ChannelWeave Media URLs only (/products/<tenant>/...).
      • appends image to the end of the draft image order.
    • POST /api/listings/website/drafts/:id/images/reorder
      • body: { "image_ids": ["<uuid>", ...] }
      • requires every current draft image id exactly once.
      • updates listing_draft_image.sort_order to match provided order.
    • POST /api/listings/website/drafts/:id/images/sync-from-stock-manifest
      • body: {}
      • requires linked stock on the draft.
      • syncs missing tenant ChannelWeave Media images from linked stock product image manifest into the draft image manifest.
      • returns added_count.
    • DELETE /api/listings/website/drafts/:id/images/:image_id
      • deletes one draft image row.
  • draft delete route:
    • DELETE /api/listings/website/drafts/:id
      • deletes one Website listing_draft row for the current tenant.
      • before delete, clears listing.draft_id on tenant Website listings linked to that draft so published listings remain valid.
      • returns 404 when the draft is not found.
  • draft archive routes:
    • POST /api/listings/website/drafts/:id/archive
      • sets listing_draft.status = 'archived'
      • returns 409 when the draft is currently publishing
    • POST /api/listings/website/drafts/:id/unarchive
      • sets listing_draft.status = 'draft' from archived state
      • returns 409 when draft is not archived or currently publishing
    • POST /api/listings/ebay/drafts/:id/archive
      • sets listing_draft.status = 'archived'
      • returns 409 when the draft is currently publishing
    • POST /api/listings/ebay/drafts/:id/unarchive
      • sets listing_draft.status = 'draft' from archived state
      • returns 409 when draft is not archived or currently publishing
  • published-listing routes operate on canonical listing rows (status in ('published', 'ended', 'archived')).
  • Website draft publish route lifecycle:
    • POST /api/listings/website/drafts/:id/publish
      • sets listing_draft.status = 'publishing' while publish is in flight
      • sets listing_draft.status = 'published' on success
      • sets listing_draft.status = 'error' and last_error on failure
      • returns 409 when the draft is already publishing

List endpoints (thumbnail metadata)

The following list endpoints now include image-preview metadata in each row:

  • GET /api/inventory/stock/items
  • GET /api/inventory/stock/low
  • GET /api/listings/drafts
  • GET /api/listings

Row-level image fields:

  • image_url (string | null)
    • canonical source image URL when a product/listing image exists
  • thumb_url (string | null)
    • thumbnail variant URL for list rendering
    • null when a thumbnail variant is unavailable (for example external-hosted image URLs)
  • has_image (boolean)
    • true when an image association exists even if thumb_url is null

Published listings rows (GET /api/listings) also include:

  • description (string | null)
    • listing description text used for the Item-column preview summary

Draft listings rows (GET /api/listings/drafts) also include:

  • description (string | null)
    • draft description text used for the Item-column preview summary

Listing revision-draft endpoints

  • POST /api/listings/ebay/revision-drafts
    • body: { "listing_id": "<ebay_item_id>" }
    • creates a new eBay revision draft for a published listing.
  • GET /api/listings/ebay/:listing_id/revision-diff
    • query:
      • left_draft_id=<uuid>
      • right_draft_id=<uuid>
    • compares two eBay drafts from the same listing history and returns field-level changes.
  • GET /api/listings/:channel/:listing_id
    • for eBay listing detail responses, payload now includes listing.revision_history[]:
      • draft_id
      • version, version_label (Original, Revision n)
      • status
      • is_live_source
      • compare_from_draft_id
      • created_at, updated_at, and SKU/title summary fields
  • POST /api/listings/website/revision-drafts
    • body: { "listing_id": "<website_channel_listing_id>" } (for example WL-000003)
    • for a published Website listing:
      • reuses existing linked draft when listing.draft_id still exists
      • otherwise creates a new draft from published listing fields + listing.publish_snapshot, links listing.draft_id, and returns editor URL
    • response includes draft_id, listing_id, edit_url, and optional reused: true.

Inventory barcode policy routes

  • GET /api/inventory/stock/barcode-policy
    • returns configured customer barcode policy if present
    • otherwise returns the effective default policy
    • response includes:
      • configured
      • policy
      • effective
  • POST /api/inventory/stock/barcode-policy
    • body:
      • internal_prefix
      • internal_sequence_width
      • internal_symbology: code_128
      • manufacturer_barcode_requirement: optional|recommended|required
    • rule:
      • ChannelWeave always generates an internal barcode on stock create when the request does not provide one

Stock create/update payloads now accept these barcode fields:

  • internal_barcode
  • manufacturer_barcode
  • manufacturer_barcode_format: upc_a|ean_13|ean_8|isbn_13|gtin_14|unknown|other

Stock group detail endpoint

  • GET /api/inventory/stock/groups

    • optional query: active=true|false
    • each returned group now includes:
      • is_active
      • stockcount
      • low_stock_count
    • UI can derive group status:
      • disabled when is_active = false
      • low stock when low_stock_count > 0
      • healthy when stockcount > 0 and low_stock_count = 0
      • no stock when stockcount = 0
  • GET /api/inventory/stock/groups/lookup

    • optional query: active=true|false
    • returns id, group_code, group_name, is_active
  • POST /api/inventory/stock/groups

    • accepts optional is_active boolean (defaults to true)
  • POST /api/inventory/stock/groups/:id

    • accepts optional is_active boolean update
  • DELETE /api/inventory/stock/groups/:id

    • deletes one stock group by UUID for the current tenant
    • linked stock rows are preserved and become ungrouped (stock_group_id becomes null)
    • response includes deleted group summary and linked stock count at delete time

Stock item delete endpoint

  • DELETE /api/inventory/stock/items/:id
    • session-authenticated (requireAuth("json")), tenant-scoped by customer_id
    • deletes one stock item by UUID from the current tenant
    • removes any stock_bin_assignment rows linked to that stock item
    • listing/listing-draft links are cleared by FK ON DELETE SET NULL
    • returns 409 when operational/history links exist (for example sales order lines, purchase order lines, credit note lines, stock adjustments, stock-check rows, or stock transfers)
    • when the deleted stock item was the last row referencing its stock_product_id, the orphaned stock_product row is deleted and related product-level rows are removed via cascade

Stock item image endpoints

  • GET /api/inventory/stock/items/:id/images

    • session-authenticated (requireAuth("json")), tenant-scoped by customer_id
    • returns product-level image rows from stock_product_image
    • when no rows exist, the route attempts a media-library SKU sync first, then returns refreshed rows
  • POST /api/inventory/stock/items/:id/images

    • session-authenticated (requireAuth("json")), tenant-scoped by customer_id
    • body:
      • image_url: string (required, absolute http(s))
    • accepts tenant ChannelWeave Media URLs only (/products/<tenant>/...)
    • upserts one stock_product_image row (appended to the current manifest order when new)
  • POST /api/inventory/stock/items/:id/images/sync-from-media

    • session-authenticated (requireAuth("json")), tenant-scoped by customer_id
    • syncs media-library files into stock_product_image by SKU filename match:
      • exact: <sku>.<ext>
      • suffixed: <sku>-<suffix>.<ext>
    • response includes:
      • matched_count
      • added_count
      • matched_file_names[]
      • matched_image_urls[]
    • returns 409 when stock SKU is missing
  • POST /api/inventory/stock/items/:id/images/reorder

    • session-authenticated (requireAuth("json")), tenant-scoped by customer_id
    • body:
      • image_ids: string[] (required UUID list)
      • must include every current stock-product image exactly once
    • rewrites stock_product_image.position to match provided order

Stock adjustment endpoints

  • GET /api/inventory/stock/adjustments

    • session-authenticated and tenant-scoped by customer_id
    • supports optional query:
      • search (SKU, stock name, note, location code)
      • type=manual_adjustment|stock_check|transfer
      • stock_id=<uuid>
      • location_id=<uuid> (matches adjustment location, source, or target)
      • date_from=YYYY-MM-DD
      • date_to=YYYY-MM-DD
      • page, page_size
    • returns inventory-wide adjustment rows with stock_sku and stock_name fields for list rendering
  • GET /api/inventory/stock/items/:id/adjustments

    • session-authenticated and tenant-scoped by customer_id
    • supports optional query:
      • type=manual_adjustment|stock_check|transfer
      • page, page_size
    • returns adjustment rows with location context and linked stock-check / transfer metadata where present
  • POST /api/inventory/stock/items/:id/adjustments

    • session-authenticated and tenant-scoped by customer_id
    • body requires adjustment_type:
      • manual_adjustment:
        • quantity_delta (non-zero numeric)
        • optional location_id, note, occurred_at
        • updates stock.quantity_on_hand by delta
      • stock_check:
        • counted_quantity (numeric, >= 0)
        • optional location_id, note, occurred_at
        • writes stock_check_result and sets stock.quantity_on_hand to counted quantity
      • transfer:
        • source_location_id, target_location_id (different UUIDs)
        • transfer_quantity (numeric, > 0)
        • optional note, occurred_at
        • writes stock_transfer and transfer adjustment audit row without changing global stock.quantity_on_hand

All adjustment writes are transactional (BEGIN/COMMIT with rollback on validation or write failure).

Inbox messages threads endpoint

  • GET /api/messages/threads

    • supports optional sync=1 query to force immediate eBay read-state sync before the list query.
    • supports optional state=active|archived|all|deleted maintenance filter (default active).
    • supports optional message_type filter:
      • buyer_question
      • offer_update
      • order_financial
      • order_cancellation
      • returns_cases
      • shipping_fulfilment_update
      • account_notice
      • other_uncategorised
    • each thread row includes:
      • message_type_code and message_type_label
      • last_message_direction (inbound | outbound | null)
      • message_preview (single-line, trimmed preview, nullable)
      • archived_at, deleted_at, and derived thread_state
  • POST /api/messages/threads/:id/archive

  • POST /api/messages/threads/:id/unarchive

  • POST /api/messages/threads/:id/delete

  • POST /api/messages/threads/:id/restore

    • maintenance actions for Inbox thread lifecycle management
    • all actions are tenant-scoped and return updated thread_state