Complete reference for the SKUMan External API, enabling ERP, e-commerce, and other system integrations.
Quick Start
Section titled “Quick Start ”1. Get an API Key
Section titled “1. Get an API Key”API keys are created by tenant administrators in the SKUMan Admin Dashboard.
2. Make Your First Request
Section titled “2. Make Your First Request”curl -X GET "https://your-instance.sku-man.com/api/v1/external/products?limit=10" \ -H "X-API-Key: skm_live_your_key_here"3. Check the Response
Section titled “3. Check the Response”{ "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 }}Base URL
Section titled “Base URL ”https://{your-instance}.sku-man.com/api/v1/externalAuthentication
Section titled “Authentication ”Include your API key in every request using one of these methods:
X-API-Key: skm_live_your_key_hereAuthorization: Bearer skm_live_your_key_hereAPI Key Format
Section titled “API Key Format”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.
JWT Authentication (Management Endpoints)
Section titled “JWT Authentication (Management Endpoints)”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.
API Key vs JWT
Section titled “API Key vs JWT”| Auth method | Token format | Used for |
|---|---|---|
| API Key | skm_live_... (41 chars — skm_live_ prefix + 32-char base64url suffix) | Product CRUD, image upload |
| JWT | eyJhbGciOi... (.-delimited, 3 segments) | API key management, webhook management |
Obtaining a token
Section titled “Obtaining a token”To obtain a token programmatically for testing:
- Configure an Auth0 SPA application with your redirect URI
- 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- Exchange the authorization code for tokens at
https://{AUTH0_DOMAIN}/oauth/token - Use the
access_token(not theid_token) in the Authorization header
Required JWT claims
Section titled “Required JWT claims”The JWT must contain these custom claims (injected by an Auth0 Post-Login Action):
| Claim | Description |
|---|---|
https://sku-man.com/org_id | Auth0 Organization ID (maps to tenant) |
https://sku-man.com/roles | Must include Admin or SUPERAdmin |
https://sku-man.com/email | User email (used for audit logging) |
Header format
Section titled “Header format”Authorization: Bearer eyJhbGciOiJSUzI1NiIs...JWT verification details
Section titled “JWT verification details”| Property | Value |
|---|---|
| Algorithm | RS256 |
| Issuer | https://{AUTH0_DOMAIN}/ |
| Audience | The API identifier configured in Auth0 |
Auth0 setup requirements
Section titled “Auth0 setup requirements”Permissions
Section titled “Permissions”| Permission | Access |
|---|---|
read | Informational only — not currently enforced. All valid keys can perform GET reads regardless of this flag. |
write | Enforced. Gates all POST, PUT, PATCH, DELETE writes (create, update, delete, image upload). |
Key Rotation
Section titled “Key Rotation”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).
Product Identifier
Section titled “Product Identifier ”Endpoints that accept {id_or_sku} in the path can receive either a UUID or a SKU.
# By UUIDcurl ".../products/550e8400-e29b-41d4-a716-446655440000" -H "X-API-Key: ..."
# By SKUcurl ".../products/NIKE-AIR-001" -H "X-API-Key: ..."- The API auto-detects the format — UUIDs are recognized by their
8-4-4-4-12hex pattern - SKU lookup is case-insensitive (
nike-air-001matchesNIKE-AIR-001) - URL-encode special characters in SKUs (e.g.,
SKU%2FAforSKU/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:
curl ".../products?sku=NIKE-AIR-001" -H "X-API-Key: ..."Endpoints
Section titled “Endpoints ”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.
Products
Section titled “Products ”| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /products | API Key (read) | List products with filters |
| POST | /products | API 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/bulk | API Key (write) | Bulk upsert (up to 1,000) |
Images
Section titled “Images ”| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /products/{id_or_sku}/images | API Key (write) | Upload image (multipart) |
| POST | /products/{id_or_sku}/images/presign | API Key (write) | Get presigned upload URL |
| POST | /products/{id_or_sku}/images/complete | API Key (write) | Complete presigned upload |
| POST | /staging/{stagingId}/images/presign | API Key (write) | Presign for staged product |
| POST | /staging/{stagingId}/images/complete | API Key (write) | Complete staged presigned upload |
| POST | /products/images/bulk | API Key (write) | Bulk image upload (multipart) |
| POST | /products/images/bulk/auto | API Key (write) | Bulk auto-parse images (multipart) |
| POST | /products/images/bulk/presign | API Key (write) | Bulk presigned URLs |
| POST | /products/images/bulk/complete | API Key (write) | Complete bulk presigned batch |
| POST | /products/images/wipe | API Key (write) | Wipe a style-color's images (mirror mode) |
Inventory
Section titled “Inventory ”| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /inventory | API Key (read) | List incoming-inventory entries |
| GET | /inventory/{id} | API Key (read) | Get single inventory entry |
| POST | /inventory | API Key (write) | Upsert inventory entries (up to 1,000) |
| POST | /inventory/bulk | API 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 |
Sales
Section titled “Sales ”| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /sales-orders/bulk | API Key (write) + sales.write feature | Bulk-upsert sell-in order lines (up to 1,000) |
| POST | /sales-orders/finalize | API Key (write) + sales.write feature | Reconcile a completed batch feed (close vanished open lines) |
CLUDF
Section titled “CLUDF ”| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /cludf-values/bulk | API Key (write) + cludf module | Bulk upsert per-list custom-field values by SKU (up to 1,000) |
Webhooks
Section titled “Webhooks ”| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /webhooks | JWT | List webhooks |
| POST | /webhooks | JWT | Create webhook |
| GET | /webhooks/{id} | JWT | Get webhook details |
| PATCH | /webhooks/{id} | JWT | Update webhook |
| DELETE | /webhooks/{id} | JWT | Delete webhook |
| POST | /webhooks/{id}/rotate-secret | JWT | Rotate webhook signing secret |
| POST | /webhooks/{id}/test | JWT | Send test event |
| GET | /webhooks/{id}/deliveries | JWT | View delivery log |
API Keys
Section titled “API Keys ”| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /keys | JWT | List API keys |
| POST | /keys | JWT | Create API key |
| GET | /keys/{id} | JWT | Get API key details |
| PATCH | /keys/{id} | JWT | Update API key |
| DELETE | /keys/{id} | JWT | Delete API key (permanent) |
| POST | /keys/{id}/rotate | JWT | Rotate API key |
Metadata
Section titled “Metadata ”| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /settings/fields | API Key | List enabled custom (UDF/CPF) field keys, labels, and types |
| GET | /sync-version | API Key | Latest SKUMan Sync desktop app version info |
| GET | /health | None | Check API status |
Rate Limiting
Section titled “Rate Limiting ”Per-Key Limits
Section titled “Per-Key Limits”| Limit | Default | Configurable Range |
|---|---|---|
| Per minute | 60 requests | 1 - 10,000 |
| Per day | 10,000 requests | 1 - 1,000,000 |
Per-Tenant Aggregate Limit
Section titled “Per-Tenant Aggregate Limit”All API keys for a single tenant share a combined limit of 600 requests/minute.
Response Headers
Section titled “Response Headers”| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests per minute for this key |
X-RateLimit-Remaining | Requests remaining in current window |
X-RateLimit-Reset | Unix timestamp (ms) when window resets |
Retry-After | Seconds to wait (only on 429 responses) |
Tracing
Section titled “Tracing ”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 Handling
Section titled “Error Handling ”Error Response Format
Section titled “Error Response Format”{ "error": { "code": "ERROR_CODE", "message": "Human-readable description", "traceId": "abc123def456", "details": {} }}Error Codes
Section titled “Error Codes”| Code | Status | Description |
|---|---|---|
VALIDATION_ERROR | 400 | Invalid request body or parameters |
INVALID_JSON | 400 | Malformed JSON body |
MISSING_REQUIRED_FIELD | 400 | Required field absent |
UNAUTHORIZED | 401 | No/invalid JWT on a management endpoint (/keys, /webhooks). API-key auth failures on data endpoints do not return this code (see Authentication). |
FORBIDDEN | 403 | Access denied (e.g., upload session mismatch) |
INSUFFICIENT_PERMISSIONS | 403 | API key lacks required permission |
NOT_FOUND | 404 | Resource not found |
PRODUCT_NOT_FOUND | 404 | Product does not exist |
WEBHOOK_NOT_FOUND | 404 | Webhook not found |
API_KEY_NOT_FOUND | 404 | API key not found |
VERSION_CONFLICT | 409 | Optimistic locking conflict |
DUPLICATE_SKU | 409 | SKU already exists |
DUPLICATE_KEY | 409 | Unique constraint violation |
IDEMPOTENCY_KEY_CONFLICT | 409 | Idempotency-Key reused with a different request body |
PAYLOAD_TOO_LARGE | 413 | Request body exceeds the size limit — use the presigned bulk image flow |
QUOTA_EXCEEDED | 413 | Tenant image-storage quota exceeded |
UNPROCESSABLE_ENTITY | 422 | Semantic validation failure |
RATE_LIMIT_EXCEEDED | 429 | Rate limit exceeded |
INTERNAL_ERROR | 500 | Unexpected server error |
SERVICE_UNAVAILABLE | 503 | Service temporarily unavailable |
Idempotency
Section titled “Idempotency ”For safe retries on write operations (POST, PUT, PATCH, DELETE), include an idempotency key:
Idempotency-Key: unique-operation-idAPI Import Staging
Section titled “API Import Staging ”Staged Response Format
Section titled “Staged Response Format”{ "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"}What is NOT staged
Section titled “What is NOT staged”- 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
Bulk staging response
Section titled “Bulk staging response”{ "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"}Products
Section titled “Products ”Health Check
Section titled “Health Check ”/healthNo authentication required.
{ "status": "ok", "timestamp": 1704067200000}List Products
Section titled “List Products ”/productsRetrieve products with filtering and pagination.
Query Parameters
Section titled “Query Parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 100 | Results per page (1-1000) |
offset | integer | 0 | Number of results to skip |
updatedSince | integer | Unix timestamp (ms) - only products updated after this time | |
includeDeleted | boolean | false | Include soft-deleted products (tombstones) |
brand | string | Filter by exact brand match | |
season | string | Filter by exact season match | |
division | string | Filter by exact division match | |
bundle | string | Filter by exact bundle match | |
minPrice | number | Minimum price filter | |
maxPrice | number | Maximum price filter | |
tags | string | Comma-separated tag names (matches product_tags OR webitem_tags) | |
search | string | Case-insensitive search on SKU, name, description (max 200 chars) | |
sku | string | Exact SKU match (case-insensitive) |
Example Request
Section titled “Example Request”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_..."Example Response
Section titled “Example Response”{ "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 }}Incremental Sync Pattern
Section titled “Incremental Sync Pattern”# Initial full syncGET /products?limit=1000
# Subsequent incremental syncsGET /products?updatedSince=1704067200000&includeDeleted=trueGet Product
Section titled “Get Product ”/products/{id_or_sku}Retrieve a single product by UUID or SKU.
Response (200 OK)
Section titled “Response (200 OK)”{ "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "sku": "NIKE-AIR-001", "name": "Air Max 90", "brand": "Nike", "price": 129.99, "version": 5, "updatedAt": 1704067200000 }}Create Product
Section titled “Create Product ”/productsCreate a new product. Requires write permission.
Headers
Section titled “Headers”| Header | Required | Description |
|---|---|---|
X-API-Key | Yes | Your API key |
Content-Type | Yes | application/json |
Idempotency-Key | Recommended | Unique key for safe retries |
Request Body
Section titled “Request Body”{ "sku": "NIKE-AIR-001", "name": "Air Max 90", "brand": "Nike", "season": "SS24", "price": 129.99, "retailPrice": 150.00, "available": 250, "tags": ["new-arrival"]}Response (201 Created)
Section titled “Response (201 Created)”{ "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "sku": "NIKE-AIR-001", "name": "Air Max 90", "brand": "Nike", "createdAt": 1704067200000, "updatedAt": 1704067200000 }}Update Product
Section titled “Update Product ”/products/{id_or_sku}Update an existing product by UUID or SKU. Requires write permission.
Optimistic Locking
Section titled “Optimistic Locking”Request Body
Section titled “Request Body”{ "price": 139.99, "available": 200, "version": 5}Response (200 OK)
Section titled “Response (200 OK)”{ "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "sku": "NIKE-AIR-001", "price": 139.99, "available": 200, "version": 6, "updatedAt": 1704070800000 }}Version Conflict Response (409)
Section titled “Version Conflict Response (409)”{ "error": { "code": "VERSION_CONFLICT", "message": "Version conflict", "traceId": "abc123", "details": { "currentVersion": 6, "providedVersion": 5 } }}Delete Product
Section titled “Delete Product ”/products/{id_or_sku}Soft-delete a product by UUID or SKU. Requires write permission. The product becomes a tombstone visible via includeDeleted=true.
Response (200 OK)
Section titled “Response (200 OK)”{ "success": true, "message": "Product deleted"}Bulk Upsert
Section titled “Bulk Upsert ”/products/bulkCreate or update up to 1,000 products in a single request. Requires write permission.
Request Body
Section titled “Request Body”| Field | Type | Default | Description |
|---|---|---|---|
products | array | Array of products (1-1000) | |
matchBy | string | id | Match by id or sku |
createIfMissing | boolean | true | Create products that don't exist |
Example Request
Section titled “Example Request”{ "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 Response
Section titled “Success Response”{ "success": true, "summary": { "total": 3, "created": 1, "updated": 2, "skipped": 0, "failed": 0 }, "errors": []}Partial Failure Response
Section titled “Partial Failure Response”{ "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
Section titled “Images ”Images can be uploaded to products via the External API. Two methods are supported.
Multipart Upload
Section titled “Multipart Upload ”/products/{id_or_sku}/imagesUpload a single image file directly.
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"Response
Section titled “Response”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}Ambiguous Conflict (202)
Section titled “Ambiguous Conflict (202)”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"}Presigned URL Upload
Section titled “Presigned URL Upload ”For large files or browser-based uploads. This is a two-step process.
Step 1: Request Presigned URL
Section titled “Step 1: Request Presigned URL”/products/{id_or_sku}/images/presign{ "fileName": "product-photo.jpg", "mimeType": "image/jpeg", "fileHash": "a1b2c3d4e5f6...", "fileSize": 102400}| Field | Type | Required | Description |
|---|---|---|---|
fileName | string | Yes | Image filename (max 255 chars, no path separators) |
mimeType | string | Yes | image/jpeg, image/png, image/webp, image/gif, or image/svg+xml |
fileHash | string | No | SHA-256 hex hash (64 chars) for dedup hint |
fileSize | integer | No | File 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}Step 2: Upload File
Section titled “Step 2: Upload File”Upload directly to the uploadUrl using HTTP PUT:
curl -X PUT "https://r2-presigned-url..." \ -H "Content-Type: image/jpeg" \ --data-binary @product-photo.jpgStep 3: Complete Upload
Section titled “Step 3: Complete Upload”/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}Image Processing
Section titled “Image Processing ”| Aspect | Behavior |
|---|---|
| Original image | Stored 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. |
| Thumbnails | Generated in background at 100px, 400px, 600px (JPEG) |
| Dedup skip | Thumbnails skipped if image was deduplicated (already exist) |
| URL pattern | {CDN_URL}/{tenant-id}/thumbnails/{filename}_w{width}.jpg |
| Availability | Asynchronous — may not be immediately available after upload |
Presigned Upload for Staged Products
Section titled “Presigned Upload for Staged Products ”When API Import Staging is enabled, newly created products don't exist in the database yet. Use staging-specific endpoints:
POST /staging/{stagingId}/images/presignPOST /staging/{stagingId}/images/completeThese work identically to the product presigned endpoints but link the image to a staged item. The image is committed when the batch is committed.
Bulk Image Uploads
Section titled “Bulk Image Uploads ”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.
Explicit Mapping (Multipart)
Section titled “Explicit Mapping (Multipart)”/products/images/bulkMap 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 stringconst 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});curl -X POST "https://your-instance.sku-man.com/api/v1/external/products/images/bulk" \ -H "X-API-Key: skm_live_..." \ -F "metadata=[{\"sku\":\"SKU-001\",\"order\":1},{\"sku\":\"SKU-002\",\"colorCode\":\"RED\"}]" \ -F "files=@photo1.jpg" \ -F "files=@photo2.jpg"Auto-Parsing (Multipart)
Section titled “Auto-Parsing (Multipart)”/products/images/bulk/autoAutomatically 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), elseSKU-ORDER(2+ segments, numeric last part), else the whole filename is treated as the SKU with order defaulting to1 - 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 conventionformData.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});curl -X POST "https://your-instance.sku-man.com/api/v1/external/products/images/bulk/auto" \ -H "X-API-Key: skm_live_..." \ -F "files=@NIKE-001-RED-01.jpg" \ -F "files=@NIKE-001-BLU-02.jpg"Presigned Batch
Section titled “Presigned Batch”For large batches using the presigned URL flow.
Step 1: Request Batch URLs
/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
/products/images/bulk/completeComplete 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"}Response Format
Section titled “Response Format”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.
Worked Example: Presigned Bulk Flow
Section titled “Worked Example: Presigned Bulk Flow”# 1. Presign — request upload URLs for the batchcurl -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.jpgcurl -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 aboveMirror Mode
Section titled “Mirror Mode ”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 explicitorder. 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:
/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"]).
Inventory
Section titled “Inventory ”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.
Entry Type
Section titled “Entry Type”Each entry carries a type that classifies the delivery:
| Value | Meaning |
|---|---|
on_order | Quantity on order, not yet received (default) |
in_warehouse | Quantity received into the warehouse |
Identifying Products and Entries
Section titled “Identifying Products and Entries”- On write endpoints, the
productIdfield accepts either a product UUID or a SKU (resolved server-side, case-insensitive, active products only). - On upsert (
POST /inventoryandPOST /inventory/bulk), anyproductIdSKU that fails to resolve — including an ambiguous SKU matching more than one product — is skipped (reported inskipped/skippedSkus); if every entry is unresolved the request returns404 PRODUCT_NOT_FOUND. Upsert never returns409. - On the
GET /inventoryproductIdfilter, an unresolved SKU returns404 PRODUCT_NOT_FOUNDand an ambiguous SKU returns409 DUPLICATE_SKU— pass the UUID instead. - The
{id}in the path ofGET/PUT/DELETEis the inventory entry's UUID — not a product ID or SKU.
List Inventory Entries
Section titled “List Inventory Entries ”/inventoryRetrieve incoming-inventory entries with filtering and pagination. Requires read permission.
Query Parameters
Section titled “Query Parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 100 | Results per page (1-1000) |
offset | integer | 0 | Number of results to skip |
productId | string | 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. | |
type | string | Filter by entry type (on_order or in_warehouse) | |
startDate | string | Lower date bound (YYYY-MM-DD) | |
endDate | string | Upper date bound (YYYY-MM-DD) |
Example Response
Section titled “Example Response”{ "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 Entry
Section titled “Get Inventory Entry ”/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" }}Upsert Inventory Entries
Section titled “Upsert Inventory Entries ”/inventoryCreate or update inventory entries. The alias POST /inventory/bulk uses the same handler. Requires write permission. Up to 1,000 entries per request.
Request Body
Section titled “Request Body”| Field | Type | Default | Description |
|---|---|---|---|
entries | array | 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 | |
conflictResolution | string | replace | Merge behaviour on an existing (productId, date, type) key: replace overwrites the quantity, sum adds to it |
Each entry:
| Field | Type | Required | Description |
|---|---|---|---|
productId | string | Yes | Product UUID or SKU (1-100 chars; SKU resolved server-side) |
date | string | Yes | Delivery date, YYYY-MM-DD. Strictly calendar-validated — non-real dates (e.g. 2026-02-30, 2027-02-29) are rejected with 400 VALIDATION_ERROR. |
quantity | integer | Yes | Quantity (≥ 0) |
type | string | No | on_order (default) or in_warehouse |
source | string | No | Free-text source label (≤ 255 chars, e.g. a PO number) |
notes | string | No | Free-text notes (≤ 1,000 chars) |
Example Request
Section titled “Example Request”{ "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"}Example Response
Section titled “Example Response”{ "success": true, "inserted": 1, "updated": 1, "total": 2}A successful (non-staged) upsert fires an inventory.updated webhook.
Update Inventory Entry
Section titled “Update Inventory Entry ”/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.
Request Body
Section titled “Request Body”| Field | Type | Description |
|---|---|---|
date | string | Delivery date, YYYY-MM-DD. Strictly calendar-validated — non-real dates (e.g. 2026-02-30, 2027-02-29) are rejected with 400 VALIDATION_ERROR. |
quantity | integer | Quantity (≥ 0) |
type | string | on_order or in_warehouse |
source | string | null | Source label (≤ 255 chars; null clears it) |
notes | string | null | Notes (≤ 1,000 chars; null clears it) |
Example Response
Section titled “Example Response”{ "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 Entry
Section titled “Delete Inventory Entry ”/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.
Sales
Section titled “Sales ”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.
Bulk Upsert Sales Orders
Section titled “Bulk Upsert Sales Orders ”/sales-orders/bulkBulk-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.
Server-side resolution (no client ids)
Section titled “Server-side resolution (no client ids)”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 inerrorsand that line is excluded.customerRaw→ company — matched case-insensitively against company code, then company name, then the tenantsalesCustomerAliasesmap. An unmatched value never fails the request: the line imports with a null company link, its raw value preserved, and the value is listed inunmatchedCustomers.
Unlinked lines (null product/company) are back-fillable later by a full internal re-import.
Reconcile-batch mode (optional)
Section titled “Reconcile-batch mode (optional)”To reconcile the open-order book, stream a complete feed as a batch and then finalize it:
- On the first request send
openBatch: true. The response includes abatchId. - On every later request send that
batchId(do not sendopenBatchagain). Each request that carriesopenBatch/batchIdrecords 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). - Call
POST /sales-orders/finalizewhen 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).
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
lines | array | Yes | Sell-in order lines (1-1,000) |
openBatch | true | No | Opt into reconcile-batch mode: opens a new batch and returns its batchId (see below). Mutually exclusive with batchId. |
batchId | string (uuid) | No | Continue an already-open batch (from a prior openBatch: true response). Mutually exclusive with openBatch. |
Each line:
| Field | Type | Required | Description |
|---|---|---|---|
orderNo | string | Yes | Sales order number (≤ 200 chars). Part of the natural key. |
sku | string | Yes | Product SKU (≤ 200 chars). Resolved server-side (see above). |
orderDate | string | Yes | Order date, YYYY-MM-DD. Strictly calendar-validated — non-real dates are rejected with 400 VALIDATION_ERROR. |
qtyOrdered | integer | Yes | Quantity ordered. May be negative — ERP pick reversals / over-shipments are valid data (magnitude ≤ 100,000,000). |
customerPo | string | null | No | Customer PO reference (≤ 200 chars). |
customerRaw | string | null | No | Raw customer name (≤ 200 chars). Resolved to a company server-side (see above). |
description | string | null | No | Line description (≤ 1,000 chars). |
shipStart, shipCancel | string | null | No | Ship window dates, YYYY-MM-DD. |
warehouseCode | string | null | No | Fulfilment warehouse/location code (≤ 200 chars). |
qtyOpen, qtyPicked, qtyUnshipped, qtyCancelled, qtyShipped | integer | No | Quantity breakdown. Each may be negative (ERP reversals; magnitude ≤ 100,000,000). Send qtyOpen/qtyUnshipped/qtyPicked = 0 on closed lines (see the lifecycle contract). |
prodReference | string | null | No | Production/allocation reference (≤ 200 chars). Part of the natural key — blank ≠ assigned. |
prodRecvd | boolean | null | No | Whether production has been received. |
prodDue, prodEta, prodEtd | string | null | No | Production milestone dates, YYYY-MM-DD. |
salesRep | string | null | No | Sales rep name (≤ 200 chars). |
Example Request
Section titled “Example Request”{ "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 } ]}Example Response
Section titled “Example Response”{ "success": true, "summary": { "total": 2, "inserted": 2, "updated": 0, "rekeyedBlanks": 0, "failed": 0 }, "unmatchedCustomers": [], "errors": []}| Field | Type | Description |
|---|---|---|
success | boolean | true when no line failed. Unmatched customers are not failures. |
batchId | string (uuid) | Present only in reconcile-batch mode (openBatch: true, or an echoed continuation batchId). Pass it to /sales-orders/finalize. |
summary.total | integer | Lines received. |
summary.inserted / summary.updated | integer | New / existing lines written. |
summary.rekeyedBlanks | integer | Blank-reference lines superseded by an assigned-reference line for the same order+SKU. |
summary.failed | integer | Lines not imported (ambiguous SKU + lines in a failed slice). |
unmatchedCustomers | array | Deduped customerRaw values that resolved to no company. |
errors | array | Per-row (ambiguous SKU: { orderNo, sku, error }) and per-slice ({ lineNumbers, orderNos, error }, each capped at 50 entries) failures. |
Finalize Sales Order Batch
Section titled “Finalize Sales Order Batch ”/sales-orders/finalizeReconcile 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.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
batchId | string (uuid) | Yes | The batchId returned by the batch's first /sales-orders/bulk request. |
totalRows | integer | Yes | Total 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. |
confirm | boolean | Yes | false = dry run (report only, batch stays open); true = execute the close and close the batch. |
Dry run vs. confirm
Section titled “Dry run vs. confirm”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 returns409).
Example Request
Section titled “Example Request”{ "batchId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", "totalRows": 1420, "confirm": true }Example Response
Section titled “Example Response”{ "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).
CLUDF
Section titled “CLUDF ”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).
Bulk Upsert Values
Section titled “Bulk Upsert Values ”/cludf-values/bulkRequest Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
listId | string | Yes | Custom-list id. Missing or soft-deleted → 404. |
items | array | Yes | 1-1,000 items |
items[].sku | string | Yes | Product SKU — case-sensitive, variant-exact |
items[].values | object | Yes | At 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.
Example Request
Section titled “Example Request”{ "listId": "550e8400-e29b-41d4-a716-446655440000", "items": [ { "sku": "SKU-001", "values": { "cludf1": 100, "Selling Forecast": 50 } }, { "sku": "SKU-002", "values": { "cludf1": null } } ]}Response (200 OK)
Section titled “Response (200 OK)”{ "updated": 2, "deleted": 1, "skipped": 0, "errors": []}Webhooks
Section titled “Webhooks ”Real-time notifications for product changes. Webhooks fire for both External API mutations and internal UI changes.
Events
Section titled “Events”| Event | Trigger |
|---|---|
product.created | New product created |
product.updated | Product modified (including image changes) |
product.deleted | Product soft-deleted |
product.bulk_updated | Bulk operation completed (one per sub-batch of 200) |
inventory.updated | Incoming inventory upserted (POST) or a single entry updated (PUT) |
inventory.deleted | Single incoming inventory entry deleted |
inventory.cleared | All incoming inventory cleared — by an admin action, or automatically when an external API inventory upload runs with auto-inventory-clear enabled |
Webhook Headers
Section titled “Webhook Headers”Every webhook request includes these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | SKUMan-Webhooks/1.0 |
X-SKUMan-Signature | HMAC signature: v1={hmac_sha256_hex} |
X-SKUMan-Old-Signature | Old HMAC signature (only during 24h secret rotation grace period) |
X-SKUMan-Timestamp | Unix timestamp (seconds) when signed |
X-SKUMan-Event | Event type (e.g., product.updated) |
X-SKUMan-Delivery-ID | Unique delivery UUID |
Payload Format
Section titled “Payload Format”The payload shape varies by event.
product.created
Section titled “product.created”{ "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}product.updated
Section titled “product.updated”{ "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}product.deleted
Section titled “product.deleted”{ "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.
Bulk Updated Payload
Section titled “Bulk Updated Payload”The product.bulk_updated event fires once per sub-batch (200 products). The payload shape varies by source.
External API (source: "external_api")
Section titled “External API (source: "external_api")”{ "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}Internal UI (source: "internal")
Section titled “Internal UI (source: "internal")”{ "total": 200, "created": 5, "updated": 195, "createdProducts": [{ "id": "550e8400-...", "sku": "SKU-001" }], "updatedProducts": [{ "id": "6ba7b810-...", "sku": "SKU-002" }], "source": "internal"}In-app Save (source: "internal_save")
Section titled “In-app Save (source: "internal_save")”{ "total": 200, "source": "internal_save"}| Field | Type | Present | Description |
|---|---|---|---|
batchStart | integer | External API only | Starting index of this sub-batch |
batchSize | integer | External API only | Number of products in this sub-batch |
total | integer | Internal only | Total products in the save operation |
created | integer | External API + internal bulk-save only | Number of products created |
updated | integer | External API + internal bulk-save only | Number of products updated |
createdProducts | {id, sku}[] | External API + internal bulk-save only | Products that were created |
updatedProducts | {id, sku}[] | External API + internal bulk-save only | Products that were updated |
source | string | Always | "external_api", "internal", or "internal_save" |
batchId | string | null | External API only | Idempotency batch ID |
Test Webhook Payload
Section titled “Test Webhook Payload”The POST /webhooks/{id}/test endpoint sends a test event with event type "test":
{ "test": true, "timestamp": 1704067200000}Inventory Event Payloads
Section titled “Inventory Event Payloads”Inventory events have different payload shapes from product events.
inventory.updated
Section titled “inventory.updated”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"]}| Field | Type | Description |
|---|---|---|
action | string | Always "upsert" for POST /inventory and /inventory/bulk. PUT /inventory/:id fires action: "update" with a different (entryId/productId) payload. |
entryCount | integer | Total entries in the request |
inserted | integer | New entries created |
updated | integer | Existing entries updated |
productIds | string[] | Distinct product UUIDs affected |
inventory.deleted
Section titled “inventory.deleted”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"}| Field | Type | Description |
|---|---|---|
entryId | string | UUID of the deleted inventory entry |
productId | string | UUID of the product the entry belonged to |
source | string | Always "external_api" for external-API deletes |
inventory.cleared
Section titled “inventory.cleared”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"}| Field | Type | Description |
|---|---|---|
deletedEntries | integer | Total inventory entries removed |
updatedProducts | integer | Products whose inventory fields were reset |
source | string | "auto_clear" when fired by the API-upload auto-clear path; omitted on an admin clear |
Signature Verification
Section titled “Signature Verification”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) );}import hmacimport hashlibimport time
def verify_webhook_signature(timestamp, body, signature, secret): # Verify timestamp is recent now = int(time.time()) if abs(now - int(timestamp)) > 300: return False
# Calculate expected signature payload = f"{timestamp}.{body}" expected = "v1=" + hmac.new( secret.encode(), payload.encode(), hashlib.sha256 ).hexdigest()
return hmac.compare_digest(signature, expected)Secret Rotation
Section titled “Secret Rotation”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.
Retry Policy
Section titled “Retry Policy”Failed deliveries are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 2 hours |
Endpoint Requirements
Section titled “Endpoint Requirements”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)
Webhook Management
Section titled “Webhook Management ”List Webhooks
Section titled “List Webhooks ”/webhooksLists 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 Webhook
Section titled “Get Webhook ”/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.
Create Webhook
Section titled “Create Webhook ”/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"}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | Webhook name (1-100 chars) | |
url | string | Yes | Endpoint URL (HTTPS required, except localhost) | |
events | string[] | No | ["product.created", "product.updated"] | Events to subscribe to |
secret | string | No | Auto-generated whsec_... | Signing secret (16-64 chars) |
Update Webhook
Section titled “Update Webhook ”/webhooks/{id}Updates an existing webhook. All fields optional — send only what you want to change. Returns { data: <webhook> }.
| Field | Type | Description |
|---|---|---|
name | string | Webhook name (1-100 chars) |
url | string | Endpoint URL (HTTPS required, except localhost) |
events | string[] | Events to subscribe to (min 1) |
isActive | boolean | Enable or disable delivery |
Delete Webhook
Section titled “Delete Webhook ”/webhooks/{id}{ "success": true, "message": "Webhook deleted"}Test Webhook
Section titled “Test Webhook ”/webhooks/{id}/testEmpty 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"}Rotate Webhook Secret
Section titled “Rotate Webhook Secret ”/webhooks/{id}/rotate-secretGenerates 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."}Delivery Log
Section titled “Delivery Log ”/webhooks/{id}/deliveries| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Results per page (1-100) |
offset | integer | 0 | Number to skip |
status | string | 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
Section titled “API Keys ”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.
List Keys
Section titled “List Keys ”/keysLists all API keys for the tenant (hashes only — the full key is never returned). Returns { data: [ ... ] }.
Get Key
Section titled “Get Key ”/keys/{id}Returns a single key's metadata as { data: <key> }. The full key value is never returned.
Create Key
Section titled “Create Key ”/keys{ "name": "E-commerce Sync", "permissions": { "read": true, "write": false }, "rateLimitPerMinute": 120, "rateLimitPerDay": 50000, "expiresAt": "2026-12-31T00:00:00Z"}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | Key name (1-100 chars) | |
permissions.read | boolean | No | true | Allow read access |
permissions.write | boolean | No | false | Allow write access |
rateLimitPerMinute | integer | No | 60 | Per-minute rate limit (1-10,000) |
rateLimitPerDay | integer | No | 10,000 | Per-day rate limit (1-1,000,000) |
expiresAt | string | No | ISO 8601 expiration date |
Update Key
Section titled “Update Key ”/keys/{id}Updates an existing key. All fields optional — send only what you want to change. Returns { data: <key> }.
| Field | Type | Description |
|---|---|---|
name | string | Key name (1-100 chars) |
permissions | object | { read, write } — both read and write must be sent together |
rateLimitPerMinute | integer | Per-minute rate limit (1-10,000) |
rateLimitPerDay | integer | Per-day rate limit (1-1,000,000) |
Delete Key
Section titled “Delete Key ”/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"}Rotate Key
Section titled “Rotate Key ”/keys/{id}/rotateCreates 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."}Metadata
Section titled “Metadata ”Read-only helper endpoints authenticated with an API key.
Field Settings
Section titled “Field Settings ”/settings/fieldsLists 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" } }}Sync Version
Section titled “Sync Version ”/sync-versionReturns 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}Product Schema
Section titled “Product Schema ”| Field | Type | Max Length | Description |
|---|---|---|---|
id | string | Unique product identifier (auto-generated as a UUID, but the column is TEXT — externally-imported ids may be non-UUID) | |
sku | string | 50 | Required. Unique product SKU |
name | string | 255 | Product name |
masterStyleNumber | string | 50 | Style number |
brand | string | 100 | Brand name |
season | string | 50 | Season code (e.g., "SS24") |
description | string | 5000 | Product description |
tabName | string | 100 | Tab/category name |
group | string | 100 | Group |
className | string | 100 | Class |
price | number | Wholesale price (>= 0) | |
retailPrice | number | Suggested retail price (>= 0) | |
currency | string | 10 | Currency code (optional; not injected by default — reads fall back to USD when unset) |
available | integer | Available quantity (>= 0) | |
totalOnOrder | integer | Quantity on order (>= 0) | |
ats | integer | Available to sell (>= 0) | |
size | string | 255 | Size |
colorCode | string | 50 | Color code |
colorGroup | string | 100 | Color group/family |
body | string | 100 | Body/style |
bundle | string | 100 | Bundle/collection |
division | string | 50 | Division |
webitemid | string | 100 | Variant group ID (groups color/size variants) |
webitemname | string | 255 | Variant group name |
images | string[] | Array of image URLs | |
tags | string[] | 50 each | Array of tag names |
assignedBuyerIds | string[] | Assigned buyer IDs | |
incomingInventory | array | { date: string, quantity: integer } entries | |
lockedFields | string[] | Fields locked from editing | |
udf1-udf40 | mixed | User-defined fields | |
status | string | 50 | Free-form availability label (e.g. "In Stock", "Sold Out", "Pre-Order"), stored in data_json |
cpf1-cpf8 | mixed | Custom price fields (string or number) | |
version | integer | Version for optimistic locking (>= 1) | |
createdAt | integer | Creation timestamp (ms, read-only) | |
updatedAt | integer | Last update timestamp (ms, read-only) | |
_deleted | boolean | Tombstone marker (read-only) | |
deletedAt | integer | Deletion timestamp (ms, read-only) |
Code Examples
Section titled “Code Examples ”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 productsasync 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 productasync 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 upsertasync 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 imageasync 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 syncasync 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();}import requestsimport osimport time
SKUMAN_API = 'https://your-instance.sku-man.com/api/v1/external'API_KEY = os.environ['SKUMAN_API_KEY']
headers = { 'X-API-Key': API_KEY, 'Content-Type': 'application/json'}
# List productsdef list_products(**kwargs): response = requests.get( f'{SKUMAN_API}/products', headers=headers, params=kwargs ) return response.json()
# Create productdef create_product(product): response = requests.post( f'{SKUMAN_API}/products', headers={**headers, 'Idempotency-Key': f"create-{product['sku']}"}, json=product ) return response.json()
# Bulk upsertdef bulk_upsert(products): response = requests.post( f'{SKUMAN_API}/products/bulk', headers=headers, json={ 'products': products, 'matchBy': 'sku', 'createIfMissing': True } ) return response.json()
# Upload imagedef upload_image(product_id, file_path): with open(file_path, 'rb') as f: return requests.post( f'{SKUMAN_API}/products/{product_id}/images', headers={'X-API-Key': API_KEY}, files={'image': f} ).json()
# Incremental syncdef sync_products(last_sync_time): offset = 0 limit = 500 has_more = True
while has_more: result = list_products( updatedSince=last_sync_time, includeDeleted=True, limit=limit, offset=offset )
for product in result['data']: if product.get('_deleted'): delete_local_product(product['id']) else: upsert_local_product(product)
has_more = result['pagination']['hasMore'] offset += limit
return int(time.time() * 1000)# Health checkcurl "https://your-instance.sku-man.com/api/v1/external/health"
# List productscurl "https://your-instance.sku-man.com/api/v1/external/products?limit=10" \ -H "X-API-Key: skm_live_..."
# List products with tag filtercurl "https://your-instance.sku-man.com/api/v1/external/products?tags=bestseller,new-arrival" \ -H "X-API-Key: skm_live_..."
# Get single productcurl "https://your-instance.sku-man.com/api/v1/external/products/550e8400-..." \ -H "X-API-Key: skm_live_..."
# Create productcurl -X POST "https://your-instance.sku-man.com/api/v1/external/products" \ -H "X-API-Key: skm_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: create-ABC123" \ -d '{"sku":"ABC-123","name":"Test Product","price":99.99}'
# Update product with versioncurl -X PUT "https://your-instance.sku-man.com/api/v1/external/products/550e8400-..." \ -H "X-API-Key: skm_live_..." \ -H "Content-Type: application/json" \ -d '{"price":109.99,"version":5}'
# Delete productcurl -X DELETE "https://your-instance.sku-man.com/api/v1/external/products/550e8400-..." \ -H "X-API-Key: skm_live_..."
# Upload imagecurl -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"
# Bulk upsertcurl -X POST "https://your-instance.sku-man.com/api/v1/external/products/bulk" \ -H "X-API-Key: skm_live_..." \ -H "Content-Type: application/json" \ -d '{ "products": [ {"sku":"SKU-001","name":"Product 1","price":99.99}, {"sku":"SKU-002","name":"Product 2","price":149.99} ], "matchBy": "sku", "createIfMissing": true }'Best Practices
Section titled “Best Practices ”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 requestsfor (const p of products) await createProduct(p);
// Good: 1 bulk requestawait 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
