portage-ucp-etsy
Etsy adapter for portage-ucp. Implements Portage::Ucp::Adapter against Etsy's Open API v3. Generic only — no merchant-specific business logic. Plain Net::HTTP, no Etsy SDK runtime dependency.
What it covers — and what it deliberately doesn't
Unlike the other adapters in this project, this is catalog + redirect-link checkout + order, not a full transactional adapter. Etsy's public API has no cart, checkout, or add-to-cart endpoint at all — buying only ever happens on etsy.com itself.
| UCP capability | Backing Etsy API | Notes |
|---|---|---|
dev.ucp.shopping.catalog |
Open API v3 | search_catalog (client-side title filter, see below), get_product |
dev.ucp.shopping.checkout |
— | create_checkout/get_checkout only. update_checkout/complete_checkout/cancel_checkout are left unoverridden — calling them raises Portage::Ucp::NotImplementedError rather than pretending to do something. dev.ucp.shopping.checkout is still advertised (one overridden method is enough), so an agent discovers what's actually backed by trying it. |
dev.ucp.shopping.order |
Open API v3 (shop receipts) | get_order |
dev.ucp.shopping.cart |
— | not implemented at all; there's no Etsy cart resource to back it |
dev.ucp.shopping.identity |
— | not implemented; Etsy buyer/seller OAuth identity is a separate concern from the shop-owner token used here |
create_checkout's Checkout#links point at each requested listing's own etsy.com page — the closest real equivalent to "add to cart and check out" this API allows. These Checkout objects are not real Etsy resources — nothing on Etsy's side tracks them. They live only in the Adapter instance's memory (get_checkout just reads back what create_checkout stored), so they don't survive a process restart or a different Adapter instance. get_order's checkout_id is always blank for the same underlying reason: there's no real checkout for a receipt to link back to.
search_catalog is weaker than every other adapter's. Etsy's shop-listings endpoint supports no keyword filter (only limit/offset/sort) — this fetches a page of active listings and filters by title client-side. Fine for a small shop, misleading for a large one with more listings than fit in one page.
Installation
# Gemfile
gem "portage-ucp-etsy"
bundle install
Setup
You need an OAuth access_token (from the shop owner's one-time consent — Etsy's authorization-code+PKCE flow, outside this gem's scope) and your app's x-api-key (the OAuth client's keystring, required on every request in addition to the bearer token).
require "portage/ucp/etsy"
client = Portage::Ucp::Etsy::Client.new(
access_token: ENV.fetch("ETSY_ACCESS_TOKEN"),
api_key: ENV.fetch("ETSY_API_KEY")
)
adapter = Portage::Ucp::Etsy::Adapter.new(client: client, shop_id: ENV.fetch("ETSY_SHOP_ID"))
Refreshing the access token
Etsy access tokens expire quickly and rotate the refresh_token on every use — persist the new one each time, the old one stops working immediately.
fetcher = Portage::Ucp::Etsy::AccessTokenFetcher.new(
client_id: ENV.fetch("ETSY_CLIENT_ID"),
refresh_token: ENV.fetch("ETSY_REFRESH_TOKEN")
)
result = fetcher.fetch
result.access_token # => pass into Client.new
result.refresh_token # => save this — the old one is now invalid
result.expires_in # => seconds until it needs refreshing again
Usage
# Catalog — product_id is Etsy's listing_id
products = adapter.search_catalog(query: "mug", limit: 10)
product = adapter.get_product(product_id: products.first.id)
# Checkout — a redirect, not a real transaction
checkout = adapter.create_checkout(
line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
idempotency_key: SecureRandom.uuid
)
checkout.links.first.url # => hand this to the shopper/agent to complete the purchase on etsy.com
# Order
order = adapter.get_order(order_id: some_receipt_id)
Wiring into portage-ucp
Drop the adapter into a Dispatcher (or the MCP server) the same as any other backend:
dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
dispatcher.call(
capability: "dev.ucp.shopping.checkout",
action: "create_checkout",
arguments: { line_items: [{ product_id: listing_id, quantity: 1 }], idempotency_key: SecureRandom.uuid }
)
Errors
Portage::Ucp::Etsy::Error # base class
Portage::Ucp::Etsy::ApiError # any non-2xx response from Etsy's Open API v3
Development
bundle exec rspec # tests (WebMock-stubbed, no live Etsy account needed)
bundle exec rubocop # lint
# refresh a real access_token for a connected shop
ETSY_CLIENT_ID=... ETSY_REFRESH_TOKEN=... bundle exec rake etsy_access_token
License
MIT — Copyright (c) 2026 Tom Whitbread.