Shopify in C#, part 2: customers done right — search, pagination by cursor, addresses, metafields
Part 2from the Shopify in C# series · 6 parts in all
Part 1 stopped at the toy level of the customers API. Real integration work lives in four sub-features: cursor pagination (new this year, and mandatory knowledge), the search syntax, address subresources, and metafields. All REST this time — GraphQL arrives in part 3 — but everything here survives the pivot conceptually.
Pagination: the ?page= era ends
This year the Admin API is switching pagination from page numbers to opaque cursors,
because page= scans offset rows and degrades badly on million-customer
stores. The new contract: pass page_info (an opaque token you never parse)
plus limit, and read the next token from the Link response
header.
// GET /admin/api/2019-10/customers.json?limit=50
// (first call: optionally ?since_id=... to start a stream)
// Response headers:
// Link: <https://maplecart.myshopify.com/admin/api/2019-10/customers.json?
// page_info=hijgklmn&limit=50>; rel="next"
// (rel="previous" appears when you walked backwards; no Link = end of list)
using System.Text.RegularExpressions;
static string? NextLink(HttpResponseMessage r) =>
r.Headers.TryGetValues("Link", out var vals)
? Regex.Match(string.Join(",", vals),
"<([^>]+)>; rel=\"next\"").Groups[1].Value is { Length: > 0 } v ? v : null
: null;
// page_info is opaque BY DESIGN - never URL-decode it, never persist it;
// persist since_id or created_at boundaries instead, then re-walk.
var uri = "api/2019-10/customers.json?limit=50";
while (uri is not null)
{
var r = await http.GetAsync(uri);
Process(await r.Content.ReadAsStringAsync());
uri = NextLink(r) is { } next
? next[(next.IndexOf("/admin/") + "/admin/".Length)..] : null;
}
Search: a query language in a query parameter
// GET /admin/customers/search.json?query=... - fielded, AND-composable
// query=email:[email protected]
// query=first_name:Beth last_name:Carpenter (space = AND)
// query=tag:vip customer_tag:wholesale
// query=orders_count:>5
// Free text searches names/emails/addresses; fielded terms are exact/prefix.
var hits = await http.GetAsync(
"customers/search.json?query=tag:vip+orders_count:%3E3");
Addresses: the subresource pattern
// A customer has many addresses; the REST shape is a subresource:
// GET /admin/customers/{id}/addresses.json
// POST /admin/customers/{id}/addresses.json add one
// PUT /admin/customers/{id}/addresses/{aid}.json edit
// PUT /admin/customers/{id}/addresses.json -d address_ids[]=... set default
var addr = JsonSerializer.Serialize(new { address = new {
first_name = "Beth", last_name = "Carpenter",
address1 = "1200 Main St", city = "Springfield",
province = "IL", country = "US", zip = "62704",
@default = true }}); // @default maps to JSON "default" (reserved word)
await http.PostAsync($"customers/{id}/addresses.json",
new StringContent(addr, Encoding.UTF8, "application/json"));
Metafields: your namespace in their objects
// Metafields attach arbitrary typed data to any resource without changing
// the schema - the "extension columns" of Shopify. Scoped to your namespace.
var mf = JsonSerializer.Serialize(new { metafield = new {
@namespace = "loyalty", key = "tier",
value = "gold", value_type = "string" }});
await http.PostAsync($"customers/{id}/metafields.json",
new StringContent(mf, Encoding.UTF8, "application/json"));
// Read back: GET /admin/customers/{id}/metafields.json?namespace=loyalty
Two habits worth keeping for the whole series: never parse the Link
cursor, and never build a local customer schema — Shopify's object + metafields is the
storage. Next part changes the verb: the GraphQL Admin API, where one query replaces six
REST calls.