markaDocs
Architecture

Listings and channels

One canonical listing, projected onto external marketplaces as ChannelListings — how authority, drift detection, sell-through and cross-channel decrements actually work.

A seller creates one Listing. Where a channel is connected, Marka creates a ChannelListing — a projection of that canonical record onto an external marketplace, at most one per listing per channel. The projection is derived, not copied: it is regenerated from the canonical listing on every sync, and the interesting engineering is entirely in what happens when the remote copy and the canonical one stop agreeing.

Channels are not on the public API

/api/v1 has no channel endpoints. Connections are established and managed in the Marka app. This page describes the model so that the behaviour you observe through the API — a listing that pauses itself, a quantity that moves without your calling anything — has an explanation. Cross-posting to eBay requires the cross_post_oneway entitlement, which is Pro or higher. Publishing to Shopify is free on every plan.

Get an API key to try the API on your own account, then make your first call with Getting started.

Authority: which side is allowed to edit

Every listing carries an authority, and so does every projection of it:

ListingAuthority: MARKA | EBAY | ETSY | SHOPIFY

Authority answers one question: which system owns the truth for this listing? It is enforced by a guard at the top of every editing domain function:

assertMarkaAuthority(listing);
// throws DomainError("listing.authority_locked",
//   `Listing ${listing.id} is under ${listing.authority} authority; edits must go through the
//    external channel. Promote to Marka authority first.`)

activateListing, pauseListing and updateListingFields all run it. So a listing imported from eBay under EBAY authority cannot be edited through Marka — including through the API, where the guard surfaces as a 400 with code: "listing.authority_locked". The inverse guard exists too: applyExternalListingMirror, which writes remote changes into a Marka row, refuses to run on a MARKA-authority listing. Neither side can silently overwrite the other.

Authority is not permanent. promoteListingAuthorityToMarka moves ownership to Marka and emits an authority.promoted event; that is the deliberate, audited act of taking control of an imported listing.

The ChannelListing row

model ChannelListing {
  id                     String               @id @default(cuid())
  listingId              String
  channel                Channel
  channelListingId       String?
  authority              ListingAuthority
  status                 ChannelListingStatus
  projectionVersion      Int                  @default(1)
  lastSyncedAt           DateTime?
  lastSyncHash           String?
  lastErrorCode          String?
  lastErrorMessage       String?
  syncMode               ChannelSyncMode
  channelVariantId       String?
  channelInventoryItemId String?

  @@unique([listingId, channel])
  @@index([channel, syncMode, status])
}

@@unique([listingId, channel]) is the "at most one projection per channel" rule, in the schema rather than in application code.

syncMode decides the direction of travel:

ModeMeaning
EXPORT_MIRRORMarka owns it; push canonical → remote. The default for a projection Marka created.
IMPORT_MIRRORThe remote owns it; pull remote → Marka. The default for an imported listing.
MARKA_PUBLISHPublished by Marka onto the channel.
MARKA_MANAGEDManaged by Marka on the channel.
DISCONNECTEDExcluded from syncing entirely.

status is one of draft, active, paused, ended, error.

lastSyncHash is a SHA-256 of the projected payload plus its scope. It is what makes a no-op sync cheap: if the hash matches, the remote already has this exact projection.

What gets projected

The projection is built per channel, and it is narrower than the canonical record — deliberately. Two details worth knowing because they are visible in the result:

Quantity is availability, not stock. The pushed quantity is Math.max(0, quantityTotal - quantityReserved). So a reservation on Marka — a buyer part-way through checkout — shrinks the quantity the channel shows on the next sync, without any explicit inventory call.

Money crosses to major units only at the connector edge. Everything inside Marka is integer minor units; the connector converts to the major-unit decimal string the channel's API wants, at the boundary and nowhere else. eBay titles are truncated to 80 characters there too, because that is eBay's limit and not Marka's.

Sync scope is inventory_only for IMPORT_MIRROR and EBAY-authority rows — where Marka does not own the content and must not overwrite it — and full_projection otherwise.

Drift: when the remote copy is edited directly

Before an update sync overwrites a remote listing, the connector compares the remote's current state against what Marka last pushed. The compared fields are title, description, price, quantity, condition and brand. If any differ, the remote was edited outside Marka, and the sync does not simply steamroll it.

Instead the projection is marked: lastErrorCode becomes REMOTE_DRIFT_DETECTED, an audit row is written, and a channel.drift_detected event is emitted carrying the list of changed fields.

What happens next depends on what drifted, and this is the sharpest rule in the whole subsystem:

A quantity drift auto-pauses the Marka listing

The drift consumer pauses the canonical listing only when the changed fields include quantity — because a remote quantity that dropped without Marka's involvement is the signature of a sale that happened somewhere else, and the risk of continuing to sell it on Marka is overselling a thing you no longer have. Cosmetic drift (a retitled or repriced remote listing) does not pause anything; it waits for the seller to decide. Auto-pause can be disabled with the CHANNEL_DRIFT_AUTOPAUSE environment variable, which turns off the pause and leaves syncing running.

The seller then resolves it explicitly, and there are exactly two answers:

  • Pull — the remote wins. The projection flips to IMPORT_MIRROR and its authority moves to the channel, so from then on Marka mirrors the remote rather than pushing to it.
  • Overwrite — Marka wins. A sync is re-requested with forcePush: true, which skips the drift check and pushes the canonical projection over the remote edit.

The drift summary the seller sees says exactly that: "eBay listing was edited externally. Changed: <fields>. Next sync will overwrite those changes."

Sell-through: keeping channels from double-selling

This is the part that matters most and is easiest to get wrong, so it is worth being precise about what happens on each path.

A sale on Marka fans out a decrement. When a Marka-native order reaches its terminal state — a shipment dispatched, or a pickup completed — a fan-out publishes a forced sync to every other active, non-disconnected, non-import-mirror projection of that listing. The originating channel is excluded, because it already knows.

A sale on Shopify decrements for real. The Shopify orders/create webhook records the sale against a line-item-grained idempotency ledger and commits the inventory decrement in the same serializable transaction, then fans out to the other channels. Cancellations and refunds restock by the same route.

A sale on eBay pauses rather than decrements. eBay notifications (ITEM_SOLD, ITEM_REVISED, ITEM_ENDED, ITEM_CREATED, OFFER_ACCEPTED, BEST_OFFER_ACCEPTED) request a sync; the sync's drift check sees the remote quantity has changed and emits channel.drift_detected with changes: ["quantity"]; the drift consumer pauses the Marka listing. The end state is safe — the item stops being buyable on Marka — but arrives via pause, not via a numeric decrement.

A unique item that sells anywhere ends everywhere. For inventoryType: "unique" there is nothing to decrement — there was one. When a sync ends a unique listing on one channel, the worker re-publishes a forced sync for each sibling projection, routed with that sibling's own channel, to pause or end it too.

There is a related subtlety in markListingSold: it decrements quantityTotal and clamps quantityReserved at zero rather than assuming it can subtract. That clamp exists specifically because an externally-originated sale consumes a unit that was never reserved on Marka.

The sync worker

Channel syncing runs as a durable background worker with exactly three subscriptions:

TopicFunction
channel.import_requestedchannel-sync::import-ebay-listing
channel.sync_requestedchannel-sync::ebay-sync
channel.drift_detectedchannel-sync::on-drift

Despite the name, ebay-sync serves every channel — the payload's channel field routes to the eBay or Shopify branch. Two same-topic subscribers would double-deliver every event, so one subscriber that dispatches is the correct shape.

The order of operations inside a sync is worth knowing because it explains the failure modes:

  1. Kill-switch gate, before any database read or remote call.
  2. Load the listing, the projection and the connection; refresh OAuth tokens.
  3. If the projection is under channel authority, pull instead of push.
  4. Entitlement check — a seller who downgraded out of cross-posting is skipped gracefully, not errored.
  5. Execute the sync; check drift; publish success or drift.

Errors are classified rather than lumped: an authentication failure (AUTH_REVOKED, NEEDS_REINSTALL) is terminal and is not retried, because retrying a revoked token cannot succeed; a rate limit or transient remote error is thrown as a retryable error for the engine to back off and re-run; anything else is a permanent SYNC_FAILED that marks the projection error with the code and message attached.

Live remote traffic is behind a kill switch

Both connectors head-gate on an environment flag — MARKA_EBAY_ENABLE_LIVE_REMOTE and MARKA_SHOPIFY_ENABLE_LIVE_REMOTE — checked before any credential is touched, with a second defence-in-depth gate inside the sync itself for non-worker callers. Whether a given deployment has them enabled is deployment state, not something the code decides. Treat channel projection as a capability of the platform whose availability you should confirm for your account rather than assume.

There is no scheduled drift sweep. Drift is detected during an update sync, not by a cron walking every projection. A remote edit is noticed the next time that listing syncs.

What is not built

Being precise about the edges:

  • Etsy exists as an enum value only. There is no Etsy connector, no domain code and no worker branch for it. ETSY appearing in the Channel and ListingAuthority enums is schema headroom, not a shipped integration.
  • eBay-originated sales do not produce a numeric decrement, only a pause via drift (above).
  • There is no scheduled reconciliation between a projection and its remote.

Where to look

ConcernFile
Channel domain functions, drift and acknowledgementpackages/domain/src/channels.ts
Authority guards, markListingSoldpackages/domain/src/listings.ts
The sync worker and its drift consumerapps/iii-engine/workers/channel-sync/src/
Cross-channel decrement fan-outapps/web/lib/channel-fanout.ts
Projection building, drift comparisonpackages/connectors-ebay/src/
ChannelListing, ChannelConnectionpackages/db/prisma/schema.prisma

On this page