A craft seller doesn't ask whether your marketplace has a better UX than Shopify. They ask: what about my 300 products that are already there?
Craft creators don't always start from scratch. Many already have a Shopify store, customers in a Facebook group, or years of sales history on Etsy. A marketplace without market position gives them no obvious reason to switch — it means an extra account, an extra catalog to manage, extra work with no guaranteed return.
Artovnia — an art and craft marketplace, bootstrapped without a marketing budget — had to find another way, especially because we intend it to become the biggest and best platform for art and handmade. Not "how to convince a seller to migrate," but "how to sync with what the seller already has".
And TikTok Shop is on the horizon. That competition is better answered with an integration than by waiting for it to take sellers away.
Why a hub, not an integration
The simplest path is integrating with one platform, "and the rest will come later." I know that plan. And I know what "come later" really means — when WooCommerce time arrives, you find that Shopify's logic is baked into the core in ten different ways, and each one is a week of refactoring.
Instead, from the first model in the database, an integration hub was built — provider-neutral by design, not as the result of a later cleanup. Sellers live on different platforms. WooCommerce, TikTok Shop, PrestaShop, Etsy — each will come in time. Hardcoding one platform's logic into the core would mean rewriting the foundations with every new integration.
Shopify became the first working adapter. During the writing of this article, the WooCommerce implementation kicked off — and became the first real test of whether "provider-neutral" is anything more than a catchy phrase.
Hub architecture
The whole thing forms a simple hierarchy: a seller connects to the hub through the vendor panel, the hub holds state in neutral models, and a specific provider just executes operations.
Seller
│
▼
Artovnia Vendor Panel
│
▼
External Commerce Hub
│
├── IntegrationConnection
├── IntegrationEntityMapping
├── IntegrationLocationMapping
├── IntegrationSyncRun
├── IntegrationSyncItem
├── IntegrationWebhookEvent
└── IntegrationConflict
│
├── Shopify Provider
├── WooCommerce Provider
├── TikTok Shop Provider
├── PrestaShop Provider
└── Future ProvidersEverything above the provider line is shared across every integration. Everything below
it — swappable.

Domain model — the heart of the system
The most important decision in the entire module wasn't choosing Shopify as the first provider. It was the set of models that doesn't know Shopify exists.
- IntegrationConnection — a seller's connection to a specific account/store at a given provider
- IntegrationEntityMapping — mapping of a product, variant, or inventory item between a provider and Artovnia
- IntegrationLocationMapping — mapping a provider's warehouse location to a stock location in Artovnia
- IntegrationSyncRun — the header for a single synchronization run, with counters for successes, failures, and conflicts
- IntegrationSyncItem — a single unit of work within a run, with a status, retry count, and idempotency key
- IntegrationWebhookEvent — a durable webhook inbox, with deduplication by provider identifier
- IntegrationConflict — a difference between a provider value and an Artovnia value, waiting for a seller's decision
In code, these models converge in a single service. Note the boundary: Shopify is not part of the system's heart. It's just an executor.
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 instead of a hardcoded list
The hub needs to know which integrations are available — but the vendor panel shouldn't have a hardcoded list of "Shopify, WooCommerce, ...". Hence, a provider registry:
listProviderManifests() {
return providerManifests
}
getProviderManifest(provider: string) {
return getProviderManifest(provider)
}The hub doesn't ask "is this Shopify?" — it checks the manifest: what capabilities it has, what name to show in the panel, what authorization types it supports. The vendor panel can display available integrations without any hardcoding in the UI.
Importantly: WooCommerce is already present in the registry with its own capabilities manifest, even though the adapter itself is still being implemented. The provider registry is not a list of finished integrations. It's a contract.
{
"provider": "woocommerce",
"capabilities": [
"catalog.read",
"catalog.webhooks",
"inventory.read",
"inventory.webhooks",
"orders.read"
],
"production_ready": false
}Provider-specific side effects
One thing that changed after completing Shopify: webhook registration.
Initially, that logic lived in neutral connection endpoints. When the second provider arrived, it was moved to provider-specific endpoints. This means the hub doesn't need to know the details of how Shopify or WooCommerce register their webhooks. The hub owns workflow, synchronization, security, and idempotency. Integration details stay on the adapter side.
Queue, not an API call
Every operation — webhook, import, stock correction after an order — goes through the same path:
Shopify Webhook
│
▼
IntegrationWebhookEvent
│
▼
IntegrationSyncRun
│
▼
IntegrationSyncItem
│
▼
Provider Executor
│
▼
Shopify Adapter
│
▼
Medusa Inventory / Product / ConflictThis pattern wasn't born from a desire for elegance. Each element came from a concrete requirement:
- Shopify webhooks need a fast 200 response — hence asynchrony.
- Webhooks can arrive twice — hence the inbox with deduplication.
- Something may fail temporarily — hence the retry counter and dead-letter.
- You need to know what happened to every operation — hence per-item status and diagnostics.
Webhook classification originally only understood Shopify's format (products/update, orders/paid). When WooCommerce came in, it became provider-aware and handles both Shopify's slash topics and WooCommerce's dot topics (product.updated, order.created). The sync item creation workflow stayed shared, even though the source systems speak different dialects.
In the executor, dispatch comes down to a simple provider branch (simplified illustration):
// steps/execute.ts — simplified
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',
}
}And inside the Shopify adapter, that distinction goes down to the specific operation stored in the sync item:
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}`,
}
}When Shopify was the only provider, dispatch ended at one adapter. Now there's a separate branch for WooCommerce. For now, the WooCommerce adapter returns woocommerce_operation_not_implemented, but the boundary between providers already exists and is waiting.
The interesting part: the first real validation of this decision came sooner than I expected. I started implementing WooCommerce before this article was published. The models didn't change. The workflows didn't change. The webhook inbox didn't change. A new provider folder appeared, with its own executor and webhook handling. That was the first real test. And the hub passed it.
Idempotency, not just a buzzword
Synchronization that isn't idempotent is a pretty diagram right up until the first duplicated webhook. In the database, it looks concrete — a unique index on the idempotency key, scoped per connection:
"idempotency_key" TEXT NOT NULL,
CREATE UNIQUE INDEX "UNQ_integration_sync_item_idempotency"
ON "integration_sync_item" ("connection_id", "idempotency_key");And writing a webhook in the service first checks whether the event already exists — and if two requests hit the same event in the same millisecond, the second one gets back the same record, not an error:
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
}Secrets as infrastructure, not an afterthought
Shopify access tokens aren't stored in plaintext. They go into IntegrationSecret as an AES-256-GCM encrypted payload, with a key version (key_version) and rotation date (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}`
}The encryption key comes from a dedicated environment variable. The fallback to JWT_SECRET exists only as a last resort and logs a warning when used. This isn't the glamorous part of the integration — but without it, the provider hub would be a toy, not a piece of production infrastructure.
Self-healing mappings
A typical integration, when it can't find a mapping for an incoming identifier, throws an error and stops. That's enough for a single store, but in a marketplace — where product import and the first webhooks can overlap in time — a missing mapping shouldn't immediately mean failure:
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
}If an inventory item mapping doesn't have an internal_id yet, the adapter tries to recover it from the variant mapping, product mapping, metadata, SKU, or title. Only when all paths fail does the sync item end as skipped/failed. In practice, a manual "Refresh from Shopify" in the seller panel handles the rest.
Product and variant import
The seller connects their store via OAuth, maps their warehouse locations to locations in Artovnia, and fills in default GPSR data before importing anything. The import itself doesn't create a product directly in the catalog — it goes through createProductRequestWorkflow, the same verification path a manually added product takes. Along the way, images are pulled from cdn.shopify.com and uploaded to Medusa's File module, and prices are converted to PLN.
Three levels are mapped, each stored in IntegrationEntityMapping: product, variant, and inventory item. This means two sellers can have identical numeric product IDs in their Shopify stores — without any conflict, because mappings are scoped by connection_id.
Conflicts vs. automatic stock sync
This distinction is more of a business decision than a technical one — but more important than most architectural choices.
Stock levels must sync without asking for permission. If a product gets sold on Artovnia but is already gone in Shopify — that's not a minor bug. It's a financial and reputational nightmare: a customer pays for something they won't receive. You don't defer that to a manual decision, not even for a moment.
Description, title, product status — different story. That's the seller's property in the marketplace catalog. The first import generates no conflicts. A conflict appears later, when the seller changes something in Shopify after the import — the panel shows the difference between the Shopify version and the Artovnia version, and lets them click one button: apply the change from Shopify, or keep the Artovnia version. A manual decision, but reduced to a single click.
Bidirectional stock synchronization
Inbound, from Shopify to Artovnia, ends with the same operation regardless of whether the location already has a recorded stock level:
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, after an order is placed in Artovnia, goes the other way through the Shopify Admin GraphQL, with an idempotency key computed deterministically from the sync item:
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,
})And the Shopify client normalizes the field Shopify requires before sending the mutation, even if we don't have it:
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,
})Thanks to @idempotent(key: $idempotencyKey), retrying this mutation — for example after a connection timeout — won't duplicate the stock correction in Shopify.

Bug: InventoryLevel vs InventoryItem
The inventory_levels/update webhook arrives with an admin_graphql_api_id field that looks like this:
gid://shopify/InventoryLevel/111411429438?inventory_item_id=45067497472062At first glance: an inventory item identifier — the webhook is about inventory, after all. It's not. It's an InventoryLevel identifier. The real inventory_item_id is in the query string of that same GID.
The function that resolves this:
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)
}The source of truth is payload.inventory_item_id. Parsing the query string from admin_graphql_api_id is just a fallback.
Using admin_graphql_api_id as a mapping key was breaking associations silently — sync was landing on the wrong records, or finding nothing. Understanding that Shopify mixes two different objects in a single webhook took longer than the fix itself. After the fact: dedicated regression tests for this exact case.
Status
Synchronization works end-to-end in both directions. An order in Shopify changes stock in Shopify and, after the webhook, in Artovnia. An order in Artovnia reduces stock locally and sends a correction to Shopify. Both directions end in the logs with status completed.
Why Medusa.js proved to be the right choice for a marketplace
This isn't a general "headless freedom" pitch. Specific framework mechanisms made the system described above possible to build in a reasonable time:
Module system. Each Medusa module defines its own models and gets a free CRUD service via MedusaService(...) — visible exactly in ExternalCommerceModuleService above. Without this, nine models would mean nine hand-written CRUD implementations.
Workflows with compensation. A Medusa workflow is more than a sequence of steps — each step can have a compensating function, meaning rollback logic. In the connection creation workflow, the secret write step and the seller–connection link creation step both have defined compensation. If something fails along the way, earlier steps are safely rolled back.
Links (defineLink). The external-commerce module has no direct dependency on the sellers module. The relationship between seller and integration_connection is defined via defineLink in a separate file. The integration module stays cleanly isolated, and the relationship can be queried through the query graph — no foreign key at the model level.
Query graph. Lets you fetch data spanning multiple modules in a single query — for example, a connection along with its seller — without manual joins at the application level.
Inventory module. Artovnia's stock levels are managed by the built-in Inventory module. The Shopify adapter doesn't update any custom stock table — it only updates stocked_quantity at a specific location through that module. This automatically keeps everything else that uses Inventory in sync — checkout, reservations.
These are the concrete reasons why adding the next provider should cost only a new adapter, not a week of rewriting foundations.
Conclusion
At the time of publication, WooCommerce is already connected to the shared architecture and running through its own execution path.
The next platforms — TikTok Shop, PrestaShop, Etsy — should primarily be a matter of implementing an adapter, not rebuilding the hub. That was the goal from day one.
The goal is simple: if a creator already has products, stock levels, and customers somewhere — onboarding to Artovnia should feel like connecting an account, not building a business from scratch.
That's why the first provider was Shopify and that's why Shopify is just the beginning of development of this integration HUB.


