Engineering research / ap2

AP2 as an A2A Extension: Carrying Mandates in A2A Messages and Data Parts

How AP2 mandates are carried over A2A: declaring the extension on the agent card with roles and the required flag, activating it per request, carrying mandates as keyed data parts, which role sends which, and what a receiver must validate.

By Sandeep Belgavi · Published 2026-11-02

Agent and AI engineering · Practical technical guide · 2 November 2026

How AP2 mandates are carried over A2A: declaring the extension on the agent card with roles and the required flag, activating it per request, carrying mandates as keyed data parts, which role sends which, and what a receiver must validate.

Three agents and one extension

Tanishka's shopping agent buys three books from the Belgavi's Book Store merchant agent, with a credentials provider agent holding her payment methods. They talk over A2A, and the mandates that authorize the purchase have to be carried inside A2A messages in a form every receiver recognises, from a peer that is entitled to send them, under an extension both sides have agreed to use.

This article covers that binding: declaring AP2, switching it on per request, where each mandate sits, which AP2 role sends it, and what the receiver checks. What a mandate means is covered in the related articles.

Which version this describes

The A2A binding is specified in the v0.1-alpha document docs/a2a-extension.md at the AP2 v0.1.0 tag. AP2 v0.2.0 (released 28 April 2026; main branch at commit e1ea56d, read on 2 November 2026) rewrote the core around SD-JWT Checkout and Payment Mandates and leaves the transport to the commerce protocol, so its specification pages have no A2A extension chapter. Its sample agents still use A2A with the extension, carrying the new mandates under the data keys ap2.mandates.CheckoutMandateSdJwt and ap2.mandates.PaymentMandateSdJwt. The examples below follow the v0.1 profile and the v0.1 pydantic models, and the transport follows the samples, which run A2A protocol version 0.3.0.

There is one discrepancy to settle before writing any code. The v0.1 document gives the extension URI as https://github.com/google-agentic-commerce/ap2/tree/v0.1; the sample code at both tags uses https://github.com/google-agentic-commerce/ap2/v1. Extension matching is an exact string comparison, so two implementations that each followed a different source will never activate the extension with each other. Pick one, pin it with the version you implement, and test against your actual peers.

Declaring the extension on the agent card

An A2A agent card lists extensions under capabilities.extensions; each entry has a uri, a description, a required flag and free-form params. The AP2 profile requires params.roles, an array of at least one of merchant, shopper, credentials-provider and payment-processor, and says an agent in the merchant role should set the extension to required, meaning clients must use AP2 to pay. The v0.2 sample cards set required to true and omit params, so do not assume a peer advertises roles.

Agent card for the fictional store, following the v0.1 extension profile; checked as valid JSON.

{
  "name": "BelgaviBookStoreMerchant",
  "description": "Finds books at Belgavi's Book Store and returns merchant-signed carts.",
  "url": "https://agents.example/a2a/merchant",
  "protocolVersion": "0.3.0",
  "version": "1.0.0",
  "defaultInputModes": ["application/json"],
  "defaultOutputModes": ["application/json"],
  "capabilities": {
    "extensions": [
      {
        "uri": "https://github.com/google-agentic-commerce/ap2/v1",
        "description": "Accepts AP2 mandates for book orders.",
        "required": true,
        "params": {"roles": ["merchant"]}
      }
    ]
  },
  "skills": [
    {"id": "search_catalog", "name": "Search catalog",
     "description": "Find books and return a signed cart.", "tags": ["books"]}
  ]
}

Activating it for a request

Declaring support does not switch anything on. Extensions are inactive until a client asks for them on a request. The AP2 samples send the HTTP header X-A2A-Extensions with the URI; the server activates the intersection of the requested URIs and the ones it supports, and its executor raises an error ("Payment extension not activated") if AP2 is not among them. A2A 1.0 names the header A2A-Extensions, lets a server ignore requested extensions it does not support, says the response should echo the activated ones, and defines ExtensionSupportRequiredError (JSON-RPC code -32008) for a request that did not activate an extension the server marked required.

So the client reads the echo: a peer that did not activate AP2 did not process your data part as a mandate, whatever it replies. A peer whose card lacks the extension should not receive mandates at all.

Mandates as keyed data parts

Each mandate is carried in an A2A data part whose data object has exactly one well-known key. The profile also allows an optional risk_data part.

MandateA2A containerData keySent by roleTo
IntentMandateMessageap2.mandates.IntentMandateshoppermerchant
CartMandateArtifactap2.mandates.CartMandatemerchantshopper
PaymentMandateMessageap2.mandates.PaymentMandateshopper (the merchant when forwarding)credentials provider, merchant, processor

Do not confuse the A2A message role field, which is only user or agent, with the AP2 role, which comes from the sender's agent card. Here is the cart artifact the merchant agent returns, with fields from the v0.1 CartMandate and W3C-based PaymentRequest models.

CartMandate artifact in A2A 0.3 shape (kind discriminator); field names from the v0.1 models; the JWT is truncated.

{
  "artifactId": "cart-artifact-7731",
  "name": "Signed cart CART-7731",
  "parts": [
    {
      "kind": "data",
      "data": {
        "ap2.mandates.CartMandate": {
          "contents": {
            "id": "CART-7731",
            "user_cart_confirmation_required": true,
            "payment_request": {
              "method_data": [{"supported_methods": "CARD", "data": {}}],
              "details": {
                "id": "order-7731",
                "display_items": [
                  {"label": "The Monsoon Atlas", "amount": {"currency": "INR", "value": 499.0}, "refund_period": 30},
                  {"label": "Kannada for Beginners", "amount": {"currency": "INR", "value": 350.0}, "refund_period": 30},
                  {"label": "Stars over Hampi", "amount": {"currency": "INR", "value": 650.0}, "refund_period": 30},
                  {"label": "Shipping and tax", "amount": {"currency": "INR", "value": 57.82}, "refund_period": 0}
                ],
                "total": {"label": "Total", "amount": {"currency": "INR", "value": 1556.82}}
              }
            },
            "cart_expiry": "2026-11-02T10:15:00Z",
            "merchant_name": "Belgavi's Book Store"
          },
          "merchant_authorization": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im1lcmNoYW50LWtleS0xIn0..."
        }
      }
    }
  ]
}

A2A 1.0 removed the kind discriminator: a part holds exactly one of text, raw, url or data, so a receiver serving both versions should accept either shape.

The document and the models disagree

Base your schema on the models you ship, not on the prose examples. In the v0.1.0 tag the document's IntentMandate example uses required_refundability while the model field is requires_refundability; the PaymentMandate example nests payment_details while the model uses payment_mandate_contents and user_authorization; and the cart example uses the W3C camel-case displayItems where the model uses display_items. The most security-relevant mismatch is in the CartMandate example: it names the signature merchant_signature, beside a separate timestamp, and the cart flag user_signature_required, where the model has merchant_authorization and user_cart_confirmation_required. A receiver built from the example looks for the wrong signature field, and a lenient parser that ignores unknown fields sees no signature at all and drops the refundability requirement too. Reject unknown fields on mandates and name the field you found.

Tradeoffs and failure modes

  • Required or optional. Marking AP2 required turns away clients that cannot pay with mandates. Leaving it optional keeps a non-AP2 path open, so receivers must refuse mandate data whenever the extension is not active.
  • Strict or lenient parsing. Rejecting unknown fields catches drift such as the misnamed signature field, and breaks when a peer upgrades. Version the URI and the keys, and fail loudly with the field named.
  • URI mismatch. Peers that copied different URIs never activate the extension with each other, and nothing else looks wrong.
  • Header name. The 0.3 samples send X-A2A-Extensions; A2A 1.0 specifies A2A-Extensions. Test each peer with the header its version expects.

What a receiver should validate

  1. The AP2 extension is activated for this request. Otherwise refuse the data part rather than parse it.
  2. Exactly one data part with the expected key for this endpoint. Report the keys you found.
  3. The sender holds the AP2 role entitled to send this mandate, according to its agent card.
  4. The body validates against the model version you implement.
  5. For a CartMandate, merchant_authorization is present, its signature verifies against the merchant key, it has not expired, and its cart_hash matches the contents.
  6. For a PaymentMandate, it references the cart you signed and the total matches.

Runnable with Python 3.11 or newer; structural checks only, signature verification is left out. The comments show its output.

AP2_URI = 'https://github.com/google-agentic-commerce/ap2/v1'
EXPECTED = {'ap2.mandates.IntentMandate': 'shopper', 'ap2.mandates.PaymentMandate': 'shopper',
            'ap2.mandates.CartMandate': 'merchant'}

def check(parts, activated, sender_roles, want):
    if AP2_URI not in activated:
        return 'reject: AP2 extension not activated for this request'
    found = [p['data'] for p in parts if p.get('kind') == 'data' and want in p.get('data', {})]
    if len(found) != 1:
        keys = [k for p in parts if p.get('kind') == 'data' for k in p.get('data', {})]
        return 'reject: expected one %s part, found keys %s' % (want, keys)
    if EXPECTED[want] not in sender_roles:
        return 'reject: %s must come from a %s, sender roles %s' % (want, EXPECTED[want], sender_roles)
    body = found[0][want]
    if want == 'ap2.mandates.CartMandate' and not body.get('merchant_authorization'):
        return 'reject: CartMandate without merchant_authorization'
    return 'accept'

cart = {'kind': 'data', 'data': {'ap2.mandates.CartMandate': {'contents': {'id': 'CART-7731'},
                                                             'merchant_authorization': None}}}
print(check([cart], {AP2_URI}, ['merchant'], 'ap2.mandates.CartMandate'))
print(check([cart], set(), ['merchant'], 'ap2.mandates.CartMandate'))
print(check([cart], {AP2_URI}, ['shopper'], 'ap2.mandates.CartMandate'))
pm = {'kind': 'data', 'data': {'ap2.mandates.PaymentMandateSdJwt': 'eyJ...'}}
print(check([pm], {AP2_URI}, ['shopper'], 'ap2.mandates.PaymentMandate'))
# reject: CartMandate without merchant_authorization
# reject: AP2 extension not activated for this request
# reject: ap2.mandates.CartMandate must come from a merchant, sender roles ['shopper']
# reject: expected one ap2.mandates.PaymentMandate part, found keys ['ap2.mandates.PaymentMandateSdJwt']

The companion lab in numbers

The lab models four exchanges: the IntentMandate, the CartMandate artifact, and the PaymentMandate to the credentials provider and then the merchant. At the defaults all four are accepted under an activated extension and the order is placed. Without the activation header, the required flag stops the first message with a -32008 style rejection. Make it optional and switch receiver enforcement off as well, and all four mandates are processed without the extension activated. Remove merchant_authorization and the shopping agent refuses the cart; stop it verifying and one unsigned cart is accepted. A merchant card advertising ["shopper"] stops the flow at discovery, or yields one cart from a non-merchant peer if unchecked. The unprefixed key and the v0.2 sample key are both rejected with the key named. A credentials provider without the extension is caught by the missing echo, or gets the mandate as opaque data if nobody checks.

Sources and scope

Checked against these sources on 2 November 2026. The Python validator was run with Python 3.13 and its output is shown; the JSON examples parse. The lab is a deterministic model; no A2A server was called.

AP2 rides on A2A as an extension: declare it on the agent card with roles and the required flag, activate it per request and read the echo, and carry each mandate in a data part under its exact key. Receivers should refuse mandate data outside an activated extension, check the sender role and the merchant signature, and pin the extension URI and model version, because the v0.1 document and the sample code disagree.

Views are my own and do not represent any employer, past or present.