AP2 Mandate Verification
Endpoint: POST /agentic_commerce/ap2/verify
Auth: None at this endpoint — verification is of the mandate itself, not the caller (see Trust model below)
Protocol: Agent Payments Protocol (AP2) (Google), mandate format per IETF SD-JWT (RFC 9901)
curl -X POST https://ainative-browser-builder.up.railway.app/agentic_commerce/ap2/verify \
-H "Content-Type: application/json" \
-d '{"mandate": "<sd-jwt-string>"}'
What it's for
AP2 is Google's open protocol for letting an AI agent carry a cryptographically verifiable mandate — a signed credential proving the agent's human principal actually authorized an intent, a cart, or a payment — instead of the agent acting on bare, unverifiable say-so. AINative's /agentic_commerce/ap2/verify endpoint does the real, server-side work of checking one of these mandates before anything downstream is allowed to treat it as authorized: signature validity, expiry, and selective-disclosure integrity are all actually enforced, not assumed.
This endpoint is discoverable as the ap2 capability in the platform's UCP discovery document (GET /.well-known/ucp).
The mandate format: SD-JWT
AP2 mandates are Selective Disclosure JWTs (SD-JWT) — a JWT whose payload hides individual claims behind salted hash digests, plus a separately-presented list of disclosures that reveal only the claims the presenter chooses to share. The wire format is:
<signed-jwt>~<disclosure1>~<disclosure2>~...
- The first segment (before the first
~) is a normal signed JWT. Its payload carries an_sdarray — base64url-encoded SHA-256 digests of every hideable claim — plus_sd_alg(currently always"sha-256"). - Every segment after a
~is one disclosure: a base64url-encoded JSON array of exactly three elements,[salt, claim_name, claim_value]. - A disclosure is only trusted if hashing it reproduces one of the digests in the JWT's own
_sdarray. A disclosure whose hash doesn't match anything in_sdis treated as a forged or tampered claim and rejected outright — it is never silently folded into the result.
What verification actually checks
Reading the real implementation (ap2_mandate_service.py's verify_sd_jwt()), every one of these is a real, enforced check — none of them are stubbed:
- JWT signature — verified against the configured issuer secret (HMAC). An invalid or missing-
~string is rejected before signature checking even runs. - Expiry (
exp) — enforced viaverify_exp: True. This matters because thesd-jwtreference PyPI library does not check expiry itself (its own source has a literal# TODO: Check exp/nbf/iat); AINative's implementation is hand-rolled on top of PyJWT specifically to not inherit that gap. - Disclosure integrity — every disclosure's SHA-256 digest must appear in the JWT's
_sdarray, or the whole mandate is rejected. - Disclosure shape — each disclosure must decode to valid base64url JSON and be exactly a 3-element
[salt, claim_name, claim_value]array.
Only claims with a matching, well-formed disclosure are reconstructed into the returned claims object. A claim hidden behind _sd with no corresponding disclosure presented simply never appears in the result — it stays undisclosed, which is the whole point of SD-JWT.
Request
POST /agentic_commerce/ap2/verify
Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
mandate | string | Yes | The full SD-JWT presentation string: <signed-jwt>~<disclosure1>~<disclosure2>~... |
Response — success
200 OK:
{
"result": "success",
"reference": "k3F2k9...base64url-sha256-of-the-mandate",
"claims": {
"transaction_id": "txn_real_1",
"payment_amount": { "amount": 5000, "currency": "usd" },
"payee": { "id": "ainative", "name": "AINative Studio" }
}
}
| Field | Type | Description |
|---|---|---|
result | string | Always "success" on a 200 response. |
reference | string | A deterministic receipt reference: base64url(SHA-256(mandate)). The same mandate string always produces the same reference — it is not a random id. |
claims | object | The reconstructed claims from every disclosure that verified against _sd. Shape depends entirely on what the issuer disclosed — there is no fixed schema beyond what the issuer chose to include (commonly transaction_id, payment_amount, payee, etc., per AP2's Payment Mandate shape). |
Response — error
Any verification failure returns 400 Bad Request with the same structured shape:
{
"result": "error",
"error": "invalid_credential",
"message": "Malformed SD-JWT: missing '~' disclosure separator."
}
| Field | Type | Description |
|---|---|---|
result | string | Always "error". |
error | string | Currently always "invalid_credential" — every verification failure (bad signature, expired, malformed, tampered disclosure) maps to this one error code today. |
message | string | Human-readable reason. Real examples produced by the live endpoint: "Malformed SD-JWT: missing '~' disclosure separator.", "Mandate has expired.", "Invalid signature.", "Unmatched disclosure: hash does not appear in the mandate's _sd array (forged or tampered claim)." |
The malformed-mandate example above was reconfirmed by posting a deliberately malformed mandate to the live production endpoint on 2026-10-27 — it is not quoted from an older report. Note that the platform's structured-error middleware wraps this body inside an outer detail field in the actual HTTP response (plus error_code/next_action metadata); the result/error/message shape above is the payload the service layer itself produces.
A request missing the mandate field entirely gets a standard FastAPI/Pydantic 422 validation error instead (no mandate string was even present to verify).
If the server has no issuer secret configured at all, the endpoint returns 500 with {"detail": "AP2 verification not configured"} — this is an operational misconfiguration, not a mandate-verification outcome.
Trust model today
Be precise about what this endpoint actually trusts: verification is currently against a single shared secret, AP2_ISSUER_SECRET, configured once on the server (HMAC, HS256). Any mandate signed with that same secret verifies — there is no per-issuer public-key registry yet, so the endpoint cannot yet distinguish "issuer A's mandate" from "issuer B's mandate" by key identity; it can only tell a correctly-signed mandate from an incorrectly-signed one.
This is intentional for the current increment, not an oversight: the acceptance criteria behind this endpoint called for real verification mechanics — signature, expiry, and disclosure integrity — ahead of a real mandate-issuing counterparty actually existing to integrate against. A full per-issuer public-key trust store (resolving iss to a specific registered public key, supporting asymmetric algorithms like ES256/RS256) is tracked as a separate, later increment and is not part of what this endpoint does today. Treat any claim that this endpoint supports multi-issuer trust as inaccurate until that work actually ships.
Mandate types
The platform's UCP discovery document advertises three mandate types this endpoint is prepared to verify:
| Type | Description |
|---|---|
intent | An intent mandate — the user authorized the agent to act toward a goal, without fixing exact terms yet. |
cart | A cart mandate — the user authorized a specific, itemized cart. |
payment | A payment mandate — the user authorized an exact payment amount, payee, and instrument. |
The verifier itself (verify_sd_jwt()) does not branch on mandate type — the same signature/expiry/disclosure checks apply uniformly. Type-specific business rules (e.g. rejecting a payment mandate that requests more than its own declared payment_amount constraint) belong to whatever calls this endpoint and inspects the returned claims, not to the verifier itself.
curl examples
Malformed mandate (real 400):
curl -s -X POST https://ainative-browser-builder.up.railway.app/agentic_commerce/ap2/verify \
-H "Content-Type: application/json" \
-d '{"mandate": "garbage-not-a-jwt"}'
Missing field (real 422):
curl -s -X POST https://ainative-browser-builder.up.railway.app/agentic_commerce/ap2/verify \
-H "Content-Type: application/json" \
-d '{}'
Related documents
| Document | Purpose |
|---|---|
| UCP Discovery Document | Lists AP2 as one of the platform's advertised payment capabilities |
| Agent402 API | Agent-side x402 wallets — a different, HTTP-402-based payment mechanism from AP2 mandates |
See also: AP2 protocol spec (Google) and RFC 9901 (SD-JWT) for the underlying standards this endpoint implements.