# 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.

- Skill: `l-riiver/shopifysharp-skill` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add l-riiver/shopifysharp-skill`
- Raw SKILL.md: https://api.skillmd.com/api/skills/l-riiver/shopifysharp-skill/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: L-Riiver (https://skillmd.com/u/l-riiver)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/l-riiver/shopifysharp-skill

---


# ShopifySharp Skill

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

---

## 1. Installation

```bash
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

```csharp
// 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

```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.

```csharp
// 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

```csharp
// 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

```csharp
// 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)

```csharp
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`.

```csharp
// 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)

```csharp
// 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.

```csharp
// 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 `ShopifyException` — **it must always be caught**.
4. The policy does NOT suppress the definitive exception — it just delays it as much as possible.

```csharp
// 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

```csharp
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

```csharp
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

```csharp
var customer = await customerService.GetAsync(customerId);
var results  = await customerService.SearchAsync(new CustomerSearchListFilter
{
    Query = "email:user@example.com"
});
```

### Inventory

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

### Metafields

```csharp
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`

```csharp
// 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

| Scenario | Use |
|---|---|
| Simple CRUD of products/orders/customers | REST (`IProductService`, etc.) |
| Complex filtering or resource joins | GraphQL (`IGraphService`) |
| Exporting >10,000 records | GraphQL Bulk Operations |
| Resources deprecated in REST `2026-01` | GraphQL (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

```csharp
// 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

```csharp
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

| Do | Avoid |
|---|---|
| Enforce `ApiVersion = "2026-01"` in DI | Omitting the API version |
| Centralize DI inside `AddShopifyServices()` | Registering services one by one in `Program.cs` |
| `Request.EnableBuffering()` + `Position = 0` in webhooks | Reading `Request.Body` without buffering |
| `leaveOpen: true` in webhook's `StreamReader` | Letting the reader close the stream |
| Use `LeakyBucketExecutionPolicy` | Implementing manual `Thread.Sleep` / `Task.Delay` |
| Catch `ShopifyRateLimitException` as a fallback | Assuming the policy never throws exceptions |
| Load credentials from `IConfiguration` | Hardcoding 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 received | Trusting payloads without validation |
| Use GraphQL for bulk operations and complex queries | Forcing 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.

