Artovnia is a Polish marketplace for handmade goods, built on Medusa.js. Sellers list their products, buyers order through Artovnia's checkout, and fulfillment, stock, and payouts all live inside the marketplace — not on someone else's platform.
The problem is that most sellers already run their own store somewhere else. They already have a Shopify or WooCommerce shop set up, with products, photos, descriptions, and stock levels that took months to build. Asking them to recreate all of that by hand inside Artovnia is a huge onboarding barrier, and a great way to lose sellers before they publish a single product.
So I built an external commerce hub: a system that lets a seller connect their existing Shopify or WooCommerce store to their Artovnia account, import their catalog automatically, and keep inventory synchronized in both directions afterward. Sell the last unit on Shopify, and Artovnia's stock updates. Sell it on Artovnia, and Shopify's stock updates back.
That sounds like a straightforward integration problem. It isn't — and why it isn't is what this article is actually about.
Every integration architecture looks provider-neutral after the first provider. Mine did too.
Shopify worked: products imported correctly, webhooks were verified, and inventory synchronized in both directions — marketplace orders even updated stock back in Shopify. Everything looked clean.
Then I added WooCommerce. That was the moment I found out whether I had actually designed a provider-neutral architecture, or simply built a Shopify integration with nicer class names.
The second provider changes everything
The first provider is deceptive. When there's only one external system, almost every design decision feels reasonable. Need inventory synchronization? Call Shopify. Need product import? Call Shopify. Need webhooks? Parse Shopify payloads. Need retries? Store Shopify metadata. Everything still looks modular, because there's only one implementation.
The second provider exposes every hidden assumption. Suddenly you discover that identifiers have a completely different format, authentication works differently, webhook signatures use another algorithm, pagination behaves differently, inventory is represented differently, stock updates require a different API, and catalog entities don't map one-to-one anymore.
If those assumptions leaked into your domain model, adding the second provider becomes a rewrite instead of an extension. That was exactly what I wanted to avoid.
The real goal wasn't Shopify
Being a marketplace rather than a single store changes the problem considerably. This isn't an integration between one store and Shopify. Every seller owns their own connection, with their own credentials, and imported products still have to go through the marketplace approval flow. Marketplace rules like GPSR (the EU's General Product Safety Regulation), shipping profiles, and category validation still apply regardless of where a product came from.
So the real problem wasn't:
"Connect Shopify."
It was:
"Allow sellers to connect external commerce platforms without making the marketplace domain depend on any of them."
Once I started looking at the problem that way, the architecture became much clearer. The hub shouldn't know how Shopify represents inventory. It shouldn't know how WooCommerce signs webhooks. It shouldn't know whether authentication uses OAuth, wc-auth, or something completely different. Its only responsibility is orchestrating integration work.
The architectural boundary
The final architecture looks surprisingly small.
Vendor API Public Webhooks Medusa Events
│ │ │
└────────────────────┴─────────────────────┘
│
▼
External Commerce Hub
│
┌────────────┴────────────┐
│ │
▼ ▼
Shopify WooCommerce
Adapter Adapter
Everything above the adapters is shared. Everything below them is provider-specific. That sounds obvious — in practice, keeping that boundary clean required much more discipline than I expected.
Shared doesn't mean generic
One mistake I often see in "provider-neutral" architectures is trying to hide every provider behind one giant generic interface — something like:
provider.getProducts()
provider.updateStock()
provider.getOrders()
It looks elegant, until one provider exposes inventory deltas, another requires absolute quantities, and a third has locations that the others simply don't. The abstraction starts leaking.
Instead of creating one universal provider API, I moved the shared boundary one level higher: the shared layer owns the lifecycle, and the adapter owns provider semantics. That distinction turned out to be far more stable.
Sync items became the execution boundary
The most important object in the entire hub isn't the provider. It isn't even the connection — it's the sync item. Every piece of integration work eventually becomes an integration_sync_item, for example:
webhook.products/update
webhook.inventory_levels/update
webhook.product.updated
webhook.order.created
order_placed.inventory_delta
Notice what's missing: none of those operations mention Shopify or WooCommerce by name. They're simply pieces of work that have to be executed, and creating one always goes through the same method regardless of provider:
async createSyncItem(data: {
run_id: string
connection_id: string
seller_id: string
operation: string
idempotency_key: string
external_id?: string | null
internal_id?: string | null
entity_type?: string | null
source_hash?: string | null
target_hash?: string | null
diagnostic_payload?: Record<string, unknown> | null
}) {
return await this.createIntegrationSyncItems({
...data,
status: IntegrationItemStatuses.PENDING,
})
}
operation is one of those provider-agnostic strings from the list above. source_hash and target_hash are what let a later step decide whether a catalog change is an actual conflict or a no-op. diagnostic_payload is where provider-specific state — like the WooCommerce outbound plan I'll get to later — gets stashed without the hub ever needing to understand it.
That single decision simplified almost everything else — retries, replay, diagnostics, dead letters, audit logs, execution status. Everything revolves around the same object.
Atomic execution
Before any provider code runs, the executor first tries to claim the sync item.
const claimResult = await pg.raw(
`
UPDATE integration_sync_item
SET
status = ?,
attempt = attempt + 1,
updated_at = NOW()
WHERE id = ?
AND deleted_at IS NULL
AND status IN (?, ?)
AND attempt < ?
RETURNING *
`,
[
IntegrationItemStatuses.RUNNING,
input.sync_item_id,
IntegrationItemStatuses.PENDING,
IntegrationItemStatuses.FAILED,
maxAttempts,
],
)
const [syncItem] = firstRows(claimResult)
This tiny query does much more work than it appears. It guarantees that only one worker owns the execution attempt. That matters because multiple execution paths may exist simultaneously: public webhook requests, subscribers, retries, and manual replay. Without an atomic claim, processing the same webhook twice becomes surprisingly easy.
Policy before execution
Owning the sync item doesn't automatically mean it should run. The next step is checking the synchronization policy attached to the seller's connection. Inbound synchronization can be disabled, outbound inventory can be disabled, and entire connections may become inactive. Instead of scattering those checks across adapters, every provider goes through the same gate.
const executionBlock = getSyncItemExecutionBlock(
connection,
syncItem
)
const executor = executionBlock
? null
: getIntegrationSyncExecutor(connection.provider)
If synchronization is disabled, execution never reaches the adapter, which keeps another boundary clean: adapters don't need to know whether they should execute — only how.
The world's most boring dispatcher
One of my favourite files in the project is also one of the smallest.
export const getIntegrationSyncExecutor = (
provider: string
) => {
if (provider === IntegrationProviders.SHOPIFY) {
return executeShopifySyncItem
}
if (provider === IntegrationProviders.WOOCOMMERCE) {
return executeWooCommerceSyncItem
}
return null
}
There's no reflection, no dependency injection magic, no plugin discovery — just one dispatcher. At first glance it looks almost disappointingly simple. But that's exactly why I like it.
Adding WooCommerce didn't require touching retry logic, replay, webhook processing, mapping storage, diagnostics, or synchronization history. The only new responsibility added to the shared layer was:
"When the provider is WooCommerce, execute the WooCommerce adapter."
That's the kind of boring code I actually enjoy writing, because boring code usually means the architecture is doing its job.
Webhooks aren't commands
One lesson I learned very early was that public webhooks shouldn't directly execute business logic. Instead, they first become durable events. The processing pipeline looks like this:
Provider webhook
│
▼
Verify signature
│
▼
Store webhook event
│
▼
Create sync run
│
▼
Create sync item
│
▼
Execute provider adapter
The first two steps are almost boringly mechanical, which is exactly the point. Verifying the signature is a straight HMAC comparison:
export function verifyShopifyWebhookHmac(input: {
rawBody: string | Buffer
hmacHeader?: string | null
secret: string
}) {
if (!input.hmacHeader) {
return false
}
const rawBody = Buffer.isBuffer(input.rawBody)
? input.rawBody
: Buffer.from(input.rawBody, 'utf8')
const expected = crypto
.createHmac('sha256', input.secret)
.update(rawBody)
.digest('base64')
const expectedBuffer = Buffer.from(expected, 'utf8')
const receivedBuffer = Buffer.from(input.hmacHeader, 'utf8')
if (expectedBuffer.length !== receivedBuffer.length) {
return false
}
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
}
Storing the event is where idempotency actually happens — not by checking first, but by racing against the database's unique constraint and recovering gracefully from the conflict:
async recordWebhookEvent(data: {
provider: string
connection_id?: string | null
topic: string
provider_event_id: string
occurred_at?: Date | null
headers?: Record<string, unknown> | null
payload?: Record<string, unknown> | null
}) {
const existing = await this.listIntegrationWebhookEvents({
provider: data.provider,
provider_event_id: data.provider_event_id,
} as any)
if (existing.length > 0) {
return existing[0]
}
try {
return await this.createIntegrationWebhookEvents({
...data,
received_at: new Date(),
status: IntegrationWebhookStatuses.RECEIVED,
} as any)
} 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,
} as any)
if (existingAfterRace) {
return existingAfterRace
}
throw error
}
}
Two deliveries for the same event can race each other here, and the second one simply loses — it finds the row the first one created and returns that instead of throwing. This gives several advantages almost for free: every received payload is stored, and every execution attempt is traceable. Failed executions can be replayed. Most importantly, receiving a webhook becomes completely separate from executing its side effects — one of the most valuable architectural decisions in the whole project.
Mappings are the spine of the system
Every integration eventually runs into the same problem: external identifiers are not your identifiers. Even worse, they aren't comparable across providers.
Shopify identifies products with GraphQL GIDs:
gid://shopify/Product/123
gid://shopify/ProductVariant/456
gid://shopify/InventoryItem/789
WooCommerce uses numeric identifiers, and internally, Medusa has its own IDs. Trying to "convert" one into another quickly becomes fragile.
Instead, the hub treats mappings as first-class entities. Instead of asking "What is the Shopify ID of this product?", the question becomes "Which external entity is linked to this marketplace entity for this specific seller and this specific connection?"
That distinction matters: the same seller may eventually connect multiple stores, different sellers can have identical numeric product IDs, and future providers may represent the same concept completely differently. Mappings are therefore scoped by both seller and connection, which makes every lookup deterministic.
Import doesn't create products
One consequence of building for a marketplace rather than a single store is that importing a product isn't the same as publishing it. Both Shopify and WooCommerce eventually produce normalized product data, but neither provider creates products directly — instead, the import enters the existing marketplace workflow.
Provider Preview
│
▼
Provider Normalizer
│
▼
createProductRequestWorkflow
│
▼
Marketplace approval
│
▼
createProductsWorkflow
│
▼
Repair pending mappings
createProductRequestWorkflow is a normal Medusa workflow, not something built specifically for the integration hub — it's the same workflow a seller's dashboard form submits to when they create a product by hand (hook registration trimmed for brevity):
export const createProductRequestWorkflow = createWorkflow(
'create-product-request',
function (input: {
data: CreateRequestDTO
seller_id: string
additional_data?: any
}) {
const productPayload = transform(input, (input) => ({
...input.data.data,
status: input.data.data.status === 'draft' ? 'draft' : 'proposed'
}))
const product = createProductsWorkflow.runAsStep({
input: {
products: [productPayload],
additional_data: transform(input, (input) => ({
...input.additional_data,
seller_id: input.seller_id
}))
}
})
const requestPayload = transform(
{ input, productPayload, product },
({ input, productPayload, product }) => ({
...input.data,
submitter_id: input.data.submitter_id || input.seller_id,
data: {
...productPayload,
product_id: product[0].id
},
status:
productPayload.status === 'draft'
? ('draft' as RequestStatus)
: ('pending' as RequestStatus)
})
)
const request = createRequestStep(requestPayload)
const link = transform({ request, input }, ({ request, input }) => [
{
[SELLER_MODULE]: { seller_id: input.seller_id },
[REQUESTS_MODULE]: { request_id: request.id },
},
])
createRemoteLinkStep(link)
emitEventStep({
eventName: SellerRequest.CREATED,
data: { ...input.data, sellerId: input.seller_id }
})
return new WorkflowResponse(request)
}
)
The provider adapter's job stops at producing productPayload — a normalized product shape. Everything after that call is identical whether the product came from a WooCommerce import, a Shopify import, or a seller typing it in by hand. Import doesn't create products; it submits a request, and the request goes through the same door as everyone else's.
This preserves all marketplace rules: seller ownership, GPSR defaults, shipping profiles, category validation, and approval.
One interesting side effect is that mappings may temporarily exist without an internal product — and that's perfectly valid. The imported product already exists externally; the marketplace product simply hasn't been approved yet. When approval completes, the mapping is repaired automatically. The mapping lifecycle follows the business lifecycle, not the other way around.
Catalog data and inventory are different problems
One design decision I made fairly early saved a lot of pain later: catalog data and operational inventory shouldn't use the same synchronization strategy.
Imagine an approved marketplace product. The seller changes its title in Shopify — should the marketplace immediately overwrite the title? Probably not. Now imagine inventory: someone buys the last item. Should inventory wait until a seller reviews a conflict? Definitely not.
So the hub treats those two categories differently. Catalog changes create conflicts. Inventory updates synchronize automatically — and in both directions. A sale on Shopify triggers a webhook that updates marketplace stock; a sale on the marketplace triggers an outbound event that updates stock back on Shopify (or WooCommerce). Both sides stay in sync during normal operation — neither one is a silent source of truth that overwrites the other, which is exactly why the two categories need genuinely different handling.
The difference sounds obvious in hindsight, but treating both kinds of data as "just synchronization" would have created a very frustrating seller experience.
Shopify taught me something unexpected
One of the most annoying bugs I encountered came from Shopify inventory webhooks. The payload contains something like this:
gid://shopify/InventoryLevel/111411429438?inventory_item_id=45067497472062
At first glance it looks like an inventory identifier. It isn't — it represents an InventoryLevel, while the actual mapping uses InventoryItem. The adapter therefore extracts the correct identifier first:
export const getInventoryItemExternalIdFromPayload = (
payload: Record<string, unknown>
) => {
if (payload.inventory_item_id) {
return String(payload.inventory_item_id)
}
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])
}
}
return payload.id ? String(payload.id) : null
}
The interesting part isn't the bug itself — it's where the fix lives. The shared hub never learned anything about InventoryLevel; that knowledge stayed entirely inside the Shopify adapter. Adding provider-specific knowledge to the shared layer would have made every future provider slightly more Shopify-shaped.
Shopify wasn't the proof
When Shopify was finished, I felt pretty good about the architecture. Everything worked: products imported, mappings were created, stock synchronized, webhooks replayed correctly. It looked reusable.
But there was no proof — there was only one implementation. Architectures don't become reusable because we call them reusable. They become reusable when a second implementation fits naturally. WooCommerce was that proof.
WooCommerce broke almost every assumption
On paper, Shopify and WooCommerce solve the same business problem. Technically, they're completely different. Shopify exposes GraphQL; WooCommerce exposes REST. Shopify uses OAuth; WooCommerce uses wc-auth — its built-in REST API key exchange flow, which issues a consumer key/secret pair rather than following the OAuth2 authorization-code flow Shopify uses. In code, that difference is just a redirect to a different kind of URL:
export const buildWooCommerceAuthorizeUrl = ({
storeUrl,
appName,
scope,
userId,
returnUrl,
callbackUrl,
}: {
storeUrl: string
appName: string
scope: WooCommerceAuthScope
userId: string
returnUrl: string
callbackUrl: string
}) => {
const authorizeUrl = new URL(
`${normalizeWooCommerceStoreUrl(storeUrl)}/wc-auth/v1/authorize`
)
authorizeUrl.searchParams.set('app_name', appName)
authorizeUrl.searchParams.set('scope', scope)
authorizeUrl.searchParams.set('user_id', userId)
authorizeUrl.searchParams.set('return_url', returnUrl)
authorizeUrl.searchParams.set('callback_url', callbackUrl)
return authorizeUrl.toString()
}
The seller authorizes on their own WordPress admin, WooCommerce redirects back with a consumer key and secret, and that's the entire "OAuth equivalent" for this provider. Shopify identifies products one way, WooCommerce identifies them another way. Even outbound inventory behaves differently: Shopify supports delta mutations, while WooCommerce expects the final quantity.
If the shared layer had been secretly designed around Shopify, WooCommerce would have forced major changes across the hub. It didn't. Instead, WooCommerce added exactly what a provider should add: a client, an authentication flow, webhook verification, normalizers, inventory helpers, and an adapter. Nothing else. The shared execution model stayed exactly the same.
That was the first moment I trusted the architecture. Not after the first provider — after the second.
The hardest problem wasn't synchronization
Surprisingly, synchronization itself wasn't the difficult part. Retry safety was.
Marketplace orders can fail halfway through. HTTP requests can time out. Workers can restart. The question wasn't "Can I update WooCommerce stock?" — it was "Can I safely retry that update tomorrow without subtracting stock twice?"
Shopify solved that problem differently from WooCommerce. For Shopify, the adapter can send inventory deltas together with an idempotency key. WooCommerce required another strategy: instead of remembering the delta, the adapter stores the entire outbound execution plan.
const diagnostic = getDiagnosticPayload(context.syncItem)
const plan =
diagnostic.woocommerce_outbound_plan ??
await buildOutboundPlan(context, client)
That plan contains the previously observed quantity together with the target quantity. If execution needs to be retried, the adapter doesn't calculate anything again — it simply replays the stored plan.
The hub doesn't know how either strategy works. It only knows that a sync item is being executed. The adapter owns idempotency; the hub owns lifecycle. That boundary ended up being much cleaner than trying to invent one universal stock update abstraction.
What adding the second provider didn't change
One exercise I like doing after finishing a major feature is asking a simple question: what didn't I have to touch?
Adding WooCommerce changed a lot of provider-specific code. It didn't change the architecture. I didn't have to rewrite:
- connection lifecycle
- encrypted secret storage
- webhook inbox
- sync runs
- sync items
- retry logic
- replay
- diagnostics
- entity mappings
- conflict handling
- seller ownership
- product approval workflow
Those concepts already belonged to the marketplace. WooCommerce simply became another implementation — exactly what I wanted when I started building the hub.
Provider-neutral doesn't mean provider-blind
One lesson became very clear while implementing both providers: trying to hide every provider difference behind one generic abstraction doesn't simplify the system. It usually moves complexity into the wrong place.
A provider-neutral architecture shouldn't pretend Shopify and WooCommerce are identical. They aren't. Instead, it should define a stable boundary.
The shared layer owns:
- lifecycle
- orchestration
- persistence
- retry
- replay
- diagnostics
- policies
The adapter owns:
- authentication
- API protocol
- payload parsing
- identifier formats
- pagination
- webhook verification
- inventory semantics
- idempotency strategy
That split lets each provider be different without forcing the core to understand those differences.
Medusa turned out to be a good fit
One reason this architecture worked well is Medusa's modular design. Inside Artovnia's Medusa backend, the external-commerce hub lives as a dedicated module instead of becoming part of product or order services. Workflow steps orchestrate integration work, subscribers react to marketplace events, the Inventory Module remains responsible for stock, and Query Graph resolves marketplace entities. Each piece keeps doing its own job — the integration layer simply coordinates them.
That separation made the codebase much easier to reason about than a collection of provider-specific services spread across the application.
The next provider should feel boring
There's one rule I now use to judge whether the architecture is healthy: adding another provider should feel boring. Not because writing an adapter is easy, but because I already know where every new piece belongs.
A new provider should require:
providers/new-provider/
client.ts
auth.ts
webhooks.ts
normalizers.ts
adapter.ts
Maybe a few provider-specific API routes, maybe additional tests. Nothing more — no new retry framework, no new synchronization model, no new mapping tables, no new webhook pipeline.
If adding another provider requires changing the core architecture, the core wasn't actually provider-neutral. That's the test I'm applying right now while building the next adapter.
Looking back
When I started this project, I expected Shopify to be the hard part. Instead, Shopify was mostly about understanding one external API.
The real difficulty was figuring out where provider-specific knowledge should stop. That question affected almost every architectural decision: Where should retries live? Who owns idempotency? Where do mappings belong? What creates conflicts? Who decides whether synchronization should happen?
Those decisions matter much more than choosing GraphQL or REST, because APIs change. Architecture tends to stay.
Final thoughts
The title of this article could have been "How I integrated Shopify." That wouldn't have been very interesting.
The more interesting story was what happened next. Adding WooCommerce forced me to validate every abstraction I had created. Some survived, some didn't — the ones that survived became part of the shared hub, and the ones that didn't were pushed back into provider adapters, where they belonged.
Looking back, Shopify didn't prove the architecture. WooCommerce did. Adding the second provider didn't require redesigning retries, webhook processing, mappings, or synchronization — it required adding another adapter.
That's the difference between building integrations and building an integration platform.
This article focuses on the technical architecture. The hub itself now runs in production on Artovnia, a handmade-goods marketplace.
If you're interested in the marketplace context, product decisions, synchronization flows, and the complete implementation journey, I wrote a much more detailed case study here:
I'd love to hear how you've approached multi-provider integrations. Did you build a shared execution model, or did each provider end up becoming its own mini-platform?
Top comments (0)