---
title: "AP2 Mandate Verification"
description: "Real server-side SD-JWT verification of Agent Payments Protocol (AP2) mandates"
canonical: "https://docs.ainative.studio/docs/web3/ap2-mandate-verification"
last-updated: "2026-10-28T06:34:16.301Z"
---

# AP2 Mandate Verification

Source: https://docs.ainative.studio/docs/web3/ap2-mandate-verification

> Real server-side SD-JWT verification of Agent Payments Protocol (AP2) mandates

# 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](#trust-model-today) below)
**Protocol:** [Agent Payments Protocol (AP2)](https://github.com/google-agentic-commerce/AP2) (Google), mandate format per [IETF SD-JWT (RFC 9901)](https://datatracker.ietf.org/doc/html/rfc9901)

:::tip Live & Public
```bash
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](./ucp-discovery) (`GET /.well-known/ucp`).

## The mandate format: SD-JWT

AP2 mandates are [Selective Disclosure JWTs (SD-JWT)](https://datatracker.ietf.org/doc/html/rfc9901) — 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 `_sd` array — 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 `_sd` array. A disclosure whose hash doesn't match anything in `_sd` is 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:

1. **JWT signature** — verified against the configured issuer secret (HMAC). An invalid or missing-`~` string is rejected before signature checking even runs.
2. **Expiry (`exp`)** — enforced via `verify_exp: True`. This matters because the `sd-jwt` reference 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.
3. **Disclosure integrity** — every disclosure's SHA-256 digest must appear in the JWT's `_sd` array, or the whole mandate is rejected.
4. **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

```http
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`:

```json
{
  "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:

```json
{
  "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)."` |

:::info Verified against the live endpoint
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):**

```bash
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):**

```bash
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](./ucp-discovery) | Lists AP2 as one of the platform's advertised payment capabilities |
| [Agent402 API](./agent402) | Agent-side x402 wallets — a different, HTTP-402-based payment mechanism from AP2 mandates |

See also: [AP2 protocol spec](https://github.com/google-agentic-commerce/AP2) (Google) and [RFC 9901 (SD-JWT)](https://datatracker.ietf.org/doc/html/rfc9901) for the underlying standards this endpoint implements.
