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.skuman.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
}
}

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

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_... (40 chars)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
readGET requests (list, retrieve products)
writePOST, PUT, DELETE requests (create, update, delete, image upload)

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

MethodEndpointAuthDescription
GET /healthNoneCheck API status
MethodEndpointAuthDescription
GET /productsAPI Key (read)List products with filters
POST /productsAPI Key (write)Create product
GET /products/{id}API Key (read)Get single product
PUT /products/{id}API Key (write)Update product
DELETE /products/{id}API Key (write)Soft-delete product
POST /products/bulkAPI Key (write)Bulk upsert (up to 1,000)
MethodEndpointAuthDescription
POST /products/{id}/imagesAPI Key (write)Upload image (multipart)
POST /products/{id}/images/presignAPI Key (write)Get presigned upload URL
POST /products/{id}/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
MethodEndpointDescription
GET /keysList API keys
POST /keysCreate API key
GET /keys/{id}Get API key details
PATCH /keys/{id}Update API key
DELETE /keys/{id}Revoke API key
POST /keys/{id}/rotateRotate API key
MethodEndpointDescription
GET /webhooksList webhooks
POST /webhooksCreate webhook
GET /webhooks/{id}Get webhook details
PATCH /webhooks/{id}Update webhook
DELETE /webhooks/{id}Delete webhook
POST /webhooks/{id}/rotate-secretRotate webhook signing secret
POST /webhooks/{id}/testSend test event
GET /webhooks/{id}/deliveriesView delivery log

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 API key provided
INVALID_API_KEY401API key not found or bad format
EXPIRED_API_KEY401API key has expired
REVOKED_API_KEY401API key has been revoked
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
UNPROCESSABLE_ENTITY422Semantic validation failure
RATE_LIMIT_EXCEEDED429Rate limit exceeded
INTERNAL_ERROR500Unexpected server error
SERVER_ERROR500Server processing error
DATABASE_ERROR500Database 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 staged for admin review"
}
  • 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 products staged for admin review"
}

GET /health

No authentication required.

{
"status": "ok",
"version": "1.0",
"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)
Terminal window
curl "https://api.skuman.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}

Retrieve a single product by UUID.

{
"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",
"version": 1,
"createdAt": 1704067200000,
"updatedAt": 1704067200000
}
}

PUT /products/{id}

Update an existing product. 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}

Soft-delete a product. 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,
"failed": 0
},
"errors": []
}
{
"success": false,
"summary": {
"total": 3,
"created": 1,
"updated": 1,
"failed": 1
},
"errors": [
{ "sku": "SKU-003", "error": "Invalid price format" }
]
}

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

POST /products/{id}/images

Upload a single image file directly.

Terminal window
curl -X POST "https://api.skuman.com/api/v1/external/products/550e8400-.../images" \
-H "X-API-Key: skm_live_..." \
-F "image=@product-photo.jpg"
{
"url": "https://cdn.example.com/tenant-id/1704067200000-abc-product-photo.jpg",
"deduplicated": false,
"staged": 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}/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, or image/gif
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}/images/complete
{
"uploadSessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}

Response (201):

{
"url": "https://cdn.example.com/tenant-id/1704067200000-abc-product-photo.jpg",
"deduplicated": false,
"staged": false
}
AspectBehavior
Original imageStored as-is — no trimming, resizing, or re-encoding
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.


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 or bulk imported
inventory.deletedSingle incoming inventory entry deleted
inventory.clearedAll incoming inventory cleared (admin)

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
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"sku": "NIKE-AIR-001",
"product": {
"id": "550e8400-...",
"sku": "NIKE-AIR-001",
"name": "Air Max 90",
"price": 139.99,
"updatedAt": 1704067200000,
"version": 6
},
"changes": ["price"]
}

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"
}
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
createdintegerAlwaysNumber of products created
updatedintegerAlwaysNumber of products updated
createdProducts{id, sku}[]AlwaysProducts that were created
updatedProducts{id, sku}[]AlwaysProducts that were updated
sourcestringAlways"external_api" or "internal"
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 upsert or bulk import.

{
"action": "upsert",
"entryCount": 5,
"inserted": 3,
"updated": 2,
"productIds": ["550e8400-e29b-41d4-a716-446655440000", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"]
}
FieldTypeDescription
actionstring"upsert" for single upsert, "import" for bulk import
entryCountintegerTotal entries in the request
insertedintegerNew entries created
updatedintegerExisting entries updated
productIdsstring[]Distinct product UUIDs affected

Fires when a single entry is deleted.

{
"entryId": "550e8400-e29b-41d4-a716-446655440000",
"deleted": 1
}
FieldTypeDescription
entryIdstringUUID of the deleted inventory entry
deletedintegerNumber of rows deleted (typically 1)

Fires when an admin clears all incoming inventory.

{
"deletedEntries": 150,
"updatedProducts": 42
}
FieldTypeDescription
deletedEntriesintegerTotal inventory entries removed
updatedProductsintegerProducts whose inventory fields were reset

Signature format: v1={hmac_sha256_hex}

const crypto = require('crypto');
function verifyWebhookSignature(req, secret) {
const timestamp = req.headers['x-skuman-timestamp'];
const signature = req.headers['x-skuman-signature'];
const body = 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)

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-100,000)
rateLimitPerDayintegerNo10,000Per-day rate limit (1-100,000,000)
expiresAtstringNo
ISO 8601 expiration date
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."
}

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)
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
}
]
}

FieldTypeMax LengthDescription
iduuid
Unique product identifier (auto-generated)
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 (default: USD)
availableinteger
Available quantity (>= 0)
totalOnOrderinteger
Quantity on order (>= 0)
atsinteger
Available to sell (>= 0)
sizestring50Size
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-udf8mixed
User-defined fields
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)

const SKUMAN_API = 'https://api.skuman.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.

headers: {
'Idempotency-Key': `create-${product.sku}-${Date.now()}`
}
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: February 2026