The x402 payment-identifier extension: client-generated idempotency ids.
Implements the
payment-identifier extension.
A server advertises the extension in PaymentRequired.extensions under
the "payment-identifier" key (extension/1); the client echoes the
advertisement and adds its id under info.id (enricher/1):
# server (PaymentRequired.extensions)
%{"payment-identifier" => %{"info" => %{"required" => true}, "schema" => %{...}}}
# client (PaymentPayload.extensions)
%{"payment-identifier" => %{"info" => %{"required" => true, "id" => "..."}, "schema" => %{...}}}A valid id is 16 to 128 characters drawn from [A-Za-z0-9_-]
(valid_id?/1); generate_id/0 produces one. Servers extract the echoed
id with extract_id/1 and bind it to the request through fingerprint/2:
the same id reused for a different request is a conflict.
Legacy paymentIdentifier format (deprecated)
Releases before 0.7.0 used a "paymentIdentifier" key whose value was a
Base64 JSON {"paymentId": ...} string, a bare %{"paymentId" => id}
map, or an %{"info" => ...} envelope around either. extract_id/1
still understands that format — reporting it as {:legacy, id} — and
encode/1, decode/1, and fetch_payment_id/1 still produce and
consume it. Every legacy function is deprecated and will be removed in
1.0.0; servers emit a [:x402, :payment_identifier, :legacy] telemetry
event (and a one-time warning log) when they see it.
Summary
Types
Errors returned by extract_id/1.
An extracted id, tagged with the wire format it arrived in.
Request context hashed into fingerprint/2.
Where a legacy-format identifier was observed.
Payment identifier value used for idempotency.
Functions
Decodes a legacy Base64 JSON payload and returns the payment identifier.
Encodes a legacy payment identifier to a Base64 JSON payload.
Builds a client-side enricher for X402.Client.build_payment/3's
:extensions option.
Builds the server-side advertisement for PaymentRequired.extensions.
Returns the extension key on the wire.
Extracts the client's payment id from a PaymentPayload.extensions map.
Extracts and validates "paymentId" from a decoded legacy payload map.
Computes the request fingerprint a payment id is bound to.
Generates a random 32-character identifier (24 random bytes, Base64url without padding).
Returns the deprecated pre-0.7.0 extension key.
Returns whether an advertised extensions map marks the id as required.
Returns the JSON schema the server advertises for the extension.
Checks whether id is a valid spec-format identifier.
Types
@type decode_error() ::
:invalid_base64 | :invalid_json | :missing_payment_id | :invalid_payment_id
@type encode_error() :: :invalid_payment_id | :invalid_json
@type extract_error() :: :invalid_payment_id | {:legacy, decode_error()}
Errors returned by extract_id/1.
@type extracted() :: {:spec, payment_id()} | {:legacy, payment_id()}
An extracted id, tagged with the wire format it arrived in.
@type fingerprint_context() :: %{ optional(:method) => atom() | String.t(), optional(:path) => String.t(), optional(:tool) => String.t() }
Request context hashed into fingerprint/2.
@type legacy_source() :: :gate | :mcp
Where a legacy-format identifier was observed.
@type payment_id() :: String.t()
Payment identifier value used for idempotency.
Functions
@spec decode(String.t()) :: {:ok, payment_id()} | {:error, decode_error()}
Decodes a legacy Base64 JSON payload and returns the payment identifier.
{:ok, encoded} = X402.Extensions.PaymentIdentifier.encode("payment-123")
X402.Extensions.PaymentIdentifier.decode(encoded)
#=> {:ok, "payment-123"}
X402.Extensions.PaymentIdentifier.decode("not-base64")
#=> {:error, :invalid_base64}
@spec encode(payment_id()) :: {:ok, String.t()} | {:error, encode_error()}
Encodes a legacy payment identifier to a Base64 JSON payload.
{:ok, encoded} = X402.Extensions.PaymentIdentifier.encode("payment-123")
{:ok, "payment-123"} = X402.Extensions.PaymentIdentifier.decode(encoded)
Builds a client-side enricher for X402.Client.build_payment/3's
:extensions option.
When the server advertised payment-identifier (or always: true), the
returned function attaches the spec-format extension to the payload:
the advertised info and schema are echoed unchanged and info.id is
added. Otherwise the payload passes through untouched. Without an
advertisement the always: true form attaches %{"info" => %{"id" => id}}.
The returned function yields {:error, :invalid_payment_id} when an
explicit :id fails valid_id?/1.
Options
:id- Explicit id to attach. When omitted a freshgenerate_id/0value is produced on every invocation of the returned function. The default value isnil.:always(boolean/0) - Attach the extension even when the server did not advertisepayment-identifier. Defaults tofalse: the enricher is a no-op for servers that do not support the extension. The default value isfalse.
Examples
iex> enricher = X402.Extensions.PaymentIdentifier.enricher(id: "abcdefghijklmnop")
iex> advertised = %{"payment-identifier" => X402.Extensions.PaymentIdentifier.extension(required: true)}
iex> payment_required = %{"extensions" => advertised}
iex> payload = %{"extensions" => advertised}
iex> {:ok, enriched} = enricher.(payload, payment_required)
iex> enriched["extensions"]["payment-identifier"]["info"]
%{"required" => true, "id" => "abcdefghijklmnop"}
iex> enricher = X402.Extensions.PaymentIdentifier.enricher()
iex> enricher.(%{"extensions" => %{}}, %{"extensions" => %{}})
{:ok, %{"extensions" => %{}}}
Builds the server-side advertisement for PaymentRequired.extensions.
Options
:required(boolean/0) - Whether clients must supply an id (info.required). The default value isfalse.
Examples
iex> X402.Extensions.PaymentIdentifier.extension()["info"]
%{"required" => false}
iex> X402.Extensions.PaymentIdentifier.extension(required: true)["info"]
%{"required" => true}
iex> extension = X402.Extensions.PaymentIdentifier.extension()
iex> extension["schema"] == X402.Extensions.PaymentIdentifier.schema()
true
@spec extension_key() :: String.t()
Returns the extension key on the wire.
Examples
iex> X402.Extensions.PaymentIdentifier.extension_key()
"payment-identifier"
@spec extract_id(term()) :: {:ok, extracted() | nil} | {:error, extract_error()}
Extracts the client's payment id from a PaymentPayload.extensions map.
Returns {:ok, {:spec, id}} for the "payment-identifier" format,
{:ok, {:legacy, id}} for the deprecated "paymentIdentifier" format,
and {:ok, nil} when no id is present (including when the spec extension
is echoed without an info.id). The spec key takes precedence: the legacy
key is only consulted when the spec key carries no id.
Errors are :invalid_payment_id for a spec-format id that fails
valid_id?/1 (or a non-map spec value), and {:legacy, reason} for a
malformed legacy value. Legacy ids are not subject to the spec's length
and character rules — any non-empty string is accepted.
Examples
iex> extensions = %{"payment-identifier" => %{"info" => %{"id" => "abcdefghijklmnop", "required" => false}}}
iex> X402.Extensions.PaymentIdentifier.extract_id(extensions)
{:ok, {:spec, "abcdefghijklmnop"}}
iex> X402.Extensions.PaymentIdentifier.extract_id(%{"payment-identifier" => %{"info" => %{"id" => "short"}}})
{:error, :invalid_payment_id}
iex> X402.Extensions.PaymentIdentifier.extract_id(%{"paymentIdentifier" => %{"paymentId" => "pay-1"}})
{:ok, {:legacy, "pay-1"}}
iex> X402.Extensions.PaymentIdentifier.extract_id(%{"paymentIdentifier" => %{}})
{:error, {:legacy, :missing_payment_id}}
iex> X402.Extensions.PaymentIdentifier.extract_id(%{})
{:ok, nil}
iex> X402.Extensions.PaymentIdentifier.extract_id(nil)
{:ok, nil}
@spec fetch_payment_id(term()) :: {:ok, payment_id()} | {:error, :missing_payment_id | :invalid_payment_id}
Extracts and validates "paymentId" from a decoded legacy payload map.
X402.Extensions.PaymentIdentifier.fetch_payment_id(%{"paymentId" => "pay-1"})
#=> {:ok, "pay-1"}
X402.Extensions.PaymentIdentifier.fetch_payment_id(%{})
#=> {:error, :missing_payment_id}
@spec fingerprint(map(), fingerprint_context()) :: String.t()
Computes the request fingerprint a payment id is bound to.
The fingerprint is the lowercase hex SHA-256 of the newline-joined
sequence scheme, network, asset, amount, payTo (read from
requirements, string or atom keys) followed by the context's :method,
:path, and :tool, in that order. Absent values contribute an empty
string; atoms are stringified (:get becomes "get"). Two requests with
the same fingerprint are the same request for idempotency purposes; a
reused id with a different fingerprint is a conflict.
Examples
iex> requirements = %{"scheme" => "exact", "network" => "eip155:8453", "asset" => "0xusdc", "amount" => "1000", "payTo" => "0xabc"}
iex> a = X402.Extensions.PaymentIdentifier.fingerprint(requirements, %{method: :get, path: "/api"})
iex> b = X402.Extensions.PaymentIdentifier.fingerprint(requirements, %{method: :get, path: "/api"})
iex> a == b
true
iex> String.length(a)
64
iex> requirements = %{"scheme" => "exact", "network" => "eip155:8453", "asset" => "0xusdc", "amount" => "1000", "payTo" => "0xabc"}
iex> a = X402.Extensions.PaymentIdentifier.fingerprint(requirements, %{tool: "search"})
iex> b = X402.Extensions.PaymentIdentifier.fingerprint(requirements, %{tool: "other"})
iex> a == b
false
@spec generate_id() :: payment_id()
Generates a random 32-character identifier (24 random bytes, Base64url without padding).
Examples
iex> id = X402.Extensions.PaymentIdentifier.generate_id()
iex> String.length(id)
32
iex> X402.Extensions.PaymentIdentifier.valid_id?(id)
true
@spec legacy_extension_key() :: String.t()
Returns the deprecated pre-0.7.0 extension key.
Examples
iex> X402.Extensions.PaymentIdentifier.legacy_extension_key()
"paymentIdentifier"
Returns whether an advertised extensions map marks the id as required.
Accepts the PaymentRequired.extensions map (string or atom keys).
Examples
iex> extensions = %{"payment-identifier" => X402.Extensions.PaymentIdentifier.extension(required: true)}
iex> X402.Extensions.PaymentIdentifier.required?(extensions)
true
iex> X402.Extensions.PaymentIdentifier.required?(%{"payment-identifier" => %{"info" => %{}}})
false
iex> X402.Extensions.PaymentIdentifier.required?(%{})
false
iex> X402.Extensions.PaymentIdentifier.required?(nil)
false
@spec schema() :: map()
Returns the JSON schema the server advertises for the extension.
Examples
iex> schema = X402.Extensions.PaymentIdentifier.schema()
iex> schema["properties"]["id"]["pattern"]
"^[A-Za-z0-9_-]+$"
Checks whether id is a valid spec-format identifier.
Valid ids are 16 to 128 characters long and
contain only A-Z, a-z, 0-9, _, and -.
Examples
iex> X402.Extensions.PaymentIdentifier.valid_id?("abcdefghijklmnop")
true
iex> X402.Extensions.PaymentIdentifier.valid_id?("too-short")
false
iex> X402.Extensions.PaymentIdentifier.valid_id?("has spaces in the id")
false
iex> X402.Extensions.PaymentIdentifier.valid_id?(nil)
false