Sprzedawca rękodzieła nie pyta, czy Twój marketplace ma lepszy UX od Shopify. Pyta: a co z moimi 300 produktami, które już tam mam?
Twórcy rękodzieła nie zawsze zaczynają od zera. Często mają już sklep na Shopify, klientów w grupie na Facebooku albo całe lata historii sprzedaży na Etsy. Marketplace bez pozycji na rynku nie daje im żadnego oczywistego powodu, żeby się przenosić — to dodatkowe konto, dodatkowy katalog do pilnowania, dodatkowa praca bez gwarantowanego zwrotu.
Artovnia — marketplace sztuki i rękodzieła, budowany bootstrapowo, bez budżetu marketingowego — musiała znaleźć inną drogę, tymbardziej, że ma stać się największą i najlepszą platformą dla sztuki i rękodzieła w Polsce. Nie "jak przekonać sprzedawcę do migracji", lecz "jak zsynchronizować się z tym, co sprzedawca już ma".
A na horyzoncie jest też TikTok Shop. Na tę konkurencję lepiej odpowiedzieć integracją, niż czekać, aż przejmie sprzedawców.
Dlaczego hub, a nie integracja
Najprostsza ścieżka to integracja z jedną platformą, "a reszta dojdzie później". Znam ten plan. I wiem, co oznacza "dojdzie później" — gdy przychodzi czas na WooCommerce, okazuje się, że logika Shopify jest wbita w rdzeń na dziesięć sposobów, a każdy z nich to tydzień refaktoryzacji.
Zamiast tego, od pierwszego modelu w bazie, powstał hub integracji — provider-neutralny z założenia, a nie jako efekt późniejszego sprzątania. Sprzedawcy siedzą na różnych platformach. WooCommerce, TikTok Shop, PrestaShop, Etsy — każde z nich dojdzie z czasem. Hardkodowanie logiki jednej platformy w rdzeniu oznaczałoby przepisywanie fundamentów przy każdej kolejnej integracji.
Pierwszym działającym adapterem został Shopify. W trakcie prac nad publikacją tego artykułu ruszyła implementacja WooCommerce — i stała się pierwszym realnym testem, czy "provider-neutral" nie jest tylko ładnie brzmiącym hasłem.
Architektura huba
Całość układa się w prostą hierarchię: sprzedawca łączy się z hubem przez panel vendora, hub trzyma stan w neutralnych modelach, a konkretny provider tylko wykonuje operacje.
Seller
│
▼
Artovnia Vendor Panel
│
▼
External Commerce Hub
│
├── IntegrationConnection
├── IntegrationEntityMapping
├── IntegrationLocationMapping
├── IntegrationSyncRun
├── IntegrationSyncItem
├── IntegrationWebhookEvent
└── IntegrationConflict
│
├── Shopify Provider
├── WooCommerce Provider
├── TikTok Shop Provider
├── PrestaShop Provider
└── Future ProvidersWszystko nad linią providerów jest wspólne dla każdej integracji. Wszystko pod nią — wymienne.

Model domenowy — serce systemu
Najważniejszą decyzją w całym module nie był wybór Shopify jako pierwszego providera. Był nim zestaw modeli, który nie wie, że Shopify istnieje.
- IntegrationConnection — połączenie sprzedawcy z konkretnym kontem/sklepem u danego providera
- IntegrationEntityMapping — mapowanie produktu, wariantu, inventory itemu między providerem a Artovnią
- IntegrationLocationMapping — mapowanie lokalizacji magazynowej providera na stock location w Artovnii
- IntegrationSyncRun — nagłówek jednego przebiegu synchronizacji, z licznikami sukcesów, porażek i konfliktów
- IntegrationSyncItem — pojedyncza jednostka pracy w ramach przebiegu, ze statusem, liczbą prób i kluczem idempotencji
- IntegrationWebhookEvent — trwały inbox webhooków, z deduplikacją po identyfikatorze providera
- IntegrationConflict — różnica między wartością providera a wartością Artovnii, czekająca na decyzję sprzedawcy
W kodzie te modele schodzą się w jednym serwisie. Zwróć uwagę na granicę: Shopify nie jest częścią serca systemu. Jest tylko wykonawcą.
class ExternalCommerceModuleService extends MedusaService({
IntegrationConnection,
IntegrationComplianceProfile,
IntegrationSecret,
IntegrationEntityMapping,
IntegrationCategoryMapping,
IntegrationLocationMapping,
IntegrationSyncRun,
IntegrationSyncItem,
IntegrationWebhookEvent,
IntegrationConflict,
}) {
// connection lifecycle, encrypted secrets,
// sync runs/items, webhook inbox, conflicts
}Provider registry zamiast hardkodowanej listy
Hub musi wiedzieć, jakie integracje są dostępne — ale panel vendora nie powinien mieć na sztywno zakodowanej listy "Shopify, WooCommerce, ...". Stąd registry providerów:
listProviderManifests() {
return providerManifests
}
getProviderManifest(provider: string) {
return getProviderManifest(provider)
}Hub nie pyta "czy to Shopify?" — sprawdza manifest: jakie ma capability, jaką nazwę pokazać w panelu, jakie typy autoryzacji obsługuje. Panel vendora może wyświetlać dostępne integracje bez żadnego hardkodowania w UI.
Co ważne: WooCommerce jest już obecny w registry z własnym manifestem capabilities, choć sam adapter nadal jest w trakcie implementacji. Provider registry nie jest listą gotowych integracji. Jest kontraktem.
{
"provider": "woocommerce",
"capabilities": [
"catalog.read",
"catalog.webhooks",
"inventory.read",
"inventory.webhooks",
"orders.read"
],
"production_ready": false
}Provider-specific side effects
Jedna z rzeczy, które zmieniły się po zakończeniu Shopify: rejestracja webhooków.
Początkowo logika znajdowała się w neutralnych endpointach połączeń. Gdy pojawił się drugi provider, została przeniesiona do endpointów provider-specific. Dzięki temu hub nie musi znać szczegółów rejestracji webhooków Shopify ani WooCommerce. Hub odpowiada za workflow, synchronizację, bezpieczeństwo i idempotencję. Szczegóły integracyjne zostają po stronie adaptera.
Kolejka, nie wywołanie API
Każda operacja — webhook, import, korekta stocku po zamówieniu — przechodzi przez tę samą ścieżkę:
Shopify Webhook
│
▼
IntegrationWebhookEvent
│
▼
IntegrationSyncRun
│
▼
IntegrationSyncItem
│
▼
Provider Executor
│
▼
Shopify Adapter
│
▼
Medusa Inventory / Product / ConflictTen wzorzec nie powstał dla samej elegancji. Każdy element wynikał z konkretnego wymagania:
- Webhook Shopify musi dostać odpowiedź 200 szybko — stąd asynchroniczność.
- Webhooki przychodzą dwa razy — stąd inbox z deduplikacją.
- Coś może się nie udać tymczasowo — stąd licznik prób i dead-letter.
- Trzeba wiedzieć, co się stało z każdą operacją — stąd status i diagnostyka na każdym sync itemie.
Klasyfikacja webhooków początkowo znała wyłącznie format Shopify (products/update, orders/paid). Gdy weszło WooCommerce, stała się provider-aware i obsługuje zarówno slash topics Shopify, jak i dot topics (product.updated, order.created) WooCommerce. Workflow tworzenia sync itemów pozostał wspólny, mimo że źródłowe systemy mówią różnymi dialektami.
W executorze dispatch sprowadza się do prostego rozróżnienia po providerze (uproszczona ilustracja):
// steps/execute.ts — uproszczone
if (connection.provider === IntegrationProviders.SHOPIFY) {
result = await executeShopifySyncItem(context)
} else if (connection.provider === IntegrationProviders.WOOCOMMERCE) {
result = await executeWooCommerceSyncItem(context)
} else {
result = {
status: 'skipped',
code: 'unsupported_provider',
}
}A w samym adapterze Shopify to rozróżnienie schodzi na poziom konkretnej operacji:
export const executeShopifySyncItem = async (
context: ShopifyAdapterContext
): Promise<ShopifySyncItemExecutionResult> => {
const operation = String(context.syncItem.operation || '')
if (operation === 'webhook.products/delete') {
return await handleProductDeleteWebhook(context)
}
if (operation === 'webhook.products/create' || operation === 'webhook.products/update') {
return await handleProductWebhook(context)
}
if (
operation === 'webhook.inventory_levels/update' ||
operation === 'webhook.inventory_items/update'
) {
return await handleInventoryWebhook(context)
}
if (operation === 'webhook.orders/paid') {
return await handleOrderPaidWebhook(context)
}
if (operation === 'order_placed.inventory_delta') {
return await handleOutboundOrderInventoryDelta(context)
}
return {
status: 'skipped',
code: 'unsupported_shopify_operation',
message: `Unsupported Shopify sync operation: ${operation}`,
}
}Gdy Shopify był jedynym providerem, dispatch kończył się na jednym adapterze. Teraz jest osobna gałąź dla WooCommerce. Na razie adapter WooCommerce zwraca woocommerce_operation_not_implemented, ale boundary między providerami już istnieje i czeka.
Najciekawsze było to, że pierwsza weryfikacja tej decyzji przyszła szybciej, niż zakładałem. Zacząłem implementować WooCommerce jeszcze przed publikacją tego artykułu. Modele się nie zmieniły. Workflowy się nie zmieniły. Webhook inbox się nie zmienił. Pojawił się nowy folder providera, własny executor i własne webhook handling. To był pierwszy realny test. I hub go zdał.
Idempotencja nie jako slogan
Synchronizacja, która nie jest idempotentna, jest ładnym schematem do momentu pierwszego zduplikowanego webhooka. W bazie wygląda to następująco — unikalny indeks na klucz idempotencji, scope'owany per połączenie:
"idempotency_key" TEXT NOT NULL,
CREATE UNIQUE INDEX "UNQ_integration_sync_item_idempotency"
ON "integration_sync_item" ("connection_id", "idempotency_key");A zapis webhooka w serwisie najpierw sprawdza, czy event już istnieje — a jeśli dwa requesty trafią na ten sam event w tej samej milisekundzie, drugi dostaje z powrotem ten sam rekord, nie błąd:
const existing = await this.listIntegrationWebhookEvents({
provider: data.provider,
provider_event_id: data.provider_event_id,
})
if (existing.length > 0) {
return existing[0]
}
try {
return await this.createIntegrationWebhookEvents({
...data,
received_at: new Date(),
status: IntegrationWebhookStatuses.RECEIVED,
})
} catch (error: any) {
const isUniqueViolation =
error?.code === '23505' ||
String(error?.message || '').includes(
'UNQ_integration_webhook_event_provider_event'
)
if (!isUniqueViolation) {
throw error
}
const [existingAfterRace] = await this.listIntegrationWebhookEvents({
provider: data.provider,
provider_event_id: data.provider_event_id,
})
if (existingAfterRace) {
return existingAfterRace
}
throw error
}Sekrety jako część infrastruktury, nie afterthought
Access tokeny Shopify nie są przechowywane jawnie. Trafiają do IntegrationSecret jako zaszyfrowany payload AES-256-GCM, z wersją klucza (key_version) i datą rotacji (rotated_at):
private encryptPayload(payload: Record<string, unknown>): string {
const key = this.deriveEncryptionKey(this.getEncryptionSecret())
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const plainText = JSON.stringify(payload)
let encrypted = cipher.update(plainText, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag()
return `gcm:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`
}Klucz szyfrujący pochodzi z dedykowanej zmiennej środowiskowej. Fallback do JWT_SECRET istnieje tylko jako mechanizm awaryjny i loguje wtedy ostrzeżenie. To nie jest efektowna część integracji — ale bez niej provider hub byłby zabawką, nie elementem produkcyjnej infrastruktury.
Samonaprawiające się mapowania
Typowa integracja, gdy nie znajdzie mapowania dla przychodzącego identyfikatora, rzuca błąd i kończy. To wystarczy dla jednego sklepu, ale w marketplace'ie — gdzie import produktu i pierwsze webhooki mogą się nakładać w czasie — brakujące mapowanie nie powinno być od razu porażką:
async function repairInventoryMappingFromVariant(...) {
// 1. Find variant mapping from inventory mapping metadata
// 2. Load Artovnia variant through query graph
// 3. Fallback to product mapping
// 4. Fallback to SKU/title matching
// 5. Resolve internal Medusa inventory item
// 6. Update IntegrationEntityMapping as LINKED
}Jeśli mapping inventory itemu nie ma jeszcze internal_id, adapter próbuje go odzyskać z mapowania wariantu, produktu, metadanych, SKU lub tytułu. Dopiero gdy wszystkie ścieżki zawiodą, sync item kończy się jako skipped/failed. W praktyce manualny "Odśwież z Shopify" w panelu sprzedawcy naprawia resztę.
Import produktów i wariantów
Sprzedawca łączy sklep przez OAuth, mapuje swoje lokalizacje magazynowe na lokalizacje w Artovnii i uzupełnia domyślne dane GPSR, zanim cokolwiek zaimportuje. Sam import nie tworzy produktu wprost w katalogu — trafia do createProductRequestWorkflow, czyli tej samej ścieżki weryfikacji, którą przechodzi produkt dodany ręcznie. Po drodze obrazy są ściągane z cdn.shopify.com i przesyłane do modułu File Medusy, a ceny przeliczane do PLN.
Mapowane są trzy poziomy, każdy w IntegrationEntityMapping: produkt, wariant i inventory item. Dzięki temu dwóch sprzedawców może mieć w swoich sklepach Shopify identyczny numeryczny ID produktu — bez żadnego konfliktu, bo mapowania są scope'owane po connection_id.
Konflikty kontra automatyczny stock
To rozróżnienie jest bardziej biznesowe niż techniczne, ale ważniejsze od większości decyzji architektonicznych.
Stan magazynowy musi się synchronizować bez pytania o zgodę. Jeśli produkt zostanie sprzedany na Artovnii, a w Shopify go już nie ma — to nie jest drobny bug. To koszmar finansowy i wizerunkowy: klient płaci za coś, czego nie dostanie. Tego nie odkłada się do ręcznej decyzji, nawet na chwilę.
Opis, tytuł, status produktu — inna sprawa. To własność sprzedawcy w katalogu marketplace'u. Pierwszy import nie generuje konfliktów. Konflikt powstaje później, gdy sprzedawca zmieni coś w Shopify po imporcie — panel pokazuje różnicę między wersją Shopify a wersją Artovnii i pozwala kliknąć jeden przycisk: zastosuj zmianę z Shopify albo zostaw wersję z Artovnii. Ręczna decyzja, ale sprowadzona do jednego kliknięcia.
Dwukierunkowa synchronizacja stocku
Inbound, z Shopify do Artovnii, kończy się tą samą operacją niezależnie od tego, czy lokalizacja już ma zapisany poziom stocku:
const inventoryService = container.resolve(Modules.INVENTORY)
const existingLevels = await inventoryService.listInventoryLevels({
inventory_item_id: internalInventoryItemId,
location_id: locationMapping.stock_location_id,
})
if (existingLevels?.[0]) {
await inventoryService.updateInventoryLevels([
{
inventory_item_id: internalInventoryItemId,
location_id: locationMapping.stock_location_id,
stocked_quantity: nextStockedQuantity,
},
])
} else {
await inventoryService.createInventoryLevels([
{
inventory_item_id: internalInventoryItemId,
location_id: locationMapping.stock_location_id,
stocked_quantity: nextStockedQuantity,
},
])
}Outbound, po złożeniu zamówienia w Artovnii, idzie w drugą stronę przez Shopify Admin GraphQL, z idempotency key wyliczanym deterministycznie z sync itemu:
const adjustmentIdempotencyKey = stableHash({
provider: 'shopify',
operation: 'order_placed.inventory_delta',
sync_item_id: syncItem.id,
idempotency_key: syncItem.idempotency_key || null,
})
const adjustment = await client.adjustInventoryQuantities({
reason: 'correction',
name: 'available',
referenceDocumentUri: `gid://artovnia/ExternalCommerceSyncItem/${syncItem.id}`,
idempotencyKey: adjustmentIdempotencyKey,
changes,
})A klient Shopify przed wysłaniem mutacji normalizuje pole, którego Shopify wymaga, nawet jeśli go nie znamy:
const normalizedInput = {
...inventoryAdjustmentInput,
changes: input.changes.map((change) => ({
...change,
changeFromQuantity: change.changeFromQuantity ?? null,
})),
}
const data = await this.graphql(`
mutation ShopifyInventoryAdjustQuantities(
$input: InventoryAdjustQuantitiesInput!,
$idempotencyKey: String!
) {
inventoryAdjustQuantities(input: $input) @idempotent(key: $idempotencyKey) {
userErrors {
field
message
}
inventoryAdjustmentGroup {
createdAt
reason
referenceDocumentUri
}
}
}
`, {
input: normalizedInput,
idempotencyKey,
})Dzięki @idempotent(key: $idempotencyKey) retry tej mutacji — na przykład po timeout połączenia — nie zduplikuje korekty stocku w Shopify.

Bug: InventoryLevel vs InventoryItem
Webhook inventory_levels/update przychodzi z polem admin_graphql_api_id, które wygląda tak:
gid://shopify/InventoryLevel/111411429438?inventory_item_id=45067497472062Na pierwszy rzut oka: identyfikator inventory itema — webhook dotyczy inwentarza. Nie jest. To identyfikator InventoryLevel. Prawdziwy inventory_item_id siedzi w query stringu tego samego GID-a.
Funkcja, która to rozwiązuje:
export const getInventoryItemExternalIdFromPayload = (
payload: Record<string, unknown> | undefined
) => {
const directId = payload?.inventory_item_id
if (directId !== undefined && directId !== null) {
return String(directId)
}
const adminGraphqlId = payload?.admin_graphql_api_id
if (typeof adminGraphqlId === 'string') {
const match = adminGraphqlId.match(/[?&]inventory_item_id=([^&]+)/)
if (match?.[1]) {
return decodeURIComponent(match[1])
}
}
const fallbackId = payload?.id
return fallbackId === undefined || fallbackId === null ? null : String(fallbackId)
}Źródłem prawdy jest payload.inventory_item_id. Parsowanie query stringa z admin_graphql_api_id to tylko fallback.
Wzięcie admin_graphql_api_id jako klucza mapowania psuło powiązania bez żadnego błędu w logach — synchronizacja trafiała w złe rekordy albo nic nie znajdowała. Zrozumienie, że Shopify miesza w jednym webhooku dwa różne obiekty, zajęło dłużej niż sama naprawa. Po fakcie: dedykowane testy regresyjne na ten konkretny przypadek.
Status
Synchronizacja działa end-to-end w obie strony. Zamówienie w Shopify zmienia stock w Shopify i, po webhooku, w Artovnii. Zamówienie w Artovnii zmniejsza stock lokalnie i wysyła korektę do Shopify. Oba kierunki kończą się w logach statusem completed.
Dlaczego Medusa.js udowodniła, że jest dobrym wyborem na marketpalce?
To nie jest ogólna "wolność headless". Konkretne mechanizmy frameworka zdecydowały, że system opisany wyżej dało się zbudować w rozsądnym czasie:
System modułów. Każdy moduł Medusa definiuje własne modele i dostaje za darmo serwis CRUD przez MedusaService(...) — widać to dokładnie w ExternalCommerceModuleService powyżej. Bez tego dziewięć modeli oznaczałoby dziewięć razy napisany od zera CRUD.
Workflowy z kompensacją. Workflow w Medusa to nie tylko sekwencja kroków — każdy krok może mieć funkcję kompensującą, czyli logikę rollbacku. W workflowie tworzenia połączenia krok zapisu sekretu i krok tworzenia linku sprzedawca–połączenie mają zdefiniowaną kompensację. Jeśli coś po drodze się wywali, wcześniejsze kroki są bezpiecznie wycofywane.
Links (defineLink). Moduł external-commerce nie ma żadnej bezpośredniej zależności od modułu sprzedawców. Relacja między seller a integration_connection jest zdefiniowana przez defineLink w osobnym pliku. Moduł integracji zostaje czysto izolowany, relację można odpytać przez query graph — bez foreign key na poziomie modelu.
Query graph. Pozwala pobrać dane rozpięte na wielu modułach jednym zapytaniem — na przykład połączenie razem z jego sellerem — bez ręcznego joinowania na poziomie aplikacji.
Inventory module. Stany magazynowe Artovnii prowadzi wbudowany moduł Inventory. Adapter Shopify nie aktualizuje żadnej własnej tabeli stocku — tylko stocked_quantity w konkretnej lokalizacji przez ten moduł. Automatycznie utrzymuje to zgodność ze wszystkim innym, co z Inventory korzysta — checkout, rezerwacje.
To są konkretne powody, dla których kolejny provider powinien kosztować tylko nowy adapter, nie tydzień przepisywania fundamentów.
Zakończenie
W momencie publikacji WooCommerce jest już podłączony do wspólnej architektury i przechodzi przez własną ścieżkę wykonawczą.
Kolejne platformy — TikTok Shop, PrestaShop, Etsy — powinny być już przede wszystkim problemem implementacji adaptera, nie przebudowy huba. To był cel od początku.
Cel jest prosty: jeśli twórca ma już produkty, stany magazynowe i klientów — onboarding do Artovnii powinien wyglądać jak podłączenie konta, nie jak budowanie biznesu od nowa.
Dlatego pierwszym providerem był Shopify i dlatego Shopify jest tylko początkiem rozwoju tego HUB integracji.


