Skip to content

SKUMan External API

Complete reference for the SKUMan External API, enabling ERP, e-commerce, and other system integrations.


API keys are created by tenant administrators in the SKUMan Admin Dashboard.

Terminal window
curl -X GET "https://your-instance.sku-man.com/api/v1/external/products?limit=10" \
-H "X-API-Key: skm_live_your_key_here"
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"sku": "NIKE-AIR-001",
"name": "Air Max 90",
"price": 129.99,
"available": 250,
"version": 5
}
],
"pagination": {
"limit": 10,
"offset": 0,
"total": 1250,
"hasMore": true
}
}

https://{your-instance}.sku-man.com/api/v1/external

Include your API key in every request using one of these methods:

X-API-Key: skm_live_your_key_here

All API keys follow this format:

skm_live_[32 random characters]

Example: skm_live_7Hk2mP9xQrL5nWdF3jYv8bAc1eGtKs6i

The 32-character suffix is base64url and may contain - or _. Validate keys with ^skm_live_[A-Za-z0-9_-]{32}$ rather than an alphanumeric-only pattern — a naive [A-Za-z0-9]{32} check rejects legitimate keys.

API Key and Webhook management endpoints (/keys/*, /webhooks/*) require an Auth0 JWT instead of an API key. These are admin-only operations — the JWT must belong to a user with the Admin or SUPERAdmin role within the Auth0 Organization.

Auth methodToken formatUsed for
API Keyskm_live_... (41 chars — skm_live_ prefix + 32-char base64url suffix)Product CRUD, image upload
JWTeyJhbGciOi... (.-delimited, 3 segments)API key management, webhook management

To obtain a token programmatically for testing:

  1. Configure an Auth0 SPA application with your redirect URI
  2. Initiate the authorization request with the correct audience:
https://{AUTH0_DOMAIN}/authorize?
response_type=code&
client_id={CLIENT_ID}&
redirect_uri={REDIRECT_URI}&
audience={AUTH0_AUDIENCE}&
scope=openid email&
organization={AUTH0_ORG_ID}&
code_challenge={PKCE_CHALLENGE}&
code_challenge_method=S256
  1. Exchange the authorization code for tokens at https://{AUTH0_DOMAIN}/oauth/token
  2. Use the access_token (not the id_token) in the Authorization header

The JWT must contain these custom claims (injected by an Auth0 Post-Login Action):

ClaimDescription
https://sku-man.com/org_idAuth0 Organization ID (maps to tenant)
https://sku-man.com/rolesMust include Admin or SUPERAdmin
https://sku-man.com/emailUser email (used for audit logging)
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
PropertyValue
AlgorithmRS256
Issuerhttps://{AUTH0_DOMAIN}/
AudienceThe API identifier configured in Auth0
PermissionAccess
readInformational only — not currently enforced. All valid keys can perform GET reads regardless of this flag.
writeEnforced. Gates all POST, PUT, PATCH, DELETE writes (create, update, delete, image upload).

Keys are created, rotated, and revoked from the API Keys endpoints. Rotating a key keeps the old one valid for a 24-hour grace period (see Rotate Key).


Endpoints that accept {id_or_sku} in the path can receive either a UUID or a SKU.

Terminal window
# By UUID
curl ".../products/550e8400-e29b-41d4-a716-446655440000" -H "X-API-Key: ..."
# By SKU
curl ".../products/NIKE-AIR-001" -H "X-API-Key: ..."
  • The API auto-detects the format — UUIDs are recognized by their 8-4-4-4-12 hex pattern
  • SKU lookup is case-insensitive (nike-air-001 matches NIKE-AIR-001)
  • URL-encode special characters in SKUs (e.g., SKU%2FA for SKU/A)
  • If a SKU happens to look like a UUID (extremely rare), use the list endpoint with ?sku= instead
  • SKUs in the path must be 1-50 characters after URL-decoding; longer or empty values return 400 VALIDATION_ERROR
  • If a SKU is ambiguous (shared by more than one product), the request returns 409 DUPLICATE_SKU — retry with the product UUID

For exact SKU lookup on the list endpoint, use the sku query parameter:

Terminal window
curl ".../products?sku=NIKE-AIR-001" -H "X-API-Key: ..."

Every path below links to its detail section. /health requires no auth; data endpoints use an API Key; management endpoints (/keys/*, /webhooks/*) use an Auth0 JWT.

MethodEndpointAuthDescription
GET/productsAPI Key (read)List products with filters
POST/productsAPI Key (write)Create product
GET/products/{id_or_sku}API Key (read)Get single product
PUT/products/{id_or_sku}API Key (write)Update product
DELETE/products/{id_or_sku}API Key (write)Soft-delete product
POST/products/bulkAPI Key (write)Bulk upsert (up to 1,000)
MethodEndpointAuthDescription
POST/products/{id_or_sku}/imagesAPI Key (write)Upload image (multipart)
POST/products/{id_or_sku}/images/presignAPI Key (write)Get presigned upload URL
POST/products/{id_or_sku}/images/completeAPI Key (write)Complete presigned upload
POST/staging/{stagingId}/images/presignAPI Key (write)Presign for staged product
POST/staging/{stagingId}/images/completeAPI Key (write)Complete staged presigned upload
POST/products/images/bulkAPI Key (write)Bulk image upload (multipart)
POST/products/images/bulk/autoAPI Key (write)Bulk auto-parse images (multipart)
POST/products/images/bulk/presignAPI Key (write)Bulk presigned URLs
POST/products/images/bulk/completeAPI Key (write)Complete bulk presigned batch
POST/products/images/wipeAPI Key (write)Wipe a style-color's images (mirror mode)
MethodEndpointAuthDescription
GET/inventoryAPI Key (read)List incoming-inventory entries
GET/inventory/{id}API Key (read)Get single inventory entry
POST/inventoryAPI Key (write)Upsert inventory entries (up to 1,000)
POST/inventory/bulkAPI Key (write)Alias for POST /inventory
PUT/inventory/{id}API Key (write)Update an inventory entry
DELETE/inventory/{id}API Key (write)Delete an inventory entry
MethodEndpointAuthDescription
POST/sales-orders/bulkAPI Key (write) + sales.write featureBulk-upsert sell-in order lines (up to 1,000)
POST/sales-orders/finalizeAPI Key (write) + sales.write featureReconcile a completed batch feed (close vanished open lines)
MethodEndpointAuthDescription
POST/cludf-values/bulkAPI Key (write) + cludf moduleBulk upsert per-list custom-field values by SKU (up to 1,000)
MethodEndpointAuthDescription
GET/webhooksJWTList webhooks
POST/webhooksJWTCreate webhook
GET/webhooks/{id}JWTGet webhook details
PATCH/webhooks/{id}JWTUpdate webhook
DELETE/webhooks/{id}JWTDelete webhook
POST/webhooks/{id}/rotate-secretJWTRotate webhook signing secret
POST/webhooks/{id}/testJWTSend test event
GET/webhooks/{id}/deliveriesJWTView delivery log
MethodEndpointAuthDescription
GET/keysJWTList API keys
POST/keysJWTCreate API key
GET/keys/{id}JWTGet API key details
PATCH/keys/{id}JWTUpdate API key
DELETE/keys/{id}JWTDelete API key (permanent)
POST/keys/{id}/rotateJWTRotate API key
MethodEndpointAuthDescription
GET/settings/fieldsAPI KeyList enabled custom (UDF/CPF) field keys, labels, and types
GET/sync-versionAPI KeyLatest SKUMan Sync desktop app version info
GET/healthNoneCheck API status

LimitDefaultConfigurable Range
Per minute60 requests1 - 10,000
Per day10,000 requests1 - 1,000,000

All API keys for a single tenant share a combined limit of 600 requests/minute.

HeaderDescription
X-RateLimit-LimitMaximum requests per minute for this key
X-RateLimit-RemainingRequests remaining in current window
X-RateLimit-ResetUnix timestamp (ms) when window resets
Retry-AfterSeconds to wait (only on 429 responses)

Every response includes an X-Trace-Id header for debugging. You can send your own X-Trace-Id request header and the API will use it instead of generating one.

The trace ID appears in all error responses in the traceId field.


{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description",
"traceId": "abc123def456",
"details": {}
}
}
CodeStatusDescription
VALIDATION_ERROR400Invalid request body or parameters
INVALID_JSON400Malformed JSON body
MISSING_REQUIRED_FIELD400Required field absent
UNAUTHORIZED401No/invalid JWT on a management endpoint (/keys, /webhooks). API-key auth failures on data endpoints do not return this code (see Authentication).
FORBIDDEN403Access denied (e.g., upload session mismatch)
INSUFFICIENT_PERMISSIONS403API key lacks required permission
NOT_FOUND404Resource not found
PRODUCT_NOT_FOUND404Product does not exist
WEBHOOK_NOT_FOUND404Webhook not found
API_KEY_NOT_FOUND404API key not found
VERSION_CONFLICT409Optimistic locking conflict
DUPLICATE_SKU409SKU already exists
DUPLICATE_KEY409Unique constraint violation
IDEMPOTENCY_KEY_CONFLICT409Idempotency-Key reused with a different request body
PAYLOAD_TOO_LARGE413Request body exceeds the size limit — use the presigned bulk image flow
QUOTA_EXCEEDED413Tenant image-storage quota exceeded
UNPROCESSABLE_ENTITY422Semantic validation failure
RATE_LIMIT_EXCEEDED429Rate limit exceeded
INTERNAL_ERROR500Unexpected server error
SERVICE_UNAVAILABLE503Service temporarily unavailable

For safe retries on write operations (POST, PUT, PATCH, DELETE), include an idempotency key:

Idempotency-Key: unique-operation-id

{
"staged": true,
"batchId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"stagingId": "550e8400-e29b-41d4-a716-446655440000",
"operation": "create",
"sku": "NIKE-AIR-001",
"message": "Product creation staged for admin review"
}
  • Custom-field value writes (POST /cludf-values/bulk) always execute immediately
  • Image uploads always execute immediately (FK constraint requires the product to exist)
  • GET requests are never affected by staging
{
"staged": true,
"batchId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"summary": {
"total": 50,
"creates": 10,
"updates": 40,
"inputRows": 50
},
"message": "50 product operations staged for admin review"
}

GET/health

No authentication required.

{
"status": "ok",
"timestamp": 1704067200000
}

GET/products

Retrieve products with filtering and pagination.

ParameterTypeDefaultDescription
limitinteger100Results per page (1-1000)
offsetinteger0Number of results to skip
updatedSinceinteger
Unix timestamp (ms) - only products updated after this time
includeDeletedbooleanfalseInclude soft-deleted products (tombstones)
brandstring
Filter by exact brand match
seasonstring
Filter by exact season match
divisionstring
Filter by exact division match
bundlestring
Filter by exact bundle match
minPricenumber
Minimum price filter
maxPricenumber
Maximum price filter
tagsstring
Comma-separated tag names (matches product_tags OR webitem_tags)
searchstring
Case-insensitive search on SKU, name, description (max 200 chars)
skustring
Exact SKU match (case-insensitive)
Terminal window
curl "https://your-instance.sku-man.com/api/v1/external/products?limit=50&brand=Nike&tags=bestseller,new-arrival&updatedSince=1704067200000" \
-H "X-API-Key: skm_live_..."
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"sku": "NIKE-AIR-001",
"name": "Air Max 90",
"brand": "Nike",
"season": "SS24",
"price": 129.99,
"retailPrice": 150.00,
"available": 250,
"images": ["https://cdn.example.com/image1.jpg"],
"tags": ["bestseller", "new-arrival"],
"updatedAt": 1704067200000,
"version": 5
}
],
"pagination": {
"limit": 50,
"offset": 0,
"total": 1250,
"hasMore": true
}
}
Terminal window
# Initial full sync
GET /products?limit=1000
# Subsequent incremental syncs
GET /products?updatedSince=1704067200000&includeDeleted=true

GET/products/{id_or_sku}

Retrieve a single product by UUID or SKU.

{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"sku": "NIKE-AIR-001",
"name": "Air Max 90",
"brand": "Nike",
"price": 129.99,
"version": 5,
"updatedAt": 1704067200000
}
}

POST/products

Create a new product. Requires write permission.

HeaderRequiredDescription
X-API-KeyYesYour API key
Content-TypeYesapplication/json
Idempotency-KeyRecommendedUnique key for safe retries
{
"sku": "NIKE-AIR-001",
"name": "Air Max 90",
"brand": "Nike",
"season": "SS24",
"price": 129.99,
"retailPrice": 150.00,
"available": 250,
"tags": ["new-arrival"]
}
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"sku": "NIKE-AIR-001",
"name": "Air Max 90",
"brand": "Nike",
"createdAt": 1704067200000,
"updatedAt": 1704067200000
}
}

PUT/products/{id_or_sku}

Update an existing product by UUID or SKU. Requires write permission.

{
"price": 139.99,
"available": 200,
"version": 5
}
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"sku": "NIKE-AIR-001",
"price": 139.99,
"available": 200,
"version": 6,
"updatedAt": 1704070800000
}
}
{
"error": {
"code": "VERSION_CONFLICT",
"message": "Version conflict",
"traceId": "abc123",
"details": {
"currentVersion": 6,
"providedVersion": 5
}
}
}

DELETE/products/{id_or_sku}

Soft-delete a product by UUID or SKU. Requires write permission. The product becomes a tombstone visible via includeDeleted=true.

{
"success": true,
"message": "Product deleted"
}

POST/products/bulk

Create or update up to 1,000 products in a single request. Requires write permission.

FieldTypeDefaultDescription
productsarray
Array of products (1-1000)
matchBystringidMatch by id or sku
createIfMissingbooleantrueCreate products that don't exist
{
"products": [
{ "sku": "SKU-001", "name": "Product 1", "price": 99.99, "available": 100 },
{ "sku": "SKU-002", "name": "Product 2", "price": 149.99, "available": 50 },
{ "sku": "SKU-003", "name": "Product 3", "price": 199.99, "available": 25 }
],
"matchBy": "sku",
"createIfMissing": true
}
{
"success": true,
"summary": {
"total": 3,
"created": 1,
"updated": 2,
"skipped": 0,
"failed": 0
},
"errors": []
}
{
"success": false,
"summary": {
"total": 3,
"created": 1,
"updated": 1,
"skipped": 0,
"failed": 1
},
"errors": [
{ "sku": "SKU-003", "error": "Product not found and createIfMissing is false" }
]
}

Images can be uploaded to products via the External API. Two methods are supported.

POST/products/{id_or_sku}/images

Upload a single image file directly.

Terminal window
curl -X POST "https://your-instance.sku-man.com/api/v1/external/products/550e8400-.../images" \
-H "X-API-Key: skm_live_..." \
-F "image=@product-photo.jpg"

Returns 201 when a new image row is created, or 200 with alreadyAttached: true when the identical file was already attached (idempotent re-upload).

{
"url": "https://cdn.example.com/tenant-id/1704067200000-abc-product-photo.jpg",
"deduplicated": false,
"staged": false,
"alreadyAttached": false
}

If an image with the same normalized filename but different content already exists:

{
"staged": true,
"conflict": true,
"batchId": "f47ac10b-...",
"stagingId": "550e8400-...",
"deduplicated": false,
"message": "Image upload staged for manual conflict resolution"
}

For large files or browser-based uploads. This is a two-step process.

POST/products/{id_or_sku}/images/presign
{
"fileName": "product-photo.jpg",
"mimeType": "image/jpeg",
"fileHash": "a1b2c3d4e5f6...",
"fileSize": 102400
}
FieldTypeRequiredDescription
fileNamestringYesImage filename (max 255 chars, no path separators)
mimeTypestringYesimage/jpeg, image/png, image/webp, image/gif, or image/svg+xml
fileHashstringNoSHA-256 hex hash (64 chars) for dedup hint
fileSizeintegerNoFile size in bytes

Response (201):

{
"uploadSessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"uploadUrl": "https://r2-presigned-url...",
"key": "tenant-id/1704067200000-abc-product-photo.jpg",
"expiresInSeconds": 900
}

Upload directly to the uploadUrl using HTTP PUT:

Terminal window
curl -X PUT "https://r2-presigned-url..." \
-H "Content-Type: image/jpeg" \
--data-binary @product-photo.jpg
POST/products/{id_or_sku}/images/complete
{
"uploadSessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}

Response: 201 on a fresh attach, or 200 with alreadyAttached: true when the identical file was already attached.

{
"url": "https://cdn.example.com/tenant-id/1704067200000-abc-product-photo.jpg",
"deduplicated": false,
"staged": false,
"alreadyAttached": false
}
AspectBehavior
Original imageStored without resizing. A trimmed variant is generated when your tenant has trimmed images enabled. SVG uploads are sanitized (re-encoded); the stored Content-Type is normalized to the verified MIME type.
ThumbnailsGenerated in background at 100px, 400px, 600px (JPEG)
Dedup skipThumbnails skipped if image was deduplicated (already exist)
URL pattern{CDN_URL}/{tenant-id}/thumbnails/{filename}_w{width}.jpg
AvailabilityAsynchronous — may not be immediately available after upload

When API Import Staging is enabled, newly created products don't exist in the database yet. Use staging-specific endpoints:

POST /staging/{stagingId}/images/presign
POST /staging/{stagingId}/images/complete

These work identically to the product presigned endpoints but link the image to a staged item. The image is committed when the batch is committed.

API consumers can upload images for many products in a single request. Max 10 files per request for the multipart bulk endpoints (/products/images/bulk and /products/images/bulk/auto), or 3,000 images for the presigned bulk flow. For multipart, each file may be up to 50 MB and the whole request is capped at 500 MB total — use the presigned bulk flow for larger batches.

POST/products/images/bulk

Map multiple files to specific products using a JSON metadata field. In Node.js, you can use FormData and append files as Blob objects.

const formData = new FormData();
// 1. Add the metadata as a JSON string
const metadata = [
{ sku: "SKU-001", order: 1 },
{ sku: "SKU-002", colorCode: "RED" }
];
formData.append('metadata', JSON.stringify(metadata));
// 2. Append each image as a Blob (order MUST match the metadata array)
formData.append('files', new Blob([fs.readFileSync('photo1.jpg')]), 'photo1.jpg');
formData.append('files', new Blob([fs.readFileSync('photo2.jpg')]), 'photo2.jpg');
const response = await fetch(`${SKUMAN_API}/products/images/bulk`, {
method: 'POST',
headers: { 'X-API-Key': API_KEY },
body: formData
});
POST/products/images/bulk/auto

Automatically maps images based on filename convention: [sku]-[colorCode]-[order].ext. In Node.js, append multiple Blob objects to FormData under the files key.

  • Format: NIKE-AIR-RED-01.jpg -> SKU: NIKE-AIR, Color: RED, Order: 1
  • Parsing order: tried as SKU-COLOR-ORDER (3+ segments, numeric 1-3 digit last part), else SKU-ORDER (2+ segments, numeric last part), else the whole filename is treated as the SKU with order defaulting to 1
  • Errors: a file only fails to map if it has no name; an unmatched SKU returns a per-file error result
const formData = new FormData();
// Append each image as a Blob, ensuring filenames follow the convention
formData.append('files', new Blob([fs.readFileSync('NIKE-001-RED-01.jpg')]), 'NIKE-001-RED-01.jpg');
formData.append('files', new Blob([fs.readFileSync('NIKE-001-BLU-02.jpg')]), 'NIKE-001-BLU-02.jpg');
const response = await fetch(`${SKUMAN_API}/products/images/bulk/auto`, {
method: 'POST',
headers: { 'X-API-Key': API_KEY },
body: formData
});

For large batches using the presigned URL flow.

Step 1: Request Batch URLs

POST/products/images/bulk/presign
{
"images": [
{ "sku": "SKU-001", "fileName": "img1.jpg", "mimeType": "image/jpeg" },
{ "sku": "SKU-002", "fileName": "img2.jpg", "mimeType": "image/jpeg", "colorCode": "RED", "order": 1 }
]
}

Each image entry accepts optional colorCode (string, disambiguates SKU-only matches) and order (integer 0-999 — the explicit display slot; omit to auto-increment per product).

Presign response — the per-image status literal is pending_upload:

{
"batchId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"results": [
{ "index": 0, "sku": "SKU-001", "status": "pending_upload", "uploadSessionId": "sess-1", "uploadUrl": "https://r2-presigned-url...", "key": "tenant-id/...jpg", "expiresInSeconds": 900 },
{ "index": 1, "sku": "SKU-002", "status": "pending_upload", "uploadSessionId": "sess-2", "uploadUrl": "https://r2-presigned-url...", "key": "tenant-id/...jpg", "expiresInSeconds": 900 }
],
"summary": { "total": 2, "succeeded": 2, "failed": 0 }
}

Step 2: Complete Batch

POST/products/images/bulk/complete

Complete with the precise per-image session IDs (preferred), or complete every session created by the presign call with its batchId:

{
"uploadSessionIds": ["sess-1", "sess-2"]
}
{
"batchId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}

All bulk image endpoints return a unified result array:

{
"results": [
{ "index": 0, "sku": "SKU-001", "status": "success", "url": "...", "deduplicated": false },
{ "index": 1, "sku": "SKU-002", "status": "staged", "stagingId": "...", "message": "..." },
{ "index": 2, "sku": "BAD-SKU", "status": "error", "error": "Product not found" }
],
"summary": { "total": 3, "succeeded": 1, "alreadyAttached": 0, "staged": 1, "failed": 1 }
}

Per-result status is one of: success, already_attached, staged, skipped (bulk/complete only — reason is expired, already_completed, or error), or error. HTTP status: 200 when all items succeed, 207 on partial success (any staged or failed), 400 when every item failed.

Terminal window
# 1. Presign — request upload URLs for the batch
curl -X POST "https://your-instance.sku-man.com/api/v1/external/products/images/bulk/presign" \
-H "X-API-Key: skm_live_..." \
-H "Content-Type: application/json" \
-d '{"images":[{"sku":"SKU-001","fileName":"img1.jpg","mimeType":"image/jpeg"},{"sku":"SKU-002","fileName":"img2.jpg","mimeType":"image/jpeg","colorCode":"RED","order":1}]}'
# → returns batchId + results[].uploadSessionId + results[].uploadUrl (valid 15 min)
# 2. PUT each file straight to its uploadUrl (within 15 min)
curl -X PUT "https://r2-presigned-url-for-sess-1..." \
-H "Content-Type: image/jpeg" --data-binary @img1.jpg
curl -X PUT "https://r2-presigned-url-for-sess-2..." \
-H "Content-Type: image/jpeg" --data-binary @img2.jpg
# 3. Complete — attach the uploaded objects (within 30 min of presign)
curl -X POST "https://your-instance.sku-man.com/api/v1/external/products/images/bulk/complete" \
-H "X-API-Key: skm_live_..." \
-H "Content-Type: application/json" \
-d '{"uploadSessionIds":["sess-1","sess-2"]}'
# → returns the unified BulkImageResponse shown above

Mirror mode lets an integration treat a local folder as the database of record for images: a changed style-color re-uploads its full image set and the server replaces (wipes then re-attaches) that combo; a style-color whose images were all removed is wiped explicitly. Used by SKUMan Sync's "Mirror folder" image mode.

Mirror flag on bulk presign. POST /products/images/bulk/presign accepts an optional top-level "mirror": true. Effects:

  • Each per-image result additionally returns styleKey (the server's style-color grouping key) so clients can detect when two SKU groups resolve to the same combo.
  • Presigned URLs and upload sessions are long-lived for large runs: expiresInSeconds: 28800 (8 h) instead of 900, and sessions stay completable for 8 h instead of 30 min.
  • At bulk/complete, the first attach for each style-color in the batch wipes that combo's existing images once (all size variants of that one color; other colors untouched), then the batch's images attach in their explicit order. The wipe fires exactly once per batch per combo even when completes are chunked across requests.

Explicit wipe — for style-colors whose images were all removed locally:

POST/products/images/wipe
{
"items": [
{ "sku": "SKU-001" },
{ "sku": "SKU-002", "colorCode": "RED" }
]
}

1–200 items per request; duplicate (sku, colorCode) items are deduplicated. Each item resolves its product exactly like bulk presign does, then deletes every image of that product's style-color combo (R2 objects are only removed when no other product references them). Response:

{
"success": true,
"results": [
{ "sku": "SKU-001", "colorCode": null, "status": "wiped", "imagesDeleted": 4 },
{ "sku": "SKU-002", "colorCode": "RED", "status": "product_not_found", "imagesDeleted": 0 }
],
"summary": { "wiped": 1, "notFound": 1, "imagesDeleted": 4 }
}

Per-item failures never fail the request. Affected products get product.updated webhooks (changes: ["images"]).


Manage the per-product incoming inventory entries — the "on order" and "in warehouse" deliveries that feed each product's on-order totals and date-based planning.

Each entry carries a type that classifies the delivery:

ValueMeaning
on_orderQuantity on order, not yet received (default)
in_warehouseQuantity received into the warehouse
  • On write endpoints, the productId field accepts either a product UUID or a SKU (resolved server-side, case-insensitive, active products only).
  • On upsert (POST /inventory and POST /inventory/bulk), any productId SKU that fails to resolve — including an ambiguous SKU matching more than one product — is skipped (reported in skipped/skippedSkus); if every entry is unresolved the request returns 404 PRODUCT_NOT_FOUND. Upsert never returns 409.
  • On the GET /inventory productId filter, an unresolved SKU returns 404 PRODUCT_NOT_FOUND and an ambiguous SKU returns 409 DUPLICATE_SKU — pass the UUID instead.
  • The {id} in the path of GET/PUT/DELETE is the inventory entry's UUID — not a product ID or SKU.

GET/inventory

Retrieve incoming-inventory entries with filtering and pagination. Requires read permission.

ParameterTypeDefaultDescription
limitinteger100Results per page (1-1000)
offsetinteger0Number of results to skip
productIdstring
Filter to one product. UUID, or SKU of 1-50 characters (resolved server-side). SKUs longer than 50 characters must be passed as the product UUID.
typestring
Filter by entry type (on_order or in_warehouse)
startDatestring
Lower date bound (YYYY-MM-DD)
endDatestring
Upper date bound (YYYY-MM-DD)
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"productId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"date": "2026-09-01",
"quantity": 120,
"type": "on_order",
"source": "PO-4821",
"notes": "Fall delivery"
}
],
"pagination": { "limit": 100, "offset": 0, "total": 1, "hasMore": false }
}

GET/inventory/{id}

Retrieve a single inventory entry by its UUID. Requires read permission. Returns 404 if no entry matches.

{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"productId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"date": "2026-09-01",
"quantity": 120,
"type": "on_order",
"source": "PO-4821",
"notes": "Fall delivery",
"createdAt": "2026-08-15T10:22:00.000Z",
"updatedAt": "2026-08-20T14:05:00.000Z"
}
}

POST/inventory

Create or update inventory entries. The alias POST /inventory/bulk uses the same handler. Requires write permission. Up to 1,000 entries per request.

FieldTypeDefaultDescription
entriesarray
Inventory entries (0-1,000). An empty array is valid: it signals a full snapshot with nothing incoming — nothing is upserted, but the tenant-gated Auto-Clear (if enabled) still wipes the existing incoming book
conflictResolutionstringreplaceMerge behaviour on an existing (productId, date, type) key: replace overwrites the quantity, sum adds to it

Each entry:

FieldTypeRequiredDescription
productIdstringYesProduct UUID or SKU (1-100 chars; SKU resolved server-side)
datestringYesDelivery date, YYYY-MM-DD. Strictly calendar-validated — non-real dates (e.g. 2026-02-30, 2027-02-29) are rejected with 400 VALIDATION_ERROR.
quantityintegerYesQuantity (≥ 0)
typestringNoon_order (default) or in_warehouse
sourcestringNoFree-text source label (≤ 255 chars, e.g. a PO number)
notesstringNoFree-text notes (≤ 1,000 chars)
{
"entries": [
{ "productId": "NIKE-AIR-001", "date": "2026-09-01", "quantity": 120, "type": "on_order", "source": "PO-4821" },
{ "productId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "date": "2026-10-15", "quantity": 60 }
],
"conflictResolution": "replace"
}
{
"success": true,
"inserted": 1,
"updated": 1,
"total": 2
}

A successful (non-staged) upsert fires an inventory.updated webhook.


PUT/inventory/{id}

Update one inventory entry by its UUID. Requires write permission. The body is a partial update — at least one field must be present.

FieldTypeDescription
datestringDelivery date, YYYY-MM-DD. Strictly calendar-validated — non-real dates (e.g. 2026-02-30, 2027-02-29) are rejected with 400 VALIDATION_ERROR.
quantityintegerQuantity (≥ 0)
typestringon_order or in_warehouse
sourcestring | nullSource label (≤ 255 chars; null clears it)
notesstring | nullNotes (≤ 1,000 chars; null clears it)
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"productId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"date": "2026-09-01",
"quantity": 90,
"type": "on_order",
"source": "PO-4821",
"notes": "Revised PO quantity",
"createdAt": "2026-08-15T10:22:00.000Z",
"updatedAt": "2026-08-20T14:05:00.000Z"
}
}

Returns 404 if no entry matches. Returns 202 when staging is enabled. Fires an inventory.updated webhook.


DELETE/inventory/{id}

Delete one inventory entry by its UUID. Requires write permission.

{
"success": true,
"message": "Inventory entry deleted"
}

Returns 404 if no entry matches. Returns 202 when staging is enabled. Fires an inventory.deleted webhook.


Ingest sell-in data — the sales order lines that record what each customer has ordered. This is the automated feed counterpart to the in-app sales import; use it to push order books from an ERP on a schedule.

POST/sales-orders/bulk

Bulk-create or update up to 1,000 sell-in order lines. Requires write permission.

The natural key for a line is (orderNo, sku, prodReference) — a blank prodReference is a distinct line from an assigned one, so an order+SKU can hold both a blank-reference line and one or more assigned-reference lines. Quantities for in-payload duplicates of a key are summed; other fields take the last non-null value.

Product and customer links are resolved on the server — the wire schema does not accept companyId or productId, and sending either returns 400 VALIDATION_ERROR. This keeps tenant isolation by construction (a client can never point a line at another tenant's company or product id).

  • sku → product — matched case-insensitively against active products. Exactly one match links the line's product; no match imports the line with a null product link; an ambiguous match (more than one product shares the SKU) is reported per-row in errors and that line is excluded.
  • customerRaw → company — matched case-insensitively against company code, then company name, then the tenant salesCustomerAliases map. An unmatched value never fails the request: the line imports with a null company link, its raw value preserved, and the value is listed in unmatchedCustomers.

Unlinked lines (null product/company) are back-fillable later by a full internal re-import.

To reconcile the open-order book, stream a complete feed as a batch and then finalize it:

  1. On the first request send openBatch: true. The response includes a batchId.
  2. On every later request send that batchId (do not send openBatch again). Each request that carries openBatch/batchId records its lines' natural keys against the batch — even lines whose slice failed or whose SKU was ambiguous (feed-presence, not write-success, so a present line is never closed as stale).
  3. Call POST /sales-orders/finalize when the whole feed has been sent.

openBatch and batchId are mutually exclusive — sending both is a 400. A batchId that is unknown or has expired returns 409 BATCH_INCOMPLETE. Batches live in memory with a 30-minute TTL from when they were opened; finish and finalize within that window (a lapsed batch must be restarted with a fresh openBatch).

Requests that send neither field behave exactly as before (pure upsert, no batchId in the response).

FieldTypeRequiredDescription
linesarrayYesSell-in order lines (1-1,000)
openBatchtrueNoOpt into reconcile-batch mode: opens a new batch and returns its batchId (see below). Mutually exclusive with batchId.
batchIdstring (uuid)NoContinue an already-open batch (from a prior openBatch: true response). Mutually exclusive with openBatch.

Each line:

FieldTypeRequiredDescription
orderNostringYesSales order number (≤ 200 chars). Part of the natural key.
skustringYesProduct SKU (≤ 200 chars). Resolved server-side (see above).
orderDatestringYesOrder date, YYYY-MM-DD. Strictly calendar-validated — non-real dates are rejected with 400 VALIDATION_ERROR.
qtyOrderedintegerYesQuantity ordered. May be negative — ERP pick reversals / over-shipments are valid data (magnitude ≤ 100,000,000).
customerPostring | nullNoCustomer PO reference (≤ 200 chars).
customerRawstring | nullNoRaw customer name (≤ 200 chars). Resolved to a company server-side (see above).
descriptionstring | nullNoLine description (≤ 1,000 chars).
shipStart, shipCancelstring | nullNoShip window dates, YYYY-MM-DD.
warehouseCodestring | nullNoFulfilment warehouse/location code (≤ 200 chars).
qtyOpen, qtyPicked, qtyUnshipped, qtyCancelled, qtyShippedintegerNoQuantity breakdown. Each may be negative (ERP reversals; magnitude ≤ 100,000,000). Send qtyOpen/qtyUnshipped/qtyPicked = 0 on closed lines (see the lifecycle contract).
prodReferencestring | nullNoProduction/allocation reference (≤ 200 chars). Part of the natural key — blank ≠ assigned.
prodRecvdboolean | nullNoWhether production has been received.
prodDue, prodEta, prodEtdstring | nullNoProduction milestone dates, YYYY-MM-DD.
salesRepstring | nullNoSales rep name (≤ 200 chars).
{
"lines": [
{
"orderNo": "SO-10042",
"customerPo": "PO-8891",
"customerRaw": "Acme Retail",
"sku": "NIKE-AIR-001",
"orderDate": "2026-07-01",
"shipStart": "2026-08-15",
"shipCancel": "2026-09-01",
"qtyOrdered": 120,
"qtyOpen": 120,
"warehouseCode": "DC-01"
},
{
"orderNo": "SO-10042",
"customerRaw": "Acme Retail",
"sku": "NIKE-AIR-002",
"orderDate": "2026-07-01",
"qtyOrdered": 0,
"qtyOpen": 0,
"qtyCancelled": 60
}
]
}
{
"success": true,
"summary": { "total": 2, "inserted": 2, "updated": 0, "rekeyedBlanks": 0, "failed": 0 },
"unmatchedCustomers": [],
"errors": []
}
FieldTypeDescription
successbooleantrue when no line failed. Unmatched customers are not failures.
batchIdstring (uuid)Present only in reconcile-batch mode (openBatch: true, or an echoed continuation batchId). Pass it to /sales-orders/finalize.
summary.totalintegerLines received.
summary.inserted / summary.updatedintegerNew / existing lines written.
summary.rekeyedBlanksintegerBlank-reference lines superseded by an assigned-reference line for the same order+SKU.
summary.failedintegerLines not imported (ambiguous SKU + lines in a failed slice).
unmatchedCustomersarrayDeduped customerRaw values that resolved to no company.
errorsarrayPer-row (ambiguous SKU: { orderNo, sku, error }) and per-slice ({ lineNumbers, orderNos, error }, each capped at 50 entries) failures.
POST/sales-orders/finalize

Reconcile a completed batch feed: close (zero the open quantities of) the order lines that were already open in SKUMan but did not appear anywhere in this batch. Requires write permission and the sales.write feature.

Call this once, after the last /sales-orders/bulk chunk of the batch. Closing zeroes qtyOpen, qtyUnshipped, and qtyPicked on stale open lines (the lines are kept, not deleted) and audits a reconcile event.

FieldTypeRequiredDescription
batchIdstring (uuid)YesThe batchId returned by the batch's first /sales-orders/bulk request.
totalRowsintegerYesTotal number of raw lines sent across every chunk of the batch (≥ 0). Must equal the server's running count for the batch or the request is rejected — this is the completeness check.
confirmbooleanYesfalse = dry run (report only, batch stays open); true = execute the close and close the batch.
  • confirm: false{ "success": true, "wouldClose": <int> }. Nothing is written; the batch stays open so you can inspect the count and then confirm.
  • confirm: true{ "success": true, "closed": <int> }. Stale open lines are zeroed, the reconcile is audited, and the batch is closed (a replay returns 409).
{ "batchId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", "totalRows": 1420, "confirm": true }
{ "success": true, "closed": 37 }

Returns 409 BATCH_INCOMPLETE when the batchId is unknown/expired, or when totalRows does not match the number of raw lines the server recorded for the batch (the batch stays open so the missing chunks can be resent).


Each custom list owns up to 50 slotted per-list custom fields (CLUDF). This endpoint bulk-writes field values only — it does not create or modify field definitions (do that from the admin dashboard).

POST/cludf-values/bulk
FieldTypeRequiredDescription
listIdstringYesCustom-list id. Missing or soft-deleted → 404.
itemsarrayYes1-1,000 items
items[].skustringYesProduct SKU — case-sensitive, variant-exact
items[].valuesobjectYesAt least one fieldKey: value pair

fieldKey resolution: a key matching cludf<N> resolves to slot N first; otherwise a case-insensitive label match is tried. Slot wins on collision. Unknown keys are counted in skipped. Coercion: number/currency/decimal/percentage via Number() (non-finite → null); text/date stringified; a null or "" value deletes the value row.

{
"listId": "550e8400-e29b-41d4-a716-446655440000",
"items": [
{ "sku": "SKU-001", "values": { "cludf1": 100, "Selling Forecast": 50 } },
{ "sku": "SKU-002", "values": { "cludf1": null } }
]
}
{
"updated": 2,
"deleted": 1,
"skipped": 0,
"errors": []
}

Real-time notifications for product changes. Webhooks fire for both External API mutations and internal UI changes.

EventTrigger
product.createdNew product created
product.updatedProduct modified (including image changes)
product.deletedProduct soft-deleted
product.bulk_updatedBulk operation completed (one per sub-batch of 200)
inventory.updatedIncoming inventory upserted (POST) or a single entry updated (PUT)
inventory.deletedSingle incoming inventory entry deleted
inventory.clearedAll incoming inventory cleared — by an admin action, or automatically when an external API inventory upload runs with auto-inventory-clear enabled

Every webhook request includes these headers:

HeaderDescription
Content-Typeapplication/json
User-AgentSKUMan-Webhooks/1.0
X-SKUMan-SignatureHMAC signature: v1={hmac_sha256_hex}
X-SKUMan-Old-SignatureOld HMAC signature (only during 24h secret rotation grace period)
X-SKUMan-TimestampUnix timestamp (seconds) when signed
X-SKUMan-EventEvent type (e.g., product.updated)
X-SKUMan-Delivery-IDUnique delivery UUID

The payload shape varies by event.

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"sku": "NIKE-AIR-001",
"product": { "id": "550e8400-...", "sku": "NIKE-AIR-001", "name": "Air Max 90", "price": 129.99, "version": 1 },
"source": "external_api",
"batchId": null
}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"sku": "NIKE-AIR-001",
"changes": ["price"],
"product": { "id": "550e8400-...", "sku": "NIKE-AIR-001", "name": "Air Max 90", "price": 139.99, "updatedAt": 1704067200000, "version": 6 },
"source": "external_api",
"batchId": null
}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"sku": "NIKE-AIR-001",
"source": "external_api",
"batchId": null
}

source is one of external_api, internal, internal_save, internal_delete_all, staging_commit, or api_image_upload; batchId is a string on external-API events and null otherwise.

The product.bulk_updated event fires once per sub-batch (200 products). The payload shape varies by source.

{
"batchStart": 0,
"batchSize": 200,
"created": 5,
"updated": 195,
"createdProducts": [{ "id": "550e8400-...", "sku": "SKU-001" }],
"updatedProducts": [{ "id": "6ba7b810-...", "sku": "SKU-002" }],
"source": "external_api",
"batchId": null
}
{
"total": 200,
"created": 5,
"updated": 195,
"createdProducts": [{ "id": "550e8400-...", "sku": "SKU-001" }],
"updatedProducts": [{ "id": "6ba7b810-...", "sku": "SKU-002" }],
"source": "internal"
}
{
"total": 200,
"source": "internal_save"
}
FieldTypePresentDescription
batchStartintegerExternal API onlyStarting index of this sub-batch
batchSizeintegerExternal API onlyNumber of products in this sub-batch
totalintegerInternal onlyTotal products in the save operation
createdintegerExternal API + internal bulk-save onlyNumber of products created
updatedintegerExternal API + internal bulk-save onlyNumber of products updated
createdProducts{id, sku}[]External API + internal bulk-save onlyProducts that were created
updatedProducts{id, sku}[]External API + internal bulk-save onlyProducts that were updated
sourcestringAlways"external_api", "internal", or "internal_save"
batchIdstring | nullExternal API onlyIdempotency batch ID

The POST /webhooks/{id}/test endpoint sends a test event with event type "test":

{
"test": true,
"timestamp": 1704067200000
}

Inventory events have different payload shapes from product events.

Fires on inventory upsert (POST /inventory or /inventory/bulk).

{
"action": "upsert",
"entryCount": 5,
"inserted": 3,
"updated": 2,
"productIds": ["550e8400-e29b-41d4-a716-446655440000", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"]
}
FieldTypeDescription
actionstringAlways "upsert" for POST /inventory and /inventory/bulk. PUT /inventory/:id fires action: "update" with a different (entryId/productId) payload.
entryCountintegerTotal entries in the request
insertedintegerNew entries created
updatedintegerExisting entries updated
productIdsstring[]Distinct product UUIDs affected

Fires when a single entry is deleted via the External API.

{
"entryId": "550e8400-e29b-41d4-a716-446655440000",
"productId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"source": "external_api"
}
FieldTypeDescription
entryIdstringUUID of the deleted inventory entry
productIdstringUUID of the product the entry belonged to
sourcestringAlways "external_api" for external-API deletes

Fires when an admin clears all incoming inventory, or automatically when an external API inventory upload runs with auto-inventory-clear enabled.

{
"deletedEntries": 150,
"updatedProducts": 42,
"source": "auto_clear"
}
FieldTypeDescription
deletedEntriesintegerTotal inventory entries removed
updatedProductsintegerProducts whose inventory fields were reset
sourcestring"auto_clear" when fired by the API-upload auto-clear path; omitted on an admin clear

Signature format: v1={hmac_sha256_hex}

const crypto = require('crypto');
// Capture the raw request bytes so verification runs against exactly what was signed:
// app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } }));
function verifyWebhookSignature(req, secret) {
const timestamp = req.headers['x-skuman-timestamp'];
const signature = req.headers['x-skuman-signature'];
const body = req.rawBody; // the raw received bytes, not JSON.stringify(req.body)
// Verify timestamp is recent (within 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false;
}
// Calculate expected signature
const payload = `${timestamp}.${body}`;
const expected = 'v1=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}

During the 24-hour grace period after rotating a webhook secret, both X-SKUMan-Signature (new secret) and X-SKUMan-Old-Signature (old secret) are sent. Verify against both during the transition.

Failed deliveries are retried with exponential backoff:

AttemptDelay
1Immediate
21 minute
35 minutes
415 minutes
51 hour
62 hours

Your webhook endpoint must:

  • Respond within 30 seconds
  • Return a 2xx status code for success
  • Use HTTPS (except localhost for development)
  • Not resolve to a private IP address (SSRF protection)

GET/webhooks

Lists all webhook subscriptions for the tenant. The signing secret is never returned.

{
"data": [
{
"id": "550e8400-...",
"name": "E-commerce Webhook",
"url": "https://shop.example.com/...",
"events": ["product.created", "product.updated"],
"is_active": true,
"created_by": "auth0|...",
"created_at": "2026-01-15T12:00:00Z",
"updated_at": "2026-01-15T12:00:00Z",
"last_triggered_at": null,
"last_success_at": null,
"last_failure_at": null,
"consecutive_failures": 0,
"disabled_at": null,
"disabled_reason": null
}
]
}
GET/webhooks/{id}

Returns a single webhook as { data: <webhook> } (same fields as List; secret never returned). 404 WEBHOOK_NOT_FOUND if it does not exist.

POST/webhooks
{
"name": "E-commerce Webhook",
"url": "https://shop.example.com/api/webhooks/inventory",
"events": ["product.created", "product.updated", "product.deleted"],
"secret": "my-custom-secret-at-least-16-chars"
}
FieldTypeRequiredDefaultDescription
namestringYes
Webhook name (1-100 chars)
urlstringYes
Endpoint URL (HTTPS required, except localhost)
eventsstring[]No["product.created", "product.updated"]Events to subscribe to
secretstringNoAuto-generated whsec_...Signing secret (16-64 chars)
PATCH/webhooks/{id}

Updates an existing webhook. All fields optional — send only what you want to change. Returns { data: <webhook> }.

FieldTypeDescription
namestringWebhook name (1-100 chars)
urlstringEndpoint URL (HTTPS required, except localhost)
eventsstring[]Events to subscribe to (min 1)
isActivebooleanEnable or disable delivery
DELETE/webhooks/{id}
{
"success": true,
"message": "Webhook deleted"
}
POST/webhooks/{id}/test

Empty request body. Queues a test-type delivery to the webhook's URL (see the Test Webhook Payload).

{
"success": true,
"message": "Test webhook queued for delivery"
}
POST/webhooks/{id}/rotate-secret

Generates a new signing secret. The old secret remains valid for 24 hours. During the grace period, both X-SKUMan-Signature and X-SKUMan-Old-Signature headers are sent.

{
"data": {
"id": "550e8400-...",
"name": "E-commerce Webhook",
"url": "https://shop.example.com/...",
"events": ["product.created", "product.updated"],
"is_active": true,
"secret": "whsec_newSecret123...",
"gracePeriodEnds": "2026-01-02T00:00:00.000Z"
},
"message": "Secret rotated. Old secret valid for 24 hours. Store the new secret securely."
}
GET/webhooks/{id}/deliveries
ParameterTypeDefaultDescription
limitinteger50Results per page (1-100)
offsetinteger0Number to skip
statusstring
Filter: pending, success, failed, retrying
{
"data": [
{
"id": "delivery-uuid",
"event_type": "product.updated",
"status": "success",
"attempt_number": 1,
"attempted_at": "2026-01-15T12:00:00Z",
"response_status": 200,
"response_time_ms": 150,
"error_message": null,
"next_retry_at": null
}
]
}

API keys authenticate the data endpoints (/products, /inventory, images, /cludf-values). The management endpoints below (/keys/*) instead require Auth0 JWT authentication, not API keys — see Authentication.

GET/keys

Lists all API keys for the tenant (hashes only — the full key is never returned). Returns { data: [ ... ] }.

GET/keys/{id}

Returns a single key's metadata as { data: <key> }. The full key value is never returned.

POST/keys
{
"name": "E-commerce Sync",
"permissions": { "read": true, "write": false },
"rateLimitPerMinute": 120,
"rateLimitPerDay": 50000,
"expiresAt": "2026-12-31T00:00:00Z"
}
FieldTypeRequiredDefaultDescription
namestringYes
Key name (1-100 chars)
permissions.readbooleanNotrueAllow read access
permissions.writebooleanNofalseAllow write access
rateLimitPerMinuteintegerNo60Per-minute rate limit (1-10,000)
rateLimitPerDayintegerNo10,000Per-day rate limit (1-1,000,000)
expiresAtstringNo
ISO 8601 expiration date
PATCH/keys/{id}

Updates an existing key. All fields optional — send only what you want to change. Returns { data: <key> }.

FieldTypeDescription
namestringKey name (1-100 chars)
permissionsobject{ read, write } — both read and write must be sent together
rateLimitPerMinuteintegerPer-minute rate limit (1-10,000)
rateLimitPerDayintegerPer-day rate limit (1-1,000,000)
DELETE/keys/{id}

Permanently deletes the key immediately — there is no grace period (unlike rotation, where the old key stays valid for 24 hours). To retire a key gracefully, rotate it instead.

{
"success": true,
"message": "API key deleted"
}
POST/keys/{id}/rotate

Creates a new key with the same settings. The old key remains valid for 24 hours.

{
"data": {
"id": "new-key-uuid",
"key": "skm_live_...",
"oldKeyId": "old-key-uuid",
"gracePeriodEnds": "2026-01-02T00:00:00.000Z"
},
"message": "API key rotated. Old key will remain valid for 24 hours."
}

Read-only helper endpoints authenticated with an API key.

GET/settings/fields

Lists the tenant's enabled custom (UDF/CPF) field keys, labels, and types. Use it to discover which udfN/cpfN slots to map before writing products. CPF entries carry a label only (no type).

{
"udf": {
"udf1": { "label": "Fabric", "type": "text" },
"udf2": { "label": "Lead Time", "type": "number" }
},
"cpf": {
"cpf1": { "label": "Landed Cost" }
}
}
GET/sync-version

Returns the latest SKUMan Sync desktop-app version info. When no version has been published, only latestVersion (as null) is returned.

{
"latestVersion": "2.3.1",
"releaseNotes": "Bug fixes and performance improvements",
"mandatory": false
}

FieldTypeMax LengthDescription
idstring
Unique product identifier (auto-generated as a UUID, but the column is TEXT — externally-imported ids may be non-UUID)
skustring50Required. Unique product SKU
namestring255Product name
masterStyleNumberstring50Style number
brandstring100Brand name
seasonstring50Season code (e.g., "SS24")
descriptionstring5000Product description
tabNamestring100Tab/category name
groupstring100Group
classNamestring100Class
pricenumber
Wholesale price (>= 0)
retailPricenumber
Suggested retail price (>= 0)
currencystring10Currency code (optional; not injected by default — reads fall back to USD when unset)
availableinteger
Available quantity (>= 0)
totalOnOrderinteger
Quantity on order (>= 0)
atsinteger
Available to sell (>= 0)
sizestring255Size
colorCodestring50Color code
colorGroupstring100Color group/family
bodystring100Body/style
bundlestring100Bundle/collection
divisionstring50Division
webitemidstring100Variant group ID (groups color/size variants)
webitemnamestring255Variant group name
imagesstring[]
Array of image URLs
tagsstring[]50 eachArray of tag names
assignedBuyerIdsstring[]
Assigned buyer IDs
incomingInventoryarray
{ date: string, quantity: integer } entries
lockedFieldsstring[]
Fields locked from editing
udf1-udf40mixed
User-defined fields
statusstring50Free-form availability label (e.g. "In Stock", "Sold Out", "Pre-Order"), stored in data_json
cpf1-cpf8mixed
Custom price fields (string or number)
versioninteger
Version for optimistic locking (>= 1)
createdAtinteger
Creation timestamp (ms, read-only)
updatedAtinteger
Last update timestamp (ms, read-only)
_deletedboolean
Tombstone marker (read-only)
deletedAtinteger
Deletion timestamp (ms, read-only)

import fs from 'node:fs';
const SKUMAN_API = 'https://your-instance.sku-man.com/api/v1/external';
const API_KEY = process.env.SKUMAN_API_KEY;
// List products
async function listProducts(options = {}) {
const params = new URLSearchParams(options);
const response = await fetch(`${SKUMAN_API}/products?${params}`, {
headers: { 'X-API-Key': API_KEY }
});
return response.json();
}
// Create product
async function createProduct(product) {
const response = await fetch(`${SKUMAN_API}/products`, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': `create-${product.sku}`
},
body: JSON.stringify(product)
});
return response.json();
}
// Bulk upsert
async function bulkUpsert(products) {
const response = await fetch(`${SKUMAN_API}/products/bulk`, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': `bulk-${Date.now()}`
},
body: JSON.stringify({
products,
matchBy: 'sku',
createIfMissing: true
})
});
return response.json();
}
// Upload image
async function uploadImage(productId, filePath) {
const formData = new FormData();
formData.append('image', new Blob([fs.readFileSync(filePath)]), 'photo.jpg');
const response = await fetch(`${SKUMAN_API}/products/${productId}/images`, {
method: 'POST',
headers: { 'X-API-Key': API_KEY },
body: formData
});
return response.json();
}
// Incremental sync
async function syncProducts(lastSyncTime) {
let offset = 0;
const limit = 500;
let hasMore = true;
while (hasMore) {
const { data, pagination } = await listProducts({
updatedSince: lastSyncTime,
includeDeleted: true,
limit,
offset
});
for (const product of data) {
if (product._deleted) {
await deleteLocalProduct(product.id);
} else {
await upsertLocalProduct(product);
}
}
hasMore = pagination.hasMore;
offset += limit;
}
return Date.now();
}

1. Use Idempotency Keys

Always include idempotency keys for write operations to handle network retries safely. The key must be identical across retries of the same operation — never include a timestamp or random value, or each retry is treated as a new request and creates a duplicate.

headers: {
'Idempotency-Key': `create-${product.sku}` // stable across retries of the same logical operation
}
2. Implement Incremental Sync

Don't fetch all products every time. Use updatedSince with includeDeleted=true for efficient syncing.

const { data } = await listProducts({
updatedSince: lastSyncTime,
includeDeleted: true
});
3. Handle Version Conflicts

Implement retry logic for version conflicts - fetch fresh data and retry.

async function updateWithRetry(id, updates, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const current = await getProduct(id);
try {
return await updateProduct(id, {
...updates,
version: current.data.version
});
} catch (e) {
if (e.code !== 'VERSION_CONFLICT') throw e;
}
}
throw new Error('Max retries exceeded');
}
4. Respect Rate Limits

Monitor X-RateLimit-Remaining and implement backoff when approaching limits.

if (response.headers.get('X-RateLimit-Remaining') < 10) {
await sleep(1000); // Slow down
}
5. Use Bulk Operations

For large updates, use bulk upsert (up to 1,000 products) instead of individual requests.

// Bad: 1000 individual requests
for (const p of products) await createProduct(p);
// Good: 1 bulk request
await bulkUpsert(products);
6. Secure Your API Keys
  • Never commit API keys to version control
  • Use environment variables or secret managers
  • Rotate keys regularly (at least quarterly)
  • Use read-only keys when write access isn't needed
7. Verify Webhook Signatures

Always verify HMAC signatures and check timestamp freshness to prevent replay attacks. During secret rotation, check both X-SKUMan-Signature and X-SKUMan-Old-Signature.

8. Handle Staging Responses

If the tenant has API Import Staging enabled, write operations return 202 instead of the usual success codes. Your integration should handle both 201/200 (live) and 202 (staged) responses gracefully.


Last updated: July 2026