Bazaar provides plugs for UCP implementations:
| Plug | Purpose |
|---|---|
UCP | UCPHeaders followed by Idempotency, one plug for the whole pipeline |
UCPHeaders | Read the UCP headers and negotiate the protocol version |
Idempotency | Replay responses for repeated Idempotency-Key requests |
ValidateRequest | Validate request bodies against the generated schemas |
ValidateResponse | Validate 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
endBazaar.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
- Reads
UCP-Agentand parses itsprofileandversionparameters - Reads
UCP-Request-ID, or generates one, and echoes it in the response - Reads
Request-Signaturefor verification downstream - 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 negotiationThe 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]
# ...
endThe 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
- On the first request for a key, stores the response status and body once the action has run
- On a repeat with the same key and the same method, path and body, replays the stored response verbatim and halts
- On a repeat with the same key but a different body, answers 409 with an error document
- Only
POST,PUTandPATCHtake part; other methods just get the key inconn.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
endError 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)
# ...
endNext Steps
- Handlers Guide - Access plug data in handlers
- Testing Guide - Test with plugs