Onlineshops and Marketplaces
Connect a shop or marketplace: sales channels, listings, outbound sync and order import.
A shop app connects an online shop or marketplace — Shopware 6, Shopify, Amazon, eBay, Channelpilot — to the ERP. Products, prices, stock and images go out; orders come in.
Backend vs. channel
Sales channel backend
Sales channel
How many
One per app per tenant
Many — one per connected store
Represents
The integration itself ("Shopware 6")
One shop or marketplace account
Created
On install
On install (a default one) and by the user afterwards
validChannelTypes on the backend restricts which channel types users may attach. Use ['ECOMMERCE'] for shops and ['MARKETPLACE'] for marketplaces (POS and STORE also exist).
await migratorErp.setMigration('salesChannelBackend', async (methods) =>
{
let backend = await methods.findSalesChannelBackend();
if (backend)
{
backend = await methods.getSalesChannelBackend(backend.id);
await methods.activateSalesChannelBackend(backend);
}
else
{
backend = await methods.createSalesChannelBackend('My Shop', ['ECOMMERCE']);
}
const channels = await methods.getSalesChannels();
if (channels?.length) { return null; } // never create a second default channel
const channel = await methods.createSalesChannel(backend, 'My Shop', '', 'ECOMMERCE');
return channel.id; // stored as the migration note
});Three details in that snippet matter:
Reactivate on reinstall. A deactivated backend disappears from the ERP's channel-creation dialog, so users can no longer create channels for your app.
findSalesChannelBackend()returns only an id. Re-read the full record before patching it.Guard against a second default channel, or every reinstall adds one.
Only the first sales channel is license-free. Creating another without a license produces a 402, which the framework swallows — logging a warning and returning an empty object. Your migration continues, so code that expects channel.id must tolerate its absence.
Serving many channels from one app
Everything channel-specific is keyed by the channel id:
Requests
The X-Sales-Channel header, pinned into the request context
Configuration
Per-channel parameters stored in your app
Credentials
Per channel — one tenant may connect several stores
Webhooks
Channel-scoped events, and the channel id in the callback URL
Scheduled work
One cron task per channel
Register handlers for sales_channel.create and sales_channel.delete, and provision everything above in the create handler. That is what makes a second store work without a reinstall.
Cache API clients per channel, never globally. A single-flight or cache key that ignores the channel lets a request for store B reuse store A's pending client — with A's credentials. That means writing one customer's data into another customer's shop.
Migration order
The order of steps inside your migration is the order they run, and two orderings bite:
Register webhooks after the channel exists
Channel-scoped callback URLs carry the channel id, which you read from the previous step's result. Register first and you get subscriptions pointing at ?salesChannel=null — permanently, because the wrong URL is part of a subscription's identity. Every existing shop app carries a cleanup migration for exactly this.
Also: create an EAV group in one step and write to it in a separate step, so a retry of the write doesn't try to recreate the group.
Listings
Each article can have one listing per sales channel, holding the channel-specific data — above all the external platform id.
Query listings through the article.shopListing view, filtered by your channel:
The view is article-rooted: it returns a row even when your channel has no listing — with a null listing id. Never treat "a row came back" as "a listing exists". Check the projected listing id itself, or you will follow up with a lookup by null.
Listing state is a handshake between the ERP and your app:
DISABLED, NEED_SYNCHRONIZE, NEED_DELETION
QUEUED_FOR_SYNCHRONIZE, QUEUED_FOR_DELETION, SYNCHRONIZED, ERRONEOUS
Move a listing to QUEUED_FOR_SYNCHRONIZE when you enqueue it, and to SYNCHRONIZED or ERRONEOUS when the sync finishes. Await that final write — if it fails silently, the queue entry is marked complete while the listing stays stuck and gets re-enqueued forever.
Skip listings that are DISABLED. Otherwise a channel-creation backfill enqueues the entire catalogue.
Storing external ids
Use custom fields. Two groups are conventional:
a group named after your app with a single
idattribute, attached to the entities you need;a group named
<app>_article_listingonarticle_listingfor richer per-listing data.
Attach the group only to the entities that actually need it. A group shared across accounts, documents, listings, media and variants means an id written for one entity type leaks onto all the others — a real problem that had to be undone later with a migration.
Also note that removing data from a group removes it across every entity the group is attached to.
When you write an external id back to a listing, set x-vario-suppress-own-webhooks — otherwise your write re-triggers the article event you subscribe to, and you loop. See the FAQ.
Outbound: the queue
Webhooks fire far more often than a platform API should be called, so outbound work goes through a queue in your app:
Entries move QUEUED → PROCESSING → COMPLETED, or to FAILED. Points worth knowing:
Check the transfer toggle when inserting, not when processing. Then the queue only ever holds work the user has enabled.
Deduplicate against both
QUEUEDandPROCESSING. Checking onlyQUEUEDlets a webhook that arrives while the worker is busy create a duplicate.Dedup is check-then-act, so it races. Add an in-process guard per entity — and be aware it does not span processes, so a multi-instance deployment still needs a shared guard.
A
FAILEDentry is not retried automatically. The drain only picks upQUEUED. Offer a retry action.Mask errors before persisting them. The stored request extract is shown in the UI and can contain credentials or customer data.
If the platform applies a whole batch in one transaction, a single bad item rolls back the rest — put the successful entries back to QUEUED rather than marking them complete.
Inbound: order import
Orders come in through the ERP's multi-part import:
The flow is: copy your import preset, stream the data up as a DMS file, create the import runs, wait until the data is extracted, then validate or execute. Presets are created during your migration and define the field mapping per file.
Deduplicate inside the import script by looking for an existing document carrying the platform's order id. That single guard is what makes the whole pipeline safe to re-run — and it is why you should keep other identifiers in their own attributes rather than reusing the order-id field.
Because of that guard, an overlap is cheap and a gap is not:
Advance your "imported up to" watermark only after a successful import — never in validation-only mode.
Query a little further back than the watermark. Platform feeds are eventually consistent, and an order can surface after your cursor has moved past it. Re-seen orders are dropped by the dedup guard.
What else to watch out for
Tax mapping is by name, and names are fragile. Mapping an ERP tax type to a platform tax class by name breaks as soon as the platform name contains a space or a % — the value cannot be safely interpolated and is dropped, silently losing the price. Validate names, and prefer ids where the platform offers them.
Platform limits are low. Shopware caps every search at 500 results; Amazon's SP-API is aggressively rate limited and reports its own limits in response headers. Chunk id lists and paginate.
Some pagination is state-driven. If your fetch filters on a status you then change, the result set shrinks by itself and you must not pass a page number — but in validation-only mode nothing changes, so you must not paginate at all or you loop forever on page one.
A stored external reference is not proof the remote object exists. With transactional platform APIs, a rolled-back sync can leave you holding a reference to something that was never created. Verify before pointing new objects at it.
Uninstall leaves almost everything in place — webhooks, cron tasks and the backend all survive. Reinstall therefore has to be idempotent and must reactivate the backend.
Last updated
Was this helpful?