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-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";
}

Summary

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

  • Tenant-aware URL structure with subdomain routing
  • Six domain areas: Catalog, Sales, Inventory, Customers, Employees, Reports
  • Consistent patterns for pagination, errors, and HATEOAS links
  • Real-time SignalR events for inventory and price updates
  • Complete controller implementation with authorization policies

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.