Products on your site show incorrect stock levels, managers update prices in MoySklad but they don't reflect on the site. Orders have to be entered manually—leading to 20–30% shipping errors and lost customers. Two-way synchronization solves this: products, stock, orders, and statuses update automatically. The result is accurate data on both the site and MoySklad without your manual effort. Savings on manual data entry can reach 200,000 rubles per year.
What problems does two-way synchronization solve?
Stock discrepancies are the most common pain point. A customer sees a product in stock, but it's already gone, or vice versa. Our synchronization updates stock every 5 minutes, reducing shipping errors by 80% compared to manual management. The second problem is manual order transfer. Employees spend up to 2 hours daily duplicating information. Two-way synchronization automatically creates an order in MoySklad when placed on the site and updates the status upon shipment. The third is data conflicts. If a price is changed simultaneously in both systems, we apply a clear source of truth rule: prices and stock from MoySklad, SEO and images from the site.
How we implement the integration
Architecture and stack
We use the MoySklad REST API version 1.2, PHP 8.3 with Laravel 11 framework and queues via Redis. Authentication uses a Bearer token. All requests go through an HTTP client with retry logic on errors. Webhooks are configured for order change events so statuses update instantly. The architecture accounts for N+1 queries and uses chunked data loading (batches of 100 items).
According to the official MoySklad API documentation, this ensures reliable integration.
Example API client implementation
class MoiSkladClient
{
private string $baseUrl = 'https://api.moysklad.ru/api/remap/1.2';
private function headers(): array
{
return [
'Authorization' => 'Bearer ' . config('services.moysklad.token'),
'Content-Type' => 'application/json;charset=utf-8',
'Accept-Encoding' => 'gzip',
];
}
public function get(string $path, array $params = []): array
{
return Http::withHeaders($this->headers())
->get("{$this->baseUrl}/{$path}", $params)
->throw()
->json();
}
public function post(string $path, array $data): array
{
return Http::withHeaders($this->headers())
->post("{$this->baseUrl}/{$path}", $data)
->throw()
->json();
}
}
| Entity | Source of truth | Notes |
|---|---|---|
| Products (name, description, price) | MoySklad | Managers edit there |
| Stock | MoySklad | Updates on receipt/sale |
| Product images | Site | Uploaded via CMS |
| SEO fields (meta, slug) | Site | Do not exist in MS |
| Orders | Site → MS | Created on site, sent to MS |
| Order statuses | MS → Site | Manager changes in MS |
Fetching products and stock
class ProductSyncService
{
public function fetchProducts(int $offset = 0, int $limit = 100): array
{
return $this->ms->get('entity/product', [
'offset' => $offset,
'limit' => $limit,
'expand' => 'productFolder,images',
'filter' => 'archived=false',
]);
}
public function fetchStocks(): array
{
return $this->ms->get('report/stock/all/current', [
'stockType' => 'stock',
'includeRelated' => false,
]);
}
public function syncToSite(): void
{
$offset = 0;
$limit = 100;
do {
$response = $this->fetchProducts($offset, $limit);
$products = $response['rows'];
foreach ($products as $msProduct) {
$this->upsertProduct($msProduct);
}
$offset += $limit;
} while ($offset < $response['meta']['size']);
$stocks = $this->fetchStocks();
foreach ($stocks as $stock) {
Product::whereExternalId($stock['assortmentId'])
->update(['stock' => max(0, (int)$stock['stock'])]);
}
}
private function upsertProduct(array $msProduct): void
{
$msId = $msProduct['id'];
$data = [
'external_id' => $msId,
'name' => $msProduct['name'],
'article' => $msProduct['article'] ?? null,
'price' => $this->parsePrice($msProduct['salePrices'][0]['value'] ?? 0),
'description' => $msProduct['description'] ?? '',
'ms_updated_at'=> Carbon::parse($msProduct['updated']),
];
$product = Product::updateOrCreate(['external_id' => $msId], $data);
}
private function parsePrice(int $msPrice): float
{
return $msPrice / 100;
}
}
Order transfer and webhooks
public function pushOrder(Order $order): string
{
$positions = [];
foreach ($order->items as $item) {
$positions[] = [
'assortment' => [
'meta' => [
'href' => "{$this->baseUrl}/entity/product/{$item->product->external_id}",
'type' => 'product',
],
],
'quantity' => $item->quantity,
'price' => $item->price * 100,
];
}
$msOrder = $this->ms->post('entity/customerorder', [
'name' => "Order #{$order->id}",
'organization' => [
'meta' => [
'href' => "{$this->baseUrl}/entity/organization/" . config('services.moysklad.org_id'),
'type' => 'organization',
],
],
'agent' => $this->getOrCreateCounterparty($order->customer),
'positions' => $positions,
'description' => "Source: site\nEmail: {$order->customer->email}",
'attributes' => [
[
'meta' => ['href' => $this->orderIdAttributeHref()],
'value' => (string)$order->id,
],
],
]);
$order->update(['ms_order_id' => $msOrder['id']]);
return $msOrder['id'];
}
When an order changes in MoySklad, the webhook notifies our server and the status is automatically updated on the site:
public function handleMsWebhook(Request $request): Response
{
$events = $request->json('events', []);
foreach ($events as $event) {
if ($event['meta']['type'] === 'customerorder') {
$msOrderId = basename($event['meta']['href']);
SyncOrderStatusJob::dispatch($msOrderId);
}
}
return response()->noContent();
}
class SyncOrderStatusJob implements ShouldQueue
{
public function handle(MoiSkladClient $ms): void
{
$msOrder = $ms->get("entity/customerorder/{$this->msOrderId}");
// status mapping and order update
}
}
How often is data updated and how are conflicts resolved?
Stock synchronizes every 5 minutes, products and orders every 10 minutes. This keeps data up-to-date without excessive API load. The frequency can be adjusted to your needs.
Conflicts are inevitable with two-way synchronization. Our approach: price, stock, SKU — MoySklad priority; SEO fields and images — site; title — by last modification date. We ensure important changes are never lost. Shipping errors cost on average 50,000 rubles per month — synchronization minimizes them.
Comparison of approaches
| Characteristic | One-way | Two-way |
|---|---|---|
| Products, prices, stock | ✅ | ✅ |
| Orders (site→MS) | ❌ | ✅ |
| Statuses (MS→site) | ❌ | ✅ |
| Conflict resolution | not needed | ✅ |
| Manager manual work | ~1 hour/day | automated |
| Shipping errors | 20-30% | <5% |
Two-way synchronization reduces order processing time by 10x and virtually eliminates shipping errors.
Why choose two-way synchronization?
We have been integrating online stores with MoySklad for over 5 years, completing 30+ projects including complex ones with thousands of SKUs. Our engineers have proven expertise in Laravel and REST APIs. We guarantee the integration will work and promptly fix any issues. Savings on manual data entry reach 200,000 rubles per year, and costs for fixing shipping errors drop to 50,000 rubles per month.
Process and timeline
- Analysis — we study the catalog structure, define entities and mapping rules.
- Design — we design the integration: which fields, frequency, error handling.
- Implementation — we write code, configure webhooks, queues.
- Testing — we run a test sync on a data copy and compare results.
- Deployment — we deploy to production and monitor.
Estimated timeline: one-way sync (MoySklad → site) — 2–3 business days; two-way (with orders, statuses, webhooks) — 6–8 business days. The timeline may increase for complex catalog structures (variants, bundles). We offer a free audit of your current system and propose the optimal solution. Contact us to permanently eliminate manual data transfer. Get a consultation today.







