Connecting one marketplace to a store is an afternoon’s work. Running a PrestaShop marketplace integration across Etsy and eBay at the same time is a different problem entirely, because three systems now each believe they own the authoritative stock number for a SKU, each uses its own authentication model, and each enforces its own rate-limit budget.
The failure you actually meet in production is never “the API call didn’t work.” It is a unit oversold on eBay because an Etsy order landed 400 ms earlier. It is a listing that outlived the product someone deleted last Tuesday. It is an order imported twice because a webhook retried while your worker sat inside a transaction. So the useful question is not whether PrestaShop can talk to eBay and Etsy, it obviously can, but how you design the sync so it stays correct after the store goes live.
1.0 PrestaShop Webservice API vs. a marketplace connector module: when should you build?
The honest answer depends less on your PHP skill than on who carries the maintenance cost when eBay deprecates an API version.
The PrestaShop Webservice API is a generic CRUD layer over the store’s ObjectModel entities. It sits at /api/ and authenticates with a back-office access key sent as the HTTP Basic username with an empty password (PrestaShop devdocs). It reaches products, combinations, stock_availables and orders, and it knows nothing about marketplaces. Etsy taxonomy, eBay item specifics, OAuth refresh, listing IDs, retry policy and idempotency guards all remain yours to write.
| Custom build on the Webservice API | Marketplace connector module | |
|---|---|---|
| Time to first synced listing | Weeks, auth, mapping and queueing come first | Hours to days; mapping screens included |
| Marketplace API upgrades | You track Etsy and eBay release notes | Vendor’s responsibility, if the vendor stays active |
| Data mapping control | Total, per-brand attribute logic is possible | Bounded by the module’s mapping interface |
| Sync architecture | Your choice of queue, workers, locks, backoff | Usually cron-driven; real-time behaviour varies |
| Race-condition handling | Only as good as you build it | Often the weakest area |
| Rate-limit budget | You own the token buckets and can prioritise | Opaque; check how the module throttles |
| Debuggability | Your logs, your correlation IDs | Vendor log format; per-SKU trace varies |
| Ongoing cost | Developer time, indefinitely | Licence plus upgrade friction |
| Best suited to | 2+ channels, complex attributes, custom pricing | Standard eBay/Etsy sync, small teams |
In practice the sequence beats the either/or: buy the connector to prove the channel earns revenue, then build once the catalogue outgrows its mapping model. Connectors such as Knowband’s PrestaShop eBay Marketplace Integration and PrestaShop Etsy Marketplace Integration exist because that first layer, profiles, category mapping, listing state, order import, status flow-back, is the same work on every project. Whichever route you take, the criteria below still apply; a module only moves who implements them.

1.1 What the Webservice API will not do for you
It moves records; it does not run business logic. POST /api/orders skips PaymentModule::validateOrder(), so stock decrements, invoices and order-state emails never fire. There is also no documented request quota, which is not the same as no ceiling, every /api/ call boots the framework, so your real limit is PHP-FPM workers, and a resync loop exhausts those long before eBay’s limits bite. One escape hatch helps here: a module can register its own webservice resources (module webservice concepts) and expose a single marketplace_sync endpoint instead of eight generic calls per product.
2.0 Data mapping: PrestaShop combinations vs. Etsy taxonomy and eBay item specifics
This is where marketplace projects quietly lose weeks. The three catalogue models do not merely use different field names; they disagree about what a product is.
PrestaShop models a product as a nested category tree, an id_category_default, and combinations generated from attribute groups. eBay wants a single leaf categoryId plus item specifics, whose names, cardinality and permitted values are dictated per category and retrieved through the Taxonomy API’s getItemAspectsForCategory (eBay Taxonomy API). Etsy wants a taxonomy_id from its own seller taxonomy plus that node’s properties, and it caps variations at two properties per listing.
Three consequences bite during the first catalogue push:
- A product with three attribute groups, size, colour, finish, cannot be a single Etsy listing. Either you collapse a dimension into the title or you split the product, so one PrestaShop product now maps to several Etsy listing IDs.
- eBay rejects a publish when a required aspect is missing, so validate the mapping per category before the push rather than discovering it from a 400.
- Both marketplaces revise their taxonomies, which makes a hard-coded category ID a future outage.
2.1 Make the SKU the join key on every channel
Before any mapping work, settle identity. The PrestaShop reference should be the primary synchronisation identifier, and every combination needs its own, TSHIRT-BLK-M, TSHIRT-BLK-L, TSHIRT-WHT-M. eBay’s Inventory API is SKU-addressed by design (eBay Inventory API), so a combination with an empty reference is unsyncable. Validate that at onboarding, not on every push.
2.2 Store the mapping as data, and version it
Resist writing mapCategory() in PHP. Mappings are business data a merchant edits, not code you redeploy.
-- Illustrative schema for the mapping layer
CREATE TABLE ps_mp_category_map (
id_map INT UNSIGNED NOT NULL AUTO_INCREMENT,
channel ENUM('etsy','ebay') NOT NULL,
id_category INT UNSIGNED NOT NULL, -- PrestaShop category
remote_node VARCHAR(64) NOT NULL, -- etsy taxonomy_id | ebay categoryId
aspects_json TEXT NULL, -- static aspect defaults
taxonomy_rev VARCHAR(32) NULL, -- eBay category tree version
PRIMARY KEY (id_map),
UNIQUE KEY channel_cat (channel, id_category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE ps_mp_listing_link (
id_product INT UNSIGNED NOT NULL,
id_attribute INT UNSIGNED NOT NULL DEFAULT 0, -- 0 = simple product
channel ENUM('etsy','ebay') NOT NULL,
remote_id VARCHAR(64) NOT NULL, -- listing_id | offerId
remote_sku VARCHAR(64) NOT NULL,
last_pushed_at DATETIME NULL,
PRIMARY KEY (id_product, id_attribute, channel),
UNIQUE KEY chan_remote (channel, remote_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Store eBay’s category tree version alongside the mapping. When getDefaultCategoryTreeId returns a new version, every row with a stale taxonomy_rev needs re-validation, which is the difference between a scheduled remap and a Monday of publish failures.
3.0 Inventory sync architecture: webhooks, scheduled polling, or both
Compare what each side actually emits, and the answer stops being a matter of taste.
| Channel | Push capability | Coverage gap |
|---|---|---|
| PrestaShop | No native outbound webhooks; a module hooks actionUpdateQuantity, actionValidateOrder, actionProductUpdate | Nothing fires for direct SQL edits or some import paths |
| Etsy | Webhooks cover four order events only: order.paid, order.canceled, order.shipped, order.delivered (Etsy webhooks) | No receipt-created event, and no inventory-change event |
| eBay | Notification API with topic subscriptions and HTTPS destinations (eBay Notification API) | Topic availability varies; enumerate with getTopics rather than assuming |
Etsy emits nothing when a buyer’s checkout changes a quantity, and nothing when a receipt is created. That gap forces polling into the design no matter how good your webhook handling is. So the working rule is webhooks for latency, polling for truth: webhooks drive the fast path, while a reconciliation pass every 5–15 minutes re-reads quantities and recent receipts and repairs what the fast path missed. Reconciliation is not a fallback you hope never runs, it is what makes the system eventually correct.
If you run a connector instead, the same architecture appears as cron jobs. A module that is installed, licensed and mapped perfectly still looks broken when its scheduled task never executes, so cron verification belongs in the go-live checklist, not the troubleshooting notes.
3.1 Never call a marketplace API during checkout
<?php
// Illustrative: module hook. Enqueue only — never call a marketplace API here.
public function hookActionUpdateQuantity($params)
{
$sku = $this->resolveSku((int) $params['id_product'], (int) $params['id_product_attribute']);
if (!$sku) {
return;
}
Db::getInstance()->insert('mp_sync_queue', [
'sku' => pSQL($sku),
'op' => 'stock.push',
'payload' => pSQL(json_encode(['quantity' => (int) $params['quantity']])),
'dedupe_key' => pSQL('stock:' . $sku), // collapses bursts
'created_at' => date('Y-m-d H:i:s'),
], false, true, Db::REPLACE); // REPLACE = latest intent wins
}
An HTTP call inside the hook adds marketplace latency to checkout and fails the order when Etsy is slow, so the hook only enqueues. REPLACE on a per-SKU dedupe_key then turns twenty stock movements in a minute into one outbound call carrying the current quantity.
4.0 Race conditions when one SKU sells on three channels at once
Take four units of TSHIRT-BLK-M. An eBay buyer takes two, an Etsy buyer one, the storefront one, all within two seconds. Three workers wake, each reads stock, computes a quantity and pushes. Two independent bugs are now lurking.
Relative arithmetic loses updates. A worker sending “decrement by 2” while another sends “decrement by 1” against a value both read as 4 lands on 2 or 3 depending on ordering. Always push absolute quantities read at push time. StockAvailable::getQuantityAvailableByProduct() is the single source of truth, and marketplace quantities are projections of it, never inputs to arithmetic.
Interleaved pushes leave a stale final state. Worker A reads 2, worker B reads 1 a moment later, B pushes 1, then A’s slower request lands and writes 2. eBay now advertises a unit that does not exist. A per-SKU lock plus a monotonic version check is the cheapest way to enforce ordering.
<?php
// Illustrative: serialise all pushes for one SKU across all channels.
$lockName = 'mp_sku_' . md5($sku);
$db = Db::getInstance();
// MySQL advisory lock: waits up to 3s, returns 1 on acquire, 0 on timeout.
if (!(int) $db->getValue("SELECT GET_LOCK('" . pSQL($lockName) . "', 3)")) {
$this->requeue($job, 5); // another worker owns this SKU; retry shortly
return;
}
try {
$qty = (int) StockAvailable::getQuantityAvailableByProduct($idProduct, $idAttribute);
$ver = (int) $db->getValue(
'SELECT stock_version FROM ' . _DB_PREFIX_ . 'mp_sku_state WHERE sku = "' . pSQL($sku) . '"'
);
if ($job['version'] < $ver) {
return; // a newer push already went out; discard this one
}
$this->etsy->setQuantity($sku, $qty); // absolute value
$this->ebay->setQuantity($sku, $qty); // absolute value
$db->execute(
'UPDATE ' . _DB_PREFIX_ . 'mp_sku_state
SET stock_version = stock_version + 1, synced_qty = ' . $qty . ', synced_at = NOW()
WHERE sku = "' . pSQL($sku) . '"'
);
} finally {
$db->execute("SELECT RELEASE_LOCK('" . pSQL($lockName) . "')");
}
GET_LOCK is session-scoped, so it spans PHP-FPM workers and cron processes on the same database and releases when the connection dies, which a lock flag in a row does not. Across several database servers, move the lock to Redis and keep the version column as a backstop.
No lock removes the residual risk, though. Between an Etsy checkout and your push, eBay can legitimately sell the same unit, so mitigate that commercially: publish available – 1 on fast movers, and push 0 on the last unit immediately rather than at the next reconciliation.
5.0 How Etsy API rate limits, eBay API rate limits and PrestaShop’s ceiling compound
Each budget looks generous on its own. In a three-way sync, however, they multiply against your SKU count.
| Channel | Documented limits | Signals to read |
|---|---|---|
| Etsy | Per-app QPS and QPD quotas on a sliding window; a new app’s documented default is 10 requests/second and 10,000 per 24 hours, raised on request (Etsy rate limits) | x-limit-per-second, x-remaining-this-second, x-limit-per-day, x-remaining-today; retry-after on 429 |
| eBay | Per-application daily call limits per API and resource, raised through the Application Growth Check (eBay API call limits) | Developer Analytics API getRateLimits — quota, used, remaining, reset window |
| PrestaShop | No published quota; bounded by PHP-FPM workers and framework boot cost per /api/ call | Your own 5xx rate and FPM queue depth |
A 3,000-SKU full resync needs one Etsy inventory write per listing, one eBay offer update per SKU and one PrestaShop read per SKU. At Etsy’s default 10 QPS, that is a five-minute sustained burn with no headroom for the order poller, the one job that must never stall. Three rules keep the budget manageable:
- Run one token bucket per channel, shared by every worker. Enforce it in Redis, because a per-process limiter multiplies your real rate by the number of workers.
- Prioritise queues. Order pulls and stock-to-zero pushes outrank price updates and image uploads. Quota spent on thumbnails while an oversell brews is a design error.
- Batch and diff. eBay’s Inventory API offers bulk price and quantity updates, so batch to the current per-call ceiling, then compare against synced_qty and stop spending quota to confirm that 7 still equals 7.
6.0 Authentication: Etsy API OAuth, eBay OAuth 2.0 and PrestaShop API keys
Three models, and only two of them expire.
PrestaShop sends the access key as the HTTP Basic username with an empty password. No expiry, no refresh, no token-refresh failure mode, the risk runs the other way, since a key never rotates unless you rotate it.
Etsy uses the OAuth 2.0 authorization code grant with mandatory PKCE: a code_verifier of 43–128 characters and an S256 code_challenge. Access tokens carry expires_in: 3600 and refresh tokens last 90 days (Etsy authentication). Every request needs the x-api-key header and the bearer token, and the token format is {user_id}.{token} that numeric prefix belongs to the credential, and stripping it is a classic first-day bug.
eBay issues user access tokens valid for 7,200 seconds and refresh tokens valid for 47,304,000 seconds, roughly eighteen months (eBay OAuth tokens). Application tokens cover public data, but anything touching a seller’s inventory needs the user token.
The failure that actually kills sync jobs is not expiry. It is ten workers noticing expiry at the same instant. So guard the refresh with a single-flight lock, re-check the stored token under that lock, and refresh on a two-minute margin rather than reactively on a 401. Persist the rotated refresh token in the same write as the access token, since a crash between two writes loses the grant. Then alert on refresh success age as well as refresh failure, because a grant that has not refreshed in 24 hours is a problem even when nothing has thrown (eBay OAuth best practices).
7.0 Marketplace order sync: taxes, shipping, currency and idempotency
Order import is where accountants find your bugs. A marketplace order is not a PrestaShop order with different field names; it is a settled financial document. Four mismatches need resolving before you write the importer.
Marketplace-facilitator tax. Etsy and eBay collect and remit sales tax or VAT for the seller in many jurisdictions. That tax sits in the payload, yet it is neither the merchant’s liability nor their revenue, so importing it into a normal tax rule inflates the VAT return. Map it to a zero-rate rule plus a separate ledger field, then confirm the treatment with the merchant’s accountant.
Shipping. Marketplaces send an amount already computed against their own shipping profiles. Do not re-run PrestaShop carrier rules, because the totals diverge and the order never reconciles against the payout. Instead create one carrier per channel and set the cost from the payload.
Currency. The payload carries its own currency and the payout may arrive in a third. Persist the marketplace amount, the shop-currency amount and the conversion_rate used at import, since reusing today’s rate to reprint a three-month-old invoice produces a document that matches nothing.
Creation path. Build the cart, then call PaymentModule::validateOrder() — the only path that decrements stock, generates the invoice, sets the order state and fires the hooks other modules rely on.
<?php
// Illustrative: idempotent marketplace order import.
$externalId = $channel . ':' . $remoteOrderId;
if (Db::getInstance()->getValue(
'SELECT id_order FROM ' . _DB_PREFIX_ . 'mp_order_link
WHERE external_id = "' . pSQL($externalId) . '"'
)) {
return; // already imported — webhook retry or overlapping poll
}
$cart = new Cart();
$cart->id_customer = $idCustomer;
$cart->id_currency = (int) Currency::getIdByIsoCode($payload['currency']);
$cart->id_carrier = (int) Configuration::get('MP_CARRIER_' . strtoupper($channel));
$cart->id_address_delivery = $idAddress;
$cart->id_address_invoice = $idAddress;
$cart->add();
foreach ($payload['lines'] as $line) {
$cart->updateQty((int) $line['qty'], (int) $line['id_product'], (int) $line['id_attribute']);
}
$module = Module::getInstanceByName('mpsync');
$module->validateOrder(
(int) $cart->id,
(int) Configuration::get('PS_OS_PAYMENT'),
(float) $payload['total_paid'],
$channel . ' Marketplace',
null,
['transaction_id' => $remoteOrderId],
(int) $cart->id_currency,
false,
$cart->secure_key
);
Db::getInstance()->insert('mp_order_link', [
'external_id' => pSQL($externalId),
'id_order' => (int) $module->currentOrder,
'imported_at' => date('Y-m-d H:i:s'),
]);
Put a UNIQUE index on external_id. The SELECT above is only a fast path; the index is the guarantee, because two workers can pass that check in the same millisecond. Then page eBay’s getOrders and Etsy’s shop receipts by a persisted updated-since cursor rather than a fixed offset that shifts under you.
Idempotency extends past orders. A product push is four calls, create inventory item, create offer, upload images, publish, so record per-step state keyed on (sku, channel, step) and let a retry resume instead of restarting. Etsy also signs webhooks with webhook-id, webhook-timestamp and webhook-signature, so deduplicate on webhook-id and reject timestamps drifting more than about 300 seconds. That header alone retires the retry class of duplicates.
8.0 Monitoring a production marketplace sync
A sync that fails loudly is a maintenance task. One that fails silently is a refund queue.
| Log every operation with | Alert on |
|---|---|
| Correlation ID spanning webhook → queue → API call | Stock divergence, local ≠ remote for more than N minutes |
| SKU, channel, operation, absolute quantity sent | Auth failure, any OAuth refresh error, paged immediately |
| Full request and response for non-2xx, secrets redacted | Rate-limit burn, remaining daily quota below 20% before 18:00 |
| Remaining rate-limit budget per channel | Queue age, oldest unprocessed job older than 5 minutes |
| Token expiry and last-successful-refresh timestamps | Order gap, zero orders from a channel in a normally busy window |
| Reconciliation results: checked, repaired, unresolved | Cron silence, a scheduled sync job that has not run on time |
Two metrics deserve top billing. Queue age is the leading indicator, since it rises before customers see anything wrong. Reconciliation repair count is the honesty metric, when the 15-minute pass keeps fixing the same SKUs, your fast path is broken and polling is quietly covering for it.
Finally, log the absolute quantity you sent rather than the delta. When a merchant asks why eBay showed 3 while PrestaShop showed 1, the answer must be recoverable from the log line alone.
9.0 Troubleshooting four PrestaShop marketplace sync failures you will actually meet
9.1 Listings show stale quantities while the logs show HTTP 200
Check the payload, not the status code. On Etsy, a listing inventory update replaces the entire inventory structure, so a partial structure can reset offerings you never meant to touch. On eBay, updating an inventory item is not the same as updating the published offer, and the offer carries the sellable quantity. Read one SKU back immediately after a push and compare field by field. On a connector-based setup, walk the same chain first: is the product mapped, does it have a valid reference, is the sync job enabled, and did the cron fire?
9.2 429s appear only during the nightly full resync
Your token bucket is per-process, so eight cron workers run at eight times the intended rate. Move the limiter into shared storage. Then question the full resync itself, diff against synced_qty and push only changes, and the nightly burn usually drops by an order of magnitude. Honour retry-after on Etsy 429s, and check eBay’s getRateLimits before a large batch rather than hitting the wall mid-run.
9.3 The same order imports twice, hours apart
Overlapping poll windows plus a missing constraint. Confirm the UNIQUE index on external_id exists, because a duplicate-check SELECT without it is a race rather than a guard. Then check the cursor: a poller asking every 15 minutes for “orders updated in the last 30 minutes” offers every order twice by design. Persist a high-water mark, overlap it deliberately, and let the database enforce uniqueness.
9.4 Sync stops for one channel at 3 a.m. with no errors
Usually refresh-token loss. An Etsy refresh token silently passed its 90-day window, or a concurrent refresh rotated the token twice and your worker persisted the loser. Check the token store’s expires_at and last-successful-refresh timestamp first. Then confirm the server’s cron service or external scheduler is still executing the sync jobs at all, a stopped scheduler produces exactly this symptom and exactly these empty logs.
10.0 A production readiness checklist for PrestaShop multichannel selling
Before you call an Etsy or eBay integration finished, walk this list:
- Products, every synced product and combination carries a unique reference, categories map to valid marketplace nodes, required aspects are populated, and images meet marketplace requirements.
- Inventory, PrestaShop is the source of truth, pushes carry absolute quantities behind a per-SKU lock, zero-stock pushes go out immediately, and reconciliation runs on a schedule.
- Orders, the marketplace order ID is stored under a unique constraint, facilitator tax and shipping are mapped deliberately, and imports run through validateOrder().
- API and auth, credentials are stored securely, refresh runs on a margin behind a single-flight lock, and rate limits are shared across workers.
- Cron and monitoring, every required job is verifiably executing, queue age and reconciliation repairs are visible, and auth failures raise an alert rather than a log line.
11.0 Where to start
Building a PrestaShop marketplace integration from scratch, the order that avoids rework runs: SKU integrity, mapping tables, absolute-quantity pushes behind a per-SKU lock, order import with a unique external ID, reconciliation, and webhooks last. Webhooks are the optimisation. A correct polling loop with locks and idempotency is already a system you can trust.
If you would rather start from a working connector and spend development time on what is genuinely specific to your catalogue, judge the module against that same list, mapping, scheduling, error visibility, duplicate protection, reconciliation, and confirm its cron jobs before go-live. Either way, the goal is not to synchronise once. It is to still be synchronised in six months.
If you have questions or need assistance with your website performance or migration, our experts are here to help. Contact the Knowband team at support@knowband.com today for reliable eCommerce plugins tailored to your eCommerce needs.


