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
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| BThe 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
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:
| Artifact | Direction | Carries |
|---|---|---|
ap2.merchant_authorization | Shop → Agent | Detached-JWS signature by the shop over the canonicalised checkout (JCS, RFC 8785). Proves the shop committed to these exact terms. |
ap2.checkout_mandate | Agent → Shop | SD-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 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:
<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:
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_mandatewhile 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.
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
UcpCapabilitywithextends = 'dev.ucp.shopping.checkout' - DI-tag the capability
ucp.capabilitywithcapability="dev.ucp.shopping.ap2_mandate" - Subscribe to
UcpEvents::CHECKOUT_RESPONSEto addap2.merchant_authorization - Subscribe to
UcpEvents::CHECKOUT_COMPLETE_REQUESTto validateap2.checkout_mandate - Re-use core's
ES256SignerandJsonCanonicalizationservices — 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-mandatescenario
The simulator's extension.ap2-mandate scenario verifies:
- The shop's response carries a
ap2.merchant_authorizationJWS. - The JWS can be verified against the public key in
signing_keys[]. - A signed
checkout_mandateround-trip succeeds (with the simulator acting as the wallet, signing with its bundled test issuer key).
→ Next: Using the UCP Simulator API