Developer guide

Integrating a payment plugin with UCP

This page walks you through making an existing Shopware payment plugin

3. Integrating a payment plugin with UCP

This page walks you through making an existing Shopware payment plugin (Stripe, Mollie, Adyen, PayPal, Klarna, …) appear as a UCP payment handler so AI agents can pay through it on behalf of buyers.

The payment plugin keeps its existing PaymentHandlerInterface for the storefront. To opt into UCP you implement one additional interface and tag a service.

3.1 The Trust Triangle (why an extra interface?)

UCP's payment architecture is built around a Trust Triangle between the business (your shop), the platform (the AI agent), and the payment credential provider (the PSP behind your payment plugin). The agent never touches the buyer's raw payment credentials; it only ever holds an opaque token issued by the PSP. The token then flows to your shop, which uses it through your existing PSP integration to capture funds.

mermaid
sequenceDiagram
    participant Buyer
    participant Agent as Platform agent
    participant PSP as PSP<br/>(Stripe, Mollie, …)
    participant Shop as Shopware shop
    participant Plugin as Your payment plugin

    Note over Agent,PSP: Tokenisation happens here, never on the shop
    Buyer->>Agent: "Pay with card ending 4242"
    Agent->>PSP: Tokenise raw PAN
    PSP-->>Agent: opaque token (e.g. tok_visa_123)

    Note over Agent,Shop: Token flows to the shop via UCP
    Agent->>Shop: POST /ucp/v1/checkout-sessions/{id}/complete<br/>{ instruments: [{ handler_id: "com.psp.tokenizer", credential: { token: "tok_visa_123" } }] }
    Shop->>Plugin: UcpPaymentHandlerInterface::prepareInstrument(payload)
    Plugin-->>Shop: { paymentMethodId, token }
    Shop->>PSP: capture(token, amount) via existing PaymentHandlerInterface
    PSP-->>Shop: ok
    Shop-->>Agent: 200 OK { order_id }

The interface you implement essentially translates between the UCP shape (handler_id + opaque credential) and your existing plugin's shape (Shopware paymentMethodId + token).

3.2 Implement UcpPaymentHandlerInterface

php
<?php declare(strict_types=1);

namespace Acme\StripeForShopware\Ucp;

use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Ucp\Payment\UcpPaymentHandlerDescriptor;
use Shopware\Core\Framework\Ucp\Payment\UcpPaymentHandlerInterface;
use Shopware\Core\Framework\Ucp\Payment\UcpPaymentInstrumentDescriptor;
use Shopware\Core\Framework\Ucp\UcpVersion;
use Shopware\Core\System\SalesChannel\SalesChannelContext;

#[Package('framework')]
final class StripeUcpHandler implements UcpPaymentHandlerInterface
{
    public const NAME_ID = 'com.stripe.tokenizer';

    public function __construct(
        private readonly StripeClient $stripe,
        private readonly StripeConfigRepository $configRepo,
        private readonly PaymentMethodResolver $paymentMethodResolver,
    ) {
    }

    public function getNameId(): string
    {
        return self::NAME_ID;
    }

    public function describe(SalesChannelContext $context): UcpPaymentHandlerDescriptor
    {
        $stripeConfig = $this->configRepo->forSalesChannel($context->getSalesChannelId());

        return new UcpPaymentHandlerDescriptor(
            id: 'stripe_' . $context->getSalesChannelId(),
            nameId: self::NAME_ID,
            version: UcpVersion::CURRENT,
            specUrl: 'https://docs.stripe.com/ucp/handler',
            schemaUrl: 'https://docs.stripe.com/ucp/schemas/handler.json',
            availableInstruments: [
                new UcpPaymentInstrumentDescriptor('card', constraints: [
                    'brands' => ['visa', 'mastercard', 'amex'],
                ]),
            ],
            config: [
                'publishable_key' => $stripeConfig->publishableKey,
                'environment' => $stripeConfig->isTestMode ? 'test' : 'live',
                'tokenization_specification' => [
                    'type' => 'PAYMENT_GATEWAY',
                    'parameters' => [
                        'gateway' => 'stripe',
                        'gatewayMerchantId' => $stripeConfig->accountId,
                    ],
                ],
            ],
        );
    }

    public function prepareInstrument(array $instrumentPayload, SalesChannelContext $context): array
    {
        $credentialType = $instrumentPayload['credential']['type'] ?? null;
        $token = $instrumentPayload['credential']['token'] ?? null;

        if ($token === null) {
            throw new InvalidArgumentException('Stripe handler requires credential.token');
        }

        // Optional: brand + last 4 for display in the order summary
        $display = $instrumentPayload['display'] ?? [];

        return [
            'paymentMethodId' => $this->paymentMethodResolver->resolveForHandler(
                StripeHandlerIdentifier::class,
                $context,
            ),
            'token' => $token,
            'displayBrand' => $display['brand'] ?? null,
            'displayLast4' => $display['last_digits'] ?? null,
        ];
    }

    public function supportsTokenisation(): bool
    {
        return true;
    }

    public function tokenize(string $type, array $credential, SalesChannelContext $context): ?array
    {
        // Optional: implement if your plugin offers in-protocol tokenisation
        // (less common today; most agents tokenise client-side).
        if ($type !== 'card') {
            return null;
        }

        $stripeToken = $this->stripe->tokens->create([
            'card' => [
                'number'    => $credential['number'],
                'exp_month' => $credential['exp_month'],
                'exp_year'  => $credential['exp_year'],
                'cvc'       => $credential['cvc'],
            ],
        ]);

        return [
            'token' => $stripeToken->id,
            'expires_at' => null,
            'instrument_summary' => [
                'brand' => $stripeToken->card->brand,
                'last_digits' => $stripeToken->card->last4,
            ],
        ];
    }
}

3.3 Register the service

xml
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services">
    <services>
        <service id="Acme\StripeForShopware\Ucp\StripeUcpHandler">
            <argument type="service" id="Acme\StripeForShopware\StripeClient"/>
            <argument type="service" id="Acme\StripeForShopware\StripeConfigRepository"/>
            <argument type="service" id="Shopware\Core\Framework\Ucp\Payment\PaymentMethodResolver"/>

            <tag name="ucp.payment_handler" handler_id="com.stripe.tokenizer"/>
        </service>
    </services>
</container>

The UcpPaymentHandlerCompilerPass picks up every tagged service and registers it in the UcpPaymentHandlerRegistry. From here on:

  • Your handler appears in /.well-known/ucp under ucp.payment_handlers["com.stripe.tokenizer"] for every active Sales Channel that has at least one Shopware payment method bound to your plugin.
  • Agents can pick handler_id: "com.stripe.tokenizer" in payment.instruments[] when completing a checkout.
  • Your prepareInstrument() is called automatically by the CheckoutController to translate the UCP-shape payload into the paymentMethodId + token that the existing Shopware\Core\Checkout\Payment\PaymentProcessor expects.

3.4 Filtering availability by cart context

The spec lets businesses filter their advertised payment handlers based on cart context — e.g. removing Klarna for subscription items, removing PayPal for digital-only carts, removing handlers that don't support the buyer's region.

UcpPaymentHandlerInterface::describe() receives the live SalesChannelContext, but to filter on cart contents you subscribe to the UcpEvents::PROFILE_BUILT event in your plugin and prune the handlers list:

php
<?php declare(strict_types=1);

namespace Acme\StripeForShopware\Ucp;

use Shopware\Core\Framework\Ucp\Event\UcpProfileBuiltEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
final class FilterStripeBySubscription
{
    public function __invoke(UcpProfileBuiltEvent $event): void
    {
        if (!$event->getCart()?->hasSubscriptionItems()) {
            return;
        }
        $event->removePaymentHandlersWhere(static fn (array $handler) =>
            $handler['id'] === self::HANDLER_ID
        );
    }
}

3.5 Verifying against the simulator

The simulator's Checkout: end-to-end happy path scenario picks the first available payment handler from the profile and uses it to complete a checkout. To confirm your handler works end-to-end:

  1. Enable your plugin's payment method on a Sales Channel that has UCP active (otherwise the profile won't list it).
  2. Start the simulator and point it at the shop.
  3. Run Checkout: end-to-end happy path with the Payment instrument type parameter set to whatever matches your handler (e.g. card).
  4. Inspect the debug pane's checkout.complete exchange — the request body should contain payment.instruments[].handler_id equal to your name_id.
Simulator's debug pane showing a complete checkout call
Simulator's debug pane showing a complete checkout call

If your handler is missing from the profile, the simulator falls back to the bundled com.shopware.invoice handler (which always works) so the rest of the flow continues to be testable.

3.6 PCI scope reminder

Implementing UcpPaymentHandlerInterface does not put your plugin or your shop into PCI scope on its own — the platform tokenizes credentials with the PSP before they reach Shopware. As long as your handler only ever sees an opaque token, you stay out of PCI-DSS scope. The exception is tokenize() — if you implement it, you're processing raw credentials and PCI scope applies to the code path that calls the PSP. Most plugins choose not to implement tokenize() and let the platform tokenise client-side.

→ Next: AP2 mandates plugin integration