Shopify in C#, part 3: the GraphQL Admin API — one endpoint, variables, cost budget, and real customers
Part 3from the Shopify in C# series · 6 parts in all
The GraphQL Admin API has been quietly eating the REST surface, and this year two
changes make it the default: API versions are now real (the URL pins one), and private
apps are being retired in favor of custom apps with proper Admin API access
tokens (shpat_...). Part 3 is the pivot: one endpoint, typed queries, and
customers in GraphQL with the pagination and cost model explained.
The new auth and the one endpoint
// 2021: custom app = static Admin API access token (shpat_...), same token
// every call, no OAuth dance for your own store.
var http = new HttpClient();
http.BaseAddress = new System.Uri("https://maplecart.myshopify.com/");
http.DefaultRequestHeaders.Add("X-Shopify-Access-Token", accessToken);
// EVERYTHING is a POST to one versioned endpoint. The version in the URL is
// a contract: objects and fields are stable inside it, upgrades are deliberate.
const string Endpoint = "admin/api/2021-01/graphql.json";
static async Task<JsonElement> PostGraphAsync(HttpClient http, string query,
object? variables = null)
{
var body = JsonSerializer.Serialize(new { query, variables });
using var content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await http.PostAsync(Endpoint, content);
var json = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
// GraphQL reports errors BESIDE data, not as an HTTP status: 200 with
// { "errors": [...] } is a failed query. Check errors first, always.
if (doc.RootElement.TryGetProperty("errors", out var errs))
throw new InvalidOperationException(errs.ToString());
return doc.RootElement.GetProperty("data").Clone();
}
Customers: a nested query in one round-trip
// The same data part 2 fetched in 3-4 REST calls, as one GraphQL document:
const string customerQuery = @"
query customer($id: ID!) {
customer(id: $id) {
id
firstName
lastName
email
tags
ordersCount # the rollup REST called orders_count
amountSpent { amount currencyCode } # money is structured here
addresses { address1 city provinceCode countryCodeV2 zip }
metafields(first: 10, namespace: ""loyalty"") {
edges { node { key value } }
}
orders(first: 5) { edges { node { name totalPriceSet { shopMoney { amount } } } } }
}
}";
var data = await PostGraphAsync(http, customerQuery, new { id = customerId });
var c = data.GetProperty("customer");
Console.WriteLine(c.GetProperty("email").GetString());
// Note the property naming: REST snake_case -> GraphQL camelCase, and
// ""loyalty"" -> "loyalty" (C# verbatim strings double embedded quotes).
Pagination in GraphQL: edges, nodes, pageInfo
// The connection pattern. first/after replaces limit/page_info.
const string listQuery = @"
query customers($cursor: String) {
customers(first: 50, after: $cursor, query: ""tag:vip"") {
edges {
node { id email firstName lastName }
cursor # per-edge cursor for resuming
}
pageInfo { hasNextPage }
}
}";
// Walk: pass the LAST edge's cursor as $cursor until hasNextPage is false.
// (Edge = node + cursor + per-edge extras like rollback cost; node = payload.)
The cost budget: why your big query got throttled
GraphQL charges per query by shape, not per request: each nesting level
multiplies cost against a leaky-bucket budget (REST was 2 req/s flat). The response
extensions.cost tells you what you spent and what the bucket holds — read it
once in development and size your pages accordingly:
// extensions: { cost: { requestedQueryCost: 260, actualQueryCost: 262,
// throttleStatus: { maximumAvailable: 1000.0, currentlyAvailable: 738,
// restoreRate: 50.0 } } }
// Currently-available under requestedQueryCost = HTTP 429. Wait a beat and
// retry once; persist restoreRate math only if you run hot continuously.
Mutations: input types and userErrors
// Mutations take an "input" object type and return userErrors - Shopify's
// own validation channel (NOT exceptions, NOT HTTP status):
const string createCustomer = @"
mutation customerCreate($input: CustomerInput!) {
customerCreate(input: $input) {
customer { id email }
userErrors { field message }
}
}";
var data2 = await PostGraphAsync(http, createCustomer, new {
input = new Dictionary<string, object?> {
["email"] = "[email protected]",
["firstName"] = "Beth",
["tags"] = new[] { "vip" },
}});
// data.customerCreate.userErrors == [] means success; else it is the
// structured "email is not valid"-style list REST returned in "errors".
That is the complete GraphQL tool belt: one endpoint, variables, connections, cost budget, userErrors. Parts 4–6 apply it where it pays the most — orders and draft-order invoices, Shopify Payments transactions and refunds, and fulfillments.