Shopify in C#, part 6: fulfillment in the GraphQL-first world — fulfillmentOrder, tracking, and the REST sunset
Part 6from the Shopify in C# series · 6 parts in all
2024–25 closed the REST era: new apps are GraphQL-first, the product endpoints were the first REST surface switched off, and every roadmap item now lands in GraphQL first. The good news: fulfillment — the last piece of MapleCart's machine — works better in GraphQL than it ever did in REST. Part 6 is the fulfillment lifecycle done right, plus the two 2025 survival skills: bulk operations and webhooks.
The fulfillmentOrder model
REST fulfillments were a subresource of orders you filled at will. GraphQL flips it: every open order carries fulfillmentOrders — planner objects that say what stock, from where, in what state (OPEN, IN_PROGRESS, CLOSED) — and you create fulfillments from them. State machine first, action second:
// 1. Find what is fulfillable on the order
const string openWork = @"
query openWork($id: ID!) {
order(id: $id) {
name
fulfillmentOrders(first: 10, query: ""status:open"") {
edges { node {
id status assignedLocation { name }
lineItems(first: 20) { edges { node {
id # fulfillmentOrderLineItem id!
remainingQuantity
lineItem { title sku }
} } }
} }
}
}
}";
// 2. Fulfill it (fulfillmentCreateV2 - the V1 mutation is gone)
const string fulfill = @"
mutation fulfill($fulfillment: FulfillmentV2Input!) {
fulfillmentCreateV2(fulfillment: $fulfillment) {
fulfillment { id status
trackingInfo { number url company }
}
userErrors { field message }
}
}";
var data = await PostGraphAsync(http, fulfill, new {
fulfillment = new Dictionary<string, object?> {
["lineItemsByFulfillmentOrder"] = new object[] {
new Dictionary<string, object?> {
["fulfillmentOrderId"] = foGid,
["fulfillmentOrderLineItems"] = new object[] {
new Dictionary<string, object?> {
["id"] = foLineItemGid, ["quantity"] = 1 }}},
},
["notifyCustomer"] = true,
["trackingInfo"] = new { number = "1Z999AA10123456784",
company = "UPS" },
}});
// 3. Order transitions to FULFILLED automatically when all lines are done;
// partial fills are normal - two fulfillmentOrders, two mutations, one order.
Survival skill 1: bulk operations
// Exporting 200k customers one 250-page loop at a time is dead. Bulk ops
// run your query server-side and hand you a JSONL file (one JSON object
// per line, child objects follow their parents in nested field order).
const string exportQuery = @"
{ customers { edges { node { id email
orders { edges { node { name } } } } } } }";
const string bulkExport = @"
mutation op($query: String!) {
bulkOperationRunQuery(query: $query) {
bulkOperation { id status }
userErrors { field message }
}
}";
var op = await PostGraphAsync(http, bulkExport, new { query = exportQuery });
// Poll currentBulkOperation { bulkOperation { status objectCount url } }
// until COMPLETED, then stream the url - it is a file, not an API response.
// The same machinery imports too: stagedUploads + bulkOperationRunMutation.
Survival skill 2: webhooks (with the base64 twist)
// fulfillment lifecycle events you want:
// fulfillment_orders/fulfillment_request_rejected|accepted
// fulfillments/create (or orders/updated with fulfillment data)
// Verification is HMAC-SHA256 like Stripe, but base64-encoded (Stripe is
// hex), signed with the APP's client secret, over the raw body bytes:
using System.Security.Cryptography;
byte[] key = Encoding.UTF8.GetBytes(appClientSecret);
byte[] body = await ReadAllBytesAsync(request.Body); // raw bytes first
string expected = Convert.ToBase64String(
HMACSHA256.HashData(key, body)); // base64!
bool ok = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(request.Headers["X-Shopify-Hmac-Sha256"]));
// Also read X-Shopify-Topic / X-Shopify-Shop-Domain / X-Shopify-Webhook-Id
// (persist that id - it is your idempotency key, same contract as Stripe).
The series, in one paragraph
Part 1 opened a store to your code with one token and {resource}.json;
part 2 did customers properly (cursors, search, metafields); part 3 pivoted to GraphQL —
one endpoint, connections, cost budget, userErrors; part 4 read the order ledger and sent
draft-order invoices; part 5 made payments legible with transactions and
calculate-then-commit refunds; and this part fulfilled orders the 2025 way, with the bulk
and webhook skills that keep an integration alive. Where Stripe taught money as a
state machine, Shopify teaches commerce as a graph — and a .NET developer
holding both is exactly who MapleCart needed.
The whole series: 1 · Admin API + first customers · 2 · customers done right · 3 · GraphQL pivot · 4 · orders & invoices · 5 · payments & refunds · 6 · this article.