Working with Product Cards
Complete guide to fetching, filtering, and paginating product cards using the createCardsList() method.
Table of Contents
- Overview
- Basic Usage
- Understanding the Request Structure
- First Request vs Pagination
- Filtering Options
- Complete Pagination Example
- Common Mistakes
- Troubleshooting
- Best Practices
- Response Structure
Overview
The createCardsList() method retrieves a list of your product cards from Wildberries. It supports:
- Cursor-based pagination for fetching large datasets
- Advanced filtering by photos, text search, brands, categories, tags
- Sorting by update date
- Efficient batching (maximum 100 cards per request)
API Endpoint: POST /content/v2/get/cards/list
Rate Limit: 100 requests/minute with 600ms intervals (burst: 5 requests)
⚠️ Important: Cards in trash are NOT returned by this method. Use getCardsTrash() to fetch trashed cards separately.
🚨 CRITICAL: Pagination Limit Restrictions
MAXIMUM: 100 cards per request - the API will reject larger values with ValidationError (HTTP 400).
Verified through testing (December 2024):
- ✅
limit: 10- Works perfectly - ✅
limit: 100- RECOMMENDED & MAXIMUM - Official Wildberries recommendation - ❌
limit: 1000- FAILS with ValidationError (HTTP 400) - ❌
limit: 5000- FAILS with ValidationError (HTTP 400)
Why this matters: Although undocumented in Wildberries API specification, the API enforces a strict limit of 100 cards maximum. Any value exceeding 100 will result in request rejection.
Correct approach: Always use limit: 100 with proper pagination (see examples below).
Basic Usage
Simplest Example - First 100 Cards
import { WildberriesSDK } from 'daytona-wildberries-typescript-sdk';
const sdk = new WildberriesSDK({
apiKey: process.env.WB_API_KEY!
});
// Get first 100 product cards
const response = await sdk.products.createCardsList({
settings: {
filter: {
withPhoto: -1 // All cards (with and without photos)
},
cursor: {
limit: 100
}
}
});
console.log(`Fetched: ${response.cards?.length ?? 0} cards`);
console.log(`Total in account: ${response.cursor?.total ?? 0} cards`);Understanding the Request Structure
Request Schema
{
settings: {
sort?: {
ascending?: boolean; // Sort by updatedAt (false = descending)
};
filter?: {
withPhoto?: number; // Photo filter: -1, 0, or 1
textSearch?: string; // Search by vendor code, nmID, barcode
tagIDs?: number[]; // Filter by tag IDs
allowedCategoriesOnly?: boolean; // Only allowed categories
objectIDs?: number[]; // Filter by subject IDs
brands?: string[]; // Filter by brand names
imtID?: number; // Filter by merged card ID
};
cursor: {
limit: number; // Cards per request (MAXIMUM: 100)
updatedAt?: string; // For pagination (ISO 8601 timestamp)
nmID?: number; // For pagination (Wildberries article ID)
};
}
}Query Parameters
{
locale?: 'ru' | 'en' | 'zh'; // Language for name, value, object fields
}First Request vs Pagination
🔑 Critical Difference
FIRST REQUEST (getting initial batch):
// ✅ CORRECT - Only specify limit
{
settings: {
cursor: {
limit: 100 // ONLY limit, DO NOT include updatedAt or nmID
},
filter: { withPhoto: -1 }
}
}// ❌ WRONG - Empty values cause validation errors
{
settings: {
cursor: {
limit: 100,
updatedAt: "", // ❌ Remove this for first request
nmID: 0 // ❌ Remove this for first request
},
filter: { withPhoto: -1 }
}
}PAGINATION REQUESTS (getting next batches):
// ✅ CORRECT - Copy updatedAt and nmID from previous response
{
settings: {
cursor: {
limit: 100,
updatedAt: "2023-12-06T11:17:00.96577Z", // From response.cursor
nmID: 370870300 // From response.cursor
},
filter: { withPhoto: -1 }
}
}How Pagination Works
- Make first request with only
limitin cursor - Receive response with cards and cursor data
- Copy
cursor.updatedAtandcursor.nmIDfrom response - Paste into next request's cursor
- Repeat until
cursor.total < limitorcards.length < limit
Filtering Options
Photo Filter
// All cards (with and without photos) - DEFAULT
withPhoto: -1
// Only cards WITHOUT photos
withPhoto: 0
// Only cards WITH photos
withPhoto: 1Text Search (Vendor Code, nmID, Barcode)
{
settings: {
filter: {
textSearch: '4603743187500888', // Searches in vendor code, nmID, barcode
withPhoto: -1
},
cursor: { limit: 100 }
}
}Filter by Brand
{
settings: {
filter: {
brands: ['Nike', 'Adidas', 'Puma'],
withPhoto: -1
},
cursor: { limit: 100 }
}
}Filter by Tag IDs
// First, get available tags
const tags = await sdk.products.getContentTags();
console.log(tags);
// Then filter by specific tag IDs
{
settings: {
filter: {
tagIDs: [345, 415], // Tag IDs from getContentTags()
withPhoto: -1
},
cursor: { limit: 100 }
}
}Filter by Subject (Category)
{
settings: {
filter: {
objectIDs: [235, 67], // Subject IDs
withPhoto: -1
},
cursor: { limit: 100 }
}
}Filter by Merged Card ID
{
settings: {
filter: {
imtID: 328632, // Get all variants of merged card
withPhoto: -1
},
cursor: { limit: 100 }
}
}Combine Multiple Filters
{
settings: {
filter: {
brands: ['Nike'],
objectIDs: [235],
withPhoto: 1, // Only with photos
tagIDs: [345]
},
cursor: { limit: 100 }
}
}Complete Pagination Example
Fetch All Product Cards with Pagination
async function getAllProductCards() {
const allCards = [];
let hasMore = true;
let cursor: any = { limit: 100 }; // Start with only limit
while (hasMore) {
const response = await sdk.products.createCardsList({
settings: {
filter: { withPhoto: -1 },
cursor
}
});
// Add cards to result
if (response.cards) {
allCards.push(...response.cards);
}
// Check if more data available
const receivedCount = response.cards?.length ?? 0;
// Stop if we received less than limit (last page)
if (receivedCount < 100) {
hasMore = false;
} else if (response.cursor) {
// Update cursor for next request
cursor = {
limit: 100,
updatedAt: response.cursor.updatedAt,
nmID: response.cursor.nmID
};
} else {
hasMore = false;
}
console.log(`Progress: ${allCards.length} cards fetched`);
// Optional: Add delay to respect rate limits
await new Promise(resolve => setTimeout(resolve, 650));
}
console.log(`Total cards fetched: ${allCards.length}`);
return allCards;
}
// Usage
const allCards = await getAllProductCards();Pagination with Specific Filters
async function getFilteredCardsWithPagination(filters: {
brands?: string[];
withPhoto?: number;
textSearch?: string;
}) {
const allCards = [];
let cursor: any = { limit: 100 };
while (true) {
const response = await sdk.products.createCardsList({
settings: {
filter: {
...filters,
withPhoto: filters.withPhoto ?? -1
},
cursor
}
});
if (response.cards) {
allCards.push(...response.cards);
}
const receivedCount = response.cards?.length ?? 0;
if (receivedCount < 100 || !response.cursor?.updatedAt) {
break;
}
cursor = {
limit: 100,
updatedAt: response.cursor.updatedAt,
nmID: response.cursor.nmID
};
console.log(`Fetched ${allCards.length} cards...`);
await new Promise(resolve => setTimeout(resolve, 650));
}
return allCards;
}
// Usage examples
const nikeCards = await getFilteredCardsWithPagination({
brands: ['Nike'],
withPhoto: 1
});
const cardsWithPhotos = await getFilteredCardsWithPagination({
withPhoto: 1
});Common Mistakes
❌ Mistake 1: Including Empty Cursor Fields in First Request
// ❌ WRONG - Causes "Validation failed"
const response = await sdk.products.createCardsList({
settings: {
cursor: {
limit: 100,
updatedAt: "", // Empty string causes validation error
nmID: 0 // Zero value causes validation error
},
filter: { withPhoto: -1 }
}
});✅ Solution: Omit updatedAt and nmID in first request:
const response = await sdk.products.createCardsList({
settings: {
cursor: {
limit: 100 // ONLY limit
},
filter: { withPhoto: -1 }
}
});❌ Mistake 2: Missing settings Wrapper
// ❌ WRONG - Missing settings wrapper
const response = await sdk.products.createCardsList({
cursor: { limit: 100 }, // Should be inside settings
filter: { withPhoto: -1 } // Should be inside settings
});✅ Solution: Wrap everything in settings:
const response = await sdk.products.createCardsList({
settings: { // ✅ Wrapper required
cursor: { limit: 100 },
filter: { withPhoto: -1 }
}
});❌ Mistake 3: Exceeding Limit Maximum (CRITICAL)
// ❌ WRONG - Limit exceeds maximum, causes ValidationError (HTTP 400)
const response = await sdk.products.createCardsList({
settings: {
cursor: { limit: 1000 } // ❌ FAILS - Maximum is 100!
}
});
// ❌ ALSO WRONG - Even higher values fail
const response = await sdk.products.createCardsList({
settings: {
cursor: { limit: 5000 } // ❌ FAILS - Maximum is 100!
}
});✅ Solution: ALWAYS use limit: 100 (maximum allowed):
const response = await sdk.products.createCardsList({
settings: {
cursor: { limit: 100 } // ✅ MAXIMUM & RECOMMENDED
}
});Error you'll see if exceeded:
ValidationError: Validation failed
HTTP Status: 400❌ Mistake 4: Not Handling Pagination
// ❌ WRONG - Only gets first 100 cards
const response = await sdk.products.createCardsList({
settings: {
cursor: { limit: 100 },
filter: { withPhoto: -1 }
}
});
// If you have 500 cards, you missed 400!✅ Solution: Implement pagination loop (see Complete Pagination Example)
❌ Mistake 5: Incorrect API Key Permissions
// ❌ WRONG - Using API key without "Content" or "Promotion" category✅ Solution: Create API key with proper permissions:
- Go to Seller Portal → Settings → API Keys
- Create new key with "Контент" or "Продвижение" category
Troubleshooting
Error: "Validation failed"
Symptoms:
- Request returns 400 status code
- Error message: "Validation failed"
Common Causes & Solutions:
Empty cursor fields in first request
typescript// ❌ Problem cursor: { limit: 100, updatedAt: "", nmID: 0 } // ✅ Solution cursor: { limit: 100 }Limit exceeds maximum (MOST COMMON)
typescript// ❌ Problem - API returns ValidationError (HTTP 400) cursor: { limit: 1000 } // ❌ Exceeds maximum! cursor: { limit: 5000 } // ❌ Exceeds maximum! // ✅ Solution - Use maximum allowed value cursor: { limit: 100 } // ✅ MAXIMUM: 100 cardsNote: This is the #1 cause of ValidationError. The API strictly enforces a 100-card limit despite incomplete documentation.
Missing settings wrapper
typescript// ❌ Problem { cursor: { limit: 100 } } // ✅ Solution { settings: { cursor: { limit: 100 } } }
Error: 401 Unauthorized or 403 Forbidden
Cause: API key lacks required permissions
Solution:
- Go to Wildberries Seller Portal
- Navigate to Settings → API Keys
- Create new key with "Контент" or "Продвижение" category
- Update
WB_API_KEYenvironment variable
Error: 429 Too Many Requests
Cause: Rate limit exceeded (100 requests/minute)
Solution:
// Add delay between requests
async function fetchWithDelay() {
const response = await sdk.products.createCardsList({...});
// Wait 650ms before next request (100 req/min = 600ms interval + buffer)
await new Promise(resolve => setTimeout(resolve, 650));
return response;
}Getting 0 Cards When You Have Products
Possible Causes:
Cards are in trash
typescript// Use separate method for trashed cards const trashedCards = await sdk.products.getCardsTrash({ settings: { cursor: { limit: 100 } } });Wrong filter applied
typescript// Check filter settings filter: { withPhoto: 0 } // Only returns cards WITHOUT photosText search too specific
typescript// Try broader search or remove filter filter: { textSearch: '' } // Empty search = no filter
Response Has No cursor Field
Cause: This is the last page of results
Solution: This is expected behavior when you've fetched all cards. The pagination loop should exit.
if (!response.cursor?.updatedAt) {
console.log('Last page reached');
break;
}Best Practices
1. ALWAYS Use Maximum Allowed Batch Size
// ✅ CORRECT - Maximum allowed by API
cursor: { limit: 100 }
// ❌ FAILS - Exceeds maximum, causes ValidationError (HTTP 400)
cursor: { limit: 500 }
// ❌ FAILS - Exceeds maximum, causes ValidationError (HTTP 400)
cursor: { limit: 1000 }Why 100? The Wildberries API strictly enforces a maximum of 100 cards per request. Use pagination to fetch all cards.
2. Implement Rate Limit Protection
async function fetchWithRateLimit(requestFn: () => Promise<any>) {
const response = await requestFn();
// Wait 650ms between requests (100 req/min = 600ms + buffer)
await new Promise(resolve => setTimeout(resolve, 650));
return response;
}3. Handle Errors Gracefully
async function safeFetchCards(cursor: any, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await sdk.products.createCardsList({
settings: { cursor, filter: { withPhoto: -1 } }
});
} catch (error: any) {
if (error.statusCode === 429 && attempt < retries) {
console.log(`Rate limited, waiting 60s (attempt ${attempt}/${retries})`);
await new Promise(resolve => setTimeout(resolve, 60000));
} else if (error.statusCode === 401 || error.statusCode === 403) {
throw new Error('Invalid API key or insufficient permissions');
} else if (attempt === retries) {
throw error;
}
}
}
}4. Log Progress for Large Datasets
async function getAllCardsWithProgress() {
const allCards = [];
let cursor: any = { limit: 100 };
let pageNumber = 1;
while (true) {
console.log(`Fetching page ${pageNumber}...`);
const response = await sdk.products.createCardsList({
settings: { cursor, filter: { withPhoto: -1 } }
});
if (response.cards) {
allCards.push(...response.cards);
console.log(` → Received ${response.cards.length} cards`);
console.log(` → Total so far: ${allCards.length}`);
console.log(` → Account total: ${response.cursor?.total ?? 'unknown'}`);
}
if ((response.cards?.length ?? 0) < 100 || !response.cursor?.updatedAt) {
break;
}
cursor = {
limit: 100,
updatedAt: response.cursor.updatedAt,
nmID: response.cursor.nmID
};
pageNumber++;
await new Promise(resolve => setTimeout(resolve, 650));
}
console.log(`✅ Completed: ${allCards.length} cards fetched`);
return allCards;
}5. Cache Results When Appropriate
import { writeFileSync, readFileSync, existsSync } from 'fs';
async function getCachedCards(cacheDuration = 3600000) { // 1 hour
const cacheFile = 'cards-cache.json';
if (existsSync(cacheFile)) {
const cache = JSON.parse(readFileSync(cacheFile, 'utf-8'));
const age = Date.now() - cache.timestamp;
if (age < cacheDuration) {
console.log('Using cached cards');
return cache.cards;
}
}
console.log('Fetching fresh cards from API');
const cards = await getAllProductCards();
writeFileSync(cacheFile, JSON.stringify({
timestamp: Date.now(),
cards
}));
return cards;
}6. Filter Early to Reduce Data Transfer
// ✅ GOOD - Filter on API side
const nikeCards = await sdk.products.createCardsList({
settings: {
filter: { brands: ['Nike'] },
cursor: { limit: 100 }
}
});
// ❌ INEFFICIENT - Fetch all, then filter client-side
const allCards = await getAllProductCards();
const nikeCards = allCards.filter(c => c.brand === 'Nike');Response Structure
Response Type
{
cards?: Array<{
nmID?: number; // Wildberries article ID
imtID?: number; // Merged card ID (same for all variants)
nmUUID?: string; // Internal technical ID (UUID)
subjectID?: number; // Subject (category) ID
subjectName?: string; // Subject name
vendorCode?: string; // Your vendor code (SKU)
brand?: string; // Brand name
title?: string; // Product title
description?: string; // Product description
needKiz?: boolean; // Requires marking code (честныйзнак.рф)
photos?: Array<{
big?: string; // Large photo URL
c246x328?: string; // 246x328px photo URL
c516x688?: string; // 516x688px photo URL
square?: string; // Square photo URL
tm?: string; // Thumbnail URL
}>;
video?: string; // Video URL
wholesale?: {
enabled?: boolean; // Wholesale enabled
quantum?: number; // Minimum wholesale quantity
};
dimensions?: {
length?: number; // Length in cm
width?: number; // Width in cm
height?: number; // Height in cm
weightBrutto?: number; // Weight in kg
isValid?: boolean; // Dimensions validated
};
characteristics?: Array<{
id?: number; // Characteristic ID
name?: string; // Characteristic name
value?: any; // Characteristic value (can be string, number, array)
}>;
sizes?: Array<{
chrtID?: number; // Size chart ID
techSize?: string; // Technical size
wbSize?: string; // Display size
skus?: string[]; // Barcodes for this size
}>;
tags?: Array<{
id?: number; // Tag ID
name?: string; // Tag name
color?: string; // Tag color (hex)
}>;
createdAt?: string; // Creation date (ISO 8601)
updatedAt?: string; // Last update date (ISO 8601)
}>;
cursor?: {
updatedAt?: string; // Copy to next request for pagination
nmID?: number; // Copy to next request for pagination
total?: number; // Total cards in account (informational)
};
}Example Response
{
"cards": [
{
"nmID": 123456789,
"imtID": 328632,
"vendorCode": "MY-PRODUCT-001",
"brand": "MyBrand",
"title": "Premium Product Title",
"description": "Detailed product description",
"subjectID": 235,
"subjectName": "Shirts",
"photos": [
{
"big": "https://basket-01.wb.ru/vol123/part456/123456789/images/big/1.jpg",
"c516x688": "https://basket-01.wb.ru/vol123/part456/123456789/images/c516x688/1.jpg"
}
],
"sizes": [
{
"chrtID": 987654,
"techSize": "XL",
"wbSize": "XL",
"skus": ["4603743187500888"]
}
],
"updatedAt": "2023-12-06T11:17:00.96577Z"
}
],
"cursor": {
"updatedAt": "2023-12-06T11:17:00.96577Z",
"nmID": 123456789,
"total": 500
}
}Related Resources
- Product Catalog Use Case - Complete product catalog sync examples
- Stock Management Guide - Managing inventory with fetched cards
- Best Practices Guide - General SDK best practices
- API Reference: ProductsModule - TypeScript API documentation
Summary
Key Takeaways:
- ✅ First request: Only include
limitin cursor - ✅ Pagination: Copy
updatedAtandnmIDfrom response cursor - ✅ Always wrap parameters in
settingsobject - ✅ Use
limit: 100for optimal performance - ✅ Implement rate limiting (650ms between requests)
- ✅ Handle errors and retry on transient failures
- ✅ Check API key has "Content" or "Promotion" permissions
Quick Reference:
// First request
const first = await sdk.products.createCardsList({
settings: {
cursor: { limit: 100 },
filter: { withPhoto: -1 }
}
});
// Next request (pagination)
const next = await sdk.products.createCardsList({
settings: {
cursor: {
limit: 100,
updatedAt: first.cursor.updatedAt,
nmID: first.cursor.nmID
},
filter: { withPhoto: -1 }
}
});Need help? Check the Troubleshooting Guide or open an issue.