Behaviour and adapter helpers for payment identifier idempotency caches.
Cache adapters are configured as {module, cache} tuples where module
implements this behaviour and cache is adapter-specific runtime state
(for example, a pid, a registered process name, or a connection handle).
X402.Plug.PaymentGate routes all replay-protection calls through this
behaviour. The claim that prevents a payment proof from being settled twice
is put_new/3, so every adapter must implement it with atomic
first-writer-wins semantics: when several processes race to claim the same
payment identifier, exactly one call may return :ok and all others must
return {:error, :already_exists}.
Clustered deployments: double-execution hazard
Per-node caches do not protect a cluster
The bundled X402.Extensions.PaymentIdentifier.ETSCache adapter stores
claims in a per-node ETS table. In a clustered BEAM deployment every
node keeps its own table, so a replayed payment proof routed to two
different nodes is claimed independently on each — the protected handler
(and its side effects) can run once per node for a single payment.
The facilitator may still reject the duplicate settlement, but by then the
resource has already been served twice.
If you run more than one node, supply an adapter backed by a shared store
instead of the default ETS adapter — the bundled
X402.Extensions.PaymentIdentifier.RedisCache (over the optional redix
dependency), or your own Mnesia- or database-backed implementation.
Writing a distributed adapter
put_new/3 must combine "insert if absent" and "expire after TTL" in a
single atomic operation of the backing store. In Redis that operation is
SET key value NX PX ttl — the bundled
X402.Extensions.PaymentIdentifier.RedisCache adapter implements exactly
this contract over a Redix connection you supervise. For a different
backing store, a minimal adapter sketch:
defmodule MyApp.RedisPaymentCache do
@behaviour X402.Extensions.PaymentIdentifier.Cache
@ttl_ms :timer.hours(1)
@impl true
def put_new(conn, payment_id, value) do
# SET ... NX PX — atomic first-writer-wins with TTL in one command.
case Redix.command(conn, ["SET", key(payment_id), encode(value), "NX", "PX", @ttl_ms]) do
{:ok, "OK"} -> :ok
{:ok, nil} -> {:error, :already_exists}
{:error, reason} -> {:error, reason}
end
end
@impl true
def put(conn, payment_id, value) do
case Redix.command(conn, ["SET", key(payment_id), encode(value), "PX", @ttl_ms]) do
{:ok, "OK"} -> :ok
{:error, reason} -> {:error, reason}
end
end
@impl true
def get(conn, payment_id) do
case Redix.command(conn, ["GET", key(payment_id)]) do
{:ok, nil} -> :miss
{:ok, encoded} -> {:hit, decode(encoded)}
{:error, reason} -> {:error, reason}
end
end
@impl true
def delete(conn, payment_id) do
case Redix.command(conn, ["DEL", key(payment_id)]) do
{:ok, _count} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp key(payment_id), do: "x402:payment:" <> payment_id
# encode/1 and decode/1 map `:verified | {:rejected, reason}` to a
# string representation of your choice.
endConfigure it on the gate as:
plug X402.Plug.PaymentGate,
payment_identifier_cache: {MyApp.RedisPaymentCache, MyApp.Redis},
routes: [...]Adapter errors other than {:error, :already_exists} fail closed: the gate
responds with HTTP 500 and the protected handler does not run.
Summary
Types
Adapter tuple accepted by X402.Plug.PaymentGate.
Result returned by get/2.
Payment identifier cache key.
Result returned by put_new/3.
Value stored for a given payment identifier.
Result returned by write/delete operations.
Callbacks
Removes the entry for key, releasing a previously successful claim.
Reads the value stored for key, or :miss when absent or expired.
Unconditionally stores value for key, resetting its TTL.
Atomically stores value for key only when no live entry exists.
Functions
Deletes a cached value for a payment identifier.
Reads a cached value for a payment identifier.
Stores a cached value for a payment identifier.
Atomically claims a payment identifier through the adapter's put_new/3.
Validates a cache adapter tuple for NimbleOptions custom validation.
Validates an optional cache adapter for NimbleOptions.
Types
Adapter tuple accepted by X402.Plug.PaymentGate.
Result returned by get/2.
@type key() :: X402.Extensions.PaymentIdentifier.payment_id()
Payment identifier cache key.
@type put_new_result() :: :ok | {:error, :already_exists} | {:error, :cache_full} | {:error, term()}
Result returned by put_new/3.
@type value() :: :verified | {:rejected, term()}
Value stored for a given payment identifier.
@type write_result() :: :ok | {:error, term()}
Result returned by write/delete operations.
Callbacks
@callback delete(cache :: term(), key()) :: write_result()
Removes the entry for key, releasing a previously successful claim.
@callback get(cache :: term(), key()) :: get_result()
Reads the value stored for key, or :miss when absent or expired.
@callback put(cache :: term(), key(), value()) :: write_result()
Unconditionally stores value for key, resetting its TTL.
@callback put_new(cache :: term(), key(), value()) :: put_new_result()
Atomically stores value for key only when no live entry exists.
This is the replay-protection claim used by X402.Plug.PaymentGate, and it
must be atomic first-writer-wins: under concurrent calls with the same
key, exactly one caller receives :ok and every other caller receives
{:error, :already_exists}. Checking existence and inserting in two
separate store operations is not acceptable — use the backing store's
atomic primitive (:ets.insert_new/2, Redis SET NX PX, an INSERT with
a unique constraint, and so on).
The entry must expire after the adapter's TTL; an expired entry must not
block a new claim for the same key. An adapter with bounded capacity must
never evict a live entry to admit a new claim — a live claim is another
payment's replay lock; refuse with {:error, :cache_full} instead
(X402.Plug.PaymentGate fails closed on it). Return {:error, :already_exists}
only for a live (non-expired) duplicate — any other {:error, reason} is
treated as an adapter failure and fails the request closed.
Functions
@spec delete(adapter(), key()) :: write_result()
Deletes a cached value for a payment identifier.
@spec get(adapter(), key()) :: get_result()
Reads a cached value for a payment identifier.
@spec put(adapter(), key(), value()) :: write_result()
Stores a cached value for a payment identifier.
@spec put_new(adapter(), key(), value()) :: put_new_result()
Atomically claims a payment identifier through the adapter's put_new/3.
Returns :ok when this caller won the claim, {:error, :already_exists}
when a live entry already holds it, or {:error, reason} on adapter
failure. See put_new/3 for the atomicity contract.
Validates a cache adapter tuple for NimbleOptions custom validation.
Validates an optional cache adapter for NimbleOptions.
nil disables idempotency caching.