A client for Shopify's GraphQL Admin API, for Elixir. HTTP is handled by Req under the hood: you never have to touch it, but its options are there when you need them.
GraphQL-only, by design: Shopify's REST Admin API is legacy. There's no OAuth, sessions or webhook plumbing either. This library talks to the API, and your app owns everything else.
What it does, beyond sending a query:
- Throttling that doesn't block needlessly. Every response's
throttleStatusfeeds a node-wide, per-shop cost budget, checked before each request. Choose, per call, whether to wait for budget or fail fast. - Errors you can act on.
THROTTLED,ACCESS_DENIED, mutationuserErrors, shops that are uninstalled or frozen, and transport failures all become oneShopifyClient.Errorwith a:reason. - Pagination as a lazy
Stream. Pages are fetched only as they're consumed. - Bulk operations the current way. Tracked by id (not the deprecated
currentBulkOperation), with results streamed line by line, never loaded into memory. - Safe by default. The access token never shows up in
inspect/1, is only ever sent to*.myshopify.com, and is never sent to bulk-result storage. Throttled requests are retried; a mutation that may have run is not.
Status: pre-release (0.1). Not published to Hex yet.
def deps do
[
{:shopify_client, github: "fluke/shopify_client_elixir"}
]
endclient =
ShopifyClient.new(
shop: "example.myshopify.com",
access_token: token,
api_version: "2026-04"
)
{:ok, %ShopifyClient.Response{data: data, cost: cost}} =
ShopifyClient.query(client, """
query Product($id: ID!) {
product(id: $id) { title status }
}
""", %{"id" => "gid://shopify/Product/1"})There is deliberately no default API version: pin one, and bump it on purpose.
# Default: wait (at most :max_wait ms at a time) for budget, and retry
# THROTTLED answers up to :max_throttle_retries times.
ShopifyClient.query(client, query, vars)
# Never sleep: return {:error, %ShopifyClient.Error{reason: :throttled}} at
# once. For callers with a better fallback than waiting.
ShopifyClient.query(client, query, vars, throttle: :fail_fast)A throttled request never ran, so ShopifyClient.Error.retry_safe?/1 is
true for it. Transport errors and 5xx responses are not retried by default,
because the operation may already have run.
ShopifyClient.new(
shop: ..., access_token: ..., api_version: "2026-04",
# One deadline for connecting, pool checkout and the response.
timeout: 2_000,
# Leave 500 points of the shop's budget to others sharing it (another app or
# service using the same shop).
reserve: 500,
# Retry *queries* (never mutations) after a network error or 5xx.
query_retries: 2
)case ShopifyClient.query(client, mutation, vars) do
{:ok, response} -> ...
{:error, %ShopifyClient.Error{reason: :user_errors, errors: errors}} -> ...
{:error, %ShopifyClient.Error{} = error} ->
if ShopifyClient.Error.shop_unavailable?(error), do: mark_uninstalled(shop)
endPass user_errors: :ignore to get mutation userErrors back in the data
instead.
query = """
query Products($cursor: String) {
products(first: 250, after: $cursor) {
nodes { id title }
pageInfo { hasNextPage endCursor }
}
}
"""
client
|> ShopifyClient.stream(query, %{}, path: ["products"])
|> Stream.filter(&(&1["title"] =~ "Sale"))
|> Enum.take(10){:ok, operation} = ShopifyClient.Bulk.run_query(client, "{ products { edges { node { id } } } }")
{:ok, operation} = ShopifyClient.Bulk.await(client, operation)
client
|> ShopifyClient.Bulk.stream_results(operation)
|> Stream.each(&import_product/1)
|> Stream.run()At scale, skip polling: subscribe to the bulk_operations/finish webhook,
then use ShopifyClient.Bulk.webhook_operation_id/1 and ShopifyClient.Bulk.get/3.
Req options (timeouts, proxies, a test plug) go in :req_options. For
anything more, such as a tracing or logging step, use update_req/2:
client =
ShopifyClient.new(shop: ..., access_token: ..., api_version: "2026-04",
req_options: [receive_timeout: 5_000])
client = ShopifyClient.update_req(client, &Req.Request.append_request_steps(&1, trace: &MyApp.trace/1))Point the client at a Req.Test stub with req_options: [plug: {Req.Test, MyApp.Shopify}].
ShopifyClient.Test builds Shopify-shaped bodies:
Req.Test.stub(MyApp.Shopify, fn conn ->
Req.Test.json(conn, ShopifyClient.Test.data(%{"shop" => %{"name" => "Example"}}))
end)
Req.Test.stub(MyApp.Shopify, &Req.Test.json(&1, ShopifyClient.Test.throttled()))There's a [:shopify_client, :query] span per query, plus [:shopify_client, :throttle]
and [:shopify_client, :deprecated] events. See ShopifyClient.Telemetry.
ex_shopify_schema: typed structs for Admin API types, per API version. It pairs well with this client for decoding responses.
MIT