Bazaar provides plugs for UCP implementations:

PlugPurpose
UCPUCPHeaders followed by Idempotency, one plug for the whole pipeline
UCPHeadersRead the UCP headers and negotiate the protocol version
IdempotencyReplay responses for repeated Idempotency-Key requests
ValidateRequestValidate request bodies against the generated schemas
ValidateResponseValidate response bodies against the generated schemas

Setting Up Plugs

Add the plugs to your router pipeline, and the idempotency store to your supervision tree:

# lib/my_app/application.ex
children = [
  Bazaar.Idempotency.ETS,
  MyAppWeb.Endpoint
]

# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  use Bazaar.Phoenix.Router

  pipeline :ucp do
    plug :accepts, ["json"]
    plug Bazaar.Plugs.UCP
  end

  scope "/" do
    pipe_through :ucp
    bazaar_routes "/", MyApp.UCPHandler
  end
end

Bazaar.Plugs.UCP runs UCPHeaders and then Idempotency, and hands its options to both (store:, methods:, reservation_ttl:, version:). The sections below describe each plug; use them directly when something has to run between the two.

UCPHeaders

Reads the UCP request headers and negotiates the protocol version.

What It Does

  1. Reads UCP-Agent and parses its profile and version parameters
  2. Reads UCP-Request-ID, or generates one, and echoes it in the response
  3. Reads Request-Signature for verification downstream
  4. Rejects requests pinning a protocol version this server doesn't speak with a 422 error document

Usage

plug Bazaar.Plugs.UCPHeaders
plug Bazaar.Plugs.UCPHeaders, version: false   # skip version negotiation

The negotiated version defaults to Bazaar.DiscoveryProfile.version(), the spec version the library implements.

Accessing Headers in Handler

def create_checkout(params, conn) do
  conn.assigns[:ucp_agent]          # raw header, e.g. ~s(profile="https://platform.example/.well-known/ucp")
  conn.assigns[:ucp_agent_profile]  # "https://platform.example/.well-known/ucp"
  conn.assigns[:ucp_agent_version]  # "2026-08-25" or nil
  conn.assigns[:ucp_request_id]     # "req_abc123..."
  conn.assigns[:ucp_signature]
  # ...
end

The profile URL is where the platform advertises its capabilities, including the webhook URL for order events.

Idempotency

Replays responses for repeated requests that carry an Idempotency-Key header.

What It Does

  1. On the first request for a key, stores the response status and body once the action has run
  2. On a repeat with the same key and the same method, path and body, replays the stored response verbatim and halts
  3. On a repeat with the same key but a different body, answers 409 with an error document
  4. Only POST, PUT and PATCH take part; other methods just get the key in conn.assigns.idempotency_key

The lookup runs before the action, so replaying a completed checkout's completion still returns the original response. The key is reserved before the action runs, so a second identical request arriving while the first is still in flight gets a 409 instead of running twice. The plug fingerprints conn.body_params, so it must run after Plug.Parsers (any Phoenix endpoint does this).

Usage

plug Bazaar.Plugs.Idempotency
plug Bazaar.Plugs.Idempotency, store: {MyApp.RedisIdempotency, :orders}, methods: ["POST"]

Stores

Bazaar.Idempotency.ETS keeps records in memory, never expires them, and only knows about its own node. Use it for development and a single-node deployment. In production, and always with more than one node, back the plug with Cachex: its entries carry a TTL, so keys expire instead of growing forever, and its routers spread the cache across a cluster, so a retry that lands on another node still finds the record.

Bazaar.Idempotency.Cachex is that store. Add {:cachex, "~> 4.1"} to your deps, start a cache with a default expiration, and point the plug at it:

# application.ex
import Cachex.Spec
children = [
  {Cachex, [:idempotency, [expiration: expiration(default: :timer.hours(24))]]},
  MyAppWeb.Endpoint
]

# router.ex
plug Bazaar.Plugs.UCP, store: {Bazaar.Idempotency.Cachex, :idempotency}

Any other backend implements the four callbacks of Bazaar.Idempotency.Store: fetch/2, reserve/3, put/3 and release/2. reserve/3 has to be atomic, because that's what stops two identical requests in flight from both running.

Only 2xx and 4xx responses are recorded; a 5xx releases the key so the platform's retry runs the action again. A reservation left behind by a request that crashed before responding is taken over after :reservation_ttl (30 seconds by default).

Plug Order

pipeline :ucp do
  plug Bazaar.Plugs.UCPHeaders    # headers and version first, so rejections carry a request id
  plug Bazaar.Plugs.Idempotency   # replay before validation and before the action
  plug Bazaar.Plugs.ValidateRequest
end

Error Documents

Both plugs, and Bazaar.Phoenix.Controller, render errors with Bazaar.Errors.response/2: the UCP error response (ucp.status: "error" plus messages[]) for UCP routes and the ACP Error object for ACP routes.

Logging

Use request IDs for tracing:

def create_checkout(params, conn) do
  Logger.metadata(request_id: conn.assigns[:ucp_request_id])
  Logger.info("Creating checkout", params: params)
  # ...
end

Next Steps