Offer-and-receipt extension for x402: signed offers and signed receipts.
Implements the
offer-and-receipt extension:
the resource server cryptographically commits to the payment terms it
advertises (a signed offer placed under
extensions["offer-receipt"].info.offers[] of the payment requirements)
and, after successful payment and delivery, confirms the transaction (a
signed receipt under extensions["offer-receipt"].info.receipt of the
settlement response). Both artifacts are portable, independently
verifiable, and identical for x402 v1 and v2.
Two signature formats are supported, per the specification (§3.1):
"eip712"— an EIP-712 typed-data signature with the fixed domain{name: "x402 offer" | "x402 receipt", version: "1", chainId: 1}(chain-agnostic by design, §3.2) and the canonicalOffer/Receipttypes from §4.3 and §5.3. Signing goes through theX402.Signerbehaviour; verification recovers the signer address. Requires the optionalex_keccak(and, for verification,ex_secp256k1) dependencies."jws"— a compact JWS (header.payload.signature) withES256KorEdDSA, implemented with OTP:cryptobyX402.Extensions.OfferReceipt.JWS. The protected header carries the mandatoryalgandkid(a DID URL) fields (§3.3); payloads are JCS-canonicalized (§10).
Server side: issuing offers and receipts
{:ok, signer} = X402.Signer.LocalKey.new(private_key)
{:ok, payload} =
X402.Extensions.OfferReceipt.offer_payload(
resource_url: "https://api.example.com/premium-data",
scheme: "exact",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
pay_to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
amount: "10000",
valid_until: System.os_time(:second) + 300
)
{:ok, offer} = X402.Extensions.OfferReceipt.sign_offer(payload, signer, accept_index: 0)
extensions = %{"offer-receipt" => X402.Extensions.OfferReceipt.build_extension([offer])}Client side: verifying
{:ok, [offer]} = X402.Extensions.OfferReceipt.fetch_offers(payment_required)
{:ok, %{signer: signer_address, payload: payload}} =
X402.Extensions.OfferReceipt.verify_offer(offer)A valid signature only proves which key signed — it does not prove the
key was authorized for the offered resource. Verifiers must apply an
authorization policy (§4.5.1); the simplest is checking the recovered
signer against the offer's payTo address, which verify_offer/2 supports
through the :expected_signer option.
Boundaries
- JWS verification takes an explicit
:public_key— this library never resolveskidDID URLs (no network access); resolve the key yourself and check its authorization per §4.5.1. - The JWS algorithms are limited to what OTP
:cryptoprovides:ES256KandEdDSA(both spec-named algorithms are covered).
Summary
Signed Offers
Extracts the payload from a signed envelope without verifying it.
Computes the EIP-712 digest of an offer payload (§4.3).
Builds an offer payload (§4.2) in wire shape.
Signs an offer payload as an EIP-712 artifact through an X402.Signer.
Signs an offer payload as a compact JWS artifact.
Verifies a signed offer envelope (§4.5).
Signed Receipts
Computes the EIP-712 digest of a receipt payload (§5.3).
Builds a receipt payload (§5.2) in wire shape.
Signs a receipt payload as an EIP-712 artifact through an X402.Signer.
Signs a receipt payload as a compact JWS artifact.
Verifies a signed receipt envelope (§5.5).
Extension Declaration
Builds the extensions["offer-receipt"] value for payment requirements.
Builds the extensions["offer-receipt"] value for a settlement response.
Fetches and validates the signed offers from a payment-required map.
Fetches and validates the signed receipt from a settlement response map.
Validates the structure of a signed offer envelope (§3.1.1, §4.2).
Validates the structure of a signed receipt envelope (§3.1.1, §5.2).
Types
A signed offer or receipt envelope in wire shape (string keys).
An offer or receipt payload in wire shape (string keys).
A built extensions["offer-receipt"] value (info + schema).
The result of a successful verification.
Functions
Converts a network identifier to CAIP-2 format.
Signed Offers
@spec extract_payload(envelope()) :: {:ok, payload()} | {:error, verify_error()}
Extracts the payload from a signed envelope without verifying it.
For "eip712" the transmitted payload is returned; for "jws" the JWS
payload segment is decoded. Use verify_offer/2 / verify_receipt/2 for
verified reads.
Examples
iex> X402.Extensions.OfferReceipt.extract_payload(%{
...> "format" => "eip712",
...> "payload" => %{"version" => 1},
...> "signature" => "0xsig"
...> })
{:ok, %{"version" => 1}}
iex> X402.Extensions.OfferReceipt.extract_payload(%{"format" => "carrier-pigeon"})
{:error, {:unsupported_format, "carrier-pigeon"}}
@spec offer_digest(payload()) :: {:ok, <<_::256>>} | {:error, payload_error() | :missing_dependency}
Computes the EIP-712 digest of an offer payload (§4.3).
Returns keccak256(0x19 0x01 || domainSeparator || structHash) where the
domain is {name: "x402 offer", version: "1", chainId: 1}. An absent
validUntil is hashed as 0. Requires the optional ex_keccak
dependency.
Builds an offer payload (§4.2) in wire shape.
The :network is normalized to CAIP-2 with to_caip2/1, as the
specification requires for offer payloads. version is always the current
payload schema version (1).
Options
:resource_url(String.t/0) - Required. The paid resource URL.:scheme(String.t/0) - Required. Payment scheme identifier (e.g."exact").:network(String.t/0) - Required. Network identifier — CAIP-2 or an x402 v1 name (converted viato_caip2/1).:asset(String.t/0) - Required. Token contract address or"native".:pay_to(String.t/0) - Required. Recipient wallet address.:amount- Required. Required payment amount in atomic units (encoded as a string).:valid_until(non_neg_integer/0) - Unix timestamp (seconds) when the offer expires. Omit for no expiry.
Examples
iex> X402.Extensions.OfferReceipt.offer_payload(
...> resource_url: "https://api.example.com/premium-data",
...> scheme: "exact",
...> network: "base",
...> asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
...> pay_to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
...> amount: 10_000,
...> valid_until: 1_703_123_516
...> )
{:ok,
%{
"version" => 1,
"resourceUrl" => "https://api.example.com/premium-data",
"scheme" => "exact",
"network" => "eip155:8453",
"asset" => "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo" => "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
"amount" => "10000",
"validUntil" => 1703123516
}}
iex> X402.Extensions.OfferReceipt.offer_payload(
...> resource_url: "https://a.example",
...> scheme: "exact",
...> network: "unknown-net",
...> asset: "native",
...> pay_to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
...> amount: "1"
...> )
{:error, {:unknown_network, "unknown-net"}}
@spec sign_offer(payload(), X402.Signer.t(), keyword()) :: {:ok, envelope()} | {:error, verify_error() | term()}
Signs an offer payload as an EIP-712 artifact through an X402.Signer.
Computes the EIP-712 digest with the fixed offer domain
(name: "x402 offer", version: "1", chainId: 1, §3.2) and the
canonical Offer type (§4.3), signs it through signer, and returns the
transmitted envelope
%{"format" => "eip712", "payload" => payload, "signature" => "0x..."}Per §4.3, an absent validUntil is signed and transmitted as 0.
Requires the optional ex_keccak dependency ({:error, :missing_dependency} without it).
Options
:accept_index(non_neg_integer/0) - Index intoaccepts[]this offer corresponds to. An unsigned convenience field (§4.1.1) — clients match offers by payload fields.
@spec sign_offer_jws( payload(), keyword() ) :: {:ok, envelope()} | {:error, X402.Extensions.OfferReceipt.JWS.sign_error() | payload_error()}
Signs an offer payload as a compact JWS artifact.
The payload travels inside the JWS, so the envelope omits payload
(§3.1.1). Optional payload fields stay omitted (no zero-filling — that
rule is EIP-712 specific).
key_opts are the X402.Extensions.OfferReceipt.JWS.sign/2 options
(:alg, :kid, :key) plus the :accept_index envelope option.
Examples
{:ok, offer} =
X402.Extensions.OfferReceipt.sign_offer_jws(payload,
alg: "EdDSA",
kid: "did:web:api.example.com#key-1",
key: ed25519_seed,
accept_index: 0
)
@spec verify_offer( envelope(), keyword() ) :: {:ok, verification()} | {:error, verify_error()}
Verifies a signed offer envelope (§4.5).
For "eip712" artifacts, recomputes the EIP-712 digest from the payload
exactly as transmitted and recovers the signer address (requires the
optional ex_secp256k1 / ex_keccak dependencies). For "jws"
artifacts, verifies the compact JWS against the :public_key option.
Returns {:ok, %{format: ..., payload: ...}} with :signer (EIP-712, the
recovered address) or :header (JWS, the protected header with alg and
kid).
Signature validity is not authorization
A valid signature proves which key signed, not that the key was
authorized for payload.resourceUrl (§4.5.1). Check the signer against
your authorization policy — for the common payTo-signs deployment,
pass expected_signer: payload["payTo"].
Options
:expected_signer(String.t/0) - EIP-712 only: EVM address the recovered signer must equal (case-insensitive), e.g. the offer'spayTo. When the recovered signer differs, verification fails with{:error, :unauthorized_signer}.:public_key(String.t/0) - JWS only (required for"jws"artifacts): raw public key bytes — a 32-byte Ed25519 key forEdDSA, a SEC1 secp256k1 point forES256K.:algs- JWS only: accepted algorithms. The default value is["ES256K", "EdDSA"].
Signed Receipts
@spec receipt_digest(payload()) :: {:ok, <<_::256>>} | {:error, payload_error() | :missing_dependency}
Computes the EIP-712 digest of a receipt payload (§5.3).
The domain is {name: "x402 receipt", version: "1", chainId: 1}; an
absent transaction is hashed as "". Requires the optional ex_keccak
dependency.
Builds a receipt payload (§5.2) in wire shape.
Receipts are privacy-minimal by default: transaction is only included
when passed. :issued_at defaults to the current Unix time.
Options
:resource_url(String.t/0) - Required. The paid resource URL.:network(String.t/0) - Required. Network identifier — CAIP-2 or an x402 v1 name (converted viato_caip2/1).:payer(String.t/0) - Required. Payer identifier (commonly a wallet address).:issued_at(non_neg_integer/0) - Unix timestamp (seconds) the receipt was issued. Defaults to the current time.:transaction(String.t/0) - Blockchain transaction hash. Optional — receipts are privacy-minimal by default; include it when verifiability matters more than privacy.
Examples
iex> X402.Extensions.OfferReceipt.receipt_payload(
...> resource_url: "https://api.example.com/premium-data",
...> network: "eip155:8453",
...> payer: "0x857b06519E91e3A54538791bDbb0E22373e36b66",
...> issued_at: 1_703_123_456
...> )
{:ok,
%{
"version" => 1,
"network" => "eip155:8453",
"resourceUrl" => "https://api.example.com/premium-data",
"payer" => "0x857b06519E91e3A54538791bDbb0E22373e36b66",
"issuedAt" => 1703123456
}}
@spec sign_receipt(payload(), X402.Signer.t()) :: {:ok, envelope()} | {:error, term()}
Signs a receipt payload as an EIP-712 artifact through an X402.Signer.
Uses the fixed receipt domain (name: "x402 receipt", version: "1",
chainId: 1, §3.2) and the canonical Receipt type (§5.3). Per §5.3, an
absent transaction is signed and transmitted as "".
@spec sign_receipt_jws( payload(), keyword() ) :: {:ok, envelope()} | {:error, X402.Extensions.OfferReceipt.JWS.sign_error() | payload_error()}
Signs a receipt payload as a compact JWS artifact.
See sign_offer_jws/2; key_opts are :alg, :kid, and :key.
@spec verify_receipt( envelope(), keyword() ) :: {:ok, verification()} | {:error, verify_error()}
Verifies a signed receipt envelope (§5.5).
Same contract as verify_offer/2, with the receipt domain and types.
issuedAt policy checks (freshness) and on-chain transaction checks are
the caller's responsibility.
Extension Declaration
Builds the extensions["offer-receipt"] value for payment requirements.
Takes the signed offer envelopes for the response's accepts[] entries
and returns the %{"info" => %{"offers" => offers}, "schema" => schema}
declaration (§4.1, §6.1/§6.3). All offers must share one signature format
— the specification's schemas are format-specific and servers use one
format consistently (§6). Raises ArgumentError for empty, mixed-format,
or structurally invalid offers (programmer errors).
Builds the extensions["offer-receipt"] value for a settlement response.
Takes the signed receipt envelope and returns the
%{"info" => %{"receipt" => receipt}, "schema" => schema} declaration
(§5.1, §6.5/§6.7). Raises ArgumentError for a structurally invalid
receipt (programmer error).
@spec fetch_offers(map()) :: {:ok, [envelope()]} | {:error, :extension_not_present | {:invalid_extension, term()}}
Fetches and validates the signed offers from a payment-required map.
Accepts either the full decoded PAYMENT-REQUIRED map (looks under
"extensions") or the extensions map itself. Validation is fail-closed:
every offer envelope must be structurally valid per §3.1.1
("eip712" offers carry a payload and a 65-byte hex signature; "jws"
offers carry a three-part compact JWS and no payload).
Examples
iex> X402.Extensions.OfferReceipt.fetch_offers(%{"accepts" => []})
{:error, :extension_not_present}
@spec fetch_receipt(map()) :: {:ok, envelope()} | {:error, :extension_not_present | {:invalid_extension, term()}}
Fetches and validates the signed receipt from a settlement response map.
Accepts either the full decoded settlement response (looks under
"extensions") or the extensions map itself.
@spec validate_offer(envelope()) :: :ok | {:error, verify_error()}
Validates the structure of a signed offer envelope (§3.1.1, §4.2).
Examples
iex> X402.Extensions.OfferReceipt.validate_offer(%{
...> "format" => "jws",
...> "signature" => "eyJhbGciOiJFUzI1NksiLCJraWQiOiJrIn0.eyJ2ZXJzaW9uIjoxfQ.c2ln"
...> })
:ok
iex> X402.Extensions.OfferReceipt.validate_offer(%{
...> "format" => "jws",
...> "payload" => %{"version" => 1},
...> "signature" => "a.b.c"
...> })
{:error, {:invalid_field, "payload"}}
@spec validate_receipt(envelope()) :: :ok | {:error, verify_error()}
Validates the structure of a signed receipt envelope (§3.1.1, §5.2).
Types
A signed offer or receipt envelope in wire shape (string keys).
An offer or receipt payload in wire shape (string keys).
A built extensions["offer-receipt"] value (info + schema).
@type verification() :: %{ optional(:signer) => String.t(), optional(:header) => map(), format: String.t(), payload: payload() }
The result of a successful verification.
@type verify_error() :: payload_error() | X402.Extensions.OfferReceipt.JWS.verify_error() | {:unsupported_format, term()} | :invalid_envelope | :missing_public_key | :unauthorized_signer | :missing_dependency | :invalid_signature
Functions
Converts a network identifier to CAIP-2 format.
Offer and receipt payloads must carry CAIP-2 identifiers even in x402 v1
flows (§4.2, §5.2). Strings that already contain a : are passed through;
x402 v1 names are mapped ("base" → "eip155:8453", "solana" → its
CAIP-2 chain reference); anything else is {:error, {:unknown_network, network}}.
Examples
iex> X402.Extensions.OfferReceipt.to_caip2("eip155:8453")
{:ok, "eip155:8453"}
iex> X402.Extensions.OfferReceipt.to_caip2("base-sepolia")
{:ok, "eip155:84532"}
iex> X402.Extensions.OfferReceipt.to_caip2("solana")
{:ok, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"}
iex> X402.Extensions.OfferReceipt.to_caip2("mystery-chain")
{:error, {:unknown_network, "mystery-chain"}}