<!-- markdown mirror of https://mintid.net/en/sdk/payment-identity-profile — generated at build time -->

> A draft transport binding: where an identity requirement rides inside a payment-required response, where the answer rides on the retry, and what a buyer-side session does and does not let you conclude.

index — 12 documents

1.  [overviewsdk/00](/en/sdk)
2.  [getting startedsdk/01](/en/sdk/getting-started)
3.  [python sdksdk/02](/en/sdk/python)
4.  [service modesdk/03](/en/sdk/service-mode)
5.  [typescript clientsdk/04](/en/sdk/typescript-client)
6.  [mcp serversdk/mcp](/en/sdk/mcp-server)
7.  [conformancesdk/05](/en/sdk/conformance)
8.  [reason codessdk/06](/en/sdk/reason-codes)
9.  [substrate readssdk/07](/en/sdk/substrate-reads)
10.  [credentials & holderssdk/08](/en/sdk/credentials-and-holders)
11.  [payment identitysdk/09](/en/sdk/payment-identity-profile)
     *   [a transport binding](#binding)
     *   [two rails](#two-rails)
     *   [seller-side](#seller-side)
     *   [buyer-side](#buyer-side)
     *   [what a session means](#what-session-means)
     *   [request signing](#request-signing)
     *   [reason codes](#reason-codes)
     *   [what it does not do](#limits)
12.  [becoming a verifiersdk/10](/en/sdk/becoming-a-verifier)
13.  [protocol statesdk/11](/en/sdk/protocol-state)
14.  [record · assurance vocabularyrecord/01](/en/sdk/kyc-trust-model)
15.  [record · packagingrecord/02](/en/sdk/packaging)
16.  [record · multi-languagerecord/03](/en/sdk/multi-language-clients)
17.  [record · service contractrecord/04](/en/sdk/verifier-service-contract)

SDK docs · sdk/09

# Payment-identity profile

A draft transport binding: where an identity requirement rides inside a payment-required response, where the answer rides on the retry, and what a buyer-side session does and does not let you conclude.

## A transport binding, not a protocol

It says where an identity _requirement_ rides inside a payment-required response that already exists, and where the _answer_ rides on the retry, beside the payment header. It adds no message, signature scheme or role of its own, never modifies the payment payload, and is optional in both directions.

**It is a draft and cannot freeze** while its dispute annex — who posts a claim bond when the harmed party never registered — is still open, so expect additive change: new fields and format tags, not silent changes to the ones below. The schemas declare canonical `$id` URLs that **are not served yet**: validate against the copies you are given. Every object is `additionalProperties: false`, so an unnamed field is a validation failure, not an extension point — a price or billing field included.

## Two rails

Rail

What travels

Who is described

What the receiver does

Seller-side

a reference to an issuer-signed record

the party being paid

resolves that record’s standing itself, from proven chain state

Buyer-side

an answer to the receiver’s own challenge

the party paying

validates it on its own verifier service

The seller-side rail is shipped end to end; the buyer-side one is shipped **as transport** — declaration, headers, ordering, sessions. The proof engine behind an answer is built and in production; what a given answer establishes is set by the policy you declare, and nothing on this page widens it.

> **Related-party disclosure:** both rails run today only on the single-node production chain, whose issuer and verifier operator, and the first product built on them, are related parties of MintID — not independent adoption.

## Seller-side: a reference travels, standing is resolved

The seller’s `402` carries an `identityPolicy` object in its `extensions`, with these fields and no others:

*   `claim` — the constant `seller-endpoint-accountability`, the only claim on this rail: a verified, accountable operator stands behind this chain-qualified payment address and resource URL, without revealing who.
*   `assuranceAtLeast` — the minimum assurance grade the record meets: a public single letter, or `1`..`4` / `A1`..`A4`.
*   `verifyUrl` — the verifying party’s free, anonymous path, where anyone fetches the current signed verdict; the optional `verdict` field carries an inline copy of it, which is a hint, never a proof.

The answer rides in `X-PAYMENT-IDENTITY`, beside `X-PAYMENT`: base64url of `{"version": 1, "format": "attestation-ref/v0", "payload": "<base64url>"}`. The payload decodes to the issuer’s signed receipt — `attestation_ref`, `issuer_id`, `chain`, `pay_to`, `resource_url`, `assurance_grade`, `issued_at_unix`, `signature` — its own object **minus the status**: standing never travels inside a proof, and is resolved with the [standing read](/en/sdk/substrate-reads). There is no challenge, policy or session here, and the buyer is never identified.

## Buyer-side: declaration, headers, session

The `402` carries a `mintid_identity` object instead. `claim_policy` is restricted to four claims — `assurance_grade >=` A1..A4, `credential_active`, `unexpired`, and an optional `age_over N` — and the library refuses any other when you build the policy, rather than letting the engine discover it. `session_mode` takes one value; the schema reserves a second string that must not be offered yet.

The exchange: (1) the `402`; (2) `POST <challenge_endpoint>`, answering with the verifying party’s own challenge, unmodified — nonce, session id and the 10-second expiry are the service’s, since the extension carries policy, never invariants; (3) the retry, `X-PAYMENT` unchanged plus `X-MINTID-PRESENTATION`, base64url of `{profile, request, envelope}` with `request` that challenge verbatim; (4) the receiver checks, in order, the request signature (below), a session token if present, otherwise the answer — which must have been issued for _this_ policy (else `policy_mismatch`) and is validated on the party’s **own** service; (5) on acceptance the response carries `X-MINTID-SESSION`. A rejection rides in the payment `error` field as `mintid_identity:<reason_code>`; identity accepted but unpaid is still a `402`.

The session token is opaque, minted by the receiving party, scoped to that party, the key that signed the accepted request, and the declared counterparty. Its lifetime is local policy; the reference implementation defaults to 15 minutes. Presenting it under another signing key is `session_key_mismatch`, attaching a fresh answer to a stolen token does not upgrade it, and after the lifetime it is `session_expired`. The party keeps the decision’s public outputs and nothing else.

jsonsdk/09 · worked example

```
{ "profile": "<buyer profile id>", "session_mode": "token_exchange",
  "challenge_endpoint": "https://publisher.example/mintid/challenges",
  "counterparty": { "chain": "eip155:8453", "pay_to": "0x8335…2913", "resource_url": "https://publisher.example/api/articles/42" },
  "claim_policy": [ { "claim": "assurance_grade", "operator": ">=", "value": "A2" },
                    { "claim": "credential_active", "value": true }, { "claim": "unexpired", "value": true } ],
  "issuer_policy": { "accepted_issuer_ids": ["5656…5656"], "issuer_state_minimum": "ACTIVE" } }
```

## What the session does and does not entitle you to conclude

On the buyer side today the counterparty — the chain, the payment address, the resource URL — is **declared in the payment-required response and bound to the session. It is not proven.** The receiving party issues its challenge for that declared counterparty, the answer is nonce-bound to that challenge, and the party records which counterparty its policy named next to the accepted decision. That entitles you to say: the party that answered did so against the challenge _you_ issued, for the policy _you_ declared, in _this_ session, under the key that signed the request — an association _you_ hold about your own session. It does not entitle you to say that a scope, a mandate or any relationship to this counterparty was proven inside the answer, nor to key-binding semantics: the association between signing key and decision is session-level, like any authenticated session, and a cryptographic binding of that key is deliberately not specified here. Copy that claims otherwise is wrong.

## The request-signing tier for automated clients

Every request to the protected resource is signed, and the signature is checked before anything else.

*   **Scheme** — RFC 9421 HTTP Message Signatures, Ed25519, `tag="web-bot-auth"`, covering at least `@authority` and `signature-agent`.
*   **Parameters** — `created`, `expires`, `keyid`, `nonce`, all required; `expires` must be in the future and at most one hour after `created`, with 60 s of clock skew tolerated on `created`; a `(keyid, nonce)` pair is refused a second time inside that window.
*   **`keyid`** — the RFC 7638 thumbprint of the Ed25519 JWK published in the directory the `Signature-Agent` header names; a `kid` in that document is also accepted.

The directory document is `{"keys": [<JWK>, …]}` and only Ed25519 keys are read; it may carry `"mintid_presentation": true` as a capability hint, nothing more. **Two things the library does not ship, deliberately:** the Ed25519 verification and the key-directory fetch — the verifying core has no cryptographic dependency and makes no network call, so you inject both.

## Reason codes of the identity leg

One per request, from a closed set; your service’s own codes pass through unchanged, so a `switch` sees both vocabularies, and `presentation_rejected` is the fallback when the service names none.

Code

Meaning

`accepted · session_reused`

a fresh answer was validated and a token issued; or a live token under the same signing key

`identity_required`

signature fine, but neither a session token nor an answer

`session_expired · session_key_mismatch`

the token is unknown or past its lifetime and nothing accompanied it; or it belongs to another signing key

`policy_mismatch · presentation_malformed · profile_unknown`

the challenge was not issued for this declared policy; the header did not decode into {profile, request, envelope}; or it names a profile this party does not run

`agent_signature_missing · _malformed`

no signature headers; or bad parameters, wrong tag, a required component left uncovered

`agent_signature_expired · _replayed · _invalid`

outside the window or over the lifetime bound; this (keyid, nonce) already seen; the signature did not verify

`agent_key_unknown · agent_directory_unavailable`

the directory does not publish this keyid, or could not be resolved

Declaration-time refusals are separate — you meet them when you build or parse the policy object, never per request: `extension_malformed`, `challenge_endpoint_malformed`, `counterparty_malformed`, `claim_policy_malformed`, `claim_not_provable`, `issuer_policy_malformed`, `session_mode_gated` / `_unknown`.

## What this profile does not do

It cannot lengthen or shorten the 10-second answer lifetime, has no skip or “lite” flag, cannot widen staleness acceptance and cannot override the audience: those are invariants of the verifying core, and no field for them exists on the wire. It issues no persistent badge — an answer is perishable and non-transferable, and a “verified” signal with a lifetime is somebody’s own published verdict, never a MintID artifact. It does not let you outsource the decision, nor forward or store envelopes: your own service runs the challenge and the validation, and its decision record is the only record.

Next: the [service](/en/sdk/service-mode) the buyer-side leg calls; the [reason codes](/en/sdk/reason-codes) that pass through the leg unchanged; and [becoming a verifier](/en/sdk/becoming-a-verifier) — the registration a verifying party needs before any of this decides anything.

[previous← credentials & holders](/en/sdk/credentials-and-holders)[nextbecoming a verifier →](/en/sdk/becoming-a-verifier)

---
Source: https://mintid.net/en/sdk/payment-identity-profile · Payment-identity profile — MintID verifier SDK docs
