> For the complete documentation index, see [llms.txt](https://developer.vario-software.de/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developer.vario-software.de/documentation/apps/app-backends/onlineshops-and-marketplaces.md).

# 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**.

{% hint style="info" %}
Read [App Backends](/documentation/apps/app-backends.md) first; this page only covers what is specific to this domain.
{% endhint %}

## 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).

```javascript
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:

| Concern        | How it is scoped                                              |
| -------------- | ------------------------------------------------------------- |
| 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.

{% hint style="warning" %}
**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.
{% endhint %}

## Migration order

The order of steps inside your migration is the order they run, and two orderings bite:

{% stepper %}
{% step %}

### 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.
{% endstep %}

{% step %}

### Create import presets before the channel is created

The `sales_channel.create` handler looks them up.
{% endstep %}
{% endstepper %}

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:

```sql
SELECT --v:result{displayname='listingId'} 
       listings.id, 
       --v:result{displayname='articleId'} 
       id, 
       --v:result{displayname='externalId'} 
       listings.custom.myShop.id 
  FROM article.shopListing 
 WHERE id = '<articleId>' 
   AND listings.salesChannel.id = '<salesChannelId>'
```

{% hint style="warning" %}
**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`.
{% endhint %}

Listing state is a handshake between the ERP and your app:

| Set by the ERP                                  | Set by 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](https://developer.vario-software.de/documentation/fundamentals/custom-fields). Two groups are conventional:

* a group named after your app with a single `id` attribute, attached to the entities you need;
* a group named `<app>_article_listing` on `article_listing` for 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](/documentation/webhooks/faq.md).

## 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:

```
ERP webhook ──► queue entry (QUEUED) ──► cron ──► fetch via VQL ──► transform ──► push
```

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 `QUEUED` and `PROCESSING`.** Checking only `QUEUED` lets 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 `FAILED` entry is not retried automatically.** The drain only picks up `QUEUED`. 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**:

```
cron ──► fetch from platform ──► transform ──► stream into a multi-part import ──► validate or run
```

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.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://developer.vario-software.de/documentation/apps/app-backends/onlineshops-and-marketplaces.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
