PrestaShop Webservice API - REST Integration & ERP Sync
PrestaShop Webservice API guide: CRUD, authentication, filters, images, stock, ERP/POS sync, security, PHP client library and PS 9 Admin API.
Why the Webservice still matters
At mypresta.rocks we build the modules that talk to the Webservice (for ERPs, WMS systems, marketplaces, accounting, the lot) and most of the integrations we ship still rely on it. Despite PrestaShop 9 introducing a newer AdminAPI, the legacy Webservice is what 95% of running shops talk to right now, and it isn't going anywhere this year.
It's been part of the core since PrestaShop 1.4. It is not a module. You don't install it — you turn it on. It speaks XML by default, JSON optionally, and HTTP Basic Auth in both cases. If you've never used it, the rest of this page is the version of the docs we wish PrestaShop's site had.
Why not just write directly to the database? Because the Webservice runs validation, fires hooks, and respects business logic. Direct SQL doesn't. We've cleaned up enough "the ERP overwrote the prices" disasters to be evangelical about this.
Turn it on, then lock it down
The Webservice is disabled out of the box. Go to Advanced Parameters → Webservice, switch it on, click Add new webservice key. You'll get a long random string and a permission matrix — a grid of resources crossed with HTTP methods (GET, POST, PUT, DELETE, HEAD).
Real Back Office setup screen from ps8-dev.
This is where the global switch, key list, and per-shop configuration live. The screenshot key is redacted.
Use one key per integration. If your ERP only pulls orders and pushes stock updates, give it GET on orders and PUT on stock_availables — nothing else. When (not if) the key leaks, that's the difference between "rotate one credential" and "rebuild the catalogue."
The permission grid is your security boundary.
Tick only the resources and methods this integration needs. A stock sync key has no business touching customer data.
The Webservice needs URL rewriting to work. On Apache, .htaccess handles it. On Nginx (which is what most of our hosting clients run) add this to the server block:
location /api/ {
try_files $uri $uri/ /webservice/dispatcher.php?$args;
}If you get a 404 on /api/ but a 200 on /webservice/dispatcher.php?url=products, that's your problem — rewriting isn't reaching the dispatcher.
Authentication
HTTP Basic Auth. The key is the username, the password is left empty. The trailing colon matters:
curl -u "YOUR_API_KEY:" https://your-store.com/api/productsThree rules we'd enforce on every integration we touch:
- HTTPS, no exceptions. Basic Auth sends the key in every request header. In plain HTTP, anyone on the network reads it.
- IP whitelist if you can. For server-to-server integrations from a known IP, this is the single highest-leverage thing you can do.
- Rotate every 6–12 months. And rotate immediately on any sign of a leak.
# .htaccess in /api/
<IfModule mod_authz_core.c>
Require ip 10.0.0.5
Require ip 192.168.1.0/24
</IfModule>Store keys in environment variables. We've inherited enough projects with API keys committed to git to know how often it happens.
What you can actually read and write
The fastest way to see what your key can touch is to hit the root endpoint:
curl -u "KEY:" https://your-store.com/api/The API root tells you exactly what the current key can access.
Captured from ps8-dev with a real key. Notice the allowed methods differ per resource — that's your permission matrix in action.
The resources you'll deal with most:
- Catalogue:
products,combinations,categories,manufacturers,suppliers,product_features,product_feature_values,product_options(attribute groups),product_option_values,tags,images - Sales:
orders,order_details,order_states,order_histories,order_carriers,carts,cart_rules - Customers:
customers,addresses,groups - Stock:
stock_availables(the only one most integrations need),warehouses,supply_orders - Config:
carriers,countries,currencies,languages,taxes,zones,shops
CRUD: the four moves you'll actually make
GET — reading
# List products (IDs only)
curl -u "KEY:" https://your-store.com/api/products
# Full details
curl -u "KEY:" "https://your-store.com/api/products?display=full"
# Only the fields you actually need — much faster on big catalogues
curl -u "KEY:" "https://your-store.com/api/products?display=[id,name,price,reference]"
# Single product as JSON
curl -u "KEY:" "https://your-store.com/api/products/42?output_format=JSON"Don't ask for display=full on a 50,000-product catalogue. We've watched ERP integrations crash perfectly healthy shops doing this on a cron. Specify the fields.
JSON output keeps things tidy when you only need a few fields:
{
"products": [
{"id": 1, "price": "23.900000", "name": "Hummingbird printed t-shirt"},
{"id": 2, "price": "35.900000", "name": "Hummingbird printed sweater"},
{"id": 16, "price": "12.900000", "name": "Mountain fox notebook"}
]
}POST — creating
The workflow that won't bite you: ask for a blank schema, fill in the required fields, POST it back.
curl -u "KEY:" -X POST -H "Content-Type: application/xml" \
-d '<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
<product>
<id_category_default>2</id_category_default>
<id_tax_rules_group>1</id_tax_rules_group>
<active>1</active><state>1</state>
<price>29.99</price>
<name><language id="1">My Product</language></name>
<link_rewrite><language id="1">my-product</language></link_rewrite>
<description_short><language id="1">Short desc</language></description_short>
<associations><categories><category><id>2</id></category></categories></associations>
</product>
</prestashop>' https://your-store.com/api/productsPUT — updating
PrestaShop's API does not support partial updates. To change one field, you GET the whole resource, modify, and PUT the whole thing back. This is the single biggest cause of "the API blanked out my product description" reports we see.
curl -u "KEY:" https://your-store.com/api/products/42 -o product.xml
# Edit product.xml — then PUT it back as-is
curl -u "KEY:" -X PUT -H "Content-Type: application/xml" \
-d @product.xml https://your-store.com/api/products/42If you build the XML from scratch with only the fields you care about, every other field gets emptied or rejected. Always GET first, modify, PUT.
DELETE
curl -u "KEY:" -X DELETE https://your-store.com/api/products/42
# Several at once
curl -u "KEY:" -X DELETE "https://your-store.com/api/products/?id=[42|43|44]"There is no recycle bin. We've never once recommended DELETE for live products — set active=0 instead. Hidden products keep historical order data intact; deleted ones leave dangling references.
Filtering, sorting, paging
# Exact match
?filter[reference]=ABC-123
# Range (price 10–50)
?filter[price]=[10,50]
# Date range
?filter[date_upd]=[2025-01-01,2025-12-31]
# Starts with (% URL-encoded as %25)
?filter[name]=[Nike]%25
# Combined
?filter[active]=1&filter[id_category_default]=5&display=[id,name,price]
# Sorting
?sort=[price_ASC] ?sort=[date_upd_DESC]
# Pagination (offset,count)
?limit=50 # first 50
?limit=50,50 # results 51–100For incremental sync (the cron job that runs every 15 minutes) filter[date_upd]=[last_run,now] is what you want. Pulling the whole catalogue on every run kills database performance and gains you nothing.
Schema discovery — actually documented inside your shop
?schema=blank # Empty XML template with all fields
?schema=synopsis # Field types, required flags, max lengths, read-only markersThe synopsis is the most under-used feature of the Webservice. Before you write a single line of POST or PUT code, hit ?schema=synopsis for the resource. It tells you which fields are required, which are read-only, what the formats are, and what's actually filterable.
schema=synopsis is documentation served by your own shop.
It's accurate for your exact PrestaShop version, with the exact modules installed. The official docs lag behind. The synopsis doesn't.
Images
Images don't go through XML — they go through multipart upload to a separate endpoint:
# Upload an image to product 42
curl -u "KEY:" -X POST -F "image=@photo.jpg;type=image/jpeg" \
"https://your-store.com/api/images/products/42"
# List images on a product
curl -u "KEY:" "https://your-store.com/api/images/products/42"
# Get a specific image at a specific image type (size)
curl -u "KEY:" "https://your-store.com/api/images/products/42/15/large_default"
# Delete
curl -u "KEY:" -X DELETE "https://your-store.com/api/images/products/42/15"PHP version, which is what our ERP-importer modules use:
$ch = curl_init("$shopUrl/api/images/products/$productId");
curl_setopt($ch, CURLOPT_USERPWD, "$apiKey:");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
'image' => new CURLFile($imagePath, mime_content_type($imagePath))
]);
$response = curl_exec($ch);Resize images before uploading. 5MB DSLR JPEGs out of a PIM will choke PrestaShop's thumbnail generation on any decent-sized import. Cap them at 2048px and run them through a basic JPEG compressor — your shop will thank you.
Combinations: the part that traps everyone
Combinations are where most ERP integrations spend their development time. The data model has three layers, and they have to be created in order:
- Product Option — the attribute group (Size, Colour, Material)
- Product Option Value — the values within (S, M, L, Red, Blue)
- Combination — a specific combination of values for a product (Size:M + Colour:Red on product #42)
# Create a combination by linking existing attribute values
curl -u "KEY:" -X POST -H "Content-Type: application/xml" \
-d '<prestashop><combination>
<id_product>42</id_product>
<reference>PROD-42-M-RED</reference>
<price>5.00</price>
<minimal_quantity>1</minimal_quantity>
<associations><product_option_values>
<product_option_value><id>2</id></product_option_value>
<product_option_value><id>7</id></product_option_value>
</product_option_values></associations>
</combination></prestashop>' "https://your-store.com/api/combinations"The biggest gotcha: <price> on a combination is a price impact — it's added to the base product price, not the final price. If your ERP sends final prices, you need to subtract the base price before POSTing.
Stock for combinations
Stock isn't on the combination — it lives on a separate stock_availables record per combination per shop:
# Find the stock record
curl -u "KEY:" "https://your-store.com/api/stock_availables?filter[id_product]=42&filter[id_product_attribute]=15&display=full"
# Update — GET first, change quantity, PUT it back
curl -u "KEY:" -X PUT -H "Content-Type: application/xml" \
-d '<prestashop><stock_available>
<id>89</id><id_product>42</id_product>
<id_product_attribute>15</id_product_attribute>
<quantity>100</quantity>
</stock_available></prestashop>' "https://your-store.com/api/stock_availables/89"There's also a product-level stock record (id_product_attribute=0) which holds the sum of all combinations. Don't touch it directly — update the combination-level records and PrestaShop recalculates the total automatically.
Orders
# Recent orders, lean response
curl -u "KEY:" "https://your-store.com/api/orders?display=[id,reference,total_paid,current_state,date_add]&sort=[id_DESC]&limit=20"
# A full order with its line items
curl -u "KEY:" "https://your-store.com/api/orders/1234?display=full"
curl -u "KEY:" "https://your-store.com/api/order_details?filter[id_order]=1234&display=full"Changing order status
Don't PUT to the order. POST a new order_history record — that triggers the state change and sends the customer email PrestaShop normally sends:
curl -u "KEY:" -X POST -H "Content-Type: application/xml" \
-d '<prestashop><order_history>
<id_order>1234</id_order>
<id_order_state>4</id_order_state>
</order_history></prestashop>' "https://your-store.com/api/order_histories"The default state IDs are 1=Awaiting payment, 2=Payment accepted, 3=Processing, 4=Shipped, 5=Delivered, 6=Canceled, 7=Refunded — but every shop adds custom states over time. Query order_states for your store's actual list instead of hard-coding.
Tracking numbers live on order_carriers. GET the record for the order, set tracking_number, PUT it back.
Creating orders via API — usually a mistake
You can POST orders. We strongly recommend not doing it. Order creation involves cart validation, tax calculation, payment processing, stock deduction, and a chain of hooks — the API bypasses most of that and you end up with orders that "exist" but didn't trigger anything that should happen when an order is placed. For marketplace imports, create a cart via API and process it through a small custom front controller. That's how our marketplace-sync modules do it.
The PHP client library
composer require prestashop/prestashop-webservice-libOr grab the single PHP file from GitHub. The library is thin (basically a curl wrapper with XML helpers) but it's the de-facto standard, and using it makes your code recognisable to anyone else who's worked on PrestaShop integrations.
$ws = new PrestaShopWebservice('https://your-store.com', 'API_KEY', false);
// Listing products
$xml = $ws->get(['resource' => 'products', 'display' => 'full', 'limit' => 10]);
foreach ($xml->products->product as $p) {
echo $p->id . ' - ' . $p->name->language . "\n";
}
// Creating a product from the blank schema
$blank = $ws->get(['url' => 'https://your-store.com/api/products?schema=blank']);
$blank->product->active = 1;
$blank->product->price = 29.99;
$blank->product->name->language[0] = 'New Product';
$blank->product->name->language[0]['id'] = 1;
$blank->product->link_rewrite->language[0] = 'new-product';
$blank->product->link_rewrite->language[0]['id'] = 1;
$blank->product->id_category_default = 2;
$result = $ws->add(['resource' => 'products', 'postXml' => $blank->asXML()]);
// Updating stock for a combination
function updateStock($ws, int $pid, int $qty, int $combo = 0): void {
$xml = $ws->get([
'resource' => 'stock_availables',
'filter[id_product]' => $pid,
'filter[id_product_attribute]' => $combo,
'display' => 'full'
]);
$sid = (int)$xml->stock_availables->stock_available->id;
$sxml = $ws->get(['resource' => 'stock_availables', 'id' => $sid]);
$sxml->stock_available->quantity = $qty;
$ws->edit(['resource' => 'stock_availables', 'id' => $sid, 'putXml' => $sxml->asXML()]);
}
// Incremental order export
$xml = $ws->get([
'resource' => 'orders',
'display' => '[id,reference,total_paid,current_state,date_add]',
'filter[date_add]' => '[2025-06-01,9999-12-31]',
'sort' => '[date_add_DESC]', 'limit' => 500
]);Common integration shapes
Most integrations we build fall into four buckets. The shape determines what permissions to grant, what to sync, and how often.
ERP integration. The most common job. Products, prices, and stock flow from the ERP into PrestaShop; orders and customers flow back the other way. Use SKU/EAN as the shared identifier — never the internal PrestaShop ID. Stock sync every 15 minutes is plenty for most shops; products hourly. Track the last sync timestamp and use filter[date_upd] to pull only what's changed. When the two systems disagree, the ERP wins — decide that before you go live, not after.
Warehouse / POS. Same shape as ERP but tighter latency. POS especially: in-store sales need to drop online stock fast enough that you don't oversell. We typically push from the WMS via webhook rather than polling.
Marketplace sync. Allegro, Amazon, eBay. Product export with images going out, orders coming in (via cart + custom processing, not direct order POST), and bidirectional stock sync as the survival-critical bit — get this wrong and you sell stock you don't have.
Accounting. Read-only. Daily or weekly pull of orders, invoices, credit slips. Only GET permissions needed. Keep this key separate from the others — your accountant doesn't need PUT access to anything.
PrestaShop 9: the AdminAPI exists, but the Webservice isn't dead
PS 9 ships a brand-new AdminAPI alongside the legacy Webservice. It's the future direction. It's also not a drop-in replacement — yet.
- Auth: OAuth2 with client credentials, not Basic Auth API keys.
- Format: JSON only.
- Architecture: Symfony / API Platform, with OpenAPI/Swagger docs served at
/admin-api/docs. - Coverage: Growing, but doesn't yet cover every resource the legacy Webservice does.
# Get an OAuth2 token
curl -X POST https://your-store.com/admin-api/access_token \
-d "grant_type=client_credentials&client_id=ID&client_secret=SECRET"
# Use it as a bearer token
curl -H "Authorization: Bearer eyJ0eXAi..." https://your-store.com/admin-api/productsTokens expire after an hour by default — handle refresh in your client. Scopes (product_read, product_write, order_read) control access granularly. API clients are created at Advanced Parameters → AdminAPI.
Our practical advice in 2026: if you're building a fresh integration on PS 9+ and AdminAPI covers the resources you need, use it. Everywhere else, the legacy Webservice is still the right call — and the two coexist happily in the same integration.
When something goes wrong
401 Unauthorized. Wrong key (usually whitespace pasted in), key disabled in Back Office, the Webservice global switch turned off, or (surprisingly often) the key sent as a query string instead of via the -u Basic Auth header.
403 Forbidden. The key doesn't have permission for that resource or method. Re-check the permission matrix. We've spent hours debugging "403 on POST" only to discover GET was ticked and POST wasn't.
404 Not Found. Either the resource genuinely doesn't exist, or URL rewriting is broken. Quick test: curl -u "KEY:" "https://store.com/webservice/dispatcher.php?url=products" — if that works but /api/products returns 404, fix your rewrite rules.
XML parsing errors. Use <![CDATA[...]]> around any content with & or HTML in it. Always wrap your payload in the <prestashop> root element. Send UTF-8 with the correct Content-Type header.
Slow bulk operations. The Webservice processes one request at a time. For 10,000+ items, the things that actually move the needle:
- Use
displaywith the specific fields you need. Skipdisplay=full. - Use
filter[date_upd]to sync only the rows that changed since your last run. - Reuse a single curl handle for the whole batch — connection setup costs add up.
- For initial imports of 50K+ items, accept that direct DB + cache clear is the right tool. The API isn't designed for it.
// Bulk stock update with one persistent curl handle
$ch = curl_init();
curl_setopt($ch, CURLOPT_USERPWD, "$apiKey:");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/xml']);
foreach ($updates as $stockId => $qty) {
curl_setopt($ch, CURLOPT_URL, "$url/api/stock_availables/$stockId");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS,
"<prestashop><stock_available><id>$stockId</id><quantity>$qty</quantity></stock_available></prestashop>");
curl_exec($ch);
}
curl_close($ch);Security: things we'd insist on before going live
- One key per integration. Always.
- Minimum permissions. A stock sync key has no need to DELETE products.
- HTTPS, always. See above.
- IP whitelist server-to-server callers. The single highest-leverage measure.
- Rotate every 6–12 months. And on every team change.
- Environment variables, not source code. No keys in git, ever.
- Rate-limit at the web server. The Webservice has no built-in rate limiting. Add it at Nginx:
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
location /api/ {
limit_req zone=api burst=50 nodelay;
try_files $uri $uri/ /webservice/dispatcher.php?$args;
}And one thing PrestaShop doesn't give you out of the box: audit logging. If a customer-data resource is touched, you want to know who, when, from where, and what they read. Parse /api/ access logs from your web server, or override WebserviceRequest to write a row per call. For anything covered by GDPR or PCI, this isn't optional.
When the Webservice isn't the right tool
Three honest alternatives we reach for ourselves.
Direct database access for one-off bulk operations and read-only reporting. Orders of magnitude faster, but bypasses validation, hooks, and cache invalidation. Never for orders, payments, or anything ongoing. Always clear caches after.
Module hooks for real-time push — instead of an external system polling your API, fire a webhook from your shop when something changes:
public function hookActionProductUpdate($params) {
$product = $params['product'];
$payload = json_encode([
'id' => $product->id,
'reference' => $product->reference,
'price' => $product->price,
'quantity' => StockAvailable::getQuantityAvailableByProduct($product->id),
]);
$ch = curl_init('https://erp.example.com/webhook/product-update');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_exec($ch);
curl_close($ch);
}Custom module endpoints for operations the Webservice handles poorly — bulk imports with complex business logic, or anything that needs to wrap several Webservice calls into one atomic action. You control the format, auth, and behaviour, while reusing PrestaShop's own validation classes.
| You're doing… | Use… |
|---|---|
| Standard ERP / WMS integration | Webservice API |
| New integration on PS 9 with covered resources | AdminAPI (OAuth2, JSON) |
| One-off bulk import of 50K+ products | Direct DB + cache clear |
| Real-time event push to an external system | Module hooks calling a webhook |
| Custom business logic the API can't express | Module front controller endpoint |
| Read-only reporting | Direct DB queries |
Related reading
- PrestaShop Security Hardening — the wider checklist for the rest of your shop
- Hooks & Overrides — for the webhook pattern in the last section
- Essential development tools — what we use to inspect API traffic and XML payloads
- Smart Google Merchant Feed Manager — one of our modules that uses the Webservice under the hood
- Connecting PrestaShop to Your ERP: Integration Patterns That Actually Work