shopifysharp-skill

Integrating Shopify with a .NET/C# backend using the ShopifySharp library. Use when configuring API keys, webhooks, rate limits, DI, GraphQL with IGraphService, or handling any Shopify REST/GraphQL resources to prevent common production bugs.

ShopifySharp Skill

Library: ShopifySharp >= 6.26.0 Target API Version: Shopify 2026-01 (latest stable) Target Framework: .NET 8 / .NET 9


1. Installation

dotnet add package ShopifySharp
dotnet add package ShopifySharp.Extensions.DependencyInjection

Use 6.26.0 or higher — minimum required for Shopify API 2026-01. Reference: https://www.nuget.org/packages/ShopifySharp


2. Environment Variables and Config

Required Variables

SHOPIFY_API_KEY=your_api_key
SHOPIFY_API_SECRET=your_api_secret
SHOPIFY_SHOP=your-store.myshopify.com
SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxx

Never hardcode credentials. Always load them from IConfiguration.

Typed Options Class

// Infrastructure/ShopifyOptions.cs
public class ShopifyOptions
{
    public string ApiKey      { get; set; } = string.Empty;
    public string ApiSecret   { get; set; } = string.Empty;
    public string Shop        { get; set; } = string.Empty;
    public string AccessToken { get; set; } = string.Empty;
}

appsettings.json

{
  "Shopify": {
    "ApiKey":       "",
    "ApiSecret":    "",
    "Shop":         "your-store.myshopify.com",
    "AccessToken":  "shpat_xxxxxxxxxxxx"
  }
}

3. API Version Configuration

Always enforce the API version. Without this, ShopifySharp might default to an obsolete/unsupported version.

// Via DI options (preferred — see section 4)
builder.Services.AddShopifySharp<LeakyBucketExecutionPolicy>(options =>
{
    options.ApiVersion = "2026-01";
});

// Global fallback (useful outside DI or in scripts)
ShopifySharp.Infrastructure.ShopifyService.SetGlobalApiVersion("2026-01");

4. Dependency Injection — Extension Method Pattern

The Program.cs Bloat Problem

Registering each service individually with factory lambdas bloats Program.cs with repetitive code. The solution is a centralized extension method.

AddShopifyServices() — Recommended Pattern

// Infrastructure/ShopifyServiceExtensions.cs
using ShopifySharp;
using ShopifySharp.Extensions.DependencyInjection;

public static class ShopifyServiceExtensions
{
    public static IServiceCollection AddShopifyServices(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // 1. Bind config
        services.Configure<ShopifyOptions>(
            configuration.GetSection("Shopify"));

        // 2. Core: rate limiting + API version
        services.AddShopifySharp<LeakyBucketExecutionPolicy>(options =>
        {
            options.ApiVersion = "2026-01";
        });

        // 3. Register the required services for the project
        services.AddScoped<IProductService>(sp =>
        {
            var o = sp.GetRequiredService<IOptions<ShopifyOptions>>().Value;
            return new ProductService(o.Shop, o.AccessToken);
        });

        services.AddScoped<IOrderService>(sp =>
        {
            var o = sp.GetRequiredService<IOptions<ShopifyOptions>>().Value;
            return new OrderService(o.Shop, o.AccessToken);
        });

        services.AddScoped<ICustomerService>(sp =>
        {
            var o = sp.GetRequiredService<IOptions<ShopifyOptions>>().Value;
            return new CustomerService(o.Shop, o.AccessToken);
        });

        services.AddScoped<IInventoryLevelService>(sp =>
        {
            var o = sp.GetRequiredService<IOptions<ShopifyOptions>>().Value;
            return new InventoryLevelService(o.Shop, o.AccessToken);
        });

        services.AddScoped<IWebhookService>(sp =>
        {
            var o = sp.GetRequiredService<IOptions<ShopifyOptions>>().Value;
            return new WebhookService(o.Shop, o.AccessToken);
        });

        services.AddScoped<IMetafieldService>(sp =>
        {
            var o = sp.GetRequiredService<IOptions<ShopifyOptions>>().Value;
            return new MetafieldService(o.Shop, o.AccessToken);
        });

        // GraphQL (see section 8)
        services.AddScoped<IGraphService>(sp =>
        {
            var o = sp.GetRequiredService<IOptions<ShopifyOptions>>().Value;
            return new GraphService(o.Shop, o.AccessToken);
        });

        return services;
    }
}

Resulting Program.cs — One single line

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddShopifyServices(builder.Configuration); // Includes all Shopify configuration

builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

See references/services-catalog.md for the entire catalog of available services.


5. Authentication and Webhook Validation

Direct Instantiation (Without DI)

var productService = new ProductService("your-store.myshopify.com", "shpat_xxx");

CRITICAL: HMAC Webhook Validation in ASP.NET Core

The bug: Request.Body is a forward-only stream. If something reads it first (model binder, middleware), the HMAC calculation receives an empty stream — or vice versa. The signature validation will always fail.

The solution: Request.EnableBuffering() converts the stream to seekable, allowing multiple reads and resetting with Position = 0.

// Controllers/WebhooksController.cs
[ApiController]
[Route("api/webhooks")]
public class WebhooksController : ControllerBase
{
    private readonly IOptions<ShopifyOptions> _opts;
    private readonly ILogger<WebhooksController> _logger;

    public WebhooksController(IOptions<ShopifyOptions> opts, ILogger<WebhooksController> logger)
    {
        _opts = opts;
        _logger = logger;
    }

    [HttpPost("orders/create")]
    public async Task<IActionResult> OrdersCreate()
    {
        // STEP 1: Enable buffering BEFORE any stream reading
        Request.EnableBuffering();

        // STEP 2: Read the raw body to calculate HMAC
        string rawBody;
        using (var reader = new StreamReader(
            Request.Body,
            encoding: System.Text.Encoding.UTF8,
            detectEncodingFromByteOrderMarks: false,
            leaveOpen: true))          // leaveOpen:true prevents closing the underlying stream
        {
            rawBody = await reader.ReadToEndAsync();
        }

        // STEP 3: Reset position — the model binder/deserializer can read it now
        Request.Body.Position = 0;

        // STEP 4: Validate HMAC with rawBody
        bool isValid = AuthorizationService.IsAuthenticWebhook(
            requestHeaders:   Request.Headers,
            requestBody:      rawBody,
            shopifyApiSecret: _opts.Value.ApiSecret
        );

        if (!isValid)
        {
            _logger.LogWarning("Invalid Webhook HMAC — possible unauthorized request");
            return Unauthorized();
        }

        // STEP 5: Safely deserialize (stream is already reset)
        var order = System.Text.Json.JsonSerializer.Deserialize<Order>(rawBody);
        if (order is null) return BadRequest("Invalid payload");

        _logger.LogInformation("Order received: {OrderId}", order.Id);
        // ... process order
        return Ok();
    }
}

Why leaveOpen: true: Without this flag, disposing the StreamReader closes the underlying HTTP request stream, and resetting Position = 0 will throw an ObjectDisposedException.

Alternative Middleware (Multiple Webhook Endpoints)

// Program.cs — register before app.MapControllers()
app.Use(async (context, next) =>
{
    if (context.Request.Path.StartsWithSegments("/api/webhooks"))
        context.Request.EnableBuffering();
    await next();
});

6. Rate Limiting — LeakyBucketExecutionPolicy

Shopify applies limits: REST ~2 req/s (leaky bucket), GraphQL based on query cost.

// Automatically configured within AddShopifyServices() from Step 4.
// For direct usage outside of DI:
var policy  = new LeakyBucketExecutionPolicy();
var service = new ProductService(shop, token);
service.SetExecutionPolicy(policy);

Real Policy Behavior — Critical Accuracy

LeakyBucketExecutionPolicy manages retries transparently and automatically. However:

  1. When Shopify responds 429, the policy waits and retries without throwing an exception.
  2. If retries are exhausted (timeout or max attempts reached), it throws ShopifyRateLimitException.
  3. ShopifyRateLimitException inherits from ShopifyExceptionit must always be caught.
  4. The policy does NOT suppress the definitive exception — it just delays it as much as possible.
// Correct: catch ShopifyRateLimitException as an explicit fallback
try
{
    await productService.CreateAsync(product);
}
catch (ShopifyRateLimitException ex)
{
    // Retries exhausted — escalate, queue for deferred retry, or return 429
    logger.LogError("Rate limit exhausted after retries. RetryAfter: {s}s",
        ex.RetryAfterSeconds);
    // Example: throw new TooManyRequestsException(ex.RetryAfterSeconds);
}
catch (ShopifyException ex)
{
    logger.LogError(ex, "Shopify API Error [{StatusCode}]", ex.HttpStatusCode);
}

In high-volume scenarios (bulk imports, background jobs making thousands of calls), ShopifyRateLimitException is likely to occur even if the policy is active. Never assume the policy absorbs everything.


7. Common Endpoint Patterns (REST)

Products

var page = await productService.ListAsync(new ProductListFilter
{
    Limit  = 250,
    Fields = "id,title,variants,images,status"
});

var product = await productService.GetAsync(productId);
var created = await productService.CreateAsync(new Product
{
    Title       = "My Product",
    Vendor      = "My Brand",
    ProductType = "Apparel",
    Variants    = [new ProductVariant { Price = 29.99m, Sku = "SKU-001" }]
});
await productService.UpdateAsync(productId, new Product { Title = "Updated" });
await productService.DeleteAsync(productId);

Orders

var orders = await orderService.ListAsync(new OrderListFilter
{
    Status = "open",
    Limit  = 50
});
var order = await orderService.GetAsync(orderId, "id,email,line_items,financial_status");
await orderService.CloseAsync(orderId);

Customers

var customer = await customerService.GetAsync(customerId);
var results  = await customerService.SearchAsync(new CustomerSearchListFilter
{
    Query = "email:[email protected]"
});

Inventory

await inventoryService.SetAsync(new InventoryLevel
{
    LocationId      = locationId,
    InventoryItemId = inventoryItemId,
    Available       = 100
});

Metafields

await metafieldService.CreateAsync(new Metafield
{
    Namespace     = "custom",
    Key           = "color",
    Value         = "blue",
    Type          = "single_line_text_field",
    OwnerId       = productId,
    OwnerResource = "product"
});

8. GraphQL with IGraphService

Shopify is actively migrating critical resources to become GraphQL-only. IGraphService is the standard for:

  • High-volume operations (bulk operations)
  • Complex queries or joins between resources that REST does not support well
  • Resources already deprecated or removed from the REST API in 2026-01
// Inject IGraphService (registered in AddShopifyServices)
public class ProductGraphRepository
{
    private readonly IGraphService _graph;

    public ProductGraphRepository(IGraphService graph) => _graph = graph;

    // Basic query
    public async Task<string> GetProductAsync(string gid)
    {
        var query = """
            query GetProduct($id: ID!) {
              product(id: $id) {
                id
                title
                status
                totalInventory
                variants(first: 10) {
                  edges { node { sku price inventoryQuantity } }
                }
              }
            }
            """;

        return await _graph.PostAsync(query, new { id = gid });
        // Returns a JSON string — deserialize with System.Text.Json as needed
    }

    // Bulk Operation — export all products (>10k records)
    public async Task<string> StartBulkExportAsync()
    {
        var mutation = """
            mutation {
              bulkOperationRunQuery(
                query: "{ products { edges { node { id title variants { edges { node { sku price } } } } } } }"
              ) {
                bulkOperation { id status }
                userErrors { field message }
              }
            }
            """;

        return await _graph.PostAsync(mutation);
    }
}

When to use REST vs GraphQL

ScenarioUse
Simple CRUD of products/orders/customersREST (IProductService, etc.)
Complex filtering or resource joinsGraphQL (IGraphService)
Exporting >10,000 recordsGraphQL Bulk Operations
Resources deprecated in REST 2026-01GraphQL (Mandatory)
Simple Mutations (create, update)REST (More straightforward)

For comprehensive GraphQL queries, mutations, pagination, and bulk result handling, read references/graphql.md.


9. Cursor-based Pagination

// Correctly iterate through ALL pages
string? pageInfo = null;
do
{
    var filter = new ProductListFilter { Limit = 250, PageInfo = pageInfo };
    var page   = await productService.ListAsync(filter);

    foreach (var product in page.Items)
    {
        // process
    }

    pageInfo = page.GetNextPageFilter()?.PageInfo;
} while (pageInfo is not null);

Do not use the page parameter (deprecated since API 2019-10). Always use PageInfo.


10. Comprehensive Error Handling

try
{
    var product = await productService.GetAsync(productId);
}
catch (ShopifyException ex) when (ex.HttpStatusCode == HttpStatusCode.NotFound)
{
    logger.LogWarning("Resource not found: {Id}", productId);
}
catch (ShopifyRateLimitException ex)
{
    // LeakyBucketExecutionPolicy retries automatically, but this exception
    // is thrown when all retries are exhausted. Handle it explicitly.
    logger.LogError("Rate limit exhausted. RetryAfter: {s}s", ex.RetryAfterSeconds);
}
catch (ShopifyException ex)
{
    logger.LogError(ex, "Shopify API Error [{StatusCode}]: {Message}",
        ex.HttpStatusCode, ex.Message);
}

11. Best Practices

DoAvoid
Enforce ApiVersion = "2026-01" in DIOmitting the API version
Centralize DI inside AddShopifyServices()Registering services one by one in Program.cs
Request.EnableBuffering() + Position = 0 in webhooksReading Request.Body without buffering
leaveOpen: true in webhook's StreamReaderLetting the reader close the stream
Use LeakyBucketExecutionPolicyImplementing manual Thread.Sleep / Task.Delay
Catch ShopifyRateLimitException as a fallbackAssuming the policy never throws exceptions
Load credentials from IConfigurationHardcoding API keys
Utilize Cursor-based pagination (PageInfo)Paginating with the page param (deprecated)
Request only necessary fields (Fields = "...")Always fetching all fields
Inject interfaces (IProductService)Injecting concrete classes (ProductService)
Validate HMAC upon every webhook receivedTrusting payloads without validation
Use GraphQL for bulk operations and complex queriesForcing REST for massive operations

12. Reference Files

  • references/services-catalog.md — Comprehensive catalog of services, interfaces, and filter properties.
  • references/graphql.md — GraphQL queries, mutations, bulk operations, and pagination with IGraphService.

Load references/graphql.md when the user asks about GraphQL, bulk exports, or resources removed from the REST API.