Developer guide

AP2 mandates plugin integration

The AP2 Mandates extension (dev.ucp.shopping.ap2mandate) enables

4. AP2 mandates plugin integration

The AP2 Mandates extension (dev.ucp.shopping.ap2_mandate) enables cryptographically signed agentic checkout per the AP2 protocol. When this extension is active, the business and the platform exchange Verifiable Digital Credentials that bind every checkout to a specific user authorisation — providing non-repudiable proof that the buyer authorised the order with the platform's agent.

This is shipped as a separate plugin, swag/ucp-ap2-mandates, released independently of the Shopware core. The core only provides the extension points; the plugin provides:

  • The capability declaration so it appears in negotiation
  • The mandate verification (incoming) and signing (outgoing) plumbing
  • The session-locking rules required by the spec

This page explains how the plugin integrates with the core and how to extend it (or write a third-party AP2 plugin) if the official one doesn't fit your use case.

4.1 Where the plugin hooks in

mermaid
flowchart TB
    Core[Shopware UCP core] --> A[CapabilityRegistry]
    Core --> B[CheckoutController]
    Core --> C[ProfileBuilder]

    AP2[swag/ucp-ap2-mandates plugin] -->|registers Ap2MandateCapability| A
    AP2 -->|subscribes to UcpEvents::CHECKOUT_RESPONSE| B
    AP2 -->|subscribes to UcpEvents::PROFILE_BUILT| C

    A -->|negotiation includes ap2_mandate| Profile["/.well-known/ucp"]
    B -->|response carries ap2.merchant_authorization| Agent[Platform agent]
    Agent -->|complete with ap2.checkout_mandate| B
    B -->|verify mandate| AP2
    AP2 -->|accept or reject| B

The plugin holds zero database schema of its own — all signing keys re-use the per-Sales-Channel ES256 keys from shopware/src/Core/Framework/Ucp/Jwt/.

4.2 The mandate flow

mermaid
sequenceDiagram
    participant Buyer
    participant Agent as Platform agent
    participant Shop as Shopware shop<br/>(with AP2 plugin)
    participant Wallet as User's wallet<br/>(holds private key)

    Buyer->>Agent: "Buy this product"
    Agent->>Shop: POST /ucp/v1/checkout-sessions { cart_id }
    Shop->>Shop: compute totals, embed ap2.merchant_authorization<br/>(detached JWS over canonical checkout)
    Shop-->>Agent: 200 OK { …, ap2: { merchant_authorization: "header..signature" } }
    Agent->>Wallet: please sign this checkout for the user
    Wallet-->>Agent: ap2.checkout_mandate (SD-JWT+KB)
    Agent->>Shop: POST /complete { …, ap2: { checkout_mandate }, payment: { instruments: [{ credential: { token: payment_mandate } }] } }
    Shop->>Shop: verify checkout_mandate covers the<br/>exact checkout state we signed earlier
    Shop->>Shop: capture payment via PSP
    Shop-->>Agent: 200 OK { order_id }

Two artifacts cross the wire in opposite directions:

ArtifactDirectionCarries
ap2.merchant_authorizationShop → AgentDetached-JWS signature by the shop over the canonicalised checkout (JCS, RFC 8785). Proves the shop committed to these exact terms.
ap2.checkout_mandateAgent → ShopSD-JWT+KB (Selective Disclosure JWT with Key Binding) by the user proving they authorised this exact checkout state. Includes the merchant_authorization inside — so the mandate's signature chain covers both parties.

A separate payment_mandate (similar SD-JWT+KB) goes into payment.instruments[].credential.token and authorises the funds transfer specifically.

4.3 Implementing the plugin's capability class

The plugin registers Ap2MandateCapability as a normal UCP capability, marking it as an extension of checkout:

php
<?php declare(strict_types=1);

namespace Swag\UcpAp2Mandates\Capability;

use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Ucp\Capability\AbstractUcpCapability;

#[Package('framework')]
final class Ap2MandateCapability extends AbstractUcpCapability
{
    public const NAME = 'dev.ucp.shopping.ap2_mandate';

    public function getName(): string
    {
        return self::NAME;
    }

    public function getSpecUrl(): string
    {
        return 'https://ucp.dev/' . $this->getVersion() . '/specification/ap2-mandates';
    }

    public function getSchemaUrl(): string
    {
        return 'https://ucp.dev/' . $this->getVersion() . '/schemas/shopping/ap2_mandate.json';
    }

    public function getExtends(): string|array|null
    {
        return 'dev.ucp.shopping.checkout';
    }

    public function getProfileConfig(): ?array
    {
        return [
            // VP formats this shop accepts. Per AP2 spec.
            'vp_formats_supported' => [
                'dc+sd-jwt' => new \stdClass(),
            ],
        ];
    }
}

Tagged the same way as any other capability:

xml
<service id="Swag\UcpAp2Mandates\Capability\Ap2MandateCapability">
    <tag name="ucp.capability" capability="dev.ucp.shopping.ap2_mandate"/>
</service>

4.4 Signing the checkout (merchant_authorization)

The plugin subscribes to UcpEvents::CHECKOUT_RESPONSE and embeds the detached-JWS signature as the last step before the response leaves the shop. The signing is done over the JCS-canonicalised checkout payload, excluding the ap2 field itself:

php
public function onCheckoutResponse(UcpCheckoutResponseEvent $event): void
{
    if (!$event->session()->isExtensionActive(Ap2MandateCapability::NAME)) {
        return;
    }

    $payload = $event->payload();
    unset($payload['ap2']); // exclude per spec

    $canonical = $this->jcs->canonicalize($payload);
    $jws = $this->signer->signDetached(
        canonicalBytes: $canonical,
        privateKey: $this->keyResolver->activeFor($event->salesChannelId()),
        kid: $this->keyResolver->activeKidFor($event->salesChannelId()),
    );

    $event->mergePayload(['ap2' => ['merchant_authorization' => $jws]]);
}

The JCS canonicalizer and detached-JWS signer are reused from Shopware\Core\Framework\Ucp\Jwt\. The plugin's only contribution is the rule for what gets signed (the canonical checkout payload).

4.5 Verifying mandates on complete

On the inbound side, the plugin subscribes to the UcpEvents::CHECKOUT_COMPLETE_REQUEST event and rejects requests that either:

  • lack ap2.checkout_mandate while the extension is active in the current session (per spec, the session is security-locked once AP2 is negotiated),
  • carry a malformed mandate (bad signature, key not found, …),
  • or carry a mandate whose checkout payload doesn't match the checkout the shop has on disk.
php
public function onCompleteRequest(UcpCheckoutCompleteRequestEvent $event): void
{
    if (!$event->session()->isExtensionActive(Ap2MandateCapability::NAME)) {
        return;
    }

    $mandate = $event->payload()['ap2']['checkout_mandate'] ?? null;
    if ($mandate === null) {
        throw UcpException::ap2MandateRequired();
    }

    $verifyResult = $this->sdJwtVerifier->verifyAgainst(
        mandate: $mandate,
        expectedCheckout: $event->checkout(),
        merchantAuthorizationKid: $event->session()->signingKid(),
    );
    if (!$verifyResult->ok) {
        throw UcpException::ap2MandateInvalid($verifyResult->reason);
    }
}

4.6 Quick-start checklist for an AP2 plugin

If you're writing a third-party AP2 plugin (or extending the official one):

  • Implement UcpCapability with extends = 'dev.ucp.shopping.checkout'
  • DI-tag the capability ucp.capability with capability="dev.ucp.shopping.ap2_mandate"
  • Subscribe to UcpEvents::CHECKOUT_RESPONSE to add ap2.merchant_authorization
  • Subscribe to UcpEvents::CHECKOUT_COMPLETE_REQUEST to validate ap2.checkout_mandate
  • Re-use core's ES256Signer and JsonCanonicalization services — don't write your own
  • Honour session-locking: once negotiated, requests must carry the mandate (per spec MUST)
  • Test against the simulator's extension.ap2-mandate scenario

The simulator's extension.ap2-mandate scenario verifies:

  1. The shop's response carries a ap2.merchant_authorization JWS.
  2. The JWS can be verified against the public key in signing_keys[].
  3. A signed checkout_mandate round-trip succeeds (with the simulator acting as the wallet, signing with its bundled test issuer key).

→ Next: Using the UCP Simulator API