A Symfony-based template/example for building payment applications for the Shoper App Store. This project provides a minimal but complete scaffold with webhook endpoints, request authentication, and stub services ready to be implemented with your payment provider.
Note: This project is a simplified version of a production application. All business logic is provided as documented stubs — you need to implement the actual database persistence and payment provider integration.
- 1. Getting Started
- 2. Architecture Overview
- 3. Application Lifecycle Flow
- 4. Webhook Endpoints
- 5. Webhook Authentication
- 6. Shoper REST API Endpoints
- 7. Payment Channels
- 7. Configuration
- 8. What You Need to Implement
- 9. Publishing & Versioning
- 10. Links & Documentation
- 11. Snippets & Modules — Embedding JavaScript in the Shop Frontend
Before you can create applications for the Shoper App Store, you need to register as a partner:
- Go to Shoper Partner Program — App Creator
- Fill out the registration form (company data, NIP required)
- After approval, you will receive:
- Access to the developer panel for managing your applications
- A dedicated technical advisor for support
- Access to the API documentation and sandbox environment for testing
- Log in to the Shoper developer panel
- Create a new application — provide:
- Application name and description
- Application URL — the base URL where your app is hosted
- Webhook URL — the endpoint that will receive lifecycle events (in this template:
/api/v1/webhook/application) - Permissions/scopes — which shop resources your app needs access to (e.g. orders, payments, metafields)
- After creating the application, you will receive:
app_id— unique application identifierapp_secret(App Store secret) — used for HMAC signature verification and OAuth token exchangewebhook_secret— used specifically for webhook request signature verification
- Store these credentials securely in your
.env.localfile:APPLICATION_APP_STORE_SECRET=your_app_secret_here WEBHOOK_SECRET=your_webhook_secret_here
Add a link in the administration panel to display your application's settings iframe. Set the Object to Payments, Action to Edit, and Place to Settings:
Add a link so the application appears in the shop admin under Order Processing → Payment Methods:
Your application needs the following API permissions (privileges) to communicate with the shop:
Languages — read permission for retrieving language information:
Currencies — read permission for retrieving currency codes:
Orders — read permission for downloading order data:
Manage your own payments — read + create + edit + delete permission for payment method management:
In the developer panel, configure the following webhook URLs pointing to your deployed application:
| Event | Webhook URL | Description |
|---|---|---|
| Application lifecycle | https://your-app.com/api/v1/webhook/application |
Install, upgrade, uninstall events |
| Transaction | https://your-app.com/api/v1/transaction/webhook |
Payment transaction processing |
| Refund | https://your-app.com/api/v1/refund/webhook |
Refund processing |
Transaction webhook configuration — set the Event to "Order - transaction added":
Refund webhook configuration — set the Event to "Order - refund added":
# Clone the repository
git clone <repository-url>
cd appstore-sf-example-payment-app/app
# Install dependencies
composer install
# Configure environment variables
cp .env .env.local
# Edit .env.local with your Shoper App Store credentials (app_secret, webhook_secret)
# Start the development server
symfony server:start
# or with Docker
docker-compose up -d- Shoper provides a sandbox/development environment for testing your application before publishing
- Use tools like ngrok to expose your local development server so Shoper can reach your webhook endpoints
- Verify that webhook signatures are validated correctly by checking the
HashValidatorlogic - Test the full install → transaction → refund flow before submitting for review
src/
├── Controller/ # HTTP entry points (webhook endpoints, iframe API)
│ ├── ApplicationWebhookController # App lifecycle: install, upgrade, uninstall
│ ├── TransactionWebhookController # Payment transaction processing
│ ├── RefundWebhookController # Refund processing
│ └── FrontController # Iframe view & checkout transaction URL retrieval
├── Enum/
│ └── ApplicationWebhookType # Lifecycle event types (install/upgrade/uninstall)
├── Security/ # Request authentication layer
│ ├── HashValidator # HMAC signature verification algorithms
│ ├── WebhookAuthenticator # Symfony authenticator for webhook requests
│ └── IframeAuthenticator # Symfony authenticator for iframe requests
└── Service/ # Business logic (stub implementations)
├── ApplicationWebhookResolver # App lifecycle handler (token exchange, metafield sync)
├── PaymentService # Payment method creation in the shop
├── TransactionService # Transaction processing & URL management
└── RefundService # Refund processing
Request flow: Incoming HTTP request → Symfony Security (WebhookAuthenticator / IframeAuthenticator) → HashValidator verifies HMAC signature → Controller → Service
When a shop installs your application from the Shoper App Store, the following flow occurs:
1. Shop installs app
└─► Shoper sends POST /api/v1/webhook/application?action=install
└─► ApplicationWebhookResolver::install()
├── Create shop entity in database
├── Exchange auth_code for OAuth token (POST /webapi/rest/oauth/token)
└── Synchronize metafields (GET /webapi/rest/metafields/system, /metafield-values)
2. Payment method creation (developer-implemented, not a webhook)
└─► Your application logic calls PaymentService::createPayment()
├── Register payment in shop (POST /webapi/rest/payments)
│ Important: The `notify` field in the request body MUST contain the
│ `{payment_form}` tag. This tag is rendered by Shoper on the order
│ summary page after checkout, producing a <script id="paymentForm">
│ JSON block with transaction details (see section 4.4).
│ Your JavaScript (embedded via snippets or modules — see section 12)
│ reads this JSON to obtain the transaction context and redirect the
│ customer to the payment provider.
├── Update metafield values (PUT /webapi/rest/metafield-values/{id})
└── If the payment supports multiple channels, register them as well.
Note: This step is NOT triggered by a webhook. It must be implemented
as part of your application's own flow (e.g. after install, via CLI command,
or through an admin panel action). PaymentService is only an example
showing which API calls are needed.
3. Customer places an order — two complementary transaction creation scenarios:
Scenario A — Webhook-driven (order-transaction.create):
└─► Shoper sends the order-transaction.create webhook to the app's configured URL
└─► Partner app receives the webhook and creates a transaction in the payment provider
└── Transaction URL is stored, ready to be fetched by the shop's JS
Scenario B — On-demand (JavaScript-initiated):
└─► After checkout, the shop's order summary page renders {payment_form} JSON
└─► Your JavaScript (snippet or module) reads the JSON and calls your app
└─► Partner app creates a transaction on the fly and returns the payment URL
Both scenarios complement each other as a fallback strategy:
• If the JS request arrives first and creates the transaction, the subsequent
webhook should only log the event (no duplicate transaction).
• If the webhook arrives first, the JS endpoint should detect the existing
transaction and return its URL without re-creating it.
4. Customer is redirected to payment
└─► JavaScript on the order summary page obtains the payment URL from your app
└── Redirects the customer to the payment provider's paywall
└── After payment, the provider redirects back to paymentSuccessShopLink
or paymentFailShopLink
5. Shop admin requests refund
└─► Shoper sends POST /api/v1/refund/webhook
└─► RefundService::createRefund()
└── Process refund in payment provider
6. Shop upgrades app version
└─► Shoper sends POST /api/v1/webhook/application?action=upgrade
└─► ApplicationWebhookResolver::upgrade()
├── Update version in database
└── Re-synchronize metafields
7. Shop uninstalls app
└─► Shoper sends POST /api/v1/webhook/application?action=uninstall
└─► ApplicationWebhookResolver::uninstall()
└── Mark shop as uninstalled (soft delete)
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/v1/webhook/application |
POST, GET | Webhook HMAC | Application lifecycle (install/upgrade/uninstall) |
/api/v1/transaction/webhook |
POST | Webhook HMAC | Payment transaction processing |
/api/v1/refund/webhook |
POST | Webhook HMAC | Refund processing |
/api/v1/front/view |
GET | Iframe HMAC | Renders the application's admin panel iframe |
/api/v1/front/transaction-url |
POST | Webhook HMAC | Returns the payment redirect URL (called by checkout JS) |
Handles install, upgrade, and uninstall events. The action field determines the event type.
Headers:
| Header | Description |
|---|---|
x-webhook-sha1 |
HMAC signature for verification |
x-webhook-id |
Unique webhook request ID |
x-shop-license |
Shop identifier (license ID) |
Install request payload:
{
"action": "install",
"auth_code": "one-time-authorization-code",
"shop": "shop-license-id",
"shop_url": "https://my-shop.shoper.pl",
"application_version": 1
}Upgrade request payload:
{
"action": "upgrade",
"shop": "shop-license-id",
"application_version": 2
}Uninstall request payload:
{
"action": "uninstall",
"shop": "shop-license-id"
}Response:
| Status | Description |
|---|---|
201 Created |
Install processed successfully |
200 OK |
Upgrade/uninstall processed successfully |
409 Conflict |
Processing error (returns error message) |
405 Method Not Allowed |
Unknown action value |
Sent by the Shoper shop when a customer initiates a payment.
Headers:
| Header | Description |
|---|---|
x-shop-license |
Shop identifier (license ID) |
x-webhook-sha1 |
HMAC signature for verification |
x-webhook-id |
Unique webhook request ID |
Request payload:
{
"currency_id": "1",
"currency_value": "99.99",
"order_id": "12345",
"payment_data": "",
"payment_fail_shop_link": "https://my-shop.shoper.pl/payment/fail",
"payment_id": "1",
"payment_success_shop_link": "https://my-shop.shoper.pl/payment/success",
"transaction_id": "67890"
}Response:
| Status | Description |
|---|---|
201 Created |
Transaction processed successfully |
500 Internal Server Error |
Processing error |
Sent by the Shoper shop when a shop admin initiates a refund.
Headers: Same as transaction webhook (x-shop-license, x-webhook-sha1, x-webhook-id).
Request payload:
{
"refund_id": 1,
"transaction_id": 67890,
"status": "new",
"currency_id": 1,
"currency_value": "99.99",
"comment": "Customer requested refund",
"date": "2026-03-16 14:00:00"
}Response:
| Status | Description |
|---|---|
201 Created |
Refund processed successfully |
500 Internal Server Error |
Processing error |
Called by a JavaScript script running on the shop's order summary page (not the admin iframe).
After a customer places an order and reaches the order summary page, the shop renders a
{payment_form} block containing transaction context. Your JavaScript reads this data and
sends a request to this endpoint to obtain the payment redirect URL. The returned URL is then
used to redirect the customer to the payment provider's paywall.
There are two complementary scenarios for creating the transaction in the partner's system (see section 3 — Application Lifecycle Flow for details):
- Webhook-driven — the partner app receives an
order-transaction.createwebhook and pre-creates the transaction. When this endpoint is called, it returns the already-stored URL. - On-demand — the partner app creates the transaction when this endpoint is called for the first time, then returns the newly generated URL.
Both approaches can coexist as a fallback strategy, ensuring the customer always gets a payment link regardless of webhook delivery timing.
Request payload:
{
"shop": "shop-license-id",
"transactionId": "67890"
}Response:
{
"transaction_url": "https://payment-provider.com/pay/abc123"
}When creating a payment via POST /webapi/rest/payments
(documentation),
the notify field must contain the {payment_form} tag. Shoper replaces this tag on
the order summary page with a <script> block containing transaction context:
<script id="paymentForm" type="application/json">
{
"channelId": "",
"transactionId": "11",
"paymentData": "",
"paymentSuccessShopLink": "https://my-shop.shoper.pl/basket/finished/status/ok/paymentid/22/orderid/12/",
"paymentFailShopLink": "https://my-shop.shoper.pl/basket/finished/status/fail/paymentid/22/orderid/12/",
"applicationId": "de1955d73fe80892f0d3384982923373",
"shop": "302a2332f924dc6a30fde449db0668f71580bbca",
"time": "1773396430",
"hash": "b2f521cc91ae2046cb612db909163339..."
}
</script>| Field | Description |
|---|---|
channelId |
ID of the payment channel selected by the customer at checkout (if channels were registered via API) |
transactionId |
Shop's internal transaction identifier |
paymentData |
Custom data passed via setPaymentData() on earlier checkout steps |
paymentSuccessShopLink |
URL where the payment provider should redirect on successful payment |
paymentFailShopLink |
URL where the payment provider should redirect on failed payment |
applicationId |
Your application's ID in the Shoper App Store (developer panel) |
shop |
Unique shop identifier — sent during application installation |
time |
Unix timestamp of when the data was generated |
hash |
HMAC-SHA512 signature for verifying the request authenticity (see below) |
The hash field allows your application to verify that the request originates from a legitimate
Shoper shop. It is computed using HMAC-SHA512 with your application secret as the key.
Hash calculation algorithm:
1. Construct the data array with these keys (in this exact order):
shop, time, transactionId
2. Join as query string:
"shop={value}&time={value}&transactionId={value}"
3. Compute HMAC-SHA512:
hash = HMAC-SHA512(data: query_string, secret: application_secret)
PHP reference implementation:
$hashData = [
"shop" => $view->shop,
"time" => $view->time,
"transactionId" => $view->transactionId,
];
public function signData(array $params): string
{
$parameters = [];
foreach ($params as $k => $v) {
$parameters[] = $k . "=" . $v;
}
$p = join("&", $parameters);
return hash_hmac('sha512', $p, 'your_application_secret');
}When your application receives a request from the shop's JavaScript, verify the hash by
recomputing it with the provided shop, time, and transactionId values and comparing
the result to the received hash.
Renders the application's admin panel view inside the Shoper admin iframe.
Query parameters (added by Shoper, used for authentication):
| Parameter | Description |
|---|---|
admin-id |
ID of the logged-in admin user |
admin-name |
Name of the logged-in admin user |
place |
Location in the admin panel where the iframe is displayed |
shop |
Shop identifier |
timestamp |
Request timestamp |
admin-hash |
HMAC-SHA512 signature of the above parameters |
Response: HTML page rendered from templates/front/index.html.twig.
All webhook and iframe requests from Shoper are signed with HMAC signatures to prevent tampering. The application verifies these signatures before processing any request. This logic is implemented in src/Security/HashValidator.php.
Used for: application lifecycle, transaction, refund webhooks, and checkout transaction URL requests.
Verification algorithm:
1. Extract headers: x-webhook-sha1, x-webhook-id, x-shop-license
2. Compute HMAC-SHA512 key:
key = HMAC-SHA512(
data: "{x-shop-license}:{webhook_secret}",
secret: app_secret
)
3. Compute expected hash:
hash = SHA1("{x-webhook-id}:{key}:{request_body}")
4. Compare hash with x-webhook-sha1 header
5. If they match → request is authentic
Relevant classes:
HashValidator::verifyWebhookRequest()— implements the algorithm aboveWebhookAuthenticator— Symfony security authenticator that callsHashValidator
Used for: iframe view endpoint only.
Verification algorithm:
1. Extract query parameters: admin-id, admin-name, place, shop, timestamp
2. Sort parameters alphabetically by key
3. Join as: "admin-id={value}&admin-name={value}&place={value}&shop={value}×tamp={value}"
4. Compute expected hash:
hash = HMAC-SHA512(
data: joined_parameters,
secret: app_secret
)
5. Compare hash with admin-hash query parameter
6. If they match → request is authentic
Relevant classes:
HashValidator::verifyApplicationIframeRequest()— implements the algorithm aboveIframeAuthenticator— Symfony security authenticator that callsHashValidator
These are the Shoper shop API endpoints that your application needs to call (using the OAuth token obtained during install):
| Endpoint | Method | Purpose |
|---|---|---|
/webapi/rest/oauth/token |
POST | Exchange authorization code for OAuth access token |
/webapi/rest/metafields/system |
GET | Fetch system metafield definitions |
/webapi/rest/metafield-values |
GET | Fetch metafield values |
/webapi/rest/metafield-values/{id} |
PUT | Update metafield values |
/webapi/rest/payments |
POST | Create a payment method in the shop |
/webapi/rest/payments-channels |
POST | Create payment channels for a payment method |
/webapi/rest/orders/{id} |
GET | Fetch order details (optional) |
All API calls to a shop require the OAuth access_token in the Authorization: Bearer {token} header.
After creating a payment method in the shop (see step 2 in section 3), you can optionally register payment channels — individual payment options such as specific banks, credit/debit cards, BLIK, or Google Pay. When channels are added, they are displayed to the customer during checkout, allowing them to select a specific payment option.
Use the Shoper REST API endpoint to add channels to an existing payment method:
POST {shopUrl}/webapi/rest/payments-channels
Each channel should include a name, optional icon, and any configuration needed for your payment
provider to identify which payment option the customer selected. The selected channel ID is then
available in the channelId field of the paymentForm JSON rendered on the order summary page
(see section 4.4).
For the full API reference, see the Shoper Payments Channels documentation.
The application requires the following environment variables:
| Variable | Description |
|---|---|
APPLICATION_APP_STORE_SECRET |
Application secret from the Shoper App Store developer panel — used for HMAC verification and OAuth |
WEBHOOK_SECRET |
Webhook secret configured for this application — used for webhook signature verification |
These are injected into HashValidator via Symfony's service container.
- PHP >= 8.5
- Symfony 7.4
- Composer
This template provides the webhook infrastructure and request authentication. You need to implement:
- Database layer — Entity classes and Doctrine mappings for shops, payments, transactions
- OAuth token management — Token exchange and refresh logic in
ApplicationWebhookResolver - Metafield synchronization — Fetching and storing metafield data from the Shoper API
- Payment provider integration — Connecting to your payment provider (e.g. Stripe, PayU, Przelewy24) in
TransactionServiceandRefundService - Payment method creation — Registering your payment method in the shop via
PaymentService
- Once your application is ready, submit it through the developer panel for Shoper's review
- Provide a clear description, screenshots, and documentation for shop administrators
- Each new submission must pass Shoper's review and approval before it becomes available to shops
Each application version has a version number (passed as application_version in webhook payloads):
| Action | How |
|---|---|
| Set initial version | During application registration in the developer panel |
| Release an update | Increment the version number and submit the new version for review |
| Handle upgrades | When a shop upgrades, Shoper sends an upgrade webhook with the new application_version — your app handles it in ApplicationWebhookResolver::upgrade() |
| Changelog | Provide a description of changes with each version update in the developer panel |
Once published, the application lifecycle is managed automatically by Shoper:
| Shop Action | Webhook Sent | Your App Should |
|---|---|---|
| Installs your app | action=install with auth_code, shop, shop_url, application_version |
Create shop record, exchange auth code for OAuth token, sync metafields |
| Upgrades to new version | action=upgrade with shop, application_version |
Update version, re-sync metafields |
| Uninstalls your app | action=uninstall with shop |
Mark shop as uninstalled (soft delete) |
After the customer places an order, the order summary page renders the {payment_form} JSON
block (see section 4.4).
Your application needs JavaScript running on this page to:
- Read the
paymentFormJSON data - Send a request to your application's backend to obtain the payment redirect URL
- Redirect the customer to the payment provider's paywall
The method for embedding this JavaScript depends on which shop template engine is in use: snippets for RWD templates, or custom modules for Storefront templates.
Important: Your JavaScript should include a path guard to ensure it only runs on the relevant pages (order summary and payment panel):
const path = window.location.pathname; if (!/\/basket\/done\/+/.test(path) && !/\/panel\/payment\/+/.test(path)) { return; }
For shops using RWD templates, embed your JavaScript using snippets.
- In your application configuration in the developer panel, add a snippet
- Select the "Stopka strony" (Page Footer) section — this ensures the script loads on every page
- Your snippet code should include the path guard above, then read and process the
paymentFormdata
For more information on snippets, see the Shoper Snippets documentation.
For shops using Storefront templates, embed your JavaScript using custom modules ("own modules").
Manual setup in the shop admin:
- Navigate to Look and content → Store appearance → Current theme → Own modules
- Add a new custom module containing your JavaScript code
Automatic setup via application configuration:
To have the module installed automatically when a shop installs your application, add the module definition in your application configuration in the developer panel. This way, every shop that installs your app will have the module created automatically.
Your module code should include the same path guard and payment flow logic as the snippet version.
Below is a high-level outline of the JavaScript that should run on the order summary page:
(function () {
const path = window.location.pathname;
if (!/\/basket\/done\/+/.test(path) && !/\/panel\/payment\/+/.test(path)) {
return;
}
const paymentFormEl = document.getElementById('paymentForm');
if (!paymentFormEl) {
return;
}
const paymentData = JSON.parse(paymentFormEl.textContent);
// Send a request to your application's backend to get the payment URL.
// Include paymentData.shop, paymentData.transactionId, paymentData.hash,
// and paymentData.time for authentication.
//
// Your backend should:
// 1. Verify the hash (see section 4.4 — Hash Verification)
// 2. Find or create the transaction in the payment provider
// 3. Return the payment redirect URL
//
// On success, redirect the customer:
// window.location.href = response.transaction_url;
})();| Resource | URL |
|---|---|
| Shoper Partner Program — App Creator | https://www.shoper.pl/program-partnerski/tworca-aplikacji |
| Shoper App Store — Application Registration | https://developers.shoper.pl/developers/appstore/registration/application |
| Shoper API — Getting Started | https://developers.shoper.pl/developers/api/getting-started |
| Shoper API — Resources | https://developers.shoper.pl/developers/api/resources |
| Shoper API — Payments (insert) | https://developers.shoper.pl/developers/api/resources/payments/insert |
| Shoper API — Payments Channels | https://developers.shoper.pl/developers/api/resources/payments-channels |
| Shoper Webhooks — Introduction | https://developers.shoper.pl/developers/webhooks/introduction |
| Shoper Webhooks — order-transaction.create | https://developers.shoper.pl/developers/webhooks/methods/order-transaction-create |
| Shoper Snippets — Introduction | https://developers.shoper.pl/developers/snippets/introduction |










