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-statusenables 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:
| Status | Type | Description |
|---|---|---|
| 400 | validation-error | Request validation failed |
| 401 | authentication-required | No valid credentials |
| 403 | permission-denied | Insufficient permissions |
| 404 | resource-not-found | Entity does not exist |
| 409 | conflict | Duplicate or state conflict |
| 422 | business-rule-violation | Domain logic failure |
| 500 | internal-error | Server 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 Type | Limit | Window |
|---|---|---|
| Standard API | 1000/hour | Per tenant |
| Bulk Operations | 10/hour | Per tenant |
| Reports | 100/hour | Per tenant |
| Auth Endpoints | 20/minute | Per 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
- Client generates a UUID idempotency key per logical operation
- Server hashes the key with SHA-256 and checks Redis for a cached response
- If found (within the 24-hour TTL), the cached response is returned without re-processing
- 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
| Scenario | Key Generation Strategy |
|---|---|
| POS sale creation | {registerId}:{localTransactionId} |
| Inventory adjustment | {locationId}:{adjustmentBatchId} |
| RFID chunk upload | {sessionId}:{chunkIndex} |
| Customer creation | {email}:{timestamp} |
| Retry after network failure | Same 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
| Operation | Reason | Offline Behavior |
|---|---|---|
| Process refund/return | Payment processor must authorize refund | Queued; displayed as “pending refund” |
| Void transaction | Payment void must be confirmed with processor | Queued; displayed as “pending void” |
| New customer creation | Uniqueness check across all locations | Queued; temporary local-only record created |
| Price override beyond threshold | Manager approval may require server validation | Blocked if exceeds offline price override limit |
| Apply new discount codes | Discount validation requires server check | Only cached/pre-loaded discounts available |
| Employee PIN changes | Must sync to all terminals immediately | Blocked until online |
| Register IP changes | Server-side validation (max 2/year per ERR-5071) | Blocked until online |
| Register retirement | OWNER-only with type-to-confirm “RETIRE” (ERR-5072) | Blocked until online |
| RFID session sync | Chunk upload to server for variance calculation | Stored locally; synced when online |
| View cross-location inventory | Requires server-side aggregation | Last-synced snapshot shown |
| Generate server-side reports | Report queries run against central database | Only local sales data available |
| Integration syncs | Shopify/Amazon/Google require internet | Queued in outbox |
Operations Fully Available Offline
| Operation | Local Handling |
|---|---|
| Create sale (cash/pre-authorized card) | Stored in local SQLite, queued for sync |
| Look up items by SKU/barcode | Local catalog cache |
| Apply pre-loaded discounts | Cached discount rules |
| Clock in/out | Local time entry, synced later |
| Inventory count (manual) | Local count records |
| View local sales history | SQLite query |
| Park/recall held orders | Local 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.
| Tier | Priority | Operations | Max Latency |
|---|---|---|---|
| Critical | 1 (highest) | Sales transactions, payment confirmations, voids, refunds | < 30 seconds |
| Important | 2 | Inventory adjustments, stock transfers, RFID session uploads | < 5 minutes |
| Normal | 3 | Customer updates, product changes, price updates | < 15 minutes |
| Low | 4 | Reports, 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
| Range | Module | Description |
|---|---|---|
| ERR-1xxx | Catalog | Item, category, vendor, and product data errors |
| ERR-2xxx | Inventory | Stock levels, adjustments, transfers, counts |
| ERR-3xxx | Sales | Transactions, returns, voids, receipts, payments |
| ERR-4xxx | Customers | Customer records, loyalty, marketing |
| ERR-5xxx | Employee & Hardware | Staff, registers, locations, clock-in/out |
| ERR-6xxx | Integrations | Shopify, Amazon, Google, payment processors |
Common Error Codes per Module
ERR-1xxx: Catalog
| Code | Description | HTTP Status |
|---|---|---|
| ERR-1001 | SKU already exists | 409 |
| ERR-1002 | Barcode already assigned to another item | 409 |
| ERR-1003 | Category not found | 404 |
| ERR-1004 | Vendor not found | 404 |
| ERR-1005 | Item is inactive and cannot be sold | 422 |
| ERR-1006 | Bulk import validation failed (partial) | 422 |
| ERR-1007 | Price must be greater than zero | 400 |
| ERR-1008 | Item has active inventory; cannot delete | 422 |
ERR-2xxx: Inventory
| Code | Description | HTTP Status |
|---|---|---|
| ERR-2001 | Location not found | 404 |
| ERR-2002 | Negative inventory not allowed | 422 |
| ERR-2003 | Insufficient inventory for operation | 422 |
| ERR-2004 | Transfer source and destination are the same | 400 |
| ERR-2005 | Inventory count already finalized | 409 |
| ERR-2006 | Count session expired | 422 |
| ERR-2007 | Adjustment reason required | 400 |
ERR-3xxx: Sales
| Code | Description | HTTP Status |
|---|---|---|
| ERR-3001 | Insufficient payment amount | 422 |
| ERR-3002 | Order already voided | 409 |
| ERR-3003 | Void window expired (same-day only) | 422 |
| ERR-3004 | Return quantity exceeds original | 422 |
| ERR-3005 | Payment processing failed | 502 |
| ERR-3006 | Register not assigned to this location | 403 |
| ERR-3007 | Held order not found or already recalled | 404 |
| ERR-3008 | Discount exceeds maximum allowed percentage | 422 |
ERR-4xxx: Customers
| Code | Description | HTTP Status |
|---|---|---|
| ERR-4001 | Customer email already exists | 409 |
| ERR-4002 | Insufficient loyalty points for redemption | 422 |
| ERR-4003 | Customer not found | 404 |
| ERR-4004 | Phone number format invalid | 400 |
ERR-5xxx: Employee & Hardware
| Code | Description | HTTP Status |
|---|---|---|
| ERR-5001 | Invalid PIN | 401 |
| ERR-5002 | Employee not assigned to this location | 403 |
| ERR-5003 | Already clocked in | 409 |
| ERR-5004 | Not currently clocked in | 409 |
| ERR-5005 | PIN already in use by another employee | 409 |
| ERR-5071 | Register IP change limit exceeded (max 2 per 365 days) | 422 |
| ERR-5072 | Register retirement requires OWNER role with type-to-confirm | 403 |
ERR-6xxx: Integrations (see Chapter 13, Section 13.5 for the full list)
| Code | Description | HTTP Status |
|---|---|---|
| ERR-6001 | External authentication failed | 502 |
| ERR-6002 | External API timeout | 504 |
| ERR-6003 | Circuit breaker is open | 503 |
| ERR-6004 | Integration not configured for tenant | 422 |
| ERR-6005 | External rate limit exceeded | 429 |
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 Type | Target (p95) | Target (p99) | Examples |
|---|---|---|---|
| Simple reads | < 100ms | < 200ms | GET item by ID, GET customer by ID, GET inventory level |
| List/search queries | < 200ms | < 500ms | GET items (paginated), customer search, sales list with filters |
| Report queries | < 1s | < 3s | Sales summary, inventory value, employee performance |
| Simple writes | < 200ms | < 500ms | Create customer, adjust inventory, clock in/out |
| Transaction writes | < 500ms | < 1s | Create sale (with payment, inventory deduct, loyalty) |
| Bulk operations | < 5s | < 10s | Bulk import (up to 1,000 items), bulk price update |
| External integration calls | < 2s | < 5s | Shopify sync, payment processing (excluding terminal wait) |
| SignalR event delivery | < 100ms | < 300ms | From event publish to client WebSocket receipt |
Monitoring Thresholds
| Metric | Warning | Critical | Action |
|---|---|---|---|
| p95 response time | > 1.5x target | > 3x target | Alert on-call, check query plans |
| Error rate (5xx) | > 0.1% | > 1% | Alert on-call, check logs |
| Throughput drop | > 20% vs baseline | > 50% vs baseline | Alert 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
| Attribute | Value |
|---|---|
| Version | 5.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-02-22 |
| Author | Claude Code |
| Status | Active |
| Part | IV - Backend |
| Chapter | 10 of 32 |
This chapter is part of the POS Blueprint Book. All content is self-contained.