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

RailWhat travelsWho is describedWhat the receiver does
Seller-sidea reference to an issuer-signed recordthe party being paidresolves that record’s standing itself, from proven chain state
Buyer-sidean answer to the receiver’s own challengethe party payingvalidates 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. 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 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.
  • Parameterscreated, 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.

CodeMeaning
accepted · session_reuseda fresh answer was validated and a token issued; or a live token under the same signing key
identity_requiredsignature fine, but neither a session token nor an answer
session_expired · session_key_mismatchthe token is unknown or past its lifetime and nothing accompanied it; or it belongs to another signing key
policy_mismatch · presentation_malformed · profile_unknownthe 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 · _malformedno signature headers; or bad parameters, wrong tag, a required component left uncovered
agent_signature_expired · _replayed · _invalidoutside the window or over the lifetime bound; this (keyid, nonce) already seen; the signature did not verify
agent_key_unknown · agent_directory_unavailablethe 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 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.