Trust is earned, not given

A different perspective

2025-06-10 · Projects

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.