Skip to content

Instantly share code, notes, and snippets.

@acamino
Last active July 9, 2026 20:35
Show Gist options
  • Select an option

  • Save acamino/1736ed935bb97d9404fac06690855026 to your computer and use it in GitHub Desktop.

Select an option

Save acamino/1736ed935bb97d9404fac06690855026 to your computer and use it in GitHub Desktop.
openapi: 3.1.0
info:
title: Shopify Bridge Batch Ingestion API
version: 0.3.3
description: |
REST API for batch ingestion of product, POS, inventory, and price data into the Shopify Bridge system.
This API provides an alternative to file-based ingestion, enabling programmatic data submission
with per-entry error tracking and job status monitoring.
## Key Features
- **Batch Processing**: Submit up to 10,000 entries per request
- **Per-Entry Tracking**: Client-provided identifiers for precise error correlation
- **Flexible Processing**: Synchronous for small batches, asynchronous for large ones
- **Idempotent Operations**: Optional idempotency keys for safe retries
## Processing Modes
| Batch Size | Mode | Response |
|------------|------|----------|
| ≤ 25 entries | Synchronous | `200`/`207` with full results |
| > 25 entries | Asynchronous | `202` with job_id for polling |
> **Note:** The synchronous threshold defaults to **25 entries** for all feed types
> (products, POS, inventory, prices). Any request exceeding the threshold is processed
> asynchronously and returns `202` with a `job_id` for polling.
> Per-feed thresholds are configurable via `INGEST_SYNC_THRESHOLD_*` env vars.
## Rate Limits
| Limit | Value |
|-------|-------|
| Requests per minute | 100 |
| Payload size | 10 MB |
| Entries per request | 10,000 |
## Request Correlation (X-Request-ID)
Every endpoint accepts an optional `X-Request-ID` request header for
correlating client and server logs. If omitted (or if the value does
not match `^[A-Za-z0-9_-]{1,64}$`), the server generates a UUIDv7.
For maximum compatibility the server also reads `Request-Id` as a
fallback. The chosen value is always echoed on the `X-Request-ID`
response header — on success and on error responses (4xx/5xx).
For ingestion endpoints, the value is additionally:
- Returned in the response body's `request_id` field.
- Persisted to the `import_job_request_ids` audit table linked to
the resulting `import_jobs.id`. Retries against the same
`Idempotency-Key` with a new `X-Request-ID` produce additional
audit rows; the oldest entry identifies the original request that
produced the job.
contact:
name: API Support
email: ag@pthreemedia.com
license:
name: Proprietary
identifier: LicenseRef-Proprietary
servers:
- url: https://shopify-bridge-api.up.railway.app
description: QA Environment (Railway)
- url: http://localhost:8080
description: Local Development
tags:
- name: Products
description: Product and variant ingestion
- name: POS
description: Point of Sale product updates
- name: Inventory
description: Inventory level management
- name: Prices
description: Price updates and promotions
- name: Admin
description: Administrative endpoints for job and outbox management
security:
- BearerAuth: []
paths:
/api/v1/ingest/products/{store_number}:
post:
operationId: ingestProducts
summary: Ingest products with variants
parameters:
- $ref: '#/components/parameters/StoreNumber'
description: |
Submit product data with one or more variants per entry.
Each entry represents a complete product with its variants. SKUs are automatically
padded to 9 digits for numeric values.
## Processing Rules
| Rule | Details |
|------|---------|
| Product matching | By handle (auto-generated from title if missing) |
| Variant matching | By SKU within product (9-digit padding for numeric SKUs) |
| Multi-variant support | Up to 3 option dimensions (option1, option2, option3) |
| Handle format | Lowercase alphanumeric with hyphens only |
| Store scoping | Products are store-scoped (not location-scoped) |
## Safeguards
| Safeguard | Description |
|-----------|-------------|
| Optimistic concurrency | Version field prevents conflicting updates |
| Duplicate detection | Prevents re-creation of existing products |
| Change detection | Skips unchanged products (compares title, data, metafields) |
| Product options | Auto-populated from variant values post-processing |
| Outbox pattern | Ensures reliable Shopify sync with retry capability |
## Limitations
| Limit | Value |
|-------|-------|
| Max entries per request | 10,000 |
| Max payload size | 10 MB |
| SKU uniqueness | Per product+store (not globally) |
| Handle uniqueness | Per store |
tags:
- Products
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ProductIngestRequest'
examples:
multiVariantProduct:
summary: Multi-variant product with full metafields
value:
idempotency_key: "qa-test-products-001"
options:
force_sync: true
entries:
- entry_id: "product-1"
data:
handle: "1-4z-pwrbl-sm-e-wd-scarl-xs---925658-1"
title: "University of New Mexico 1/4 Zip Powerblend Jacket"
description: "This versatile, eco-friendly Powerblend 1/4 zip campus sweatshirt gives you a great casual look."
vendor: "Champion"
product_type: "Sweatshirts"
tags: "collection:men,collection:clothing-and-accessories,collection:sweatshirts"
published: true
metafields:
custom.product_number: "030631 CS2083/E1177237/2299"
custom.manufacturer: "Champion Products"
custom.fabric: "50% Cotton/50% Polyester"
custom.embellishment: "Embroidered/Sewn"
custom.merchandisehierarchy: "500004001998"
custom.dept: "500"
custom.deptname: "Mens Apparel"
custom.vendornumber: "30631"
custom.vendorstyle: "CS2083/E1177237"
custom.seasoncode: "BNS"
custom.vendor_partnumber: "CS2083"
custom.san_number: "804-1814"
custom.aap_flag: "False"
custom.esd_flag: "False"
custom.graphic_type: "Sustainable"
custom.size_chart: "XS,S,M,L,XL,2XL"
taxonomy.department: "500"
taxonomy.sub_department: "004"
taxonomy.class: "001"
taxonomy.sub_class: "998"
merchandising.parent_store: "9975"
merchandising.child_store: "2299"
fulfillment.dsv_flag: "False"
fulfillment.vendor: "CHAMPION PRODUCTS"
fulfillment.vendor_number: "30631"
variants:
- sku: "18886985"
price: 75
compare_at_price: 75
cost: 28.92
barcode: "18886985"
taxable: true
requires_shipping: true
tax_code: "61000"
inventory_policy: "deny"
option1_name: "COLOR"
option1_value: "Scarlet Red"
option2_name: "SIZE"
option2_value: "XSmall"
variant_image: "https://bkstr.scene7.com/is/image/Bkstr/2299-CS2083-E1177237-Scarlet-Red"
variant_metafields:
vertex.product_class: "61000"
custom.clearance_flag: "False"
custom.sale_flag: "False"
custom.vendor_partnumber: "CS2083-XS"
- sku: "18886986"
price: 75
compare_at_price: 75
cost: 28.92
barcode: "18886986"
taxable: true
requires_shipping: true
tax_code: "61000"
inventory_policy: "deny"
option1_name: "COLOR"
option1_value: "Scarlet Red"
option2_name: "SIZE"
option2_value: "Small"
variant_image: "https://bkstr.scene7.com/is/image/Bkstr/2299-CS2083-E1177237-Scarlet-Red"
variant_metafields:
vertex.product_class: "61000"
custom.clearance_flag: "False"
custom.sale_flag: "False"
custom.vendor_partnumber: "CS2083-SM"
- entry_id: "product-2"
data:
handle: "12x40-cooling-towe-team1-------432088-1"
title: "University of New Mexico 12x40 Cooling Towel"
product_type: "Tailgate & Spirit"
tags: "collection:tailgate-and-spirit,collection:gifts-and-collectibles"
published: true
metafields:
custom.product_number: "066255 52300/58/2299"
custom.manufacturer: "The Northwest Company"
custom.embellishment: "Embroidered/Sewn"
custom.merchandisehierarchy: "600011001001"
custom.dept: "600"
custom.deptname: "Gifts"
custom.vendornumber: "66255"
custom.vendorstyle: "52300/58"
custom.seasoncode: "BNS"
custom.vendor_partnumber: "52300"
custom.san_number: "804-1814"
custom.aap_flag: "False"
custom.esd_flag: "False"
custom.graphic_type: "Wordmark"
taxonomy.department: "600"
taxonomy.sub_department: "011"
taxonomy.class: "001"
taxonomy.sub_class: "001"
merchandising.parent_store: "9975"
merchandising.child_store: "2299"
fulfillment.dsv_flag: "False"
fulfillment.vendor: "The Northwest Group, LLC"
fulfillment.vendor_number: "66255"
variants:
- sku: "27490402"
price: 20
compare_at_price: 20
cost: 6.17
barcode: "190604737423"
taxable: true
requires_shipping: true
tax_code: "61360"
inventory_policy: "deny"
option1_name: "COLOR"
option1_value: "Team Color"
variant_image: "https://bkstr.scene7.com/is/image/Bkstr/2299-52300-58-Team-Color"
variant_metafields:
vertex.product_class: "61360"
custom.clearance_flag: "False"
custom.sale_flag: "False"
minimalProduct:
summary: Minimal product (required fields only)
value:
entries:
- entry_id: "prod-minimal"
data:
title: "Simple Product"
variants:
- sku: "SIMPLE-001"
price: 19.99
responses:
'200':
description: All entries processed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SyncIngestResponse'
'202':
description: Batch accepted for asynchronous processing
content:
application/json:
schema:
$ref: '#/components/schemas/AsyncIngestResponse'
'207':
description: Partial success - some entries failed
content:
application/json:
schema:
$ref: '#/components/schemas/SyncIngestResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'429':
$ref: '#/components/responses/RateLimitExceeded'
'500':
$ref: '#/components/responses/InternalServerError'
/api/v1/ingest/pos/{store_number}:
post:
operationId: ingestPOS
summary: Ingest POS product updates
parameters:
- $ref: '#/components/parameters/StoreNumber'
description: |
Submit Point of Sale product updates. Each entry creates or updates
one product with a single variant.
## Processing Rules
| Rule | Details |
|------|---------|
| Product-variant mapping | One product = one variant (1:1 enforced by handle generation) |
| Handle format | `{normalized-title}-{store_number}-{formatted_sku}` |
| Status handling | Status field ignored (always treated as ACTIVE in Shopify) |
| Vendor default | "Unknown" if empty |
| Product type | Always "POS" |
| Tax code storage | Both `variants.tax_code` AND `vertex.product_class` metafield |
| Barcode formatting | Removes `.0` suffix, validates digits only |
| POSOnly flag | Always set to `true` |
## Variant Metafields
POS entries support an optional `variant_metafields` map for variant-level metafields.
Only allowed keys are accepted (currently: `custom.vendor_partnumber`). Disallowed keys
return a validation error. The `tax_code` field is automatically mapped to the
`vertex.product_class` variant metafield.
## Default Values
| Field | Default | Condition |
|-------|---------|-----------|
| Title | "POS Product {row}" | If empty |
| Vendor | "Unknown" | If empty |
| Product Type | "POS" | Always |
| Price | 0.0 | If empty or parse error |
## Safeguards
| Safeguard | Description |
|-----------|-------------|
| Store validation | Ensures only active stores processed |
| Duplicate SKU handling | Unique handle generation includes store+SKU |
| Price change tracking | Price history stored in JSONB |
| Change detection | Product-level and variant-level comparison |
| Metafield type overrides | From Shopify definitions cache |
| Atomic updates | Variant changes trigger product sync |
## Limitations
| Limitation | Details |
|------------|---------|
| No multi-variant | POS design constraint (1 product = 1 variant) |
| Store validation | Store number must exist in database |
| Barcode format | Must be numeric (invalid values → empty string) |
| No images | Files array always empty for POS products |
| No tags | Tags array always empty for POS products |
tags:
- POS
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/POSIngestRequest'
examples:
posUpdate:
summary: POS product batch with metafields
value:
idempotency_key: "qa-test-pos-001"
entries:
- entry_id: "row-1"
data:
sku: "000000004"
title: "SUPPLIES"
vendor: "CATEGORY&DUMP SKU-NO SCAN CREDIT"
description: "School Supplies"
barcode: "000000000004"
price: 0
compare_at_price: 0
cost: 0
tax_code: "76900"
metafields:
custom.dept: "400"
taxonomy.department: "400"
custom.deptname: "School Supplies"
custom.vendornumber: "222222"
custom.merchandisehierarchy: "500999000000"
taxonomy.sub_department: "998"
taxonomy.class: "998"
taxonomy.sub_class: "998"
merchandising.child_store: "2299"
variant_metafields:
custom.vendor_partnumber: "VPN-SUPPLIES-001"
- entry_id: "row-2"
data:
sku: "000000005"
title: "Emblematic Men's Apparel"
vendor: "CATEGORY&DUMP SKU-NO SCAN CREDIT"
description: "Mens Apparel"
barcode: "000000000005"
price: 0.01
compare_at_price: 0.01
cost: 0
tax_code: "61000"
metafields:
custom.dept: "500"
taxonomy.department: "500"
custom.deptname: "Mens Apparel"
custom.vendornumber: "222222"
custom.merchandisehierarchy: "500999000000"
taxonomy.sub_department: "998"
taxonomy.class: "998"
taxonomy.sub_class: "998"
merchandising.child_store: "2299"
variant_metafields:
custom.vendor_partnumber: "VPN-APPAREL-001"
responses:
'200':
description: All entries processed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SyncIngestResponse'
'202':
description: Batch accepted for asynchronous processing
content:
application/json:
schema:
$ref: '#/components/schemas/AsyncIngestResponse'
'207':
description: Partial success - some entries failed
content:
application/json:
schema:
$ref: '#/components/schemas/SyncIngestResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'429':
$ref: '#/components/responses/RateLimitExceeded'
'500':
$ref: '#/components/responses/InternalServerError'
/api/v1/ingest/inventory/{store_number}:
post:
operationId: ingestInventory
summary: Ingest inventory level updates
parameters:
- $ref: '#/components/parameters/StoreNumber'
description: |
Submit inventory level updates for existing variants.
**Processing Modes** (via `options.mode`):
| Mode | Behavior |
|------|----------|
| `initial` | Sets absolute quantity values |
| `delta` | Adjusts quantities by the specified amounts (default) |
---
## Initial Inventory Load (`mode: initial`)
### Processing Rules
| Rule | Details |
|------|---------|
| on_hand mutation | `inventorySetQuantities` (absolute SET) |
| on_order mutation | `inventoryAdjustQuantities` (delta ADJUST) |
| Quantity mapping | on_hand → "available", on_order → "incoming" |
| pre_order mutation | `inventorySetQuantities` (absolute SET at PreOrder location) |
| pre_order mapping | pre_order → "on_hand" at PreOrder {store_number} location |
| Location resolution | Uses store_number + location_name |
| Location default | If location_name empty/"0": uses location where name = store_number |
| child_store routing | Defaults to location_name (or store_number if empty) |
| Duplicate SKU support | Updates ALL matching variants |
### Safeguards (Initial)
| Safeguard | Description |
|-----------|-------------|
| Location validation | Location must be active |
| Store validation | Store must be active |
| Transaction wrapping | Outbox + audit events atomic |
| Placeholder handling | Location/inventory item ID placeholders if Shopify IDs missing |
### Limitations (Initial)
| Limitation | Details |
|------------|---------|
| Location requirement | Location must exist in database |
| Location status | Location must be active |
| No dynamic creation | Cannot create locations dynamically |
| Zero handling | on_hand defaults to 0 if empty (not skipped) |
| Quantity parsing | Float truncated to int (e.g., 105.99 → 105) |
### Preorder Support (Initial) — gated by `FF_PREORDER_TAG_FLAG`
When the store has a PreOrder location (`has_preorder_location=true`) and the feature flag is enabled:
| Rule | Details |
|------|---------|
| PreOrder location | `pre_order` quantity set at "PreOrder {store_number}" location via `inventorySetQuantities` |
| Product tag | `Pre-Order` tag added to product if any variant has `pre_order > 0`, removed otherwise |
| Variant metafield | `custom.pre_order_flag` set to `true` when `on_order >= 1`, `false` otherwise |
| Non-preorder stores | `pre_order` field silently ignored when store has no PreOrder location |
| PreOrder location absent | If "PreOrder {store_number}" not found in DB, preorder entries skipped (warning logged) |
---
## Delta Inventory (`mode: delta`)
### Processing Rules
| Rule | Details |
|------|---------|
| Mutation | `inventoryAdjustQuantities` (delta ADJUST) |
| on_hand | Adjusts quantity_name "available" |
| on_order | Adjusts quantity_name "incoming" |
| pre_order | Adjusts at "PreOrder {store_number}" location (quantity_name "available") |
| Quantity direction | Positive (increase) or negative (decrease) |
| No local tracking | No local quantity state maintained |
### 3-Tier Variant Lookup (Delta)
| Tier | Action |
|------|--------|
| Tier 1 | Local database lookup by SKU+store+child_store |
| Tier 2 | Sync missing variants from Shopify if product exists locally |
| Tier 3 | Full Shopify product fetch + local creation |
### Safeguards (Delta)
| Safeguard | Description |
|-----------|-------------|
| Delta type validation | Rejects invalid types |
| Location validation | Location must be active |
| Store validation | Store must be active |
| Transaction isolation | Per-update atomicity |
### Limitations (Delta)
| Limitation | Details |
|------------|---------|
| No drift detection | No local inventory state tracking |
| No validation | Cannot validate final quantity result |
| Location status | Location must exist and be active |
### Preorder Support (Delta) — gated by `FF_PREORDER_TAG_FLAG`
When the store has a PreOrder location and the feature flag is enabled:
| Rule | Details |
|------|---------|
| PreOrder location | `pre_order` delta adjusted at "PreOrder {store_number}" location via `inventoryAdjustQuantities` (quantity_name "available") |
| pre_order=0 | Skipped — no PreOrder location adjust (adjust by 0 is a no-op) |
| Product tag | `Pre-Order` tag added/removed based on aggregated pre_order state across variants |
| Variant metafield | `custom.pre_order_flag` set based on projected on_order: queries Shopify for current `incoming`, computes `current + delta >= 1` |
| Shopify query failure | Graceful degradation — inventory deltas still applied, flag/tag entries skipped |
| Non-preorder stores | `pre_order` field silently ignored |
tags:
- Inventory
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/InventoryIngestRequest'
examples:
deltaUpdate:
summary: Delta inventory adjustment
value:
options:
mode: "delta"
entries:
- entry_id: "inv-001"
data:
sku: "CBS-S-BLU"
on_hand: 5
- entry_id: "inv-002"
data:
sku: "CBS-M-BLU"
on_hand: -2
initialSet:
summary: Set absolute inventory levels
value:
options:
mode: "initial"
entries:
- entry_id: "inv-003"
data:
sku: "CBS-S-BLU"
location_name: "Main Warehouse"
on_hand: 100
on_order: 50
preorderInitial:
summary: Initial inventory with preorder quantities
value:
options:
mode: "initial"
entries:
- entry_id: "inv-005"
data:
sku: "CBS-S-BLU"
on_hand: 100
on_order: 5
pre_order: 3
preorderDelta:
summary: Delta inventory with preorder adjustment
value:
options:
mode: "delta"
entries:
- entry_id: "inv-006"
data:
sku: "CBS-S-BLU"
on_hand: 10
on_order: 3
pre_order: 2
responses:
'200':
description: All entries processed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SyncIngestResponse'
'202':
description: Batch accepted for asynchronous processing
content:
application/json:
schema:
$ref: '#/components/schemas/AsyncIngestResponse'
'207':
description: Partial success - some entries failed
content:
application/json:
schema:
$ref: '#/components/schemas/SyncIngestResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'429':
$ref: '#/components/responses/RateLimitExceeded'
'500':
$ref: '#/components/responses/InternalServerError'
/api/v1/ingest/prices/{store_number}:
post:
operationId: ingestPrices
summary: Ingest price updates
parameters:
- $ref: '#/components/parameters/StoreNumber'
description: |
Submit price updates for existing variants.
## Processing Rules
| Rule | Details |
|------|---------|
| Price types | R (Regular), S (Sale), C (Clearance), CS (Clearance Sale) |
| Type R | Sets price, clears compare_at_price |
| Type S/C | Sets price, preserves/sets compare_at_price from old price |
| Type CS | Combines Sale + Clearance flags |
| Explicit compare_at_price | When provided, overrides auto-derivation from price_type |
| Change tolerance | $0.01 (float precision protection) |
| Metafield flags | `custom.sale_flag`, `custom.clearance_flag` auto-set based on type |
| child_store routing | Defaults to parent store if empty or "0" |
| Duplicate SKU support | Updates ALL matching variants |
| Cost sync | Optional `cost` field syncs to Shopify via `inventoryItemUpdate` mutation |
| Cost non-critical | Cost sync failure does not fail the price update |
## Price Type Behavior
| Type | Price | compare_at_price | sale_flag | clearance_flag |
|------|-------|------------------|-----------|----------------|
| R (Regular) | new_price | cleared (nil) | false | false |
| S (Sale) | new_price | old_price (if transitioning from R) | true | false |
| C (Clearance) | new_price | old_price (if transitioning from R) | false | true |
| CS (Clearance Sale) | new_price | old_price (if transitioning from R) | true | true |
> **Note:** When `compare_at_price` is explicitly provided in the request, it overrides
> the auto-derivation behavior above regardless of `price_type`.
## 2-Tier Variant Lookup
| Tier | Action |
|------|--------|
| Tier 1 | Local database lookup by SKU+store+child_store (includes duplicate SKU resolution via child_store metafield) |
| Tier 2 | Shopify API fallback (fetches product, creates locally, returns variants) |
## Safeguards
| Safeguard | Description |
|-----------|-------------|
| Price tolerance | $0.01 (skips sub-penny changes) |
| Skip unchanged | Skips update if price, price_type, AND compare_at_price unchanged |
| Store validation | Validates store exists and is active |
| Transaction isolation | Each SKU update in separate transaction |
| Price history | Tracked in `variant.Data["price_history"]` JSONB array |
| Max sync attempts | 3 attempts via outbox pattern |
## Limitations
| Limitation | Details |
|------------|---------|
| Variant requirement | Variants must exist (creates via Tier 2 if needed) |
| Negative prices | Rejected during validation |
| Negative compare_at_price | Rejected during validation |
| Store status | Store must be active |
| Price type default | Defaults to "R" if invalid |
| Max sync attempts | 3 via outbox pattern |
tags:
- Prices
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PriceIngestRequest'
examples:
regularPrice:
summary: Regular price update
value:
entries:
- entry_id: "price-001"
data:
sku: "CBS-S-BLU"
price: 44.99
price_type: "R"
saleWithCost:
summary: Sale price with cost update
value:
entries:
- entry_id: "price-002"
data:
sku: "CBS-S-BLU"
price: 34.99
cost: 15.50
price_type: "S"
clearanceSaleMultiStore:
summary: Clearance sale with child_store routing
value:
entries:
- entry_id: "price-003"
data:
sku: "000900027"
child_store: "2299"
price: 39.99
cost: 18.50
price_type: "CS"
saleWithCompareAtPrice:
summary: Sale price with explicit compare_at_price
value:
entries:
- entry_id: "price-004"
data:
sku: "013004838"
price: 19.99
compare_at_price: 29.99
price_type: "S"
responses:
'200':
description: All entries processed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SyncIngestResponse'
'202':
description: Batch accepted for asynchronous processing
content:
application/json:
schema:
$ref: '#/components/schemas/AsyncIngestResponse'
'207':
description: Partial success - some entries failed
content:
application/json:
schema:
$ref: '#/components/schemas/SyncIngestResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'429':
$ref: '#/components/responses/RateLimitExceeded'
'500':
$ref: '#/components/responses/InternalServerError'
# NOTE: The /api/v1/jobs/* endpoints are stubs (return 501 Not Implemented).
# Job status polling and results retrieval use the admin import-jobs endpoints below.
# The async ingest response (202) returns links pointing to:
# - status: /api/v1/admin/import-jobs/{id}
# - results: /api/v1/admin/import-jobs/{id}/results
# =========================================================================
# Admin Endpoints
# =========================================================================
/api/v1/admin/inventory/{store_number}:
get:
operationId: getVariantInventory
summary: Get live variant inventory by SKU
parameters:
- $ref: '#/components/parameters/StoreNumber'
- name: sku
in: query
required: true
description: |
SKU to look up. A SKU can map to multiple variants per store (duplicate SKUs);
all matching variants are returned. Matched as provided (no 9-digit padding).
schema:
type: string
examples:
default:
value: "000844035"
- name: location
in: query
required: false
description: |
Optional location **name** (as stored for the store) to restrict results to a
single location. Resolved to a Shopify Location GID before the lookup and applied
server-side. When omitted, every location the item is stocked at is returned.
schema:
type: string
examples:
default:
value: "9975"
description: |
Return **live** inventory for every variant matching a SKU within a store,
optionally filtered to a single location.
Inventory quantities are **not** stored locally (Shopify is the source of truth
since `inventory_levels` was dropped), so this endpoint reads live from Shopify via
a single `inventoryItems` GraphQL query. Because a SKU can map to multiple variants
per store, all matching items are returned.
## Authentication
Requires the `read:inventory` scope (the admin key is also accepted). Read-only
consumers can be granted this scope without any write access. Rate-limited like the
ingestion endpoints.
## Behavior
| Step | Details |
|------|---------|
| Store resolution | `store_number` resolved from the local database |
| Location resolution | When `location` is given, the name is resolved to a Shopify Location GID (local DB) and applied as a server-side filter |
| Inventory read | One live `inventoryItems` query to Shopify (no local quantity state) |
| Duplicate SKU support | All variants sharing the SKU are returned (see `meta.duplicate_sku_count`) |
| Quantities | Six buckets per location: `available`, `on_hand`, `committed`, `incoming`, `reserved`, `safety_stock` (signed — `available` can be negative when oversold) |
## Status Codes
| Status | Condition |
|--------|-----------|
| `200` | Inventory found. Also returned with an empty `locations` array for a variant not stocked at the filtered location |
| `400` | `sku` query parameter missing or empty |
| `401` | Missing/invalid Authorization header or invalid API key |
| `403` | API key lacks the `read:inventory` scope |
| `404` | Store not found, location not found for store, or SKU matched no inventory items |
| `429` | Rate limit exceeded |
| `502` | Failed to reach Shopify, or the inventory query failed |
## Limitations
| Limitation | Details |
|------------|---------|
| Live read | No local inventory cache; every request hits Shopify |
| Location filter | `location` must be an existing location **name** for the store |
| SKU matching | Matched as provided (no 9-digit padding) |
| Upstream timeout | 10s timeout on the Shopify query |
tags:
- Inventory
responses:
'200':
description: Inventory retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/VariantInventoryResponse'
examples:
duplicateSKU:
summary: SKU mapped to multiple variants (no location filter)
value:
data:
- inventory_item_id: "gid://shopify/InventoryItem/48882985435393"
sku: "000844035"
tracked: true
duplicate_sku_count: 1
variant_id: "gid://shopify/ProductVariant/46769643880705"
variant_title: "AUTO WINDSOCK 1-2C/WHITE/ - White"
locations:
- location_id: "gid://shopify/Location/85819195649"
location_name: "9975"
quantities:
available: 8
on_hand: 8
committed: 0
incoming: 1
reserved: 0
safety_stock: 0
updated_at: "2026-05-11T15:25:56Z"
- inventory_item_id: "gid://shopify/InventoryItem/48885539832065"
sku: "000844035"
tracked: true
duplicate_sku_count: 1
variant_id: "gid://shopify/ProductVariant/46772190970113"
variant_title: "AUTO WINDSOCK 1-2C/WHITE/ - White"
locations:
- location_id: "gid://shopify/Location/85819195649"
location_name: "9975"
quantities:
available: 8
on_hand: 8
committed: 0
incoming: 1
reserved: 0
safety_stock: 0
updated_at: "2026-05-11T15:25:56Z"
meta:
store_number: "9975"
sku: "000844035"
location: null
item_count: 2
duplicate_sku_count: 1
fetched_at: "2026-06-15T20:52:17Z"
locationFiltered:
summary: Location filter (location=9975) — one location returned per variant
value:
data:
- inventory_item_id: "gid://shopify/InventoryItem/48882985435393"
sku: "000844035"
tracked: true
duplicate_sku_count: 1
variant_id: "gid://shopify/ProductVariant/46769643880705"
variant_title: "AUTO WINDSOCK 1-2C/WHITE/ - White"
locations:
- location_id: "gid://shopify/Location/85819195649"
location_name: "9975"
quantities:
available: 8
on_hand: 8
committed: 0
incoming: 1
reserved: 0
safety_stock: 0
updated_at: "2026-05-11T15:25:56Z"
- inventory_item_id: "gid://shopify/InventoryItem/48885539832065"
sku: "000844035"
tracked: true
duplicate_sku_count: 1
variant_id: "gid://shopify/ProductVariant/46772190970113"
variant_title: "AUTO WINDSOCK 1-2C/WHITE/ - White"
locations:
- location_id: "gid://shopify/Location/85819195649"
location_name: "9975"
quantities:
available: 8
on_hand: 8
committed: 0
incoming: 1
reserved: 0
safety_stock: 0
updated_at: "2026-05-11T15:25:56Z"
meta:
store_number: "9975"
sku: "000844035"
location: "9975"
item_count: 2
duplicate_sku_count: 1
fetched_at: "2026-06-15T22:27:25Z"
'400':
description: Missing required query parameter
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
missingSku:
value:
error: "sku query parameter is required"
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Store, location, or inventory not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
storeNotFound:
summary: Store does not exist
value:
error: "store not found"
locationNotFound:
summary: Location name not found for store
value:
error: "location not found for store"
noInventory:
summary: SKU matched no inventory items
value:
error: "no inventory found for store_number+sku"
'429':
$ref: '#/components/responses/RateLimitExceeded'
'502':
$ref: '#/components/responses/BadGateway'
/api/v1/admin/import-jobs/{id}:
get:
operationId: getImportJob
summary: Get import job details
description: |
Retrieve detailed information about an import job including:
- Job status and progress
- Processing statistics (total, processed, errors)
- File metadata (if file-based import)
- Timestamps (created, updated, completed)
This endpoint provides enriched data including sync error counts from the outbox.
tags:
- Admin
parameters:
- name: id
in: path
required: true
description: Import job ID
schema:
type: integer
format: int64
examples:
default:
value: 27
responses:
'200':
description: Import job retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ImportJobResponse'
examples:
completed:
summary: Completed import job
value:
import_job:
id: 27
file_type: "api_product"
source: "api"
status: "completed"
total_rows: 2
processed_rows: 2
error_rows: 0
sync_errors: 0
created_at: "2025-01-30T10:30:00Z"
updated_at: "2025-01-30T10:30:05Z"
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
/api/v1/admin/import-jobs/{id}/results:
get:
operationId: getImportJobResults
summary: Get import job outcomes
description: |
Retrieve the processing outcomes for an import job. The response shape
depends on the job's `source`.
## API-sourced jobs (`source: api`)
Returns a per-entry rollup keyed by the client-provided `entry_id`
(`ImportJobEntriesResponse`):
- **`entries`** — one row per submitted entry, in `line_number` order,
carrying `status` (`success`, `error`, `pending`, `skipped`, or
`unknown`) and, for failed entries, a minimal `errors` array
(`error_type`, `error_code`, `error_message`).
- **`entries_pagination`** — paginates the `entries` array via
`entries_limit` / `entries_offset`.
Filter with `entry_id` (exact match) and/or `entry_status`
(comma-separated). Unlike the error-only view below, this rollup includes
successful entries, so every submitted entry is accounted for.
## File-sourced jobs
Returns error-only outcomes in two collections (`ImportJobResultsResponse`):
- **`import_errors`** — per-row processing errors recorded while transforming
the request entries (validation, conversion, resolution, database).
- **`sync_errors`** — outbox-side failures recorded while syncing the
transformed records to Shopify. Defaults to `status=failed`; pass
`sync_status=all` (or a comma-separated list) to widen the view.
> **Important:** For file-sourced jobs this view **only returns errors**.
> An empty `import_errors` array means every entry was processed without
> issues; an empty `sync_errors` array means every Shopify sync completed
> without failures. A response with both arrays empty indicates the job
> finished end-to-end successfully — there is nothing to display.
Each collection paginates independently (`limit`/`offset` for
`import_errors`, `sync_limit`/`sync_offset` for `sync_errors`).
tags:
- Admin
parameters:
- name: id
in: path
required: true
description: Import job ID
schema:
type: integer
format: int64
examples:
default:
value: 27
- name: entries_limit
in: query
description: |
Maximum `entries` per page (API-sourced jobs only).
schema:
type: integer
minimum: 1
maximum: 500
default: 100
- name: entries_offset
in: query
description: |
Number of `entries` to skip (API-sourced jobs only).
schema:
type: integer
minimum: 0
default: 0
- name: entry_status
in: query
description: |
Filter `entries` by status (API-sourced jobs only). Comma-separated
list of values from `success`, `error`, `pending`, `skipped`,
`unknown`.
schema:
type: string
examples:
errorsOnly:
value: "error"
multiple:
value: "error,pending"
- name: limit
in: query
description: Maximum import_errors per page (file-sourced jobs)
schema:
type: integer
minimum: 1
maximum: 200
default: 50
- name: offset
in: query
description: Number of import_errors to skip (file-sourced jobs)
schema:
type: integer
minimum: 0
default: 0
- name: entry_id
in: query
description: |
Filter by client-provided entry_id (exact match). Applies to the
`entries` rollup for API-sourced jobs and to `import_errors` for
file-sourced jobs.
schema:
type: string
- name: sync_limit
in: query
description: Maximum sync_errors per page
schema:
type: integer
minimum: 1
maximum: 200
default: 50
- name: sync_offset
in: query
description: Number of sync_errors to skip
schema:
type: integer
minimum: 0
default: 0
- name: sync_status
in: query
description: |
Filter sync_errors by outbox status. Comma-separated list of values
from `pending`, `processing`, `processed`, `failed`. Pass `all` to
disable the filter. Defaults to `failed`.
schema:
type: string
examples:
default:
value: "failed"
all:
value: "all"
multiple:
value: "failed,pending"
- name: sync_error_type
in: query
description: Filter sync_errors by outbox error_type (comma-separated)
schema:
type: string
- name: sync_error_code
in: query
description: Filter sync_errors by outbox error_code (comma-separated)
schema:
type: string
responses:
'200':
description: |
Outcomes retrieved successfully. API-sourced jobs return the
`ImportJobEntriesResponse` per-entry rollup; file-sourced jobs return
the `ImportJobResultsResponse` error-only collections (empty arrays
mean no issues).
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/ImportJobEntriesResponse'
- $ref: '#/components/schemas/ImportJobResultsResponse'
examples:
entries:
summary: API-sourced job — per-entry rollup
value:
entries:
- entry_id: "hp-2434-025292194-1782208810-41"
line_number: 41
status: "success"
- entry_id: "hp-2434-029185670-1782208810-26"
line_number: 26
status: "error"
errors:
- error_type: "resolution"
error_code: "variant_resolve_failed"
error_message: "Child store filtering failed for SKU 029185670: child_store metafield '2431' not found on any product for SKU 029185670"
entries_pagination:
total: 48
limit: 100
offset: 0
noErrors:
summary: Job completed without issues
value:
import_errors: []
pagination:
limit: 50
offset: 0
total: 0
sync_errors: []
sync_pagination:
limit: 50
offset: 0
total: 0
withErrors:
summary: Job with row-level and sync errors
value:
import_errors:
- id: 91234
import_job_id: 27
row_number: 5
entry_id: "product-5"
result: "error"
error_type: "validation"
error_code: "missing_required_field"
column_name: "sku"
error_message: "SKU is required"
created_at: "2026-05-21T10:31:00Z"
pagination:
limit: 50
offset: 0
total: 1
sync_errors:
- id: 55021
import_job_id: 27
store_number: "9975"
aggregate_type: "variant"
aggregate_id: 1029836
sync_operation: "price"
status: "failed"
attempts: 3
max_attempts: 3
error_type: "shopify_user_error"
error_code: "invalid_price"
error_message: "Price must be non-negative"
sku: "000900027"
product_handle: "classic-blue-shirt"
created_at: "2026-05-21T10:32:00Z"
updated_at: "2026-05-21T10:33:15Z"
processed_at: "2026-05-21T10:33:15Z"
sync_pagination:
limit: 50
offset: 0
total: 1
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
/api/v1/admin/import-jobs/{id}/outbox:
get:
operationId: getImportJobOutbox
summary: Get outbox events for import job
description: |
Retrieve outbox events linked to an import job.
Outbox events represent pending Shopify sync operations. Use this endpoint to:
- Check if sync is complete (all events processed)
- Identify failed syncs (events with status=failed)
- Debug sync issues (view payloads and error messages)
tags:
- Admin
parameters:
- name: id
in: path
required: true
description: Import job ID
schema:
type: integer
format: int64
- name: status
in: query
description: Filter by outbox status (comma-separated)
schema:
type: string
examples:
pending:
value: "pending"
multiple:
value: "pending,processing,failed"
- name: limit
in: query
schema:
type: integer
default: 100
- name: offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: Outbox events retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ImportJobOutboxResponse'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
/api/v1/admin/import-jobs/{id}/errors:
get:
operationId: getImportJobErrors
summary: Get import job errors
description: |
Retrieve only error entries for an import job.
Convenience endpoint that filters results to show only failures,
making it easier to identify and debug processing issues.
tags:
- Admin
parameters:
- name: id
in: path
required: true
description: Import job ID
schema:
type: integer
format: int64
responses:
'200':
description: Errors retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ImportJobErrorsResponse'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
/api/v1/admin/metrics/sync:
get:
operationId: getSyncMetrics
summary: Get sync-view system health snapshot
description: |
Point-in-time health snapshot of the outbound sync pipeline: connection
pools, outbox processing (global and per store), and per-store Shopify
leaky-bucket capacity.
This is the canonical sync view. When disabled by configuration it returns
`501`; the deprecated `GET /api/v1/admin/metrics` alias returns the same
payload and is always available.
Responses may be served from a short-lived cache. Within the cache window a
scrape returns the last snapshot; if a refresh fails or times out, the last
successful snapshot is returned (a `200` carrying its own `timestamp`, so
staleness is visible). A `500` is returned only when no snapshot is
available.
tags:
- Admin
responses:
'200':
description: Sync-view snapshot
content:
application/json:
schema:
$ref: '#/components/schemas/SyncMetricsResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
'501':
description: Endpoint disabled by configuration
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
disabled:
value:
error: "metrics/sync endpoint is not enabled"
/api/v1/admin/metrics:
get:
operationId: getMetricsAlias
deprecated: true
summary: Get sync-view system health snapshot (deprecated alias)
description: |
Deprecated alias of `GET /api/v1/admin/metrics/sync`. Resolves to the same
handler and returns an identical `SyncMetricsResponse`. Unlike
`/metrics/sync`, this alias is never gated, so it keeps serving existing
clients. New clients should use `/metrics/sync`.
tags:
- Admin
responses:
'200':
description: Sync-view snapshot
content:
application/json:
schema:
$ref: '#/components/schemas/SyncMetricsResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
/api/v1/admin/metrics/ingestion:
get:
operationId: getIngestionMetrics
summary: Get ingestion-view system health snapshot
description: |
Point-in-time health snapshot of the inbound ingestion pipeline:
connection pools, real-time staging health (global and per store), and an
import-job lifecycle summary.
Staging metrics come from `staging_records` and are a short-window,
real-time signal (throughput over the last 5 minutes, errors over the last
hour); staging rows are deleted after retention, so these are not
historical totals. Job metrics come from `import_jobs` and aggregate
globally by status.
A failing sub-query degrades gracefully: the affected section is omitted
and the endpoint still returns `200` with everything that succeeded (an
all-queries-fail case still returns `pools` and an empty `ingestion`).
There is no fail-hard path; this endpoint always returns `200`.
tags:
- Admin
responses:
'200':
description: Ingestion-view snapshot
content:
application/json:
schema:
$ref: '#/components/schemas/IngestionMetricsResponse'
examples:
active:
summary: One active store with in-flight staging and jobs
value:
timestamp: "2026-07-02T12:00:00Z"
pools:
api:
database:
max_open_connections: 25
open_connections: 3
in_use: 1
idle: 2
wait_count: 0
wait_duration_ms: 0
redis:
total_conns: 4
idle_conns: 3
stale_conns: 0
hits: 120
misses: 5
timeouts: 0
config:
db_max_open_conns: 25
redis_pool_size: 10
concurrency: 0
operation_concurrency: 0
batch_size: 0
ingestion:
staging:
global:
pending:
PRODUCT: 1240
PRICE: 300
processing:
PRODUCT: 512
oldest_pending_seconds: 47
throughput_per_second: 830.5
error_last_hour: 12
by_store:
"1234":
pending:
PRODUCT: 1240
processing:
PRODUCT: 512
oldest_pending_seconds: 47
throughput_per_second: 830.5
error_last_hour: 12
jobs:
in_flight:
pending: 2
parsing: 1
staged: 3
processing: 5
oldest_in_flight_seconds:
parsing: 12
staged: 90
processing: 320
completed_last_hour: 44
completed_with_errors_last_hour: 3
failed_last_hour: 1
'401':
$ref: '#/components/responses/Unauthorized'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
API key authentication via Bearer token.
**Usage:**
```
Authorization: Bearer <your_api_key>
```
**Example curl request:**
```bash
curl -X POST "https://shopify-bridge-api.up.railway.app/api/v1/ingest/products/9975" \
-H "Authorization: Bearer your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"entries": [...]}'
```
Contact ag@pthreemedia.com to request an API key.
parameters:
StoreNumber:
name: store_number
in: path
required: true
description: Store number for routing the request
schema:
type: string
examples:
default:
value: "9975"
XRequestIDHeader:
name: X-Request-ID
in: header
required: false
description: |
Optional caller-supplied identifier for this HTTP call. If omitted,
or if the value does not match `^[A-Za-z0-9_-]{1,64}$`, the server
generates a UUIDv7. The chosen value is always echoed on the
`X-Request-ID` response header. Applies to every endpoint in this
API; see the introduction's "Request Correlation" section for the
full contract and the persistence behavior for ingestion endpoints.
schema:
type: string
pattern: '^[A-Za-z0-9_-]{1,64}$'
maxLength: 64
examples:
uuidv7:
summary: Server-format (also produced when no header is supplied)
value: "0190a5b8-c4e2-7b34-b234-d56789abcdef"
opaque:
summary: Caller-supplied opaque token (any UUID, ULID, or trace ID)
value: "trace-7f8a92b1"
RequestIDHeaderFallback:
name: Request-Id
in: header
required: false
description: |
Fallback inbound header (RFC-draft-aligned; RFC 6648 drops the X-
prefix). Read only when `X-Request-ID` is absent. Same validation
rules as `X-Request-ID`. The response always uses `X-Request-ID`
— never `Request-Id` — so callers don't have to disambiguate
between two response headers.
schema:
type: string
pattern: '^[A-Za-z0-9_-]{1,64}$'
maxLength: 64
headers:
XRequestIDResponse:
description: |
Correlation identifier for this HTTP call. Echoes the caller's
`X-Request-ID` (or `Request-Id`) when one was supplied, otherwise
a server-generated UUIDv7. Always present on every response,
including 4xx/5xx — useful for support tickets where the caller
already has the value before the body was parsed.
schema:
type: string
pattern: '^[A-Za-z0-9_-]{1,64}$'
example: "0190a5b8-c4e2-7b34-b234-d56789abcdef"
schemas:
# =========================================================================
# Request Schemas
# =========================================================================
ProductIngestRequest:
type: object
required:
- entries
properties:
idempotency_key:
type: string
description: Client-provided key for idempotent retries
maxLength: 255
examples:
- "import-2025-01-06-batch-001"
entries:
type: array
description: Array of product entries to process
minItems: 1
maxItems: 10000
items:
$ref: '#/components/schemas/ProductEntry'
options:
$ref: '#/components/schemas/IngestOptions'
ProductEntry:
type: object
required:
- entry_id
- data
properties:
entry_id:
type: string
description: Client-provided identifier for error correlation
maxLength: 255
examples:
- "prod-001"
data:
$ref: '#/components/schemas/ProductData'
ProductData:
type: object
required:
- variants
properties:
handle:
type: string
description: URL-safe product identifier. Generated from title if not provided.
pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
maxLength: 255
examples:
- "classic-blue-shirt"
title:
type: string
description: Product title
maxLength: 255
examples:
- "Classic Blue Shirt"
description:
type: string
description: Product description (HTML allowed)
examples:
- "<p>A timeless classic for any occasion.</p>"
vendor:
type: string
description: Product vendor/manufacturer
maxLength: 255
examples:
- "Acme Clothing"
product_type:
type: string
description: Product type or category
maxLength: 255
examples:
- "Shirts"
tags:
type: string
description: Comma-separated list of tags
examples:
- "clothing,shirts,blue,summer"
published:
type: boolean
description: Whether the product is visible in the storefront
default: true
gift_card:
type: boolean
description: >-
Whether the product is a gift card. Can only be set at creation;
Shopify does not allow changing it on existing products. On updates
the field is accepted but ignored for the Shopify sync: the product
keeps its original gift card status and no error is returned.
default: false
gift_card_template_suffix:
type:
- string
- "null"
description: >-
Theme template suffix used when customers view the gift card
(renders templates/gift_card.<suffix>.liquid). Only applies when
gift_card is true. Unlike gift_card, it can be changed on updates.
Empty or whitespace-only values are treated as absent and fall back
to the default.
maxLength: 255
default: "gift_card"
examples:
- "birthday"
metafields:
type: object
description: Custom metafields as namespace.key to value mapping
additionalProperties:
type: string
examples:
- custom.material: "100% Cotton"
custom.care_instructions: "Machine wash cold"
custom.size_chart: "S,M,L,XL,XXL"
variants:
type: array
description: Product variants (at least one required)
minItems: 1
items:
$ref: '#/components/schemas/VariantData'
VariantData:
type: object
required:
- sku
properties:
sku:
type: string
description: Stock keeping unit. Numeric values are padded to 9 digits.
maxLength: 255
examples:
- "CBS-S-BLU"
price:
type: number
format: double
description: Selling price
minimum: 0
default: 0
examples:
- 49.99
compare_at_price:
type:
- number
- "null"
format: double
description: Original price (displayed as strikethrough for sale items)
minimum: 0
examples:
- 59.99
cost:
type:
- number
- "null"
format: double
description: Cost of goods (not visible to customers)
minimum: 0
examples:
- 22.50
barcode:
type:
- string
- "null"
description: Barcode (UPC, EAN, ISBN, etc.)
maxLength: 255
examples:
- "123456789012"
weight:
type:
- number
- "null"
format: double
description: Weight value
minimum: 0
examples:
- 200
weight_unit:
type: string
description: Weight unit
enum:
- g
- kg
- lb
- oz
default: "g"
inventory_policy:
type: string
description: Behavior when inventory reaches zero
enum:
- deny
- continue
default: "deny"
taxable:
type: boolean
description: >-
Whether the variant is subject to taxes. For gift card products
(gift_card: true) Shopify requires non-taxable variants: the
default becomes false and sending true is rejected with a
validation error. The import job results endpoint records it
with error_code `gift_card_taxable`.
default: true
requires_shipping:
type: boolean
description: Whether the variant requires shipping
default: true
tax_code:
type:
- string
- "null"
description: Tax code for tax calculation services
maxLength: 255
examples:
- "P0000000"
option1_name:
type:
- string
- "null"
description: First option name (e.g., "Size", "Color")
maxLength: 255
examples:
- "Size"
option1_value:
type:
- string
- "null"
description: First option value
maxLength: 255
examples:
- "Small"
option2_name:
type:
- string
- "null"
description: Second option name
maxLength: 255
option2_value:
type:
- string
- "null"
description: Second option value
maxLength: 255
option3_name:
type:
- string
- "null"
description: Third option name
maxLength: 255
option3_value:
type:
- string
- "null"
description: Third option value
maxLength: 255
variant_image:
type:
- string
- "null"
format: uri
description: URL to variant-specific image
examples:
- "https://cdn.example.com/images/cbs-small-blue.jpg"
variant_image_alt:
type:
- string
- "null"
description: Alt text for variant image
maxLength: 512
examples:
- "Classic Blue Shirt - Small"
variant_metafields:
type:
- object
- "null"
description: Variant-specific metafields
additionalProperties:
type: string
POSIngestRequest:
type: object
required:
- entries
properties:
idempotency_key:
type: string
maxLength: 255
entries:
type: array
minItems: 1
maxItems: 10000
items:
$ref: '#/components/schemas/POSEntry'
options:
$ref: '#/components/schemas/IngestOptions'
POSEntry:
type: object
required:
- entry_id
- data
properties:
entry_id:
type: string
maxLength: 255
data:
$ref: '#/components/schemas/POSData'
POSData:
type: object
description: |
POS item data. Store number is provided via URL path parameter.
Each POS entry creates one product with one variant (1:1 relationship).
required:
- sku
properties:
sku:
type: string
description: Stock keeping unit (padded to 9 digits for numeric values)
maxLength: 255
examples:
- "000000004"
title:
type: string
description: Product title. Defaults to "POS Product {row}" if empty.
maxLength: 255
examples:
- "SUPPLIES"
vendor:
type: string
description: Product vendor
maxLength: 255
default: "Unknown"
examples:
- "CATEGORY&DUMP SKU-NO SCAN CREDIT"
description:
type:
- string
- "null"
description: Product description
examples:
- "School Supplies"
barcode:
type:
- string
- "null"
description: Barcode (UPC). Must be numeric; invalid values are converted to empty string.
maxLength: 255
examples:
- "000000000004"
price:
type: number
format: double
description: Selling price (POS_PRICE)
minimum: 0
default: 0
examples:
- 0
- 10.01
compare_at_price:
type:
- number
- "null"
format: double
description: Original/regular price (REG_PRICE)
minimum: 0
examples:
- 0
- 0.01
cost:
type:
- number
- "null"
format: double
description: Cost of goods
minimum: 0
tax_code:
type:
- string
- "null"
description: |
Tax code for tax calculation.
Sets both `variants.tax_code` field AND `vertex.product_class` variant metafield.
maxLength: 255
examples:
- "76900"
- "61000"
metafields:
type:
- object
- "null"
description: |
Product-level metafields as namespace.key to value mapping.
Common POS metafields include:
- `custom.dept` - Department number (padded to 3 digits)
- `taxonomy.department` - Taxonomy department (padded to 3 digits)
- `custom.deptname` - Department name
- `custom.vendornumber` - Vendor number
- `custom.merchandisehierarchy` - Merchandise hierarchy code
- `taxonomy.sub_department` - Sub-department (padded to 3 digits)
- `taxonomy.class` - Class (padded to 3 digits)
- `taxonomy.sub_class` - Sub-class (padded to 3 digits)
- `merchandising.child_store` - Child store number
- `custom.size_chart` - Size chart (single line text)
additionalProperties:
type: string
variant_metafields:
type:
- object
- "null"
description: |
Variant-level metafields as namespace.key to value mapping.
Only allowed keys are accepted. Currently allowed:
- `custom.vendor_partnumber` - Vendor part number for the variant
Disallowed keys return a validation error.
The `tax_code` field is automatically mapped to `vertex.product_class`
and does not need to be included here.
additionalProperties:
type: string
InventoryIngestRequest:
type: object
required:
- entries
properties:
idempotency_key:
type: string
maxLength: 255
entries:
type: array
minItems: 1
maxItems: 10000
items:
$ref: '#/components/schemas/InventoryEntry'
options:
allOf:
- $ref: '#/components/schemas/IngestOptions'
- type: object
properties:
mode:
type: string
description: |
Processing mode:
- `initial`: Sets absolute quantity values
- `delta`: Adjusts quantities by specified amounts
enum:
- initial
- delta
default: "delta"
InventoryEntry:
type: object
required:
- entry_id
- data
properties:
entry_id:
type: string
maxLength: 255
data:
$ref: '#/components/schemas/InventoryData'
InventoryData:
type: object
description: |
Inventory update data. Store number is provided via URL path parameter.
required:
- sku
properties:
sku:
type: string
description: Stock keeping unit
maxLength: 255
location_name:
type:
- string
- "null"
description: Location name for multi-location inventory. Defaults to store value.
maxLength: 255
on_hand:
type: integer
description: Quantity on hand (absolute value or delta depending on mode)
default: 0
on_order:
type: integer
description: Quantity on order
default: 0
pre_order:
type: integer
description: |
Preorder quantity. Only processed when the store has a PreOrder location configured.
- **Initial mode**: Sets absolute quantity at the "PreOrder {store_number}" location.
- **Delta mode**: Adjusts quantity at the PreOrder location. Skipped when value is 0.
- When `FF_PREORDER_TAG_FLAG` is enabled, also drives:
- `Pre-Order` product tag (added when any variant has pre_order > 0)
- `custom.pre_order_flag` variant metafield (true when on_order >= 1)
- Silently ignored for stores without a PreOrder location.
default: 0
PriceIngestRequest:
type: object
required:
- entries
properties:
idempotency_key:
type: string
maxLength: 255
entries:
type: array
minItems: 1
maxItems: 10000
items:
$ref: '#/components/schemas/PriceEntry'
options:
$ref: '#/components/schemas/IngestOptions'
PriceEntry:
type: object
required:
- entry_id
- data
properties:
entry_id:
type: string
maxLength: 255
data:
$ref: '#/components/schemas/PriceData'
PriceData:
type: object
description: |
Price update data. Store number is provided via URL path parameter.
required:
- sku
- price
properties:
sku:
type: string
description: Stock keeping unit
maxLength: 255
child_store:
type:
- string
- "null"
description: Child store for variant routing. Defaults to parent store_number.
maxLength: 50
price:
type: number
format: double
description: New price value
minimum: 0
cost:
type:
- number
- "null"
format: double
description: |
Cost of goods (unit cost). Optional. When provided, the cost is synced
to Shopify via `inventoryItemUpdate` after the price update succeeds.
If omitted, the existing cost in Shopify remains unchanged.
minimum: 0
examples:
- 15.50
price_type:
type: string
description: |
Price type affecting compare_at_price behavior:
- `R` (Regular): Sets price, clears compare_at_price
- `S` (Sale): Sets price, preserves/sets compare_at_price
- `C` (Clearance): Sets price, preserves/sets compare_at_price
- `CS` (Clearance Sale): Combines C + S flags
When `compare_at_price` is explicitly provided, it overrides the
auto-derivation described above.
enum:
- R
- S
- C
- CS
default: "R"
compare_at_price:
type:
- number
- "null"
format: double
description: |
Explicit compare-at price (displayed as strikethrough in Shopify).
When provided, overrides the automatic compare_at_price derivation
from price_type transitions. If omitted, compare_at_price is
auto-derived based on price_type.
minimum: 0
examples:
- 29.99
IngestOptions:
type: object
properties:
force_sync:
type: boolean
description: |
Create outbox entries even for unchanged data (re-syncs to Shopify
regardless of change detection).
When omitted, the server default applies — controlled by the
`FORCE_SYNC` / `processing.force_sync` configuration, which also
governs file-based processing. When provided, the request value
overrides the server default; send `false` to opt out even if the
server default is `true`.
> **Production note:** `FORCE_SYNC` is currently set to `true` in
> production, so by default unchanged data is re-synced to Shopify.
> Send `force_sync: false` on a request to skip re-syncing unchanged
> records (change detection applies).
validate_only:
type: boolean
description: Validate entries without persisting. Useful for dry-run testing.
default: false
# =========================================================================
# Response Schemas
# =========================================================================
SyncIngestResponse:
type: object
required:
- job_id
- status
- summary
- results
properties:
job_id:
type: string
format: int64
description: System-generated job identifier
examples:
- "27"
request_id:
type: string
description: |
The `X-Request-ID` used for this HTTP call. Mirrors the
`X-Request-ID` response header for caller convenience and
for logging integrations that consume the JSON body.
examples:
- "0190a5b8-c4e2-7b34-b234-d56789abcdef"
status:
type: string
description: Overall job status
enum:
- completed
- completed_with_errors
- failed
summary:
$ref: '#/components/schemas/JobSummary'
results:
type: array
description: Per-entry results in submission order
items:
$ref: '#/components/schemas/EntryResult'
AsyncIngestResponse:
type: object
required:
- job_id
- status
- message
- links
properties:
job_id:
type: string
format: int64
description: System-generated job identifier
examples:
- "27"
request_id:
type: string
description: |
The `X-Request-ID` used for this HTTP call. Mirrors the
`X-Request-ID` response header.
examples:
- "0190a5b8-c4e2-7b34-b234-d56789abcdef"
status:
type: string
enum:
- pending
examples:
- "pending"
message:
type: string
description: Human-readable status message
examples:
- "Batch of 500 entries accepted for processing"
links:
type: object
required:
- status
- results
properties:
status:
type: string
format: uri
description: URL to poll for job status
examples:
- "/api/v1/admin/import-jobs/27"
results:
type: string
format: uri
description: URL to retrieve results when complete
examples:
- "/api/v1/admin/import-jobs/27/results"
JobSummary:
type: object
required:
- total
- processed
- created
- updated
- errors
properties:
total:
type: integer
description: Total entries submitted
minimum: 0
processed:
type: integer
description: Entries successfully processed
minimum: 0
created:
type: integer
description: New records created
minimum: 0
updated:
type: integer
description: Existing records updated
minimum: 0
errors:
type: integer
description: Entries that failed
minimum: 0
EntryResult:
type: object
required:
- entry_id
- status
properties:
entry_id:
type: string
description: Echo of client-provided entry_id
status:
type: string
enum:
- success
- error
- skipped
action:
type:
- string
- "null"
description: Action taken (null if error)
enum:
- created
- updated
- unchanged
product_id:
type:
- integer
- "null"
description: Created/updated product ID (on success)
variant_ids:
type:
- array
- "null"
description: Created/updated variant IDs (on success)
items:
type: integer
error:
$ref: '#/components/schemas/EntryError'
EntryError:
type: object
required:
- type
- message
properties:
type:
type: string
description: |
Error category:
- `validation`: Business rule violation
- `conversion`: Data type conversion failure
- `database`: Database constraint violation
- `resolution`: Reference lookup failure
enum:
- validation
- conversion
- database
- resolution
message:
type: string
description: Human-readable error description
examples:
- "SKU is required"
field:
type:
- string
- "null"
description: Field that caused the error
examples:
- "sku"
# =========================================================================
# Admin Response Schemas
# =========================================================================
ImportJobResponse:
type: object
required:
- import_job
properties:
import_job:
$ref: '#/components/schemas/ImportJob'
ImportJob:
type: object
properties:
id:
type: integer
format: int64
description: Import job ID
file_id:
type:
- string
- "null"
description: File ID (for file-based imports)
file_path:
type:
- string
- "null"
description: File path (for file-based imports)
file_type:
type: string
description: Type of import (product, pos, inventory, api_product, api_pos, etc.)
examples:
- "api_product"
- "pos"
- "product"
source:
type: string
description: Import source
enum:
- file
- api
status:
type: string
description: Job status
enum:
- pending
- processing
- completed
- completed_with_errors
- failed
total_rows:
type: integer
description: Total entries in the job
processed_rows:
type: integer
description: Successfully processed entries
error_rows:
type: integer
description: Failed entries
sync_errors:
type: integer
description: Number of failed outbox sync operations
error_message:
type:
- string
- "null"
description: Error message (if failed)
idempotency_key:
type:
- string
- "null"
description: Client-provided idempotency key
store_id:
type:
- integer
- "null"
format: int64
description: Store ID (for API imports)
request_id:
type: string
description: |
The `X-Request-ID` of the GET request that produced this
response. Distinct from `created_by_request_id`, which is the
`X-Request-ID` of the original POST whose execution produced
this `import_job` row.
examples:
- "0190a5b8-c4e2-7b34-b234-d56789abcdef"
created_by_request_id:
type: string
description: |
The `X-Request-ID` of the call whose execution produced this
`import_job` row. Sourced from the audit trail
(`import_job_request_ids`) as the oldest entry for this job.
Omitted when the job has no audit entries (e.g. jobs created
before the audit table existed).
examples:
- "0190a5b7-9d42-7000-89ab-c0ffeed00ds1"
request_ids:
type: array
description: |
Full audit trail of `X-Request-ID` values that have hit this
job, ordered by `occurred_at` ascending. Length is 1 in the
common case; >1 when the caller retried with the same
`Idempotency-Key` and a new `X-Request-ID`. Omitted when the
job has no audit entries.
items:
$ref: '#/components/schemas/RequestIDHistoryEntry'
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
RequestIDHistoryEntry:
type: object
description: |
One observed `X-Request-ID` recorded against an `import_job` in
the `import_job_request_ids` audit table. Multiple rows per job
exist when the caller retried the same `Idempotency-Key` with a
new `X-Request-ID`; the oldest entry identifies the request whose
execution produced the job.
required:
- request_id
- occurred_at
properties:
request_id:
type: string
description: The X-Request-ID observed against the job.
examples:
- "0190a5b7-9d42-7000-89ab-c0ffeed00ds1"
occurred_at:
type: string
format: date-time
description: When this request_id was recorded against the job.
ImportJobEntriesResponse:
type: object
description: |
Per-entry rollup returned for API-sourced import jobs. One row per
submitted entry, keyed by the client-provided `entry_id`, paginated via
`entries_pagination`. Includes successful entries, so every submitted
entry is accounted for.
required:
- entries
- entries_pagination
properties:
request_id:
type: string
description: |
The `X-Request-ID` of this HTTP call. Mirrors the response header.
examples:
- "0190a5b8-c4e2-7b34-b234-d56789abcdef"
entries:
type: array
description: Per-entry outcomes, in `line_number` order.
items:
$ref: '#/components/schemas/EntryOutcome'
entries_pagination:
type: object
description: Pagination for `entries`
properties:
limit:
type: integer
offset:
type: integer
total:
type: integer
EntryOutcome:
type: object
description: |
A single entry's processing outcome, keyed by the client-provided
`entry_id`. The `errors` array is present only for failed entries.
required:
- entry_id
- status
properties:
entry_id:
type: string
description: Client-provided identifier echoed back from the request.
examples:
- "hp-2434-029185670-1782208810-26"
line_number:
type: integer
description: 1-based position of the entry in the submitted batch.
examples:
- 26
status:
type: string
description: Aggregated outcome for the entry.
enum:
- success
- error
- pending
- skipped
- unknown
errors:
type: array
description: |
Minimal per-error detail. Present only when `status` is `error`.
items:
$ref: '#/components/schemas/EntryOutcomeError'
EntryOutcomeError:
type: object
description: |
Minimal error detail for a failed entry — exactly three string fields.
required:
- error_type
- error_code
- error_message
properties:
error_type:
type: string
examples:
- "resolution"
error_code:
type: string
examples:
- "variant_resolve_failed"
error_message:
type: string
examples:
- "Child store filtering failed for SKU 029185670: child_store metafield '2431' not found on any product for SKU 029185670"
ImportJobResultsResponse:
type: object
description: |
Errors-only response for the import job results endpoint. Two independently
paginated collections: `import_errors` (per-row processing errors) and
`sync_errors` (outbox/Shopify sync failures). Empty arrays mean no issues
were recorded for that stage.
required:
- import_errors
- pagination
- sync_errors
- sync_pagination
properties:
import_errors:
type: array
description: |
Per-row processing errors recorded while transforming entries.
Empty array means every entry processed without issues.
items:
$ref: '#/components/schemas/ImportResult'
pagination:
type: object
description: Pagination for `import_errors`
properties:
limit:
type: integer
offset:
type: integer
total:
type: integer
sync_errors:
type: array
description: |
Outbox-side failures recorded while syncing transformed records to
Shopify. Empty array means every Shopify sync completed successfully.
items:
$ref: '#/components/schemas/SyncError'
sync_pagination:
type: object
description: Pagination for `sync_errors`
properties:
limit:
type: integer
offset:
type: integer
total:
type: integer
ImportResult:
type: object
description: |
A single per-row processing outcome. In practice only error and skipped
rows are persisted, so this schema's `result` will almost always be
`error` (or `skipped`) when returned by `import_errors`.
properties:
id:
type: integer
format: int64
import_job_id:
type: integer
format: int64
row_number:
type: integer
entry_id:
type:
- string
- "null"
description: Client-provided entry identifier
result:
type: string
description: Outcome status. Typically `error` in errors-only responses.
enum:
- success
- error
- skipped
action:
type:
- string
- "null"
description: Action taken (created, updated, unchanged)
enum:
- created
- updated
- unchanged
product_id:
type:
- integer
- "null"
format: int64
variant_ids:
type:
- array
- "null"
items:
type: integer
format: int64
error_type:
type:
- string
- "null"
description: Error category (validation, database, conversion, resolution)
error_code:
type:
- string
- "null"
description: Machine-readable error code (e.g. `missing_required_field`, `gift_card_taxable`)
column_name:
type:
- string
- "null"
description: Column or field that triggered the error
column_value:
type:
- string
- "null"
description: Offending value (when applicable)
error_message:
type:
- string
- "null"
created_at:
type: string
format: date-time
SyncError:
type: object
description: |
A single outbox-side sync failure (Shopify mutation attempt) linked to
an import job. Distinct from `ImportResult` — that one captures the
per-row transform outcome; this one captures the per-aggregate Shopify
sync outcome.
properties:
id:
type: integer
format: int64
import_job_id:
type:
- integer
- "null"
format: int64
store_number:
type: string
aggregate_type:
type: string
enum:
- product
- variant
aggregate_id:
type: integer
format: int64
sync_operation:
type:
- string
- "null"
description: Sync operation type (product.sync, pos.sync, inventory, price)
status:
type: string
enum:
- pending
- processing
- processed
- failed
attempts:
type: integer
max_attempts:
type: integer
error_type:
type:
- string
- "null"
error_code:
type:
- string
- "null"
error_message:
type:
- string
- "null"
sku:
type:
- string
- "null"
description: Variant SKU (only for variant-level syncs)
product_handle:
type:
- string
- "null"
description: Product handle (for products) or parent product handle (for variants)
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
processed_at:
type:
- string
- "null"
format: date-time
ImportJobOutboxResponse:
type: object
required:
- outbox_events
- pagination
properties:
outbox_events:
type: array
items:
$ref: '#/components/schemas/OutboxEvent'
pagination:
type: object
properties:
limit:
type: integer
offset:
type: integer
total:
type: integer
OutboxEvent:
type: object
properties:
id:
type: integer
format: int64
aggregate_id:
type: integer
format: int64
description: Product or variant ID
aggregate_type:
type: string
description: Entity type
enum:
- product
- variant
event_type:
type: string
description: Operation type
enum:
- create
- update
store_number:
type: string
sync_operation:
type:
- string
- "null"
description: Sync operation type (product.sync, pos.sync, inventory, price)
status:
type: string
enum:
- pending
- processing
- processed
- failed
attempts:
type: integer
max_attempts:
type: integer
error_message:
type:
- string
- "null"
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
processed_at:
type:
- string
- "null"
format: date-time
ImportJobErrorsResponse:
type: object
required:
- errors
properties:
request_id:
type: string
description: |
The `X-Request-ID` of this HTTP call. Mirrors the response header.
examples:
- "0190a5b8-c4e2-7b34-b234-d56789abcdef"
errors:
type: array
items:
$ref: '#/components/schemas/ImportResult'
total:
type: integer
# =========================================================================
# Variant Inventory Response Schemas
# =========================================================================
VariantInventoryResponse:
type: object
required:
- data
- meta
properties:
data:
type: array
description: |
One entry per Shopify inventory item matching the SKU. A SKU can map to
multiple variants per store, so this may contain more than one item.
items:
$ref: '#/components/schemas/InventoryVariant'
meta:
$ref: '#/components/schemas/InventoryResponseMeta'
InventoryVariant:
type: object
description: Live inventory for a single variant (one Shopify inventory item) matching the SKU.
required:
- inventory_item_id
- sku
- tracked
- duplicate_sku_count
- variant_id
- variant_title
- locations
properties:
inventory_item_id:
type: string
description: Shopify InventoryItem GID
examples:
- "gid://shopify/InventoryItem/48882985435393"
sku:
type: string
examples:
- "000844035"
tracked:
type: boolean
description: Whether Shopify tracks inventory for this item
duplicate_sku_count:
type: integer
description: Number of variants sharing this SKU within the store
variant_id:
type: string
description: Shopify ProductVariant GID
examples:
- "gid://shopify/ProductVariant/46769643880705"
variant_title:
type: string
examples:
- "AUTO WINDSOCK 1-2C/WHITE/ - White"
locations:
type: array
description: |
Per-location inventory. Empty when the variant is not stocked at the
requested location (only possible when the `location` filter is applied).
items:
$ref: '#/components/schemas/InventoryLocation'
InventoryLocation:
type: object
required:
- location_id
- location_name
- quantities
- updated_at
properties:
location_id:
type: string
description: Shopify Location GID
examples:
- "gid://shopify/Location/85819195649"
location_name:
type: string
examples:
- "9975"
quantities:
$ref: '#/components/schemas/InventoryQuantities'
updated_at:
type: string
format: date-time
description: When Shopify last updated this location's inventory
InventoryQuantities:
type: object
description: |
The six Shopify inventory states. Values are signed integers — `available`
can be negative when a location is oversold. Missing states default to 0.
required:
- available
- on_hand
- committed
- incoming
- reserved
- safety_stock
properties:
available:
type: integer
examples:
- 8
on_hand:
type: integer
examples:
- 8
committed:
type: integer
incoming:
type: integer
examples:
- 1
reserved:
type: integer
safety_stock:
type: integer
InventoryResponseMeta:
type: object
description: Describes the query that produced the response.
required:
- store_number
- sku
- item_count
- duplicate_sku_count
- fetched_at
properties:
store_number:
type: string
examples:
- "9975"
sku:
type: string
examples:
- "000844035"
location:
type:
- string
- "null"
description: The location filter that was applied, or null when unfiltered
examples:
- "Main Warehouse"
item_count:
type: integer
description: Number of variants returned
duplicate_sku_count:
type: integer
description: Highest duplicate-SKU count across the returned items
fetched_at:
type: string
format: date-time
description: When the live Shopify read occurred (UTC)
# =========================================================================
# Admin Metrics Response Schemas
# =========================================================================
SyncMetricsResponse:
type: object
description: Sync-view health snapshot (GET /api/v1/admin/metrics/sync and its deprecated /metrics alias).
properties:
timestamp:
type: string
format: date-time
description: When the snapshot was computed (UTC). A served-stale cached snapshot keeps its original timestamp.
pools:
$ref: '#/components/schemas/PoolStats'
outbox:
$ref: '#/components/schemas/OutboxMetrics'
shopify_capacity:
type: array
description: Per-store Shopify leaky-bucket capacity. Only stores consuming quota or recently active are included.
items:
$ref: '#/components/schemas/ShopifyCapacityEntry'
IngestionMetricsResponse:
type: object
description: Ingestion-view health snapshot (GET /api/v1/admin/metrics/ingestion).
properties:
timestamp:
type: string
format: date-time
description: When the snapshot was computed (UTC).
pools:
$ref: '#/components/schemas/PoolStats'
ingestion:
$ref: '#/components/schemas/IngestionMetrics'
PoolStats:
type: object
description: |
Connection-pool stats keyed by service. Worker roles report their own
stats to Redis and appear as `worker-file`, `worker-sync`, etc.; the API
process reports itself under `api`; `all` is the aggregate across every
service, with `all.database.max_open_connections` overridden to the actual
PostgreSQL `max_connections`.
additionalProperties:
$ref: '#/components/schemas/PoolStatsEntry'
PoolStatsEntry:
type: object
properties:
database:
$ref: '#/components/schemas/DatabasePoolStats'
redis:
$ref: '#/components/schemas/RedisPoolStats'
config:
type: object
description: Per-service pool configuration. Present on `api` and worker entries; absent on `all`.
additionalProperties: true
updated_at:
type: string
format: date-time
description: When this worker last published its stats to Redis. Present on worker entries only; the live `api` and `all` entries omit it.
DatabasePoolStats:
type: object
properties:
max_open_connections:
type: integer
open_connections:
type: integer
in_use:
type: integer
idle:
type: integer
wait_count:
type: integer
format: int64
wait_duration_ms:
type: integer
format: int64
RedisPoolStats:
type: object
properties:
total_conns:
type: integer
idle_conns:
type: integer
stale_conns:
type: integer
hits:
type: integer
misses:
type: integer
timeouts:
type: integer
OutboxMetrics:
type: object
description: Outbox processing metrics, global and per store.
properties:
global:
$ref: '#/components/schemas/OutboxStoreMetrics'
by_store:
type: object
description: Per-store metrics keyed by store number. Only active stores are included (a store with only processed_last_hour activity is omitted).
additionalProperties:
$ref: '#/components/schemas/OutboxStoreMetrics'
OutboxStoreMetrics:
type: object
description: Outbox metrics for one store (or the global aggregate). Zero-valued fields are omitted from the response.
properties:
pending:
type: object
description: Pending count keyed by operation (products, prices, pos, initial_inventory, delta_inventory).
additionalProperties:
type: integer
processing:
type: object
description: In-flight count keyed by operation.
additionalProperties:
type: integer
failed_last_hour:
type: integer
processed_last_hour:
type: integer
oldest_pending_seconds:
type: integer
description: Age (seconds) of the oldest pending row. Global is the max across stores.
throughput_per_second:
type: number
format: double
description: Rows processed in the last 5 minutes divided by 300.
ShopifyCapacityEntry:
type: object
description: Per-store Shopify GraphQL leaky-bucket capacity.
properties:
store_number:
type: string
available_points:
type: number
format: double
maximum_points:
type: number
format: double
restore_rate:
type: number
format: double
capacity_percent:
type: number
format: double
description: available_points / maximum_points, as a percentage.
last_updated:
type: string
format: date-time
staleness_seconds:
type: integer
description: Seconds since this store's capacity was last observed.
IngestionMetrics:
type: object
properties:
staging:
$ref: '#/components/schemas/StagingMetrics'
jobs:
$ref: '#/components/schemas/JobsMetrics'
StagingMetrics:
type: object
description: Real-time staging pipeline health, global and per store.
properties:
global:
$ref: '#/components/schemas/StagingStoreMetrics'
by_store:
type: object
description: Per-store metrics keyed by store number. Only active stores are included (pending, processing, throughput, or errors).
additionalProperties:
$ref: '#/components/schemas/StagingStoreMetrics'
StagingStoreMetrics:
type: object
description: Staging metrics for one store (or the global aggregate). Zero-valued fields are omitted from the response.
properties:
pending:
type: object
description: Pending count keyed by file type (PRODUCT, PRICE, INVENTORY_INITIAL, INVENTORY_DELTA, POS, and the API_* variants for API-sourced jobs).
additionalProperties:
type: integer
processing:
type: object
description: In-flight (processing) count keyed by file type.
additionalProperties:
type: integer
oldest_pending_seconds:
type: integer
description: Age (seconds) of the oldest pending row. Global is the max across stores.
throughput_per_second:
type: number
format: double
description: Records processed in the last 5 minutes divided by 300.
error_last_hour:
type: integer
description: Records that errored in the last hour.
JobsMetrics:
type: object
description: |
Import-job lifecycle summary. import_jobs has no store column, so these
aggregate globally by status. Zero-valued fields are omitted from the
response.
properties:
in_flight:
type: object
description: Job count per non-terminal status (pending, parsing, staged, processing).
additionalProperties:
type: integer
oldest_in_flight_seconds:
type: object
description: Age (seconds) of the oldest job per non-terminal status.
additionalProperties:
type: integer
completed_last_hour:
type: integer
completed_with_errors_last_hour:
type: integer
failed_last_hour:
type: integer
# =========================================================================
# Error Response Schemas
# =========================================================================
ErrorResponse:
type: object
required:
- error
properties:
error:
type: string
description: Human-readable error message
ValidationErrorResponse:
type: object
required:
- error
- validation_errors
properties:
error:
type: string
examples:
- "Request validation failed"
validation_errors:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
RateLimitErrorResponse:
type: object
required:
- error
- retry_after
properties:
error:
type: string
examples:
- "Rate limit exceeded"
retry_after:
type: integer
description: Seconds until the rate limit resets
examples:
- 30
responses:
BadRequest:
description: All entries failed validation
content:
application/json:
schema:
type: object
properties:
error:
type: string
details:
type: array
items:
type: object
examples:
default:
value:
error: "All entries failed validation"
details:
- entry_id: "prod-001"
message: "SKU is required"
Unauthorized:
description: Missing or invalid Authorization header
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
missingHeader:
summary: Missing Authorization header
value:
error: "Missing Authorization header"
invalidFormat:
summary: Invalid header format (must be "Bearer <token>")
value:
error: "Invalid Authorization header format"
invalidKey:
summary: Invalid or inactive API key
value:
error: "Invalid or inactive API key"
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
default:
value:
error: "Job not found"
PayloadTooLarge:
description: Request payload exceeds size limit
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
default:
value:
error: "Payload exceeds 10MB limit"
UnprocessableEntity:
description: Invalid request schema
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationErrorResponse'
examples:
default:
value:
error: "Request validation failed"
validation_errors:
- field: "entries"
message: "must contain at least 1 item"
RateLimitExceeded:
description: Rate limit exceeded
content:
application/json:
schema:
$ref: '#/components/schemas/RateLimitErrorResponse'
examples:
default:
value:
error: "Rate limit exceeded: 100 requests per minute"
retry_after: 45
Forbidden:
description: Authenticated, but the API key lacks the required scope
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
insufficientScope:
value:
error: "Insufficient permissions"
required_scope: "read:inventory"
BadGateway:
description: Upstream Shopify request failed
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
unreachable:
summary: Could not reach Shopify for this store
value:
error: "failed to reach Shopify for this store"
queryFailed:
summary: Inventory query failed
value:
error: "failed to fetch inventory from Shopify"
InternalServerError:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
default:
value:
error: "An unexpected error occurred"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment