Payment-identity profile
A draft binding that carries an identity requirement in a 402 response and the answer on the retry, and states what a buyer-side session lets you conclude.
A binding that rides inside the payment flow you already run
The payment-identity profile says where an identity requirement rides inside an existing payment-required (HTTP 402) response, and where the answer rides on the retry, beside the payment header. It adds no message, signature scheme or role of its own, leaves the payment payload untouched, 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, a classical issuer-signed record checked against proven chain state, is shipped end to end. The buyer-side rail is shipped as transport (declaration, headers, ordering, sessions), and the zero-knowledge proof engine behind its answers is built and in production; what an 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, in production since 20 September 2026, whose issuer and verifier are operated by the CONECTIA group; the CONECTIA group and ZadQ, the first product built on them (on the classical seller rail), are related parties of MintID, and nothing measured there is 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 constantseller-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, or1..4/A1..A4.verifyUrl— the verifying party’s free, anonymous path, where anyone fetches the current signed verdict; the optionalverdictfield 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 the receipt, and is resolved with the standing read. 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.
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 complete v1 profile supersedes this declaration. Same extension key, same headers, same session token; what changes is what the answer proves. The buyer presents an agent credential, and the policy may ask for delegation itself: human-backing, the backing grade, the credential active and unexpired, a minimum key-custody class, a per-transaction limit in a named currency, and membership in the delegated scope — the action, this counterparty (its payment account or resource URL), a category and a region — each value named in the canonical form of the scope vocabulary. The counterparty would then be proven, not declared, and above a value threshold the receiving party configures, a payment whose axes the policy does not cover would be refused. Both declarations coexist per receiving party for a transition window. The axes are specified; enforcement arrives with the agent products.
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@authorityandsignature-agent. - Parameters —
created,expires,keyid,nonce, all required;expiresmust be in the future and at most one hour aftercreated, with 60 s of clock skew tolerated oncreated; 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 theSignature-Agentheader names; akidin 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.
Outside this profile’s scope
The 10-second answer lifetime, the absence of any skip or “lite” flag, the staleness bound and the audience are invariants of the verifying core, and the wire carries no field that could change them. Every answer is perishable and non-transferable: a “verified” signal with a lifetime is somebody’s own published verdict, never a MintID artifact. Your own service runs the challenge and the validation, keeps the only decision record, and neither forwards nor stores envelopes.
Next: the service the buyer-side leg calls; the reason codes that pass through the leg unchanged; and becoming a verifier — the registration a verifying party needs before any of this decides anything.