Guide to Shopware Webhooks: Features and Best Practices
Shopware webhooks let an app react to what happens inside the shops that install it: an order is placed, a payment is marked as paid, a product or customer changes, or a delivery ships. If you build a Shopware 6 app, webhooks are how events from each merchant's shop reach your app server so you can sync orders to an ERP, update a catalogue elsewhere, or trigger fulfilment.
This guide covers how Shopware app webhooks are declared and signed, why each shop has its own secret, the main event families and their payloads, and the practices that keep a Shopware app's receiver reliable.
What are Shopware webhooks?
In Shopware 6's app system, webhooks are declared in the app's manifest. Each <webhook> element names a webhook, a URL and an event. Every shop that installs the app sends a JSON POST to that URL when the event fires.
The signing key is what makes Shopware different. When a shop installs an app, it goes through a registration handshake with the app server, and the app server issues that shop its own app secret. The shop signs every request to the app, webhooks included, with that secret. An app installed in 50 shops therefore receives webhooks signed with 50 different secrets, and has to pick the right one for each request.
The signature is in the shopware-shop-signature header: a lowercase hex HMAC-SHA256 of the raw request body.
Shopware webhook features
| Feature | Details |
|---|---|
| Configuration | <webhooks> in the app manifest: <webhook name="..." url="..." event="..."/> |
| Senders | Every shop that installs the app |
| Signature header | shopware-shop-signature |
| Scheme | HMAC-SHA256, lowercase hex |
| Signed content | The raw request body |
| Secret | Per shop, issued by your app during the registration handshake |
| Rotation | Re-registration issues a new secret |
| Timestamp | timestamp in the body is signed. Shopware says to reject requests that are too old, but sets no limit |
| Event ID | source.eventId (6.4.11.0+), unchanged on retries |
| Headers | sw-version (6.4.1.0+), sw-context-language and sw-user-language (6.4.5.0+) |
| Draft filtering | onlyLiveVersion="true" (6.5.7.0+) skips draft versions for entity written events |
Shopware does not document a retry schedule, a response timeout or source IP addresses. Shops are self-hosted or run on Shopware's cloud, so requests come from each shop's own infrastructure.
Common events
Shopware's Webhook Event Reference is generated per version and lists over a hundred events. Plugins and newer versions add more. They fall into a few families:
| Family | Examples | Fires when |
|---|---|---|
| Entity written and deleted | product.written, order.written, customer.written, customer_address.deleted, media.deleted | An entity is inserted, updated or deleted |
| Checkout | checkout.order.placed, checkout.customer.register, checkout.customer.login, checkout.order.payment_ | Customers act in the storefront |
| State machine | state_, state_, state_ | An order, delivery, transaction, capture or refund enters or leaves a state |
| Communication | mail.sent, contact_form.send, newsletter.register, review_form.send | Mail is sent or a storefront form is submitted |
| App and shop | app.installed, app.updated, app.deleted, shopware.updated | The app or the shop itself changes |
Every payload has the same envelope. Here is Shopware's product.written example:
{
"data": {
"payload": [
{
"entity": "product",
"operation": "delete",
"primaryKey": "7b04ebe416db4ebc93de4d791325e1d9",
"updatedFields": []
}
],
"event": "product.written"
},
"source": {
"url": "http://localhost:8000",
"appVersion": "0.0.1",
"shopId": "dgrH7nLU6tlE",
"eventId": "7b04ebe416db4ebc93de4d791325e1d9"
},
"timestamp": 123123123
}
data.event names the event, so one endpoint can handle many events. source tells you which shop sent it: url is where your app reaches that shop's API, and shopId identifies the shop. timestamp is when the shop handled the webhook.
Entity written events carry IDs, not entities. Each item in data.payload has the entity, the operation (insert, update or delete), the primaryKey and the updatedFields. Shopware does this because webhooks are asynchronous and a full entity could be out of date by the time it arrives, so you fetch current data from the shop's API. Other events carry the data that defines the event, though not necessarily every association.
Setting up Shopware webhooks
- Implement the app registration handshake on your app server, and store the secret you issue to each shop against its
shopId. The handshake goes to your app's registration URL, not to the webhook URL. - Add a
<webhooks>element to the app manifest with one<webhook>per event, for example:
<webhooks>
<webhook name="order-placed" url="https://example.com/webhooks/shopware" event="checkout.order.placed"/>
<webhook name="order-written" url="https://example.com/webhooks/shopware" event="order.written" onlyLiveVersion="true"/>
</webhooks>
- Install or update the app in a shop. From then on, the shop sends the declared webhooks.
If you receive through Hookdeck, note that a Hookdeck source verifies with one secret. A Shopware source therefore verifies one shop's webhooks. For an app in several shops, create a source per shop, point that shop's webhook URLs at it, and paste that shop's app secret into the source's Webhook Signing Secret field. Update the secret whenever the shop re-registers. If you can't give each shop its own URL, leave verification off at Hookdeck and verify in your app, where you can look the secret up by shopId.
Securing Shopware webhooks
Read source.shopId to find the shop's secret, then compute an HMAC-SHA256 of the raw request body with that secret, encode it as hex, and compare it with shopware-shop-signature in constant time. Parsing the body to read shopId is fine, as long as you verify against the original bytes and don't trust anything else in the body until verification passes.
const crypto = require('crypto');
const express = require('express');
// Look up the secret your app issued to this shop during registration.
async function getShopSecret(shopId) {
/* e.g. SELECT app_secret FROM shops WHERE shop_id = $1 */
}
function verifyShopwareSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/shopware', express.raw({ type: '*/*' }), async (req, res) => {
let body;
try {
body = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
const secret = await getShopSecret(body?.source?.shopId);
if (!secret) return res.sendStatus(401); // unknown shop
if (!verifyShopwareSignature(req.body, req.get('shopware-shop-signature'), secret)) {
return res.sendStatus(401);
}
// Route on body.data.event. Deduplicate on body.source.eventId.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_shopware_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
received = signature_header.strip().lower().encode("utf-8")
return hmac.compare_digest(received, expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# shop_id = json.loads(raw_body)["source"]["shopId"]
# verify_shopware_signature(raw_body, request.headers.get("shopware-shop-signature"),
# get_shop_secret(shop_id))
The signed timestamp lets your receiver reject stale requests, but Shopware gives no maximum age. If you add a check, keep it optional and generous, because a retried or replayed webhook still carries its original timestamp.
Shopware warns that it may add inputs to signature generation without treating that as a breaking change. Always sign the whole raw body rather than selected fields. If you can, use Shopware's App PHP SDK or Symfony bundle, which handle verification for you.
Make Shopware webhooks production-ready. Hookdeck Event Gateway verifies the
shopware-shop-signatureheader, accepts each event as soon as the shop sends it, and durably queues every order, product and state change before your app server sees it.
Shopware webhook limitations and pain points
One secret per shop
The Problem: Verification works for one shop and fails for all the others.
Why It Happens: Each shop signs with the secret your app issued it during registration. A single configured secret, or a single verification layer in front of every shop, can only match one of them.
Workarounds:
- Store secrets keyed by
shopIdand look them up per request. - Give each shop its own receiving URL if you verify at a gateway.
How Hookdeck Can Help: A Hookdeck source per shop keeps each secret separate and gives you a per-shop view of deliveries, failures and retries.
Secrets rotate on re-registration
The Problem: A shop that re-registers your app starts failing verification.
Why It Happens: Re-registration, for example to rotate the secret or after a shop ID change, issues a new secret. During the handshake, the confirmation request carries both shopware-shop-signature (new secret) and shopware-shop-signature-previous (old secret). After that, webhooks are signed with the new one.
Workarounds:
- Update the stored secret as part of handling the confirmation request.
- Keep the previous secret for a short overlap, so requests already in flight still verify.
How Hookdeck Can Help: Hookdeck logs each failed verification, so a shop whose secret changed shows up immediately rather than as silently missing data.
Payloads with IDs only
The Problem: An order.written event tells you something changed, but not what the order looks like now.
Why It Happens: Entity written events carry primaryKey and updatedFields, not the entity, so your app must call the shop's API for the current state. A busy shop can generate many of these events quickly.
Workarounds:
- Fetch only what you need, using
updatedFieldsto decide whether a fetch is worth it. - Use
onlyLiveVersion="true"on entity events to skip draft versions, such as orders still being edited. - Prefer specific events, such as
checkout.order.placedor astate_enterevent, over broad*.writtenevents where they cover your case.
How Hookdeck Can Help: Hookdeck can rate-limit delivery to your app server, so a burst of written events turns into a steady stream of API calls to the shop.
Delivery behaviour is undocumented
The Problem: You don't know how long a shop waits for your response or how it retries.
Why It Happens: Shopware says source.eventId stays the same when a webhook is retried, so retries exist, but it doesn't document their schedule, the timeout or what counts as success.
Workarounds:
- Respond with a
2xxas soon as the signature checks out, then process asynchronously. - Deduplicate on
source.eventId. - After an outage, reconcile through each shop's API.
How Hookdeck Can Help: Hookdeck acknowledges each webhook as soon as it arrives and stores it, then retries delivery to your app server on a schedule you set and lets you replay anything that failed.
Best practices
Verify against the raw body
The signature covers the exact bytes the shop sent. In Express, mount express.raw() on the route. In Next.js, read await request.text(). In FastAPI, read await request.body().
Deduplicate on source.eventId
source.eventId is unique per event and stays the same on retries (Shopware 6.4.11.0 and later). Store it and skip events you have already processed. See our guide to webhook idempotency.
Expect version differences between shops
Shops run different Shopware versions. timestamp needs 6.4.1.0, the language headers need 6.4.5.0, eventId needs 6.4.11.0 and onlyLiveVersion needs 6.5.7.0. Read sw-version and handle missing fields.
Route on data.event and operation
One URL can receive many events. Dispatch on data.event, and for written events also check each payload item's operation, because a deletion arrives as product.written with operation set to delete.
Acknowledge fast and process asynchronously
Return 200 once the signature is verified and the event is stored, then fetch from the shop API and do the work. See why to process webhooks asynchronously.
Conclusion
Shopware app webhooks are JSON POSTs signed with a hex HMAC-SHA256 of the raw body in shopware-shop-signature, keyed with the secret your app issued each shop at registration. Look the secret up by shopId, verify against the raw bytes, update secrets when shops re-register, deduplicate on source.eventId, and fetch current entities from the shop API rather than relying on the payload.
Hookdeck Event Gateway sits in front of your app server, checks each shop's signature, and holds every event until your app has processed it.
Get started with Hookdeck for free and handle Shopware webhooks reliably in minutes.