Skip to content
Back to blogBackend

Shopware App Server: Processing Order Data

How an app server fetches and processes order data via the order.written event, including custom fields and a custom admin module.

Nico Schnaß· 9 min read

A Shopware app server gets order data by subscribing to the order.written webhook, reading the order ID from it and then loading the order through the Admin API. This post walks through the steps with the Symfony app bundle, including custom fields and a custom module in the Administration. The same approach works for every entity that is available through the Admin API and has a matching event.

An app server is a separate web application outside of Shopware that receives the shop’s webhooks and talks to it through the Admin API. The events Shopware sends as webhooks are listed in the webhook events reference.

Registering the app via manifest.xml

Creating a minimal manifest.xml

To register an app in Shopware, a manifest.xml is enough. At a minimum, it needs the following content:

manifest.xml
<?xml version="1.0" encoding="UTF-8"?>
<manifest xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/shopware/shopware/trunk/src/Core/Framework/App/Manifest/Schema/manifest-3.0.xsd">
    <meta>
        <name>MyExampleApp</name>
        <label>Example App</label>
        <description>Example app for processing order data</description>
        <author>Jane Doe</author>
        <copyright>Example Company</copyright>
        <version>1.0.0</version>
        <icon></icon>
        <license>MIT</license>
    </meta>
    <setup>
        <registrationUrl>https://app-server.example.com/app/lifecycle/register</registrationUrl>
        <secret>my-app-secret</secret>
    </setup>
</manifest>

The values secret, registrationUrl and the app name must match the app server you use. Once the app server is set up correctly, the app can be installed and activated in Shopware.

Registering the order.written webhook

Docs: Webhooks

To react to the example event order.written, the app first needs the order:read permission. Then the event and its target URL can be defined:

manifest.xml
<permissions>
    <permission>order:read</permission>
</permissions>
<webhooks>
    <webhook name="orderWritten" url="https://app-server.example.com/upload/order.written" event="order.written"/>
</webhooks>

Adding custom fields to the order

Docs: Custom fields

Custom fields are defined in the manifest via <custom-fields>. A set contains a name, labels, the related entities and the fields. The example in this post is a resubmission: staff mark an order in the Administration as needing another look by a certain date. The following set attaches three fields for this to the order (<order/>); the admin module further down counts the resubmissions that are due.

manifest.xml
<custom-fields>
    <custom-field-set>
        <name>resubmission</name>

        <label>Resubmission</label>
        <label lang="de-DE">Wiedervorlage</label>

        <related-entities>
            <order/>
        </related-entities>

        <fields>
            <bool name="resubmission_active">
                <label>Resubmission active</label>
                <label lang="de-DE">Wiedervorlage aktiv</label>
            </bool>
            <datetime name="resubmission_date">
                <label>Resubmission Date</label>
                <label lang="de-DE">Wiedervorlage Datum</label>
            </datetime>
            <text-area name="resubmission_note">
                <label>Note</label>
                <label lang="de-DE">Notiz</label>
            </text-area>
        </fields>
    </custom-field-set>
</custom-fields>

Handling the webhook in the app server

Creating the webhook endpoint in a controller

Docs: Setting up a Symfony app server

The app server provides a POST endpoint that handles the incoming WebhookAction. Before the controller is called, the Symfony app bundle verifies the signature in the shopware-shop-signature header with the shop’s secret; invalid requests never reach the controller.

WebhookController.php
#[AsController]
class WebhookController {
    #[Route('/upload/order.written', methods: ['POST'])]
    public function handle(WebhookAction $webhook): Response
    {
    }
}

Reading the primary key from the webhook

Shopware only sends the primary key of the affected entity in the webhook payload. It can be read like this:

WebhookController.php
$payload = $webhook->payload;
$firstEvent = $payload[0] ?? null;
$primaryKey = $firstEvent['primaryKey'] ?? null;
if (\is_array($primaryKey)) {
    $orderId = $primaryKey[0] ?? null;
} else {
    $orderId = $primaryKey;
}

Since the app server only receives the ID, any further data has to be loaded through the Admin API. The payload is a list; the example only handles the first entry. order.written fires on every write to the order, not only when it is created.

Getting an access token for the Admin API

Endpoint docs: Authentication

To fetch data from the Admin API, an access token is first requested via OAuth. Shopware hands the credentials (client_id and client_secret) to the app server when the app is registered; the app bundle stores them with the shop.

WebhookController.php
public function __construct(private readonly HttpClientInterface $httpClient)
{}

/* Existing Code */

$shop = $webhook->shop;
$shopUrl = \rtrim($shop->getShopUrl(), '/');

$tokenResponse = $this->httpClient->request('POST', $shopUrl.'/api/oauth/token',
    [
        'body' => [
            'grant_type' => 'client_credentials',
            'client_id' => $shop->getShopClientId(),
            'client_secret' => $shop->getShopClientSecret(),
        ]
    ]
);
$token = $tokenResponse->toArray()['access_token'] ?? null;

Fetching order data

Endpoint docs: Order search

With the token, the order is loaded through the Admin API search. The body has to be sent as JSON ('json' instead of 'body'). Without associations, the search only returns the fields of the order itself; line items and customer are requested explicitly:

WebhookController.php
$response = $this->httpClient->request('POST', $shopUrl.'/api/search/order',
    [
        'headers' => ['Authorization' => 'Bearer '.$token],
        'json' => [
            'ids' => [$orderId],
            'associations' => [
                'lineItems' => [],
                'orderCustomer' => [],
            ],
        ]
    ]
);

$order = $response->toArray()['data'][0] ?? null;

$order then holds the order as an array including line items (lineItems) and customer data (orderCustomer). The custom fields are under customFields, for example $order['customFields']['resubmission_date'].

The ClientFactory from the app SDK makes this simpler: it requests and caches the access token itself, so the token request above is no longer needed.

WebhookController.php
public function __construct(private readonly ClientFactory $clientFactory)
{}

/* Existing Code */

$client = $this->clientFactory->createSimpleClient($webhook->shop);
$response = $client->post($shopUrl.'/api/search/order', [
    'ids' => [$orderId],
    'associations' => ['lineItems' => [], 'orderCustomer' => []],
]);

$order = $response->json()['data'][0] ?? null;

Custom module in the Administration

Docs: Custom modules

A custom module is an iframe that is embedded as its own page in the Shopware Administration. Its content comes entirely from the app server. In the example, the “Resubmissions” module under Orders shows how many resubmissions are due.

Registering the module in the manifest

The module is registered in the manifest.xml with the following section:

manifest.xml
<admin>
    <module name="resubmissions"
            source="https://app-server.example.com/module/resubmissions"
            parent="sw-order"
            position="50">
                <label>Resubmissions</label>
                <label lang="de-DE">Wiedervorlagen</label>
    </module>
</admin>

The value of source must point to the app server route that serves the module.

Controller renders the module template

A regular Symfony controller in the app server is enough. The app bundle verifies the request signature here as well before passing in the ModuleAction. The controller counts all orders with an active resubmission whose date has been reached through the Admin API and passes the number to the template as dueCount:

ModuleController.php
class ModuleController extends AbstractController
{
    public function __construct(private readonly ClientFactory $clientFactory)
    {}

    #[Route('/module/resubmissions')]
    public function show(ModuleAction $module): Response
    {
        $client = $this->clientFactory->createSimpleClient($module->shop);
        $shopUrl = \rtrim($module->shop->getShopUrl(), '/');

        $response = $client->post($shopUrl.'/api/search/order', [
            'limit' => 1,
            'total-count-mode' => 1,
            'filter' => [
                ['type' => 'equals', 'field' => 'customFields.resubmission_active', 'value' => true],
                ['type' => 'range', 'field' => 'customFields.resubmission_date', 'parameters' => [
                    'lte' => (new \DateTimeImmutable())->format(\DATE_ATOM),
                ]],
            ],
        ]);

        return $this->render('module/resubmissions.html.twig', [
            'dueCount' => $response->json()['total'] ?? 0,
        ]);
    }
}

With 'total-count-mode' => 1, the search returns the exact total in the total field; 'limit' => 1 keeps the response small because the orders themselves are not needed.

Template: reporting the iframe as loaded

The template has to tell Shopware via postMessage that the page has loaded; only then does the Administration show the iframe. The message is sent exactly once:

module/resubmissions.html.twig
<script>
    window.addEventListener('load', () => {
        window.parent.postMessage('sw-app-loaded', '*');
    });
</script>

Styling with the Meteor Design System

The Meteor Design System is the design system of the Shopware Administration. With it, the module looks like the rest of the Administration, and the admin components become available.

Meteor Components with Symfony UX Vue

The Meteor Components can also be used with Symfony UX Vue. Both Symfony UX Vue and the Meteor Component Library have to be installed according to their documentation. The example assumes Webpack Encore; require.context only exists there, not in AssetMapper. The components also require the Vue I18n plugin:

Terminal
npm install vue-i18n@11

The app.js registers the Vue controllers and sets up Vue I18n for every component:

assets/app.js
import { registerVueControllerComponents } from '@symfony/ux-vue';
//import i18n
import { createI18n } from 'vue-i18n'

import './stimulus_bootstrap.js';
import "@shopware-ag/meteor-component-library/styles.css";
import "@shopware-ag/meteor-component-library/font.css";

//i18n initialisieren
document.addEventListener('vue:before-mount', (event) => {
    const i18n = createI18n({
        legacy: false,
        locale: 'de',
        fallbackLocale: 'en',
    })

    const {
        componentName, // The Vue component's name
        component, // The resolved Vue component
        props, // The props that will be injected to the component
        app, // The Vue application instance
    } = event.detail;

    app.use(i18n);
});

registerVueControllerComponents(require.context('./vue/controllers', true, /\.vue$/));

Vue controllers can then use Meteor components. Every component used is imported:

assets/vue/controllers/mt-card.vue
<template>
    <mt-card :isLoading="false" :large="false">
        <mt-text>
            {{ firstText }}
        </mt-text>
        <br />
        <template #title>
            {{ title }}
        </template>
    </mt-card>
</template>

<script setup>
import { MtCard, MtText } from "@shopware-ag/meteor-component-library";

const props = defineProps({
    firstText: {
        type: String,
        required: true,
    },
    title: {
        type: String,
        required: true,
    },
});
</script>

In the Twig template, the Vue component is embedded with vue_component:

module/resubmissions.html.twig
<div
    {{ vue_component('mt-card', {
        firstText: dueCount ~ ' Bestellungen',
        title: 'Fällige Wiedervorlagen',
    }) }}
></div>

Frequently asked questions

Which permission does the order.written webhook need?

The app needs the order:read permission. It goes into the manifest.xml under <permissions>.

Why does the webhook only contain the order ID?

Shopware only sends the primary key of the affected entity in the webhook payload. The app server loads all further data through the Admin API.

Where does the app server get its Admin API credentials?

Shopware hands over client_id and client_secret when the app is registered, and the app bundle stores them with the shop. The app server uses them to request an access token via OAuth at /api/oauth/token, or the SDK’s ClientFactory does it.

What is a custom module?

A custom module is an iframe that is embedded as its own page in the Shopware Administration. The page has to report via postMessage('sw-app-loaded', '*') that it has loaded.

Does the app server verify the webhook signature?

Yes, automatically with the Symfony app bundle. It checks the shopware-shop-signature header with the shop’s secret before the controller receives the WebhookAction.

Why does order.written arrive several times?

The event fires on every write to the order, not only when it is created. The app server therefore has to expect the same order more than once.

Written byNico SchnaßFull Stack Developer and self-hosting enthusiast.
@MobilGame06