X402.Extensions.PaymentIdentifier (X402 v0.9.0)

Copy Markdown View Source

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

decode(value) deprecated

Decodes a legacy Base64 JSON payload and returns the payment identifier.

encode(payment_id) deprecated

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

decode_error()

@type decode_error() ::
  :invalid_base64 | :invalid_json | :missing_payment_id | :invalid_payment_id

encode_error()

@type encode_error() :: :invalid_payment_id | :invalid_json

extract_error()

@type extract_error() :: :invalid_payment_id | {:legacy, decode_error()}

Errors returned by extract_id/1.

extracted()

@type extracted() :: {:spec, payment_id()} | {:legacy, payment_id()}

An extracted id, tagged with the wire format it arrived in.

fingerprint_context()

@type fingerprint_context() :: %{
  optional(:method) => atom() | String.t(),
  optional(:path) => String.t(),
  optional(:tool) => String.t()
}

Request context hashed into fingerprint/2.

legacy_source()

@type legacy_source() :: :gate | :mcp

Where a legacy-format identifier was observed.

payment_id()

@type payment_id() :: String.t()

Payment identifier value used for idempotency.

Functions

decode(value)

(since 0.1.0)
This function is deprecated. Use the spec `payment-identifier` format (extension/1, extract_id/1); removed in 1.0.0.
@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}

encode(payment_id)

(since 0.1.0)
This function is deprecated. Use the spec `payment-identifier` format (extension/1, extract_id/1); removed in 1.0.0.
@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)

enricher(opts \\ [])

(since 0.9.0)
@spec enricher(keyword()) :: (map(), map() | nil ->
                          {:ok, map()} | {:error, :invalid_payment_id})

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 fresh generate_id/0 value is produced on every invocation of the returned function. The default value is nil.

  • :always (boolean/0) - Attach the extension even when the server did not advertise payment-identifier. Defaults to false: the enricher is a no-op for servers that do not support the extension. The default value is false.

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" => %{}}}

extension(opts \\ [])

(since 0.9.0)
@spec extension(keyword()) :: map()

Builds the server-side advertisement for PaymentRequired.extensions.

Options

  • :required (boolean/0) - Whether clients must supply an id (info.required). The default value is false.

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

extension_key()

(since 0.9.0)
@spec extension_key() :: String.t()

Returns the extension key on the wire.

Examples

iex> X402.Extensions.PaymentIdentifier.extension_key()
"payment-identifier"

extract_id(extensions)

(since 0.9.0)
@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}

fetch_payment_id(payload)

(since 0.1.0)
This function is deprecated. Use the spec `payment-identifier` format (extension/1, extract_id/1); removed in 1.0.0.
@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}

fingerprint(requirements, context)

(since 0.9.0)
@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

generate_id()

(since 0.9.0)
@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

legacy_extension_key()

(since 0.9.0)
@spec legacy_extension_key() :: String.t()

Returns the deprecated pre-0.7.0 extension key.

Examples

iex> X402.Extensions.PaymentIdentifier.legacy_extension_key()
"paymentIdentifier"

required?(extensions)

(since 0.9.0)
@spec required?(term()) :: boolean()

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

schema()

(since 0.9.0)
@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_-]+$"

valid_id?(id)

(since 0.9.0)
@spec valid_id?(term()) :: boolean()

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