When I was planning the third provider for the integration hub at Artovnia.com after Shopify, the assumption was simple: since the API documentation of every major e-commerce platform mentions OAuth or tokens, a single "Connect store" button should be enough for all of them. Onboarding was supposed to differ only cosmetically, and a shared authorization pattern would handle the rest.
After adding Shoper and PrestaShop to WooCommerce and Shopify, that assumption turned out to be only partially true. While the authorization mechanisms worked fine locally, one factor was missing in production: the ability to share the integration with many independent merchants on the terms of a given platform.
The problem doesn't lie in the API itself. Each of these platforms can issue credentials to an external application in some form. It lies in a different question I didn't ask myself early enough:
The API tells you how to make a request. The platform's policy decides whether and how you're allowed to use that request to connect hundreds of other people's stores at once.
That is the real axis of this article. Not "OAuth failed", but: the availability of OAuth in the documentation is not enough to design a marketplace onboarding that complies with a given platform's distribution rules. This is often invisible in technical documentation.
A brief reminder of what this hub is
Before I get to the four providers, one concrete scenario the hub handles regardless of where the merchant comes from:
Merchant connects an existing store
→ maps the warehouse to a location in Artovnia
→ selects products for import
→ Artovnia creates product requests
→ after approval, saves mappings
→ subsequent changes go through sync run and sync items
→ inventory levels update automatically
→ description/title changes go to conflicts awaiting manual decision
Six objects worth knowing before I continue - I described them in more detail in the first article:
IntegrationConnection - which merchant's store is connected and how.
IntegrationEntityMapping - which provider record corresponds to which record in Artovnia.
IntegrationLocationMapping - which external warehouse corresponds to a stock location in Artovnia.
IntegrationSyncRun - one pass of a synchronization operation.
IntegrationSyncItem - a single durable unit of work within a run.
IntegrationConflict - a difference between the provider version and the Artovnia version, awaiting the merchant's decision.
This article shows two sides of that boundary: the provider-specific control plane before an IntegrationConnection is created, and the shared, durable synchronization runtime that operates after it is activated.
Three important questions
With Shopify and WooCommerce, one question was enough: can the API perform a given operation? With Shoper and PrestaShop, it turned out I needed to ask three separate questions, in this order:
Can the API technically perform a given operation?
How does a specific application receive credentials for that API?
Who allows that application to be shared with a hundred independent merchants, not just one store?
The first question was the only one I asked myself when designing the Shopify integration. The third question is the one that in practice determined the shape of onboarding for all four providers.
This distinction maps directly onto the hub architecture I already had. The hub has been divided into two layers from the start:
Control plane - installation, authorization, scopes, secret storage and rotation, connection activation, disconnection and access revocation. This is the layer that differs most between providers.
Data plane - import, mappings, webhook inbox, sync runs and items, queue, retry, conflicts, reconciliation. This is the layer that remained provider-neutral, as I described in the previous article.
The conclusion from four integrations is brief: the data plane remained provider-neutral. The entire control plane lifecycle turned out to be provider-specific - far more so than I assumed at the start.
Four providers, four paths to the same hub
Below I'll expand on each one separately, using the same question structure: what the documentation suggested, where the production barrier appeared, which flow I chose, what the merchant sees, what the backend stores, how events arrive, and what tradeoff remains.
WooCommerce: not quite OAuth, but closest to its UX
What the documentation suggested. WooCommerce REST API documents a dedicated authorization endpoint, /wc-auth/v1/authorize, to which the application redirects the user with parameters such as app_name, scope, return_url and callback_url.
Where the production barrier appeared. Nowhere, and that's an important part of this story. WooCommerce is the only one of the four providers that offers a ready-made flow close to the OAuth UX without a central app registration, a custom module, or the user manually copying a token.
Which flow I chose. Exactly the built-in one. The vendor panel builds a URL to /wc-auth/v1/authorize on the merchant's store domain, the merchant logs into their own WordPress and approves the read_write scope. After approval, WooCommerce POSTs the generated credentials directly to Artovnia's callback_url, and separately redirects the merchant's browser back via the return_url.
What the merchant sees. A form with the store address, a login screen for their own WordPress, a single consent screen, and a return to the Artovnia panel.
What the backend stores. The Consumer Key/Consumer Secret pair, encrypted the same way as the other providers' secrets.
How events arrive. Native WooCommerce webhooks, registered automatically on the merchant's store side as part of the same permission scope.
What tradeoff remains. There's an important caveat here that I didn't make in the first version of this text: wc-auth is not standard OAuth2. There's no authorization code exchange for a token or token refresh here - it's a one-time generation of an API key pair sent via POST. For the merchant the difference is invisible, because they go through exactly the same sequence of steps they'd expect from OAuth: redirect, consent, return. For me as an integrator the difference matters, because these keys don't expire automatically and can't be refreshed - the only way to revoke access is for the merchant to manually delete the key in their wp-admin.
WooCommerce doesn't give me a classic OAuth token. But it gives me exactly the onboarding the user would expect from OAuth.
Shopify: a deliberate merchant-owned workaround, not the default model
What the documentation suggested. Shopify describes OAuth2 as the standard path for applications installed by many unrelated merchants - it's the explicitly recommended model for apps published in the Shopify App Store. It worked locally.
Where the production barrier appeared. A public OAuth app in the App Store means a review process, listing requirements, and a distribution model designed for apps installed from a single catalog by many independent merchants. This was not the model suited to a private integration of one hub with marketplace merchant accounts. The client credentials I wanted to use are permitted by Shopify only when the app and the store belong to the same organization.
An additional problem was the rules around external sales channels. In the analyzed model, a public app didn't give me a straightforward path to moving a catalog to a marketplace that handles checkout on its own side. The model based on redirecting the buyer to the Shopify store didn't meet Artovnia's requirements.
Which flow I chose. A workaround: the merchant creates their own Shopify app inside their own organization and pastes its Client ID and Client Secret into the Artovnia panel. Since the app and the store belong to the same organization, Shopify's condition for using client credentials is met, without going through the public distribution process.
const token = await exchangeShopifyClientCredentials({
shop,
clientId,
clientSecret,
})What the merchant sees. Instructions for creating their own app in Shopify Partners/Dev Dashboard, a form for Client ID and Client Secret, and a healthcheck on Artovnia's side before the connection is activated.
What the backend stores. The merchant's Client ID and Client Secret, encrypted in IntegrationSecret; the access token is exchanged by the backend and refreshed, as it expires in less than a day.
How events arrive. Native Shopify webhooks, registered programmatically after the connection is activated.
What tradeoff remains. This model worsens onboarding compared to a public app with one click - the merchant has to go through Shopify Partners before they even see the form in Artovnia. In return, I avoid the public app distribution process, which for a single niche integration hub would be disproportionately costly relative to the benefits.
Shoper: a token instead of pretending to be a public app
What the documentation suggested. Shoper has both Authorization Code OAuth2 and client credentials in its API - technically speaking, standard authorization mechanisms are available. And as with Shopify, it worked locally without issues.
Where the production barrier appeared. The target application serving multiple merchants goes through the Shoper App Store ecosystem, with a separate submission and approval process. Shoper didn't offer an equivalent of merchant-owned client credentials that would allow a model similar to Shopify. Public distribution also involved a manual contractual process and terms disproportionate to an integration primarily used for catalog import and inventory sync.
In practice this meant over two weeks of work, ambiguous information from support, and working code that couldn't be used in production in the originally planned model.
Which flow I chose. Instead of a public app, an external integration created directly by the merchant in the Shoper panel, with a manually checked list of permissions and a generated API Token. Unfortunately the merchant has to set the required permissions for the app in the Shoper panel themselves.
What the merchant sees. A path in the Shoper panel: "Apps and integrations" → "External apps" → a tool of type "Other", the integration name, checking the required permissions, generating the token. In Artovnia the merchant pastes the store domain and that token.
What the backend stores. The API Token, encrypted the same way as the other secrets. Before the connection is activated, the backend runs a healthcheck on several basic operations:
await Promise.all([
client.getApplicationConfig(),
client.getApplicationVersion(),
client.listProducts({ limit: 1 }),
client.listProductStocks({ limit: 1 }),
])This healthcheck verifies that the token actually works and has access to basic resources before Artovnia saves it as an active connection, but it doesn't fully confirm all required permissions at once. Part of the permissions list the merchant must check in the Shoper panel is verified by a UI-side checklist, not by reading the full scope set from the token.
How events arrive. Currently without a full native event flow - Shoper relies on reconciliation, i.e. periodic state comparison, rather than a 1:1 webhook stream like Shopify or WooCommerce.
What tradeoff remains. A manual token is a less elegant technical solution than OAuth, but it can be more honest for a merchant who has had a Shoper store for years and doesn't want to wait for an app to be approved in a registry. They want to paste a token and see their products in Artovnia.
PrestaShop: a custom module as a deliberate choice, not an API requirement
What the documentation suggested. PrestaShop allows enabling the Webservice, creating an API key, and assigning permissions to it manually from the store's admin panel, without installing any additional module.
Where the production barrier appeared. There was no technical barrier on the API side. The problem was UX and reliability: manually enabling the Webservice, creating a key, setting permissions for each of several dozen resources individually, and pasting the key into Artovnia is a process prone to merchant error, and it provides no event mechanism - you'd have to periodically poll the entire catalog.
Which flow I chose. A custom module, Artovnia Connect, distributed privately as a ZIP file - this is my UX and reliability choice, not a PrestaShop requirement. After installation, the module enables the Webservice itself, generates a random 32-character key, grants it only the minimal required permissions, and sends the key directly to Artovnia's backend. If something fails along the way, the module deletes the newly created key instead of leaving it orphaned in the merchant's store.
$this->registerHook('actionUpdateQuantity');
$outbox->enqueueInventory(...);
$outbox->dispatchPending(10);What the merchant sees. A ZIP file to install in the PrestaShop panel and a one-time pairing code they enter on the Artovnia side to link the module to the correct merchant account.
What the backend stores. The Webservice key generated by the module, encrypted identically to the other providers' secrets.
How events arrive. The module registers PrestaShop hooks such as actionUpdateQuantity and saves events in a local outbox on the store side. Messages sent from the outbox are signed with HMAC. The outbox retries undelivered events on its own, before Artovnia's backend even knows about them.
What tradeoff remains. The module is additional PHP code to maintain outside Node.js, an additional installation step for the merchant, and a dependency on the merchant's store being available so the outbox can deliver events. In return, the merchant doesn't have to manually click through Webservice permission screens, and Artovnia gets an event mechanism where PrestaShop doesn't natively offer one.
A separate matter worth distinguishing: the annual fee for presence in the PrestaShop Marketplace (around €99) applies to modules submitted and sold through the official PrestaShop marketplace, not to private ZIP distribution, which is what Artovnia uses.
Artovnia doesn't pay this fee and doesn't go through the addon validation process - the module reaches the merchant directly.
What actually remained shared
Four completely different entry points all end up in the same place: an active IntegrationConnection, from which the shared data plane begins. Dispatching to a specific provider comes down to a simple branch in the executor:
export const getIntegrationSyncExecutor = (provider: string) => {
if (provider === IntegrationProviders.SHOPIFY) {
return executeShopifySyncItem
}
if (provider === IntegrationProviders.WOOCOMMERCE) {
return executeWooCommerceSyncItem
}
// Shoper, PrestaShop, Etsy...
}From that point on, status, retry, monitoring, mappings, and conflicts are handled according to the same model regardless of whether the connection started with a redirect to WordPress, a Client ID/Secret form, a pasted token, or a PHP module installation.
Provider differences still matter for the actual execution of operations, but they remain encapsulated in the specific provider's executor. The domain model described in the previous article didn't require a separate system for any of these four providers, but the queue runtime through which sync items pass was significantly hardened along the way.
An event triggers work, but is not its memory
The differences between providers don't end at authorization. Shopify and WooCommerce deliver webhooks, PrestaShop uses hooks and a local outbox, and Shoper relies more heavily on reconciliation. All these sources must ultimately trigger the same fault-tolerant synchronization runtime.
The first version of the hub treated an event almost like a queue: an endpoint or webhook emitted an event, a subscriber triggered a workflow, and the workflow executed the synchronization. This model worked as long as the process wasn't restarted between those steps, the provider didn't go unresponsive for a few minutes, or a few products weren't trying to sync simultaneously.
The most important architectural change was inverting that dependency:
An event is no longer the source of truth about work to be done. It's just a doorbell telling the worker that durable work is already waiting in PostgreSQL.
First, in a single transaction, an IntegrationSyncRun and its IntegrationSyncItem are created. Only after they are saved to the database is an event emitted containing the identifiers of the items to be executed:
const queued = await service.queueCatalogImport(input)
const executableItems = queued.items.filter(
(item) => item.status === 'pending' || item.status === 'failed'
)
if (executableItems.length) {
await eventBus.emit({
name: IntegrationEvents.SYNC_ITEM_QUEUED,
data: {
run_id: queued.run.id,
queued: executableItems.map((item) => ({
sync_item_id: item.id,
})),
},
})
}This is a seemingly small change, but it changes the failure model. If the backend saves the queue items and goes down before emitting the event, the work is not lost. A periodic backstop will find the ready items in the database and wake the queue again.
IntegrationSyncRun describes the operation as seen by the user - for example, importing 30 products or reconciling inventory. IntegrationSyncItem is a single, independently executed unit of work: one product, one inventory event, or one repair step.
Webhook, order, or user action → durable SyncRun and SyncItems in PostgreSQL → event waking the worker → atomic item claim → specific provider executor → completed / skipped / conflict / failed / dead_letter → progress update for the entire run
Lease and claim fencing instead of a plain running status
Simply setting status = running is not enough. If a worker is stopped during a deployment or the Node.js process ends in the middle of a request to the provider, the record could remain in that state forever.
That's why every running item receives a time-limited lease:
{
status: 'running',
worker_id: '18432:550e8400-e29b-41d4-a716-446655440000',
claim_token: 'clm_09f70ec9-0df4-4a32-a96b-cd0cf2ae5cb8',
claimed_at: '2026-07-30T10:00:00Z',
lease_expires_at: '2026-07-30T10:02:00Z'
}A worker doesn't "own" a task indefinitely. It has the exclusive right to execute a specific claim generation only until the lease expires. worker_id identifies the executor, while the random claim_token identifies the specific item takeover.
During a longer operation, a heartbeat extends the lease. By default it lasts 120 seconds, and the heartbeat runs every 30 seconds.
The claim is executed atomically in PostgreSQL. Below is an abbreviated version of the actual query:
SELECT pg_advisory_xact_lock(
hashtext('external-commerce:sync-item-claim')
);
WITH candidate AS MATERIALIZED (
SELECT item.id
FROM integration_sync_item item
WHERE item.attempt < item.max_attempts
AND (
(
item.status IN ('pending', 'failed')
AND COALESCE(item.available_at, item.created_at) <= NOW()
)
OR (
item.status = 'running'
AND item.lease_expires_at < NOW()
)
)
ORDER BY
item.priority DESC,
COALESCE(item.available_at, item.created_at),
item.created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE integration_sync_item item
SET
status = 'running',
attempt = item.attempt + 1,
claimed_at = NOW(),
lease_expires_at = NOW() + INTERVAL '120 seconds',
worker_id = $1,
claim_token = $2
FROM candidate
WHERE item.id = candidate.id
RETURNING item.*;FOR UPDATE SKIP LOCKED secures the record itself, while the transaction-scoped advisory lock serializes available throughput control. This prevents two workers from simultaneously concluding they're occupying the last free slot of the global limit.
The heartbeat and every finalization simultaneously require matching worker_id, claim_token, and an active lease:
UPDATE integration_sync_item
SET
lease_expires_at = NOW() + INTERVAL '120 seconds',
updated_at = NOW()
WHERE id = $1
AND status = 'running'
AND worker_id = $2
AND claim_token = $3
AND lease_expires_at > NOW()
RETURNING *;The complete, skip, and fail operations use the same ownership condition:
WHERE id = $1
AND status = 'running'
AND worker_id = $2
AND claim_token = $3
AND lease_expires_at > NOW()
This allows the system to block finalization after lease expiry, finalization of a claim belonging to a different execution, and an old worker branch after the same item has been re-claimed. worker_id contains a UUID per execution, but a separate claim_token remains the more appropriate, unambiguous identity of a specific claim generation.
This is not a promise of exactly-once delivery. External APIs and network failures mean the practical model is at-least-once execution: a task may be executed again, but it cannot be silently lost. Claim fencing protects the local item lifecycle, but it won't undo a request already made to Shopify, Shoper, or PrestaShop. That's why provider operations must still be idempotent or protected by a mapping and a stable idempotency key.
Idempotency starts before the worker
For a product import, the idempotency key includes the connection, the external product identifier, and the import generation:
const generationHash = crypto
.createHash('sha256')
.update(generationToken)
.digest('hex')
.slice(0, 16)
const idempotencyKey =
`catalog.import:${externalId}:v1:${generationHash}`The database enforces its uniqueness:
CREATE UNIQUE INDEX
"UNQ_integration_sync_item_idempotency"
ON "integration_sync_item" (
"connection_id",
"idempotency_key"
)
WHERE "deleted_at" IS NULL;A generation doesn't identify a user click or a single HTTP request. It's a stable identifier for the logical import cycle of a product. The first import uses the initial generation; a new generation is created only after a deliberate state change, such as unlinking a mapping or starting a new cycle after a marked error.
This means two clicks on the same product in the same logical state don't create two products. If an identical item already exists, the backend returns the earlier work. The payload - such as the selected category or shipping profile - is not currently part of the key. If its change is meant to represent new work, it should create a new generation or version of the import configuration.
Sync priority is part of correctness
A single shared queue for catalog import and inventory levels creates yet another problem. Importing 300 products must not block the information that the last unit of a product was just sold.
That's why operations receive different priorities:
export const IntegrationSyncPriorities = {
CATALOG_RECONCILIATION: 10,
INVENTORY_RECONCILIATION: 20,
DEFAULT: 50,
CATALOG_IMPORT: 60,
MANUAL: 70,
INVENTORY_EVENT: 90,
ORDER_INVENTORY: 100,
} as constA state change resulting from an order in Artovnia has priority 100. A provider inventory event gets 90, a manual user operation 70, catalog import 60, and periodic reconciliation 20 or 10 respectively.
The ORDER BY priority DESC alone still wouldn't be enough if all workers were already busy with low-priority imports. The claim therefore reserves part of the global throughput for high-priority tasks. In addition, separate limits apply:
- globally for the entire hub
- per provider
- per connection
separately for catalog import, which can safely use greater parallelism.
This ensures one slow PrestaShop store can't occupy all workers. Concurrency limits and backoff reduce the impact of a Shoper outage on other providers, but full isolation from a global platform failure would require an additional circuit breaker or health state at the provider level. An additional nuance is that high-priority tasks can bypass the provider-level limit, though they still fall under the global limit. A large new merchant onboarding therefore shouldn't delay inventory sync for other stores, but provider isolation is not absolute.
Retry, Retry-After, and circuit breaker
An external API error doesn't automatically mean a permanent failure. A timeout, a 429 response, a brief 502, or a store restart should lead to another attempt - but without aggressively hammering the provider.
The hub uses exponential backoff, capped at one hour:
let retryDelaySeconds = Math.min(
3600,
30 * 2 ** Math.max(0, syncItem.attempt - 1)
)
const providerRetryAfter = Number(
error.retry_after_seconds ??
(Number.isFinite(Number(error.retryAfterMs))
? Number(error.retryAfterMs) / 1000
: NaN)
)
if (Number.isFinite(providerRetryAfter) && providerRetryAfter > 0) {
retryDelaySeconds = Math.max(
retryDelaySeconds,
Math.min(3600, Math.ceil(providerRetryAfter))
)
}If the provider supplies a Retry-After, the hub respects the longer of the two delays. Repeated transient errors additionally open a circuit breaker for the specific IntegrationConnection. The problem of one store then stops generating immediate requests and no longer endlessly consumes shared throughput.
The failed status is not terminal. It means the last attempt ended in an error, but the item is waiting to be retried after available_at. Only after exhausting max_attempts does the item move to dead_letter:
UPDATE integration_sync_item
SET
status = CASE
WHEN attempt >= max_attempts THEN 'dead_letter'
ELSE 'failed'
END,
available_at = CASE
WHEN attempt >= max_attempts THEN NULL
ELSE NOW() + ($1 * INTERVAL '1 second')
END,
lease_expires_at = NULL,
worker_id = NULL,
claim_token = NULL
WHERE id = $2
AND status = 'running'
AND worker_id = $3
AND claim_token = $4
AND lease_expires_at > NOW();dead_letter doesn't mean the record disappears. It retains the store identifier, provider, operation, attempt count, and the diagnostics needed for analysis or controlled replay.
An explicit replay resets the attempt count, restores the pending status, clears the active lease and ownership, records the time and author of the last resumption, and reopens the parent run. A full history of successive replays is not yet modeled as a separate audit log. Automatic resumption during a re-import can also move a matching dead_letter item to pending, but doesn't record the actor in the same way. If the input data is meant to change, a new item with a new generation should be created rather than replaying the old operation.
Backstop and health job complete the failure model
An event may not be delivered. A subscriber may be restarted. A worker may pick up a task and disappear. A durable architecture must account for all these cases.
Every minute the backstop looks for:
- pending items that are already available
- failed items whose backoff has ended
- running items with an expired lease
It then re-emits their identifiers to the shared subscriber. It doesn't create new items - it wakes existing work already saved in the database.
A separate health job monitors:
COUNT(*) FILTER (
WHERE status = 'dead_letter'
) AS dead_letter_count,
COUNT(*) FILTER (
WHERE status = 'running'
AND lease_expires_at < NOW()
) AS expired_lease_count,
COUNT(*) FILTER (
WHERE status IN ('pending', 'failed')
AND attempt < max_attempts
AND COALESCE(available_at, created_at) <= NOW()
) AS ready_countThe age of the oldest ready item is also monitored. The number of pending tasks alone may be normal during a large import; an item waiting significantly longer than the expected threshold, however, means the queue has stopped draining.
The health job also detects orphaned SyncRuns that have no items. Historically this state could arise when a run and an item were created in two separate operations. The current flow creates both records atomically, so a new empty run signals an invariant violation.
After the grace period expires, the health job doesn't reconstruct missing items or mark the run as successful. It sets:
status = failed
error_code = sync_run_inconsistent
error_message = Sync run has no items and cannot be executed
oraz zapisuje diagnostykę:
{
"empty_run_inconsistent_at": "...",
"empty_run_inconsistent_reason": "no_sync_items_created"
}It also generates a warning and reports inconsistent_empty_run_count. Historical migration of old empty runs remains a separate case; new invariant violations don't inflate success statistics.
Event-driven, but not event-dependent
Only at this layer does the full meaning of combining events with reconciliation become clear.
An order or state change event triggers a small, high-priority sync item. A nightly audit creates low-priority reconciliation items. Both go through the same lease, retry, concurrency limits, circuit breaker, and monitoring.
Reconciliation therefore doesn't compete with events on equal terms. It has lower priority and uses throughput when the queue isn't handling more urgent changes.
A webhook can be lost. A store may be down at the moment an event is delivered, and the local outbox may be temporarily unable to reach Artovnia. Events provide low latency, while reconciliation ensures eventual detection of discrepancies.
This is the part of the architecture that truly remained provider-neutral. WooCommerce can enter the hub via wc-auth, Shopify via merchant app credentials, Shoper via API Token, and PrestaShop via a PHP module and a one-time code. Once a sync item is created, these differences no longer matter for runtime orchestration, though they remain encapsulated in the specific provider's executor.
Provider-neutrality doesn't mean every provider delivers events the same way. It means that once durable work is saved, each of them is subject to the same model of durability, concurrency, retry, and failure recovery.
Summary
These difficulties couldn't be seen by reading the API documentation alone. The key constraints were hidden in app distribution policies, acceptance processes, and the business models of individual platforms - often scattered outside the technical documentation.
In development, things happen that are outside our direct control and can undermine assumptions behind many weeks of work. My approach, however, is not to stop at a constraint but to find a solution that can be maintained in production.
In this case, it led to expanding and significantly strengthening the entire e-commerce integration module. The hub today handles different installation models and distribution policies, and its durable queue can recover work after restarts, network failures, and temporary unavailability of external APIs. Lease, heartbeat, and claim_token guard ownership of the current execution, while retry, circuit breaker, priorities, and reconciliation ensure tasks don't disappear silently and that problems with one store have limited impact on the others.
Equally important, backend load is controlled through concurrency limits, priorities, and time-distributed audits. With infrastructure deliberately kept to the necessary minimum, that predictability is invaluable.


