Chapter 10: API Design

RESTful API Architecture for Multi-Tenant POS

This chapter provides the complete API specification for the POS Platform, including all endpoints, request/response formats, and real-time communication patterns.


10.1 Base URL Structure

Tenant-Aware URL Pattern

https://{tenant}.pos-platform.com/api/v1/{resource}

Examples:

https://nexus.pos-platform.com/api/v1/items
https://acme-retail.pos-platform.com/api/v1/sales
https://fashion-outlet.pos-platform.com/api/v1/inventory

Alternative: Header-Based Tenancy

For single-domain deployments:

https://api.pos-platform.com/api/v1/{resource}
X-Tenant-Id: nexus

10.2 API Versioning Strategy

/api/v1/...  ← Current (stable)
/api/v2/...  ← Future (when breaking changes needed)

Version Header:

X-API-Version: 2025-01-15

10.3 Complete Endpoint Reference

10.3.1 Catalog Domain

Items (Products)

# List all items (paginated)
GET /api/v1/items?page=1&pageSize=50&category=apparel&active=true

# Get single item
GET /api/v1/items/{id}

# Get item by SKU
GET /api/v1/items/by-sku/{sku}

# Get item by barcode
GET /api/v1/items/by-barcode/{barcode}

# Create item
POST /api/v1/items

# Update item
PUT /api/v1/items/{id}

# Partial update
PATCH /api/v1/items/{id}

# Delete (soft delete)
DELETE /api/v1/items/{id}

# Bulk operations
POST /api/v1/items/bulk-create
PUT /api/v1/items/bulk-update
POST /api/v1/items/bulk-import

Create Item Request:

{
  "sku": "NXJ-1001-BLK-M",
  "barcode": "0123456789012",
  "name": "Classic Oxford Shirt",
  "description": "Premium cotton oxford shirt",
  "categoryId": "cat_apparel_shirts",
  "vendorId": "vendor_acme",
  "brand": "ACME Apparel",
  "cost": 24.99,
  "price": 59.99,
  "taxable": true,
  "trackInventory": true,
  "reorderPoint": 10,
  "reorderQuantity": 50,
  "tags": ["new-arrival", "oxford", "premium"],
  "attributes": {
    "color": "Black",
    "size": "Medium",
    "material": "100% Cotton"
  },
  "metadata": {
    "seasonCode": "SS2025",
    "fabricId": "fab_cotton_100"
  }
}

Item Response:

{
  "id": "item_01HQWXYZ123",
  "tenantId": "tenant_nexus",
  "sku": "NXJ-1001-BLK-M",
  "barcode": "0123456789012",
  "name": "Classic Oxford Shirt",
  "description": "Premium cotton oxford shirt",
  "categoryId": "cat_apparel_shirts",
  "categoryName": "Shirts",
  "vendorId": "vendor_acme",
  "vendorName": "ACME Apparel",
  "cost": 24.99,
  "price": 59.99,
  "taxable": true,
  "trackInventory": true,
  "isActive": true,
  "reorderPoint": 10,
  "reorderQuantity": 50,
  "totalQuantityOnHand": 145,
  "attributes": {
    "color": "Black",
    "size": "Medium",
    "material": "100% Cotton"
  },
  "inventoryByLocation": [
    { "locationId": "loc_hq", "locationName": "Warehouse", "quantity": 100 },
    { "locationId": "loc_gm", "locationName": "Greenbrier", "quantity": 25 },
    { "locationId": "loc_lm", "locationName": "Lynnhaven", "quantity": 20 }
  ],
  "createdAt": "2025-01-15T10:30:00Z",
  "updatedAt": "2025-01-20T14:22:00Z",
  "_links": {
    "self": "/api/v1/items/item_01HQWXYZ123",
    "category": "/api/v1/categories/cat_apparel_shirts",
    "vendor": "/api/v1/vendors/vendor_acme",
    "inventory": "/api/v1/inventory?itemId=item_01HQWXYZ123"
  }
}

Categories

GET /api/v1/categories                    # List all (hierarchical)
GET /api/v1/categories/{id}               # Get single
GET /api/v1/categories/{id}/items         # Items in category
POST /api/v1/categories                   # Create
PUT /api/v1/categories/{id}               # Update
DELETE /api/v1/categories/{id}            # Delete

Vendors

GET /api/v1/vendors                       # List all
GET /api/v1/vendors/{id}                  # Get single
GET /api/v1/vendors/{id}/items            # Items from vendor
POST /api/v1/vendors                      # Create
PUT /api/v1/vendors/{id}                  # Update
DELETE /api/v1/vendors/{id}               # Delete

10.3.2 Sales Domain

# List sales (paginated, filtered)
GET /api/v1/sales?page=1&pageSize=50&locationId=loc_gm&from=2025-01-01&to=2025-01-31

# Get single sale
GET /api/v1/sales/{id}

# Create sale (complete transaction)
POST /api/v1/sales

# Process return
POST /api/v1/sales/{id}/return

# Void transaction
POST /api/v1/sales/{id}/void

# Get receipt
GET /api/v1/sales/{id}/receipt

# Reprint receipt
POST /api/v1/sales/{id}/receipt/print

Create Sale Request:

{
  "locationId": "loc_gm",
  "registerId": "reg_gm_01",
  "employeeId": "emp_john",
  "customerId": "cust_jane",
  "lineItems": [
    {
      "itemId": "item_01HQWXYZ123",
      "quantity": 2,
      "unitPrice": 59.99,
      "discountAmount": 0
    },
    {
      "itemId": "item_02ABCDEF456",
      "quantity": 1,
      "unitPrice": 29.99,
      "discountAmount": 5.00
    }
  ],
  "discounts": [
    {
      "type": "percentage",
      "value": 10,
      "reason": "Loyalty Member Discount"
    }
  ],
  "payments": [
    {
      "method": "credit_card",
      "amount": 135.42,
      "reference": "ch_3MqL0Z2eZvKYlo2C",
      "cardLast4": "4242",
      "cardBrand": "visa"
    }
  ],
  "tax": {
    "stateTax": 5.99,
    "countyTax": 2.50,
    "cityTax": 1.96,
    "totalTax": 10.45
  },
  "notes": "Customer requested gift receipt"
}

Sale Response:

{
  "id": "sale_01HQWXYZ789",
  "receiptNumber": "GM-20250115-0042",
  "tenantId": "tenant_nexus",
  "locationId": "loc_gm",
  "locationName": "Greenbrier Mall",
  "registerId": "reg_gm_01",
  "employeeId": "emp_john",
  "employeeName": "John Smith",
  "customerId": "cust_jane",
  "customerName": "Jane Doe",
  "status": "completed",
  "subtotal": 144.97,
  "discountTotal": 19.50,
  "tax": {
    "stateTax": 5.99,
    "countyTax": 2.50,
    "cityTax": 1.96,
    "totalTax": 10.45
  },
  "grandTotal": 135.92,
  "lineItems": [
    {
      "id": "li_001",
      "itemId": "item_01HQWXYZ123",
      "sku": "NXJ-1001-BLK-M",
      "name": "Classic Oxford Shirt",
      "quantity": 2,
      "unitPrice": 59.99,
      "extendedPrice": 119.98,
      "discountAmount": 0,
      "netPrice": 119.98
    }
  ],
  "payments": [
    {
      "id": "pmt_001",
      "method": "credit_card",
      "amount": 135.92,
      "status": "captured",
      "cardLast4": "4242",
      "cardBrand": "visa"
    }
  ],
  "createdAt": "2025-01-15T14:32:00Z",
  "_links": {
    "self": "/api/v1/sales/sale_01HQWXYZ789",
    "receipt": "/api/v1/sales/sale_01HQWXYZ789/receipt",
    "customer": "/api/v1/customers/cust_jane"
  }
}

Return Request:

{
  "lineItems": [
    {
      "originalLineItemId": "li_001",
      "quantity": 1,
      "reason": "wrong_size"
    }
  ],
  "refundMethod": "original_payment",
  "employeeId": "emp_john"
}

10.3.3 Inventory Domain

# Get inventory levels
GET /api/v1/inventory?locationId=loc_gm&itemId=item_01HQWXYZ123

# Get inventory for all locations
GET /api/v1/inventory/by-item/{itemId}

# Adjust inventory
POST /api/v1/inventory/adjust

# Transfer between locations
POST /api/v1/inventory/transfer

# Start inventory count
POST /api/v1/inventory/count

# Submit count results
PUT /api/v1/inventory/count/{countId}

# Finalize count
POST /api/v1/inventory/count/{countId}/finalize

# Get adjustment history
GET /api/v1/inventory/adjustments?itemId={itemId}&from={date}&to={date}

Adjust Inventory Request:

{
  "locationId": "loc_gm",
  "adjustments": [
    {
      "itemId": "item_01HQWXYZ123",
      "quantityChange": -2,
      "reason": "damaged",
      "notes": "Water damage from roof leak"
    }
  ],
  "employeeId": "emp_manager"
}

Transfer Request:

{
  "fromLocationId": "loc_hq",
  "toLocationId": "loc_gm",
  "items": [
    {
      "itemId": "item_01HQWXYZ123",
      "quantity": 20
    },
    {
      "itemId": "item_02ABCDEF456",
      "quantity": 15
    }
  ],
  "notes": "Weekly replenishment",
  "employeeId": "emp_warehouse"
}

Transfer Response:

{
  "id": "transfer_01HQWXYZ",
  "transferNumber": "TRF-20250115-001",
  "status": "pending",
  "fromLocationId": "loc_hq",
  "fromLocationName": "Warehouse",
  "toLocationId": "loc_gm",
  "toLocationName": "Greenbrier Mall",
  "items": [
    {
      "itemId": "item_01HQWXYZ123",
      "sku": "NXJ-1001-BLK-M",
      "name": "Classic Oxford Shirt",
      "quantity": 20
    }
  ],
  "createdAt": "2025-01-15T09:00:00Z",
  "createdBy": "emp_warehouse"
}

10.3.4 Customers Domain

# Search customers
GET /api/v1/customers/search?q=jane&email=jane@example.com

# List customers (paginated)
GET /api/v1/customers?page=1&pageSize=50

# Get single customer
GET /api/v1/customers/{id}

# Create customer
POST /api/v1/customers

# Update customer
PUT /api/v1/customers/{id}

# Get customer purchase history
GET /api/v1/customers/{id}/purchases

# Get customer loyalty points
GET /api/v1/customers/{id}/loyalty

Customer Response:

{
  "id": "cust_jane",
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane.doe@example.com",
  "phone": "+1-555-123-4567",
  "loyaltyTier": "gold",
  "loyaltyPoints": 2450,
  "totalPurchases": 45,
  "totalSpent": 3245.67,
  "lastVisit": "2025-01-15T14:32:00Z",
  "preferredLocationId": "loc_gm",
  "marketingOptIn": true,
  "createdAt": "2024-03-15T10:00:00Z"
}

10.3.5 Employees Domain

# List employees
GET /api/v1/employees?locationId=loc_gm&active=true

# Get single employee
GET /api/v1/employees/{id}

# Clock in
POST /api/v1/employees/{id}/clock-in

# Clock out
POST /api/v1/employees/{id}/clock-out

# Get time entries
GET /api/v1/employees/{id}/time-entries?from=2025-01-01&to=2025-01-15

# Get sales performance
GET /api/v1/employees/{id}/performance?period=month

Clock-In Request:

{
  "locationId": "loc_gm",
  "pin": "1234",
  "registerId": "reg_gm_01"
}

Clock-In Response:

{
  "timeEntryId": "time_01HQWXYZ",
  "employeeId": "emp_john",
  "employeeName": "John Smith",
  "locationId": "loc_gm",
  "clockInTime": "2025-01-15T09:00:00Z",
  "status": "clocked_in"
}

10.3.6 Reports Domain

# Sales Summary
GET /api/v1/reports/sales-summary?from=2025-01-01&to=2025-01-31&locationId=loc_gm

# Inventory Value
GET /api/v1/reports/inventory-value?locationId=loc_gm

# Employee Performance
GET /api/v1/reports/employee-performance?from=2025-01-01&to=2025-01-31

# Category Sales
GET /api/v1/reports/category-sales?from=2025-01-01&to=2025-01-31

# Top Sellers
GET /api/v1/reports/top-sellers?limit=20&period=month

# Slow Movers
GET /api/v1/reports/slow-movers?daysWithoutSale=30

Sales Summary Response:

{
  "period": {
    "from": "2025-01-01T00:00:00Z",
    "to": "2025-01-31T23:59:59Z"
  },
  "summary": {
    "totalTransactions": 1250,
    "totalGrossSales": 89500.00,
    "totalDiscounts": 4250.00,
    "totalReturns": 1200.00,
    "totalNetSales": 84050.00,
    "totalTax": 6723.00,
    "averageTransactionValue": 67.24,
    "itemsSold": 3450
  },
  "byLocation": [
    {
      "locationId": "loc_gm",
      "locationName": "Greenbrier Mall",
      "transactions": 450,
      "netSales": 32500.00
    }
  ],
  "byPaymentMethod": [
    { "method": "credit_card", "amount": 65000.00, "count": 980 },
    { "method": "cash", "amount": 15000.00, "count": 220 },
    { "method": "gift_card", "amount": 4050.00, "count": 50 }
  ]
}

10.3.7 RFID Domain (Optional Module — Counting Only)

Scope: RFID endpoints support inventory counting operations only. Receiving is handled by barcode Scanner endpoints. See BRD Section 5.16.6 for Scanner vs RFID distinction.

# Tag Printing
POST /api/v1/rfid/tags/print                              # Queue tags for printing
GET  /api/v1/rfid/tags/print/{jobId}                      # Get print job status

# Session Management
POST /api/v1/rfid/scans/sessions                           # Create counting session
POST /api/v1/rfid/scans/sessions/{sessionId}/join          # Join as additional operator
POST /api/v1/rfid/scans/sessions/{sessionId}/complete      # Complete session → variance calc

# Chunked Upload (idempotent, ≤5,000 events/chunk)
POST /api/v1/rfid/scans/sessions/{sessionId}/chunks        # Upload event chunk
GET  /api/v1/rfid/scans/sessions/{sessionId}/upload-status # Check upload progress (for resume)

# Configuration (read-only for mobile app)
GET  /api/v1/rfid/config                                   # Tenant RFID configuration
GET  /api/v1/rfid/products                                 # Product catalog cache
GET  /api/v1/rfid/tag-mappings                             # EPC → SKU mappings

Key Design Decisions:

  • Chunked uploads: Sessions with 100,000+ tag reads are uploaded in chunks of 5,000 events. Server deduplicates by UNIQUE(session_id, epc) constraint, making retries safe.
  • Multi-operator: Up to 10 operators can join a single session via /join. Each scans an assigned section. Server merges results, keeping highest RSSI per EPC.
  • Offline-first: Mobile app creates sessions locally, uploads chunks when connectivity is available. GET /upload-status enables resume after network failure.

See Appendix A, Section A.13 for full request/response schemas.


10.4 Pagination Pattern

All list endpoints use cursor-based or offset pagination:

Request:

GET /api/v1/items?page=2&pageSize=50&sortBy=name&sortOrder=asc

Response Envelope:

{
  "data": [...],
  "pagination": {
    "page": 2,
    "pageSize": 50,
    "totalItems": 1250,
    "totalPages": 25,
    "hasNextPage": true,
    "hasPreviousPage": true
  },
  "_links": {
    "self": "/api/v1/items?page=2&pageSize=50",
    "first": "/api/v1/items?page=1&pageSize=50",
    "prev": "/api/v1/items?page=1&pageSize=50",
    "next": "/api/v1/items?page=3&pageSize=50",
    "last": "/api/v1/items?page=25&pageSize=50"
  }
}

10.5 Error Response Format

All errors follow RFC 7807 Problem Details:

{
  "type": "https://pos-platform.com/errors/validation-error",
  "title": "Validation Error",
  "status": 400,
  "detail": "One or more validation errors occurred.",
  "instance": "/api/v1/items",
  "traceId": "00-abc123-def456-01",
  "errors": {
    "sku": ["SKU is required", "SKU must be unique"],
    "price": ["Price must be greater than 0"]
  }
}

Common Error Types:

StatusTypeDescription
400validation-errorRequest validation failed
401authentication-requiredNo valid credentials
403permission-deniedInsufficient permissions
404resource-not-foundEntity does not exist
409conflictDuplicate or state conflict
422business-rule-violationDomain logic failure
500internal-errorServer error

10.6 SignalR Hub Events

Real-time events for POS clients:

Hub Endpoint

wss://{tenant}.pos-platform.com/hubs/pos

Event Catalog

// Server → Client Events
public interface IPosHubClient
{
    // Inventory changes
    Task InventoryUpdated(InventoryUpdateEvent data);

    // Price changes
    Task PriceUpdated(PriceUpdateEvent data);

    // Item changes
    Task ItemUpdated(ItemUpdateEvent data);
    Task ItemCreated(ItemCreateEvent data);
    Task ItemDeleted(string itemId);

    // Register events
    Task RegisterStatusChanged(RegisterStatusEvent data);

    // Shift events
    Task ShiftStarted(ShiftEvent data);
    Task ShiftEnded(ShiftEvent data);

    // Sync commands
    Task SyncRequired(SyncCommand data);
    Task CacheInvalidated(CacheInvalidateEvent data);
}

Event Payload Examples:

// InventoryUpdated
{
  "eventType": "InventoryUpdated",
  "timestamp": "2025-01-15T14:32:00Z",
  "data": {
    "itemId": "item_01HQWXYZ123",
    "locationId": "loc_gm",
    "previousQuantity": 25,
    "newQuantity": 23,
    "changeType": "sale"
  }
}

// SyncRequired
{
  "eventType": "SyncRequired",
  "timestamp": "2025-01-15T14:32:00Z",
  "data": {
    "scope": "items",
    "reason": "bulk_import",
    "affectedCount": 150
  }
}

10.7 Request/Response Headers

Required Request Headers

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
Accept: application/json
X-Request-Id: uuid-for-tracing
X-Idempotency-Key: <client-generated-uuid>  # Required on POST/PUT (see 10.11)
X-Location-Id: loc_gm              # For POS operations
X-Register-Id: reg_gm_01           # For POS operations

Response Headers

X-Request-Id: uuid-for-tracing
X-Tenant-Id: tenant_nexus
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 995
X-RateLimit-Reset: 1705330800

10.8 Rate Limiting

Endpoint TypeLimitWindow
Standard API1000/hourPer tenant
Bulk Operations10/hourPer tenant
Reports100/hourPer tenant
Auth Endpoints20/minutePer IP

Response when rate limited:

{
  "type": "https://pos-platform.com/errors/rate-limit-exceeded",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "You have exceeded the rate limit. Try again in 300 seconds.",
  "retryAfter": 300
}

10.9 API Controller Implementation

// File: src/POS.Api/Controllers/ItemsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using POS.Core.Catalog;
using POS.Core.Common;

namespace POS.Api.Controllers;

[ApiController]
[Route("api/v1/items")]
[Authorize]
public class ItemsController : ControllerBase
{
    private readonly IItemService _itemService;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<ItemsController> _logger;

    public ItemsController(
        IItemService itemService,
        ITenantContext tenantContext,
        ILogger<ItemsController> logger)
    {
        _itemService = itemService;
        _tenantContext = tenantContext;
        _logger = logger;
    }

    [HttpGet]
    [Authorize(Policy = "catalog.items.read")]
    public async Task<ActionResult<PagedResult<ItemDto>>> GetItems(
        [FromQuery] ItemQueryParams query,
        CancellationToken ct)
    {
        var result = await _itemService.GetItemsAsync(query, ct);
        return Ok(result);
    }

    [HttpGet("{id}")]
    [Authorize(Policy = "catalog.items.read")]
    public async Task<ActionResult<ItemDto>> GetItem(string id, CancellationToken ct)
    {
        var item = await _itemService.GetByIdAsync(id, ct);
        if (item is null)
            return NotFound(ProblemFactory.NotFound("Item", id));

        return Ok(item);
    }

    [HttpGet("by-sku/{sku}")]
    [Authorize(Policy = "catalog.items.read")]
    public async Task<ActionResult<ItemDto>> GetItemBySku(string sku, CancellationToken ct)
    {
        var item = await _itemService.GetBySkuAsync(sku, ct);
        if (item is null)
            return NotFound(ProblemFactory.NotFound("Item", sku));

        return Ok(item);
    }

    [HttpGet("by-barcode/{barcode}")]
    [Authorize(Policy = "catalog.items.read")]
    public async Task<ActionResult<ItemDto>> GetItemByBarcode(
        string barcode,
        CancellationToken ct)
    {
        var item = await _itemService.GetByBarcodeAsync(barcode, ct);
        if (item is null)
            return NotFound(ProblemFactory.NotFound("Item", barcode));

        return Ok(item);
    }

    [HttpPost]
    [Authorize(Policy = "catalog.items.write")]
    public async Task<ActionResult<ItemDto>> CreateItem(
        [FromBody] CreateItemRequest request,
        CancellationToken ct)
    {
        var result = await _itemService.CreateAsync(request, ct);

        return result.Match<ActionResult<ItemDto>>(
            success => CreatedAtAction(
                nameof(GetItem),
                new { id = success.Id },
                success),
            error => BadRequest(ProblemFactory.FromError(error))
        );
    }

    [HttpPut("{id}")]
    [Authorize(Policy = "catalog.items.write")]
    public async Task<ActionResult<ItemDto>> UpdateItem(
        string id,
        [FromBody] UpdateItemRequest request,
        CancellationToken ct)
    {
        var result = await _itemService.UpdateAsync(id, request, ct);

        return result.Match<ActionResult<ItemDto>>(
            success => Ok(success),
            error => error.Code switch
            {
                "NOT_FOUND" => NotFound(ProblemFactory.NotFound("Item", id)),
                _ => BadRequest(ProblemFactory.FromError(error))
            }
        );
    }

    [HttpDelete("{id}")]
    [Authorize(Policy = "catalog.items.delete")]
    public async Task<IActionResult> DeleteItem(string id, CancellationToken ct)
    {
        var result = await _itemService.DeleteAsync(id, ct);

        return result.Match<IActionResult>(
            success => NoContent(),
            error => NotFound(ProblemFactory.NotFound("Item", id))
        );
    }

    [HttpPost("bulk-import")]
    [Authorize(Policy = "catalog.items.bulk")]
    [RequestSizeLimit(10_000_000)] // 10MB
    public async Task<ActionResult<BulkImportResult>> BulkImport(
        [FromBody] BulkImportRequest request,
        CancellationToken ct)
    {
        var result = await _itemService.BulkImportAsync(request, ct);
        return Ok(result);
    }
}

10.10 Query Parameters and Filtering

// File: src/POS.Core/Common/ItemQueryParams.cs
public record ItemQueryParams
{
    public int Page { get; init; } = 1;
    public int PageSize { get; init; } = 50;
    public string? Search { get; init; }
    public string? CategoryId { get; init; }
    public string? VendorId { get; init; }
    public bool? Active { get; init; }
    public bool? TrackInventory { get; init; }
    public decimal? MinPrice { get; init; }
    public decimal? MaxPrice { get; init; }
    public string SortBy { get; init; } = "name";
    public string SortOrder { get; init; } = "asc";
}

10.11 Idempotency Framework

All state-changing endpoints (POST, PUT) require an X-Idempotency-Key header to prevent duplicate processing. This is critical for offline-first POS clients that may retry requests after network recovery.

How It Works

  1. Client generates a UUID idempotency key per logical operation
  2. Server hashes the key with SHA-256 and checks Redis for a cached response
  3. If found (within the 24-hour TTL), the cached response is returned without re-processing
  4. If not found, the request is processed normally and the response is cached

Idempotency Middleware

// File: src/POS.Api/Middleware/IdempotencyMiddleware.cs
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Caching.Distributed;

namespace POS.Api.Middleware;

public class IdempotencyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IDistributedCache _cache;
    private readonly ILogger<IdempotencyMiddleware> _logger;
    private static readonly TimeSpan IdempotencyTtl = TimeSpan.FromHours(24);

    public IdempotencyMiddleware(
        RequestDelegate next,
        IDistributedCache cache,
        ILogger<IdempotencyMiddleware> logger)
    {
        _next = next;
        _cache = cache;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Only apply to POST and PUT
        if (context.Request.Method is not ("POST" or "PUT"))
        {
            await _next(context);
            return;
        }

        var idempotencyKey = context.Request.Headers["X-Idempotency-Key"].FirstOrDefault();
        if (string.IsNullOrEmpty(idempotencyKey))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new
            {
                type = "https://pos-platform.com/errors/missing-idempotency-key",
                title = "Missing Idempotency Key",
                status = 400,
                detail = "X-Idempotency-Key header is required for POST and PUT requests."
            });
            return;
        }

        // SHA-256 hash the key for storage
        var hashedKey = HashKey(idempotencyKey, context.User.FindFirst("tid")?.Value);

        // Check for cached response
        var cachedResponse = await _cache.GetStringAsync(hashedKey);
        if (cachedResponse is not null)
        {
            _logger.LogInformation(
                "Idempotent request detected. Returning cached response for key {Key}",
                idempotencyKey[..8]);

            context.Response.StatusCode = 200;
            context.Response.ContentType = "application/json";
            await context.Response.WriteAsync(cachedResponse);
            return;
        }

        // Capture the response
        var originalBodyStream = context.Response.Body;
        using var memoryStream = new MemoryStream();
        context.Response.Body = memoryStream;

        await _next(context);

        // Cache successful responses
        if (context.Response.StatusCode is >= 200 and < 300)
        {
            memoryStream.Seek(0, SeekOrigin.Begin);
            var responseBody = await new StreamReader(memoryStream).ReadToEndAsync();

            await _cache.SetStringAsync(hashedKey, responseBody, new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = IdempotencyTtl
            });
        }

        // Copy response back to original stream
        memoryStream.Seek(0, SeekOrigin.Begin);
        await memoryStream.CopyToAsync(originalBodyStream);
    }

    private static string HashKey(string key, string? tenantId)
    {
        var input = $"{tenantId}:{key}";
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
        return $"idempotency:{Convert.ToHexString(bytes).ToLowerInvariant()}";
    }
}

Idempotency Key Guidelines

ScenarioKey Generation Strategy
POS sale creation{registerId}:{localTransactionId}
Inventory adjustment{locationId}:{adjustmentBatchId}
RFID chunk upload{sessionId}:{chunkIndex}
Customer creation{email}:{timestamp}
Retry after network failureSame key as original request

10.12 Offline-Blocked Operations

The POS client operates offline-first with a local SQLite database. Most operations work offline, but some require online connectivity due to security, consistency, or external system dependencies.

Operations Requiring Online Connectivity

OperationReasonOffline Behavior
Process refund/returnPayment processor must authorize refundQueued; displayed as “pending refund”
Void transactionPayment void must be confirmed with processorQueued; displayed as “pending void”
New customer creationUniqueness check across all locationsQueued; temporary local-only record created
Price override beyond thresholdManager approval may require server validationBlocked if exceeds offline price override limit
Apply new discount codesDiscount validation requires server checkOnly cached/pre-loaded discounts available
Employee PIN changesMust sync to all terminals immediatelyBlocked until online
Register IP changesServer-side validation (max 2/year per ERR-5071)Blocked until online
Register retirementOWNER-only with type-to-confirm “RETIRE” (ERR-5072)Blocked until online
RFID session syncChunk upload to server for variance calculationStored locally; synced when online
View cross-location inventoryRequires server-side aggregationLast-synced snapshot shown
Generate server-side reportsReport queries run against central databaseOnly local sales data available
Integration syncsShopify/Amazon/Google require internetQueued in outbox

Operations Fully Available Offline

OperationLocal Handling
Create sale (cash/pre-authorized card)Stored in local SQLite, queued for sync
Look up items by SKU/barcodeLocal catalog cache
Apply pre-loaded discountsCached discount rules
Clock in/outLocal time entry, synced later
Inventory count (manual)Local count records
View local sales historySQLite query
Park/recall held ordersLocal storage
Print receipt (reprint)Local receipt data

10.13 Sync Priority Tiers

When the POS client comes back online, queued operations are synced in priority order. Higher-priority items sync first to ensure financial accuracy.

TierPriorityOperationsMax Latency
Critical1 (highest)Sales transactions, payment confirmations, voids, refunds< 30 seconds
Important2Inventory adjustments, stock transfers, RFID session uploads< 5 minutes
Normal3Customer updates, product changes, price updates< 15 minutes
Low4Reports, analytics, non-urgent data, audit logs< 1 hour

Sync Queue Implementation

// File: src/POS.Client/Sync/SyncPriorityQueue.cs
public enum SyncPriority
{
    Critical = 1,   // Sales, payments
    Important = 2,  // Inventory
    Normal = 3,     // Customers, products
    Low = 4         // Reports, analytics
}

public class SyncQueueEntry
{
    public string Id { get; set; }
    public SyncPriority Priority { get; set; }
    public string OperationType { get; set; }  // "CreateSale", "AdjustInventory", etc.
    public string Payload { get; set; }         // Serialized request
    public DateTime QueuedAt { get; set; }
    public int RetryCount { get; set; }
    public DateTime? LastAttemptAt { get; set; }
}

Conflict Resolution

When syncing offline changes, conflicts are resolved using CRDTs (Conflict-free Replicated Data Types) as defined in Chapter 04 (L.10A.1):

  • Sales: Last-write-wins with server timestamp authority
  • Inventory: Additive counters — offline adjustments are applied as deltas, not absolute values
  • Customers: Field-level merge — each field uses latest timestamp

10.14 Error Code Catalogue

All API errors include a structured error code following the domain-based ERR-Nxxx convention. This enables precise error handling by POS clients and integration partners.

Error Response Format

{
  "type": "https://pos-platform.com/errors/business-rule-violation",
  "title": "Insufficient Inventory",
  "status": 422,
  "detail": "Item NXJ-1001-BLK-M has only 2 units at Greenbrier Mall, but 5 were requested.",
  "instance": "/api/v1/sales",
  "traceId": "00-abc123-def456-01",
  "error": {
    "code": "ERR-2003",
    "message": "Insufficient inventory for sale",
    "details": {
      "itemId": "item_01HQWXYZ123",
      "sku": "NXJ-1001-BLK-M",
      "requested": 5,
      "available": 2,
      "locationId": "loc_gm"
    }
  }
}

Error Code Ranges

RangeModuleDescription
ERR-1xxxCatalogItem, category, vendor, and product data errors
ERR-2xxxInventoryStock levels, adjustments, transfers, counts
ERR-3xxxSalesTransactions, returns, voids, receipts, payments
ERR-4xxxCustomersCustomer records, loyalty, marketing
ERR-5xxxEmployee & HardwareStaff, registers, locations, clock-in/out
ERR-6xxxIntegrationsShopify, Amazon, Google, payment processors

Common Error Codes per Module

ERR-1xxx: Catalog

CodeDescriptionHTTP Status
ERR-1001SKU already exists409
ERR-1002Barcode already assigned to another item409
ERR-1003Category not found404
ERR-1004Vendor not found404
ERR-1005Item is inactive and cannot be sold422
ERR-1006Bulk import validation failed (partial)422
ERR-1007Price must be greater than zero400
ERR-1008Item has active inventory; cannot delete422

ERR-2xxx: Inventory

CodeDescriptionHTTP Status
ERR-2001Location not found404
ERR-2002Negative inventory not allowed422
ERR-2003Insufficient inventory for operation422
ERR-2004Transfer source and destination are the same400
ERR-2005Inventory count already finalized409
ERR-2006Count session expired422
ERR-2007Adjustment reason required400

ERR-3xxx: Sales

CodeDescriptionHTTP Status
ERR-3001Insufficient payment amount422
ERR-3002Order already voided409
ERR-3003Void window expired (same-day only)422
ERR-3004Return quantity exceeds original422
ERR-3005Payment processing failed502
ERR-3006Register not assigned to this location403
ERR-3007Held order not found or already recalled404
ERR-3008Discount exceeds maximum allowed percentage422

ERR-4xxx: Customers

CodeDescriptionHTTP Status
ERR-4001Customer email already exists409
ERR-4002Insufficient loyalty points for redemption422
ERR-4003Customer not found404
ERR-4004Phone number format invalid400

ERR-5xxx: Employee & Hardware

CodeDescriptionHTTP Status
ERR-5001Invalid PIN401
ERR-5002Employee not assigned to this location403
ERR-5003Already clocked in409
ERR-5004Not currently clocked in409
ERR-5005PIN already in use by another employee409
ERR-5071Register IP change limit exceeded (max 2 per 365 days)422
ERR-5072Register retirement requires OWNER role with type-to-confirm403

ERR-6xxx: Integrations (see Chapter 13, Section 13.5 for the full list)

CodeDescriptionHTTP Status
ERR-6001External authentication failed502
ERR-6002External API timeout504
ERR-6003Circuit breaker is open503
ERR-6004Integration not configured for tenant422
ERR-6005External rate limit exceeded429

10.15 API Performance Targets

API-layer response time targets complement the database-layer targets in Chapter 09 (Indexes & Performance) and the client-layer targets in Chapter 14 (POS Client). These are measured at the API gateway, excluding network latency to the client.

Operation TypeTarget (p95)Target (p99)Examples
Simple reads< 100ms< 200msGET item by ID, GET customer by ID, GET inventory level
List/search queries< 200ms< 500msGET items (paginated), customer search, sales list with filters
Report queries< 1s< 3sSales summary, inventory value, employee performance
Simple writes< 200ms< 500msCreate customer, adjust inventory, clock in/out
Transaction writes< 500ms< 1sCreate sale (with payment, inventory deduct, loyalty)
Bulk operations< 5s< 10sBulk import (up to 1,000 items), bulk price update
External integration calls< 2s< 5sShopify sync, payment processing (excluding terminal wait)
SignalR event delivery< 100ms< 300msFrom event publish to client WebSocket receipt

Monitoring Thresholds

MetricWarningCriticalAction
p95 response time> 1.5x target> 3x targetAlert on-call, check query plans
Error rate (5xx)> 0.1%> 1%Alert on-call, check logs
Throughput drop> 20% vs baseline> 50% vs baselineAlert on-call, check infrastructure

See also: Chapter 09 for database query performance targets (< 10ms simple lookups, < 50ms joins), Chapter 14 for POS client interaction targets (< 50ms touch response, < 200ms barcode scan-to-display), and Chapter 25 for monitoring dashboard configuration.


Summary

This chapter defined the complete REST API structure for the POS Platform:

  • Tenant-aware URL structure with subdomain routing
  • Seven domain areas: Catalog, Sales, Inventory, Customers, Employees, Reports, RFID
  • Consistent patterns for pagination, errors, and HATEOAS links
  • Real-time SignalR events for inventory and price updates
  • Complete controller implementation with authorization policies
  • Idempotency framework with SHA-256 key hashing and 24-hour Redis TTL
  • Offline-blocked operations clearly identifying what requires connectivity
  • Sync priority tiers (Critical/Important/Normal/Low) for offline queue processing
  • Error code catalogue with ERR-1xxx through ERR-6xxx domain-based ranges
  • API performance targets bridging database-layer (Ch 09) and client-layer (Ch 14) targets

Next: Chapter 11: Service Layer covers the service layer that implements this API.


Document Information

AttributeValue
Version5.0.0
Created2025-12-29
Updated2026-02-22
AuthorClaude Code
StatusActive
PartIV - Backend
Chapter10 of 32

This chapter is part of the POS Blueprint Book. All content is self-contained.