# Symfony Subscription + Przelewy24 — Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add Przelewy24 payment flow to tropo.city so users can buy subscription codes that unlock paid missions in the PWA.

**Architecture:** PurchaseController handles the purchase page and P24 callbacks. P24Service encapsulates API communication. PwaSubscription entity is extended with payment fields. PwaSyncController is modified to return subscription status in sync response and fix activation logic for device transfer.

**Tech Stack:** Symfony 8, Doctrine ORM, Twig, symfony/http-client, symfony/mailer (Resend)

**Spec:** `../tropo-pwa/docs/superpowers/specs/2026-03-17-account-paid-packages-design.md`

---

## File Structure

| Action | File | Responsibility |
|---|---|---|
| Modify | `src/Entity/Pwa/PwaSubscription.php` | Add `package_id`, `p24_order_id`, `buyer_email`, `purchased_at` fields |
| Create | `src/Service/P24Service.php` | Przelewy24 API: register transaction, verify notification |
| Create | `src/Controller/PurchaseController.php` | Purchase page, P24 return URL, P24 webhook |
| Modify | `src/Controller/Api/PwaSyncController.php` | Return subscription in sync, fix activate for device transfer |
| Create | `templates/purchase/index.html.twig` | Purchase page with email form |
| Create | `templates/purchase/success.html.twig` | Success page showing activation code |
| Create | `templates/purchase/failure.html.twig` | Payment failed page |
| Create | `templates/email/purchase_confirmation.html.twig` | Email with activation code |
| Create | `migrations/VersionXXX.php` | Schema migration (auto-generated) |

---

## Chunk 1: Entity & Migration

### Task 1: Extend PwaSubscription entity

**Files:**
- Modify: `src/Entity/Pwa/PwaSubscription.php`

- [ ] **Step 1: Read current entity**

Read `src/Entity/Pwa/PwaSubscription.php` to understand current fields.

- [ ] **Step 2: Add new fields**

Add the following ORM-mapped properties:

```php
#[ORM\Column(length: 20, options: ['default' => 'base'])]
private string $packageId = 'base';

#[ORM\Column(length: 50, nullable: true)]
private ?string $p24OrderId = null;

#[ORM\Column(length: 255, nullable: true)]
private ?string $buyerEmail = null;

#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $purchasedAt = null;
```

- [ ] **Step 3: Add getters and setters**

```php
public function getPackageId(): string { return $this->packageId; }
public function setPackageId(string $packageId): static { $this->packageId = $packageId; return $this; }

public function getP24OrderId(): ?string { return $this->p24OrderId; }
public function setP24OrderId(?string $p24OrderId): static { $this->p24OrderId = $p24OrderId; return $this; }

public function getBuyerEmail(): ?string { return $this->buyerEmail; }
public function setBuyerEmail(?string $buyerEmail): static { $this->buyerEmail = $buyerEmail; return $this; }

public function getPurchasedAt(): ?\DateTimeInterface { return $this->purchasedAt; }
public function setPurchasedAt(?\DateTimeInterface $purchasedAt): static { $this->purchasedAt = $purchasedAt; return $this; }
```

- [ ] **Step 4: Change status default and add code generator**

Change status default from `'free'` to `'pending'`. Add a static method for generating codes:

```php
#[ORM\Column(length: 20, options: ['default' => 'pending'])]
private string $status = 'pending';

public static function generateCode(): string
{
    $chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I/L
    $part1 = '';
    $part2 = '';
    for ($i = 0; $i < 4; $i++) {
        $part1 .= $chars[random_int(0, strlen($chars) - 1)];
        $part2 .= $chars[random_int(0, strlen($chars) - 1)];
    }
    return "TROPO-{$part1}-{$part2}";
}
```

- [ ] **Step 5: Remove UNIQUE constraint on device_id**

The `device_id` column currently has a UNIQUE constraint. Since we now support transferring subscriptions (multiple subscriptions can have the same device_id over time, and a device_id can be null for unactivated codes), change:

```php
// From:
#[ORM\Column(length: 36, unique: true)]
// To:
#[ORM\Column(length: 36, nullable: true)]
```

Note: `activation_code` stays UNIQUE.

- [ ] **Step 6: Generate and run migration**

```bash
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
```

- [ ] **Step 7: Commit**

```bash
git add src/Entity/Pwa/PwaSubscription.php migrations/
git commit -m "feat: extend PwaSubscription with payment fields, code generator"
```

---

## Chunk 2: P24 Service

### Task 2: Create P24Service

**Files:**
- Create: `src/Service/P24Service.php`

Przelewy24 REST API v1 integration. Uses `symfony/http-client`.

- [ ] **Step 1: Add P24 env variables**

In `.env`, add:

```
P24_MERCHANT_ID=0
P24_POS_ID=0
P24_CRC_KEY=change_me
P24_API_KEY=change_me
P24_SANDBOX=true
```

- [ ] **Step 2: Create P24Service**

Create `src/Service/P24Service.php`:

```php
<?php

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;

class P24Service
{
    private string $baseUrl;
    private int $merchantId;
    private int $posId;
    private string $crcKey;
    private string $apiKey;

    public function __construct(
        private readonly HttpClientInterface $httpClient,
        string $p24MerchantId,
        string $p24PosId,
        string $p24CrcKey,
        string $p24ApiKey,
        string $p24Sandbox,
    ) {
        $this->merchantId = (int) $p24MerchantId;
        $this->posId = (int) $p24PosId;
        $this->crcKey = $p24CrcKey;
        $this->apiKey = $p24ApiKey;
        $this->baseUrl = $p24Sandbox === 'true'
            ? 'https://sandbox.przelewy24.pl'
            : 'https://secure.przelewy24.pl';
    }

    public function registerTransaction(
        string $sessionId,
        int $amountInGrosze,
        string $currency,
        string $description,
        string $email,
        string $returnUrl,
        string $notifyUrl,
    ): string {
        $sign = hash('sha384', json_encode([
            'sessionId' => $sessionId,
            'merchantId' => $this->merchantId,
            'amount' => $amountInGrosze,
            'currency' => $currency,
            'crc' => $this->crcKey,
        ], JSON_THROW_ON_ERROR));

        $response = $this->httpClient->request('POST', $this->baseUrl . '/api/v1/transaction/register', [
            'auth_basic' => [$this->posId, $this->apiKey],
            'json' => [
                'merchantId' => $this->merchantId,
                'posId' => $this->posId,
                'sessionId' => $sessionId,
                'amount' => $amountInGrosze,
                'currency' => $currency,
                'description' => $description,
                'email' => $email,
                'country' => 'PL',
                'language' => 'pl',
                'urlReturn' => $returnUrl,
                'urlStatus' => $notifyUrl,
                'sign' => $sign,
            ],
        ]);

        $data = $response->toArray();
        $token = $data['data']['token'];

        return $this->baseUrl . '/trnRequest/' . $token;
    }

    public function verifyTransaction(
        string $sessionId,
        int $orderId,
        int $amountInGrosze,
        string $currency,
    ): bool {
        $sign = hash('sha384', json_encode([
            'sessionId' => $sessionId,
            'orderId' => $orderId,
            'amount' => $amountInGrosze,
            'currency' => $currency,
            'crc' => $this->crcKey,
        ], JSON_THROW_ON_ERROR));

        $response = $this->httpClient->request('PUT', $this->baseUrl . '/api/v1/transaction/verify', [
            'auth_basic' => [$this->posId, $this->apiKey],
            'json' => [
                'merchantId' => $this->merchantId,
                'posId' => $this->posId,
                'sessionId' => $sessionId,
                'orderId' => $orderId,
                'amount' => $amountInGrosze,
                'currency' => $currency,
                'sign' => $sign,
            ],
        ]);

        return $response->getStatusCode() === 200;
    }
}
```

- [ ] **Step 3: Register service bindings**

In `config/services.yaml`, add argument bindings for P24 env vars:

```yaml
services:
    App\Service\P24Service:
        arguments:
            $p24MerchantId: '%env(P24_MERCHANT_ID)%'
            $p24PosId: '%env(P24_POS_ID)%'
            $p24CrcKey: '%env(P24_CRC_KEY)%'
            $p24ApiKey: '%env(P24_API_KEY)%'
            $p24Sandbox: '%env(P24_SANDBOX)%'
```

- [ ] **Step 4: Commit**

```bash
git add src/Service/P24Service.php config/services.yaml .env
git commit -m "feat: add P24Service for Przelewy24 API integration"
```

---

## Chunk 3: Purchase Controller & Templates

### Task 3: Create PurchaseController

**Files:**
- Create: `src/Controller/PurchaseController.php`

- [ ] **Step 1: Create the controller**

```php
<?php

namespace App\Controller;

use App\Entity\Pwa\PwaSubscription;
use App\Service\P24Service;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Uid\Uuid;

class PurchaseController extends AbstractController
{
    private const PRICE_GROSZE = 4900; // 49.00 PLN
    private const CURRENCY = 'PLN';

    #[Route('/kup', name: 'purchase_index', methods: ['GET', 'POST'])]
    public function index(
        Request $request,
        EntityManagerInterface $em,
        P24Service $p24,
    ): Response {
        if ($request->isMethod('POST')) {
            $email = trim($request->request->getString('email'));
            if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
                return $this->render('purchase/index.html.twig', [
                    'error' => 'Podaj prawidłowy adres email.',
                    'email' => $email,
                ]);
            }

            $subscription = new PwaSubscription();
            $subscription->setActivationCode(PwaSubscription::generateCode());
            $subscription->setPackageId('base');
            $subscription->setBuyerEmail($email);
            $subscription->setStatus('pending');

            $em->persist($subscription);
            $em->flush();

            $sessionId = 'tropo_' . $subscription->getId() . '_' . Uuid::v4()->toRfc4122();

            $redirectUrl = $p24->registerTransaction(
                sessionId: $sessionId,
                amountInGrosze: self::PRICE_GROSZE,
                currency: self::CURRENCY,
                description: 'TROPO — Pakiet Misji',
                email: $email,
                returnUrl: $this->generateUrl('purchase_return', [
                    'id' => $subscription->getId(),
                ], UrlGeneratorInterface::ABSOLUTE_URL),
                notifyUrl: $this->generateUrl('purchase_notify', [], UrlGeneratorInterface::ABSOLUTE_URL),
            );

            $subscription->setP24OrderId($sessionId);
            $em->flush();

            return $this->redirect($redirectUrl);
        }

        return $this->render('purchase/index.html.twig', [
            'error' => null,
            'email' => '',
        ]);
    }

    #[Route('/kup/powrot/{id}', name: 'purchase_return', methods: ['GET'])]
    public function returnFromP24(
        PwaSubscription $subscription,
    ): Response {
        if ($subscription->getStatus() === 'active') {
            return $this->render('purchase/success.html.twig', [
                'code' => $subscription->getActivationCode(),
            ]);
        }

        // P24 webhook hasn't fired yet — show waiting/retry page
        return $this->render('purchase/success.html.twig', [
            'code' => $subscription->getActivationCode(),
            'pending' => true,
        ]);
    }

    #[Route('/api/p24/notify', name: 'purchase_notify', methods: ['POST'])]
    public function notify(
        Request $request,
        EntityManagerInterface $em,
        P24Service $p24,
        MailerInterface $mailer,
    ): Response {
        $data = json_decode($request->getContent(), true);
        if (!$data) {
            return new Response('Invalid payload', 400);
        }

        $sessionId = $data['sessionId'] ?? '';
        $orderId = (int) ($data['orderId'] ?? 0);
        $amount = (int) ($data['amount'] ?? 0);

        $subscription = $em->getRepository(PwaSubscription::class)
            ->findOneBy(['p24OrderId' => $sessionId]);

        if (!$subscription) {
            return new Response('Subscription not found', 404);
        }

        if ($subscription->getStatus() === 'active') {
            return new Response('Already processed', 200);
        }

        $verified = $p24->verifyTransaction(
            sessionId: $sessionId,
            orderId: $orderId,
            amountInGrosze: $amount,
            currency: self::CURRENCY,
        );

        if (!$verified) {
            return new Response('Verification failed', 400);
        }

        $subscription->setStatus('active');
        $subscription->setPurchasedAt(new \DateTime());
        $em->flush();

        // Send confirmation email
        if ($subscription->getBuyerEmail()) {
            $email = (new Email())
                ->to($subscription->getBuyerEmail())
                ->subject('TROPO — Twój kod aktywacyjny')
                ->html($this->renderView('email/purchase_confirmation.html.twig', [
                    'code' => $subscription->getActivationCode(),
                ]));

            $mailer->send($email);
        }

        return new Response('OK', 200);
    }
}
```

- [ ] **Step 2: Exclude P24 notify from CORS**

In `config/packages/nelmio_cors.yaml`, the CORS config applies to `^/api/pwa/`. The P24 webhook is at `/api/p24/notify` which doesn't match this prefix, so it's already excluded. No change needed.

- [ ] **Step 3: Exclude P24 notify from maintenance mode**

Check `MaintenanceSubscriber` — if it blocks all routes, add exclusion for `/api/p24/`. Read the file first.

- [ ] **Step 4: Verify syntax**

```bash
php bin/console lint:container
```

- [ ] **Step 5: Commit**

```bash
git add src/Controller/PurchaseController.php
git commit -m "feat: add PurchaseController with P24 payment flow"
```

---

### Task 4: Create Twig templates for purchase flow

**Files:**
- Create: `templates/purchase/index.html.twig`
- Create: `templates/purchase/success.html.twig`
- Create: `templates/purchase/failure.html.twig`
- Create: `templates/email/purchase_confirmation.html.twig`

- [ ] **Step 1: Read base.html.twig for layout structure**

Read `templates/base.html.twig` to understand the extends pattern and available blocks.

- [ ] **Step 2: Create purchase/index.html.twig**

Purchase page with email form. Extend base layout, show package info and price, email input, submit button.

```twig
{% extends 'base.html.twig' %}

{% block title %}Kup pakiet — TROPO{% endblock %}

{% block body %}
<div class="max-w-lg mx-auto px-5 py-12">
    <h1 class="text-3xl font-bold text-gray-900 mb-2">Pakiet Misji</h1>
    <p class="text-gray-500 mb-8">Odblokuj ponad 120 dodatkowych misji dla wszystkich postaci!</p>

    <div class="bg-white border-2 border-amber-400 rounded-2xl p-6 mb-8">
        <div class="flex items-baseline justify-between mb-4">
            <span class="text-2xl font-bold text-gray-900">49 zł</span>
            <span class="text-sm text-gray-500">jednorazowo, na zawsze</span>
        </div>
        <ul class="space-y-2 text-gray-700 text-sm">
            <li>&#10003; 120+ misji z 4 postaciami</li>
            <li>&#10003; Misje domowe i terenowe</li>
            <li>&#10003; Nowe odznaki do zdobycia</li>
            <li>&#10003; Bez limitu czasu</li>
        </ul>
    </div>

    {% if error %}
        <div class="bg-red-50 text-red-700 rounded-xl px-4 py-3 mb-4 text-sm">{{ error }}</div>
    {% endif %}

    <form method="post" action="{{ path('purchase_index') }}">
        <label for="email" class="block text-sm font-medium text-gray-700 mb-2">
            Twój adres email (wyślemy na niego kod)
        </label>
        <input
            type="email"
            id="email"
            name="email"
            value="{{ email }}"
            required
            placeholder="jan@example.com"
            class="w-full border border-gray-300 rounded-xl py-3 px-4 mb-4 text-gray-900 focus:border-amber-400 focus:outline-none"
        />
        <button
            type="submit"
            class="w-full bg-gradient-to-r from-amber-400 to-orange-400 text-white font-bold text-lg py-4 rounded-2xl hover:from-amber-500 hover:to-orange-500 transition-colors"
        >
            Kup za 49 zł
        </button>
    </form>

    <p class="text-xs text-gray-400 mt-4 text-center">
        Płatność obsługiwana przez Przelewy24. Po opłaceniu otrzymasz kod aktywacyjny.
    </p>
</div>
{% endblock %}
```

- [ ] **Step 3: Create purchase/success.html.twig**

```twig
{% extends 'base.html.twig' %}

{% block title %}Zakup udany — TROPO{% endblock %}

{% block body %}
<div class="max-w-lg mx-auto px-5 py-12 text-center">
    <div class="text-6xl mb-4">&#127881;</div>
    <h1 class="text-3xl font-bold text-gray-900 mb-2">Dziękujemy za zakup!</h1>
    <p class="text-gray-500 mb-8">Twój kod aktywacyjny:</p>

    <div class="bg-white border-2 border-amber-400 rounded-2xl py-6 px-4 mb-6">
        <p class="text-3xl font-mono font-bold tracking-wider text-gray-900">{{ code }}</p>
    </div>

    <p class="text-sm text-gray-500 mb-2">
        Wpisz ten kod w aplikacji TROPO, aby odblokować nowe misje.
    </p>
    <p class="text-sm text-gray-500 mb-8">
        Kod został również wysłany na Twój adres email.
    </p>

    {% if pending is defined and pending %}
        <div class="bg-amber-50 text-amber-700 rounded-xl px-4 py-3 mb-4 text-sm">
            Płatność jest w trakcie potwierdzania. Odśwież stronę za chwilę.
        </div>
    {% endif %}

    <a href="/" class="text-amber-600 font-medium hover:underline">Wróć na stronę główną</a>
</div>
{% endblock %}
```

- [ ] **Step 4: Create purchase/failure.html.twig**

```twig
{% extends 'base.html.twig' %}

{% block title %}Płatność nieudana — TROPO{% endblock %}

{% block body %}
<div class="max-w-lg mx-auto px-5 py-12 text-center">
    <h1 class="text-3xl font-bold text-gray-900 mb-4">Płatność nie powiodła się</h1>
    <p class="text-gray-500 mb-8">Nie udało się przetworzyć płatności. Spróbuj ponownie.</p>
    <a href="{{ path('purchase_index') }}" class="inline-block bg-amber-400 text-white font-bold py-3 px-8 rounded-2xl hover:bg-amber-500 transition-colors">
        Spróbuj ponownie
    </a>
</div>
{% endblock %}
```

- [ ] **Step 5: Create email/purchase_confirmation.html.twig**

Follow the pattern from existing email templates (`templates/email/contact_confirmation.html.twig`).

```twig
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 32px 20px;">
    <h1 style="font-size: 24px; font-weight: bold; color: #1a1a1a; margin-bottom: 8px;">Dziękujemy za zakup!</h1>
    <p style="color: #6b7280; margin-bottom: 24px;">Twój kod aktywacyjny do aplikacji TROPO:</p>

    <div style="background: #fffbeb; border: 2px solid #fbbf24; border-radius: 16px; padding: 24px; text-align: center; margin-bottom: 24px;">
        <p style="font-size: 28px; font-family: monospace; font-weight: bold; letter-spacing: 2px; color: #1a1a1a; margin: 0;">{{ code }}</p>
    </div>

    <p style="color: #6b7280; font-size: 14px; margin-bottom: 8px;">
        Otwórz aplikację TROPO, wejdź w &quot;Odblokuj więcej misji&quot; i wpisz powyższy kod.
    </p>
    <p style="color: #9ca3af; font-size: 12px;">
        Zachowaj tę wiadomość — kod możesz użyć ponownie, np. po zmianie urządzenia.
    </p>
</div>
```

- [ ] **Step 6: Commit**

```bash
git add templates/purchase/ templates/email/purchase_confirmation.html.twig
git commit -m "feat: add purchase and email templates"
```

---

## Chunk 4: Sync & Activation Fixes

### Task 5: Modify PwaSyncController — return subscription in sync + fix activate

**Files:**
- Modify: `src/Controller/Api/PwaSyncController.php`

- [ ] **Step 1: Read current controller**

Read `src/Controller/Api/PwaSyncController.php`.

- [ ] **Step 2: Modify sync action to return subscription**

In the `sync` POST action, after the existing upsert logic, add a query for the device's active subscription and include it in the response:

```php
// After existing sync logic, before return:
$subscription = $em->getRepository(PwaSubscription::class)
    ->findOneBy(['deviceId' => $deviceId, 'status' => 'active']);

return $this->json([
    'ok' => true,
    'subscription' => $subscription ? ['package_id' => $subscription->getPackageId()] : null,
]);
```

- [ ] **Step 3: Fix activate action for device transfer**

Replace the current activation logic. The new logic should:
1. Find subscription by `activation_code` with status `active`
2. If not found → 404 "Nieprawidłowy kod"
3. If found and same `device_id` → return success (already active)
4. If found and different `device_id` → overwrite device_id (transfer), set `activated_at`
5. Return `{ valid: true, package_id: 'base' }`

```php
#[Route('/subscription/activate', methods: ['POST'])]
public function activateSubscription(Request $request, EntityManagerInterface $em): JsonResponse
{
    $data = json_decode($request->getContent(), true);
    $code = $data['code'] ?? '';
    $deviceId = $data['device_id'] ?? '';

    if (!$code || !$deviceId) {
        return $this->json(['valid' => false, 'error' => 'Brak kodu lub device_id'], 400);
    }

    $subscription = $em->getRepository(PwaSubscription::class)
        ->findOneBy(['activationCode' => $code, 'status' => 'active']);

    if (!$subscription) {
        return $this->json(['valid' => false, 'error' => 'Nieprawidłowy kod'], 404);
    }

    if ($subscription->getDeviceId() !== $deviceId) {
        $subscription->setDeviceId($deviceId);
        $subscription->setActivatedAt(new \DateTime());
        $em->flush();
    }

    return $this->json([
        'valid' => true,
        'package_id' => $subscription->getPackageId(),
    ]);
}
```

- [ ] **Step 4: Verify syntax**

```bash
php bin/console lint:container
```

- [ ] **Step 5: Commit**

```bash
git add src/Controller/Api/PwaSyncController.php
git commit -m "feat: sync returns subscription, activate supports device transfer"
```

---

## Chunk 5: Pricing Page Link + Final Wiring

### Task 6: Wire purchase into pricing page and navigation

**Files:**
- Modify: `templates/pricing/index.html.twig` or `templates/_partials/pricing_cards.html.twig`

- [ ] **Step 1: Read pricing templates**

Read the existing pricing templates to understand the structure.

- [ ] **Step 2: Update CTA button**

Change the "Pakiet Misji" CTA button from linking to `/pobierz` to linking to `/kup`:

```twig
<a href="{{ path('purchase_index') }}" class="...">Kup teraz</a>
```

- [ ] **Step 3: Verify the whole flow**

```bash
php bin/console cache:clear
php -S localhost:8000 -t public/
```

Visit:
- `/cennik` → verify "Kup teraz" button links to `/kup`
- `/kup` → verify form renders, email validation works
- Submit with test email → verify redirect to P24 sandbox (will fail without real P24 credentials, but URL should be correct)

- [ ] **Step 4: Commit**

```bash
git add templates/
git commit -m "feat: link pricing page to purchase flow"
```

---

## Summary

After implementation:
1. `/kup` — purchase page with email form
2. POST to P24 → redirect to payment gateway (BLIK, przelew, karta)
3. P24 webhook confirms payment → subscription becomes `active`, email sent with code
4. User enters code in PWA → `POST /api/pwa/subscription/activate` → device gets subscription
5. `POST /api/pwa/sync` returns `subscription.package_id` → PWA filters missions dynamically
6. Re-entering code on new device transfers subscription (old device loses access on next sync)

**Environment variables needed for production:**
```
P24_MERCHANT_ID=<from P24 panel>
P24_POS_ID=<from P24 panel>
P24_CRC_KEY=<from P24 panel>
P24_API_KEY=<from P24 panel>
P24_SANDBOX=false
MAILER_DSN=resend://<api-key>@default
```

**Testing with P24 sandbox:**
- Register at https://sandbox.przelewy24.pl
- Use test credentials from P24 sandbox panel
- Webhook needs public URL (use ngrok or similar for local testing)
