Flows
Storefront Customer Auth Flow
Customer login and OTP verification on the storefront.
Storefront Customer Authentication & Order History Flow
Feature area: Public Storefront / Buyer-Facing Auth
Last updated: 2026-06-12
Overview
End-users (buyers) who visit a store's public website can now create an account and log in. Once authenticated they can view their complete order history for that store and track individual orders — without any merchant credentials.
Customer accounts are per-store: the same phone number can register independently on each store. Authentication uses phone + password with OTP verification (via the existing WhatsApp/SMS provider) for registration and password reset.
A separate JWT authentication scheme (StorefrontCustomerBearer) is used so merchant tokens cannot access customer endpoints and vice versa.
Auth Scheme Isolation
| Scheme | Audience | Who uses it | Endpoints |
|---|---|---|---|
Bearer | SendyClient | Store owners, staff, drivers | /api/v1/store/…, /api/v1/admin/… |
McpBearer | OAuth MCP audience | MCP OAuth clients | /api/v1/mcp/… |
StorefrontCustomerBearer | sendy-storefront-customers | Store buyers | /api/v1/public/storefronts/{slug}/me/… |
Both merchant and customer JWTs are signed with the same JwtSettings:Secret but carry different aud claims — a merchant token is rejected by the storefront customer validator and vice versa.
Flow 1 — Registration
Customer (browser) API OTP Provider
│ │ │
├── POST auth/otp/send ────────────►│ │
│ { phone, purpose: Registration} │ │
│ ├── validate store slug │
│ ├── validate Iraqi mobile format │
│ ├── OtpVerifications.Add() │
│ ├── SaveChangesAsync() │
│ ├── SendVerificationCodeAsync() ─────►│
│◄── 200 "Verification code sent" ──┤ SMS/WhatsApp │
│ │ │
│ (customer enters 6-digit code) │ │
│ │ │
├── POST auth/register ────────────►│ │
│ { phone, otpCode, │ │
│ password, fullName? } │ │
│ ├── verify OTP (hash match + expiry) │
│ ├── check phone uniqueness per store │
│ ├── StorefrontCustomers.Add() │
│ ├── SaveChangesAsync() │
│ ├── link historical orders │
│ │ (CustomerPhone == phone, │
│ │ StoreId == store, │
│ │ StorefrontCustomerId == null) │
│ ├── generate JWT (StorefrontCustomerBearer)
│ ├── create refresh token │
│◄── 200 { token, refreshToken, │ │
│ tokenExpiresAt, │ │
│ refreshTokenExpiresAt, │ │
│ customer } │ │
Endpoint: POST /api/v1/public/storefronts/{slug}/auth/register
On success: account created, all prior guest orders with the same phone are auto-linked, JWT issued.
Flow 2 — Login
Customer (browser) API
│ │
├── POST auth/login ───────────────►│
│ { phone, password } │
│ ├── resolve store by slug
│ ├── normalize phone (Iraqi format)
│ ├── lookup StorefrontCustomer
│ │ (StoreId + Phone + !IsDeleted)
│ ├── PasswordService.VerifyPassword()
│ ├── check IsActive
│ ├── update LastLoginAt
│ ├── generate JWT
│ ├── create refresh token
│◄── 200 { token, refreshToken, … } │
│ │
│ (token expires) │
│ │
├── POST auth/refresh ─────────────►│
│ { refreshToken } │
│ ├── hash lookup in StorefrontCustomerRefreshTokens
│ ├── revoke old token
│ ├── issue new JWT + new refresh token
│◄── 200 { token, refreshToken, … } │
Login endpoint: POST /api/v1/public/storefronts/{slug}/auth/login
Refresh endpoint: POST /api/v1/public/storefronts/{slug}/auth/refresh
Logout endpoint: POST /api/v1/public/storefronts/{slug}/auth/logout — revokes the refresh token.
Flow 3 — Password Reset
Customer (browser) API
│ │
├── POST auth/otp/send ────────────►│
│ { phone, │
│ purpose: PasswordReset } │
│ ├── store OTP (purpose = StorefrontCustomerPasswordReset)
│◄── 200 "Verification code sent" ──┤
│ │
├── POST auth/password/reset ───────►│
│ { phone, otpCode, newPassword } │
│ ├── verify OTP
│ ├── lookup customer by store + phone
│ ├── PasswordHash = HashPassword(newPassword)
│ ├── SaveChangesAsync()
│◄── 200 "Password reset" ──────────┤
Endpoint: POST /api/v1/public/storefronts/{slug}/auth/password/reset
Flow 4 — Order History
Requires a valid StorefrontCustomerBearer JWT in the Authorization header.
Customer (browser) API DB
│ │ │
├── GET me/orders │ │
│ Authorization: Bearer {token} ─►│ │
│ ├── validate StorefrontCustomerBearer JWT
│ ├── extract sub (customerId) + store_id
│ ├── Orders WHERE │
│ │ StorefrontCustomerId == sub │
│ │ AND StoreId == store_id ─────►│
│◄── 200 { items, totalCount, │◄───────────────────────────────┤
│ pageIndex, totalPages } │ │
│ │ │
├── GET me/orders/{publicId} ──────►│ │
│ ├── Order WHERE publicId │
│ │ AND StorefrontCustomerId │
│ │ AND StoreId ─────────────────►│
│ │ include Items + StatusHistory │
│◄── 200 { order detail } │◄───────────────────────────────┤
List endpoint: GET /api/v1/public/storefronts/{slug}/me/orders?page=1&pageSize=20
Detail endpoint: GET /api/v1/public/storefronts/{slug}/me/orders/{publicId}
Order detail includes: status, items (SKU, name, qty, price), full status history, payment info, delivery timestamps.
Flow 5 — Authenticated Order Placement
When a logged-in customer places a new order the order is automatically linked to their account — no extra step required.
Customer (browser) API
│ │
├── POST /{slug}/orders │
│ Authorization: Bearer {token} │
│ { …order body… } ─────────────►│
│ ├── AuthenticateAsync("StorefrontCustomerBearer")
│ ├── if succeeded → extract customerId from sub
│ ├── CreateOrderAsync(…, storefrontCustomerId)
│ ├── Order.StorefrontCustomerId = customerId
│◄── 200 { publicId, … } ──────────┤
Guest (unauthenticated) order placement continues to work exactly as before — StorefrontCustomerId is left null.
Auto-Link Historical Orders
On first registration all prior guest orders for the store that match the customer's phone are linked in bulk:
SELECT Orders WHERE StoreId = @storeId
AND CustomerPhone = @phone
AND StorefrontCustomerId IS NULL
→ SET StorefrontCustomerId = @newCustomerId
This means the customer immediately sees their complete history after registering, even if they previously ordered as a guest.
JWT Token Claims
| Claim | Value |
|---|---|
sub | StorefrontCustomer.Id (Guid) |
store_id | Store.Id (Guid) |
phone | Normalized Iraqi mobile (9647XXXXXXXXX) |
role | "storefront_customer" |
aud | "sendy-storefront-customers" |
iss | JwtSettings:Issuer |
exp | JwtSettings:ExpiryMinutes from now |
API Endpoint Summary
All routes begin with /api/v1/public/storefronts/{slug}/.
| Method | Route | Auth required | Description |
|---|---|---|---|
| POST | auth/otp/send | No | Send OTP (registration or password reset) |
| POST | auth/register | No | Register with phone + OTP + password |
| POST | auth/login | No | Login → JWT + refresh token |
| POST | auth/password/reset | No | Reset password via OTP |
| POST | auth/refresh | No | Rotate refresh token |
| POST | auth/logout | Yes | Revoke refresh token |
| GET | me/orders | Yes | Paginated order history |
| GET | me/orders/{publicId} | Yes | Order detail + status history |
Source References
| Layer | File |
|---|---|
| Domain entity | Sendy.Domain/Entities/Storefront/StorefrontCustomer.cs |
| Refresh token entity | Sendy.Domain/Entities/Storefront/StorefrontCustomerRefreshToken.cs |
| OTP purposes | Sendy.Domain/Enums/Identity/OtpPurpose.cs |
| Auth service interface | Sendy.Application/Interfaces/Services/Public/IStorefrontCustomerAuthService.cs |
| Auth service implementation | Sendy.Application/Services/Public/StorefrontCustomerAuthService.cs |
| Order history service | Sendy.Application/Services/Public/StorefrontCustomerOrderHistoryService.cs |
| JWT token generator | Sendy.Infrastructure/Services/Auth/StorefrontCustomerJwtTokenGenerator.cs |
| Auth constants | Sendy.Infrastructure/Constants/AuthConstants.cs |
| Auth controller | Sendy.Api/Controllers/V1/Public/StorefrontCustomerAuthController.cs |
| Orders controller | Sendy.Api/Controllers/V1/Public/StorefrontCustomerMeController.cs |
| Scheme registration | Sendy.Api/Hosting/WebApplicationBuilderExtensions.cs |
| EF configuration | Sendy.Infrastructure/Data/EntityConfigurations/Storefront/StorefrontCustomerConfiguration.cs |
| Migration | Sendy.Infrastructure/Migrations/…AddStorefrontCustomerAuth.cs |
Source: DOCS/flows/storefront-customer-auth-flow.md