# Sendy Store API — Full Developer Reference (for LLMs) > The Sendy Store API lets a store owner or their developer programmatically manage their own store on Sendy — orders, catalog & inventory, customers, storefront, delivery, discounts, subscription, website chat, and AI (MCP) access. It is a REST API that returns JSON. This single file is meant to be given to an AI assistant so it can help you integrate with the Sendy Store API. It contains the integration basics, every guide, and the full endpoint catalogue. The machine-readable OpenAPI spec is at https://store-docs.sendyiq.com/store-openapi.json. ## Integration basics - Production base URL: https://api.sendyiq.com - Staging base URL: https://stagging-api.sendyiq.com - All routes are versioned under `/api/v1`. - Auth for server-to-server integrations: send header `X-API-Key: `. - Auth for a store dashboard you build: send header `Authorization: Bearer `. - Responses use a consistent envelope: `{ "success": bool, "message": string, "data": T, "errors": [...] }`. - Request and response JSON fields are camelCase. - Currency amounts are in IQD; phone numbers use Iraqi mobile format (e.g. 9647XXXXXXXXX). ### Quick start 1. Sign in to your Sendy store dashboard and create an API key (Settings → API keys). 2. Pick an environment — use staging first, then production. 3. Call an endpoint with your key, for example: ```bash curl -s "https://stagging-api.sendyiq.com/api/v1/store/integrations/orders" \ -H "X-API-Key: YOUR_API_KEY" ``` Scopes: API keys are scoped (e.g. `orders:read`, `orders:write`, `catalog:read`, `catalog:write`). A call returns 403 if the key lacks the required scope. See the Permissions & Scopes guide below. --- # Guides ## Getting Started _Set up access and make your first Sendy Store API call._ # Getting Started (Integration Quickstart) ## Base conventions - Base API prefix: `/api/v1` - Primary response envelope: `ApiResponse` - JSON style: camelCase bodies; some query params are snake_case where explicitly mapped. ## Auth modes - **JWT (frontend clients):** `Authorization: Bearer ` - **API key (server-to-server integrations):** `X-API-Key: ` - **OAuth 2.0 Bearer (MCP / AI agent clients):** `Authorization: Bearer ` — issued via PKCE authorization code flow at `POST /api/v1/oauth/token`. See [`03-integrations/store-mcp-server.md`](/docs/mcp/). ## Address reference dependency Store and ecommerce order creation flows require: - `addressProvinceCode` - `addressAreaId` Resolve from public endpoints: - `GET /api/v1/address-reference/provinces` - `GET /api/v1/address-reference/provinces/{code}/areas` ## Role + permission rule For JWT routes, successful access generally requires both: 1. Matching route role gate (`[Authorize(Roles = ...)]`) 2. Matching permission gate (`[HasPermission(...)]`) ## Store e-commerce modules (JWT) The store backend includes 13 dedicated management modules accessible via JWT under `/api/v1/store/`. All require `store_owner` or `store_staff` role plus a module-specific `store.*` permission. | Module | Base path | Permission prefix | |--------|-----------|------------------| | Product Categories | `/api/v1/store/categories` | `store.product_categories.*` | | Customers | `/api/v1/store/customers` | `store.customers.*` | | Suppliers | `/api/v1/store/suppliers` | `store.suppliers.*` | | Purchase Orders | `/api/v1/store/purchase-orders` | `store.purchase_orders.*` | | Expenses | `/api/v1/store/expenses` | `store.expenses.*` | | Cashier / POS | `/api/v1/store/cashier` | `store.cashier.*` | | Discount Rules | `/api/v1/store/discounts/rules` | `store.discounts.*` | | Discount Codes | `/api/v1/store/discounts/codes` | `store.discounts.*` | | Shipping Rules | `/api/v1/store/shipping/rules` | `store.shipping.*` | | Package Sizes | `/api/v1/store/package-sizes` | `store.package_sizes.*` | | Print Templates | `/api/v1/store/print-templates` | `store.print_templates.*` | | Store Settings | `/api/v1/store/settings` | `store.settings.*` | | Analytics | `/api/v1/store/analytics` | `store.analytics.view` | Call `POST /api/v1/store/settings` once on store creation before using most e-commerce features. ## Order create: required fields - `addressProvinceCode`, `addressAreaId` — from address reference API - `payment_method` — **required** (was previously optional; now returns 400 if absent) - `items[].sku` must be unique within the request (duplicate SKUs return 400) ## Ticket fields (enums) Ticket `status`, `category`, and `priority` are now enum types: - `status`: `Open`, `Pending`, `InProgress`, `New`, `Closed`, `Resolved` - `category`: `Unspecified`, `Driver`, `Delivery`, `LastMile`, `Logistics`, `Store`, `Inventory`, `Merchant`, `Catalog`, `Channel`, `Api`, `Other` ## First validation pass for any client 1. Authenticate and call `GET /api/v1/auth/me` 2. Call one list endpoint in your client surface 3. Validate required headers, scopes, and role/permission mapping 4. If 403, inspect role gate and permission gate separately --- ## Environments & Base URLs _Staging and production hosts, auth headers, and base routes._ For e-commerce stores that integrate with Sendy to sync orders, catalog, inventory, and real-time chat. --- ## Environments | Environment | API Base URL | |-------------|-------------| | **Production** | `https://api.sendyiq.com` | | **Staging** | `https://stagging-api.sendyiq.com` | --- ## Authentication All integration endpoints require an API key issued from the Sendy dashboard. ``` X-API-Key: . Content-Type: application/json (required for request bodies) ``` --- ## Base Route ``` /api/v1/store/integrations/* ``` --- ## Available Scopes | Scope | Access | |-------|--------| | `orders:read` | Read orders | | `orders:write` | Create / update / cancel orders | | `catalog:read` | Read products and inventory | | `catalog:write` | Push catalog and stock updates | | `messaging:read` | Read chat conversations | | `messaging:write` | Send messages, create conversations | --- ## Key Endpoints | Method | Path | Scope | Notes | |--------|------|-------|-------| | `GET` | `/api/v1/store/integrations/orders` | `orders:read` | List orders | | `POST` | `/api/v1/store/integrations/orders` | `orders:write` | Create single order | | `POST` | `/api/v1/store/integrations/orders/bulk` | `orders:write` | Create multiple orders | | `PUT` | `/api/v1/store/integrations/orders/{id}` | `orders:write` | Update order | | `DELETE` | `/api/v1/store/integrations/orders/{id}` | `orders:write` | Cancel order | | `POST` | `/api/v1/store/integrations/orders/send-otp` | `orders:write` | Send OTP to customer phone | | `POST` | `/api/v1/store/integrations/orders/storefront` | `orders:write` | OTP-verified storefront order | | `GET` | `/api/v1/store/integrations/catalog` | `catalog:read` | Read catalog / products | | `POST` | `/api/v1/store/integrations/catalog` | `catalog:write` | Push catalog update | | `GET` | `/api/v1/store/integrations/chat/conversations` | `messaging:read` | List chat conversations | | `POST` | `/api/v1/store/integrations/chat/hub-token` | `messaging:read` | Exchange API key for a short-lived WebSocket token | ### Real-time (WebSocket / SignalR) Connect to the chat hub using the short-lived token returned by `POST /chat/hub-token`. The raw API key must **never** appear in a URL or WebSocket frame. | Environment | WebSocket URL | |-------------|--------------| | Production | `wss://api.sendyiq.com/hubs/store-conversations` | | Staging | `wss://stagging-api.sendyiq.com/hubs/store-conversations` | After connecting, call `JoinStore(token)` to subscribe to the store's conversation stream. --- ## Contract Notes - `payment_method` is **required** on every order create request — omitting it returns 400. - Address linkage is required on create / bulk order flows: - `addressProvinceCode` — from `GET /api/v1/public/provinces` - `addressAreaId` — from `GET /api/v1/public/provinces/{code}/areas` - `fulfillmentType` must be `from_to` or `warehouse`. - Duplicate SKUs in `items` on the same order are rejected with 400. --- ## MCP / AI Agent Sendy exposes a Streamable-HTTP MCP server for AI-agent integrations. Auth is **OAuth 2.0 PKCE bearer** (not API key). | Environment | MCP Endpoint | |-------------|-------------| | Production | `https://api.sendyiq.com/api/v1/store/mcp` | | Staging | `https://stagging-api.sendyiq.com/api/v1/store/mcp` | Full setup guide: [`../03-integrations/store-mcp-server.md`](/docs/mcp/) --- ## Detailed Specs - Overview & auth: [`../03-integrations/store-api-key.md`](/docs/authentication/) - Endpoint tables: [`../03-integrations/specs/store/endpoints.md`](/api-reference/) - Auth & scopes matrix: [`../03-integrations/specs/store/auth-and-scopes.md`](/docs/scopes/) - Integration flows: [`../03-integrations/specs/store/flow.md`](/docs/flows/) - OpenAPI definition: [`../03-integrations/specs/store/api-design.json`](/api-reference/) --- ## Store Frontend (JWT) _Building a store dashboard frontend with JWT bearer auth._ # Store Frontend Integration ## Surface - Base route: `/api/v1/store/*` - Primary source: `Sendy.Api/Controllers/V1/Store/*` ## Auth requirements - JWT bearer token required. - Role gate: `store_owner` or `store_staff` (see `StoreAreaControllerBase`). - Permission gate: `store.*` permissions per endpoint. - Store RBAC endpoints require: `store.rbac.manage`. ## Critical details - Role gate and permission gate are both enforced. - After role/permission changes, client should re-login to refresh token claims. - Address-based order flows require `addressProvinceCode` + `addressAreaId` from public address reference API. ## Minimum integration checklist 1. Login and inspect roles/claims. 2. Verify standard endpoint: - `GET /api/v1/store/orders` 3. Verify RBAC endpoint: - `GET /api/v1/store/rbac/roles` 4. Verify permission catalog: - `GET /api/v1/store/rbac/permissions` ## Storefront endpoints Stores manage a public-facing storefront page from this surface: | Method | Path | Permission | Notes | |--------|------|-----------|-------| | `GET` | `/api/v1/store/storefront` | `store.storefront.view` | Read current storefront settings + public URL | | `PUT` | `/api/v1/store/storefront` | `store.storefront.manage` | Update description, cover image, niche | | `POST` | `/api/v1/store/storefront/publish` | `store.storefront.manage` | Go live — requires slug assigned | | `DELETE` | `/api/v1/store/storefront/publish` | `store.storefront.manage` | Take offline | | `GET` | `/api/v1/store/storefront/templates` | `store.storefront.view` | List available niche templates | | `POST` | `/api/v1/store/storefront/templates/{id}/apply` | `store.storefront.manage` | Apply template (sets niche + theme) | `store_owner` has both `store.storefront.view` and `store.storefront.manage` by default. `store_staff` has `store.storefront.view` only. The public customer-facing URL (`publicUrl` in the response) is `[AllowAnonymous]` and lives under `/api/v1/public/storefronts/{slug}` — it is not a store JWT endpoint. ## MCP OAuth Redirect URI Management Stores can register allowed redirect URIs so that MCP clients (Cursor, Claude Desktop, custom agents) can complete the OAuth flow. | Method | Path | Permission | Notes | |--------|------|-----------|-------| | `GET` | `/api/v1/store/oauth-mcp/redirect-uris` | `store.oauth_mcp.redirect_uris.view` | Optional `?clientId=` filter | | `POST` | `/api/v1/store/oauth-mcp/redirect-uris` | `store.oauth_mcp.redirect_uris.manage` | Body: `{ clientId, redirectUri }` | | `DELETE` | `/api/v1/store/oauth-mcp/redirect-uris/{id}` | `store.oauth_mcp.redirect_uris.manage` | Soft delete | `store_owner` and `store_staff` both have both permissions by default. ## Order Coordinates `delivery_lat` and `delivery_lng` are now returned in `GET /api/v1/store/orders` list items and order detail responses. These fields are nullable floats. ## Common failure patterns - 403 on all store endpoints: missing `store_owner/store_staff` role claim. - 403 only on RBAC endpoints: missing `store.rbac.manage`. - 400 on `POST /storefront/publish`: store has no slug assigned — contact admin to provision one. - 400 on `POST /api/v1/store/orders` (or integration orders): duplicate SKUs in `items`, or missing `payment_method`. --- ## E-Commerce Module Base Paths All routes below are under `/api/v1/store/`. | Module | Base Path | View Permission | Manage Permission | |--------|-----------|----------------|------------------| | Product Categories | `categories` | `store.product_categories.view` | `store.product_categories.manage` | | Customers | `customers` | `store.customers.view` | `store.customers.manage` | | Suppliers | `suppliers` | `store.suppliers.view` | `store.suppliers.manage` | | Purchase Orders | `purchase-orders` | `store.purchase_orders.view` | `store.purchase_orders.manage` | | Expenses | `expenses` | `store.expenses.view` | `store.expenses.manage` | | Cashier / POS | `cashier` | `store.cashier.view` | `store.cashier.manage` | | Discount Rules | `discounts/rules` | `store.discounts.view` | `store.discounts.manage` | | Discount Codes | `discounts/codes` | `store.discounts.view` | `store.discounts.manage` | | Shipping Rules | `shipping/rules` | `store.shipping.view` | `store.shipping.manage` | | Package Sizes | `package-sizes` | `store.package_sizes.view` | `store.package_sizes.manage` | | Print Templates | `print-templates` | `store.print_templates.view` | `store.print_templates.manage` | | Store Settings | `settings` | `store.settings.view` | `store.settings.manage` | | Analytics | `analytics` | `store.analytics.view` | — | ## Settings Initialization `POST /api/v1/store/settings` must be called **once** after store creation (before most e-commerce features are usable). It accepts `currencyCode`, `timezone`, `industry`, `countryCode`. Returns **409** if settings already exist — subsequent changes use `PUT /api/v1/store/settings`. ## POS Session Lifecycle ``` Open Session (POST /cashier/sessions/open) → [sell orders during session] → Close Session (POST /cashier/sessions/{id}/close) ``` - Only **one** session can be open at a time per store. Opening a second returns **409**. - `GET /cashier/sessions/active` returns the current open session (404 if none). - `GET /cashier/sessions` returns all sessions (history). ## Analytics Date Range Format All analytics endpoints use `POST` with an `AnalyticsDateRangeRequest` body: ```json { "from": "2026-01-01T00:00:00Z", "to": "2026-05-31T23:59:59Z", "granularity": "Monthly" } ``` `granularity` values: `Daily`, `Weekly`, `Monthly`. Omit for aggregated totals only. Available analytics endpoints: - `POST /analytics/sales` — revenue, orders, average order value - `POST /analytics/products` — top products by quantity and revenue - `POST /analytics/customers` — customer count, new customers, top spenders - `POST /analytics/expenses` — total expenses by category - `POST /analytics/revenue` — `RevenueDataPoint[]` for charting --- ## API Key Authentication _Issue API keys and authenticate server-to-server integrations._ # Store Integration Guide ## API-Key Surface ### Base route - `/api/v1/store/integrations/*` ### Required headers - `X-API-Key: ` - `Content-Type: application/json` (for body requests) ### Scope expectations - Orders: `orders:read`, `orders:write` - Catalog/inventory: `catalog:read`, `catalog:write` - Website chat: `messaging:read`, `messaging:write` ### New: OTP-verified storefront orders `POST /orders/send-otp` (scope `orders:write`) sends an OTP to a customer phone. Pass the returned code as `otpCode` in `POST /orders/storefront` to create an OTP-verified order. See `specs/store/endpoints.md` → **OTP for Storefront Orders**. ### New: Website Chat via API key REST endpoints at `/chat/conversations/**` (scopes `messaging:read`/`messaging:write`) mirror the dashboard inbox for programmatic bots or CRM sync. Real-time events are available via SignalR at `wss://{host}/hubs/store-conversations` using a **short-lived hub token** (exchange via `POST /chat/hub-token`, then call `JoinStore(token)`) — the raw API key never appears in a URL or WebSocket frame. See `specs/store/endpoints.md` → **Website Chat (Integration)**. ### Contract notes - Address linkage required in create/bulk order flows: - `addressProvinceCode` - `addressAreaId` - `fulfillmentType` values are `from_to` or `warehouse`. ## Store JWT Tickets CRUD Surface These endpoints are JWT role/permission based (not API-key based). ### Base route - `/api/v1/store/tickets` ### Endpoints - `GET /api/v1/store/tickets` - `GET /api/v1/store/tickets/{ticketId}` - `POST /api/v1/store/tickets` - `PUT /api/v1/store/tickets/{ticketId}` - `DELETE /api/v1/store/tickets/{ticketId}` (soft delete) ### Ownership and scoping - Requests are scoped to `OwnerType=Store`. - `OwnerId` is derived from authenticated `current.StoreId`. - Incoming payload cannot override owner context. ### Create payload (example) ```json { "subject": "Stock mismatch for order ORD-123", "status": "open", "priority": "high", "category": "inventory", "assignedUserId": "00000000-0000-0000-0000-000000000000" } ``` ## Store JWT E-Commerce Modules These endpoints use **JWT bearer** (not API-key) and are accessible to `store_owner` / `store_staff`. ### Settings (initialize first) - `POST /api/v1/store/settings` — initialize `currencyCode`, `timezone`, `industry`, `countryCode` (call once per store). Returns 409 if already set. - `GET /api/v1/store/settings` — read current settings. - `PUT /api/v1/store/settings` — update settings. ### Product categories - `GET /api/v1/store/categories` - `POST /api/v1/store/categories` - `PUT /api/v1/store/categories/{id}` - `DELETE /api/v1/store/categories/{id}` — blocked if subcategories or products exist. ### Customers - `GET /api/v1/store/customers?search=` — `search` filters name, phone, email. - `POST /api/v1/store/customers` — `primaryPhone` must be unique per store (409 on dup). ### Suppliers - `GET/POST/PUT/DELETE /api/v1/store/suppliers` ### Purchase orders - `GET/POST/PUT/DELETE /api/v1/store/purchase-orders` — min 1 item on create; server computes `totalAmount`. - `POST /api/v1/store/purchase-orders/{id}/attachments` — max 3 attachments (400 if exceeded). - `DELETE /api/v1/store/purchase-orders/{id}/attachments/{attachmentId}` ### Expenses - `GET /api/v1/store/expenses?from=&to=&category=` - `POST/PUT/DELETE /api/v1/store/expenses` ### Cashier / POS - `GET/PUT /api/v1/store/cashier/settings` - `GET /api/v1/store/cashier/sessions`, `GET /api/v1/store/cashier/sessions/active` - `POST /api/v1/store/cashier/sessions/open` — 409 if session already open. - `POST /api/v1/store/cashier/sessions/{id}/close` - `GET/POST /api/v1/store/cashier/pinned` — max 30 pinned items. - `DELETE /api/v1/store/cashier/pinned/{productId}` - `PUT /api/v1/store/cashier/pinned/reorder` ### Discounts - `GET/POST/PUT/DELETE /api/v1/store/discounts/rules` - `GET/POST/PUT/DELETE /api/v1/store/discounts/codes` — code normalized to uppercase; percentage must be 0–100; expiresAt must be after startsAt. - `GET /api/v1/store/discounts/codes/validate?code=&orderValue=` ### Shipping rules - `GET/POST/PUT/DELETE /api/v1/store/shipping/rules` ### Package sizes - `GET/POST/PUT/DELETE /api/v1/store/package-sizes` — `isDefault: true` clears previous default. ### Print templates - `GET /api/v1/store/print-templates?type=` (optional filter by `Invoice`/`ShippingLabel`/`Receipt`) - `POST/PUT/DELETE /api/v1/store/print-templates` ### Analytics All use `POST` with body `{ from, to, granularity? }`: - `POST /api/v1/store/analytics/sales` - `POST /api/v1/store/analytics/products` - `POST /api/v1/store/analytics/customers` - `POST /api/v1/store/analytics/expenses` - `POST /api/v1/store/analytics/revenue` Full permission table: `DOCS/03-integrations/specs/store/auth-and-scopes.md`. ## Order Create: New Validations - `payment_method` is **required** — omitting it returns 400. - Duplicate SKUs in `items` are rejected with 400 and a `skus` error field listing the duplicates. - `delivery_lat` and `delivery_lng` (optional decimal) can now be sent and are returned in list/detail responses. ## Breaking Change Notes (Phase-2) - FlowApp is fully removed. Use `Nawa:*` config and `/api/v1/integrations/nawa/webhooks`. - Ticket `status`, `category`, `priority` are now enums (serialized as string names by default). - `payment_method` is now required on order create. - MCP endpoints use OAuth 2.0 bearer — see [`store-mcp-server.md`](/docs/mcp/). --- ## Permissions & Scopes _Roles, permissions, and integration scopes for the Store API._ # Store integrations — authentication and scopes ## Authentication | Item | Value | |------|--------| | HTTP header | **`X-API-Key`** — send your integration API key on every request | | ASP.NET scheme | `ApiKey` | | Controller | `[Authorize(AuthenticationSchemes = "ApiKey")]` on `StoreIntegrationsController` | Unauthenticated or invalid key handling is defined by the host’s API key authentication handler (typically **401**). ## Address reference (public) **`GET /api/v1/address-reference/provinces`** and **`GET /api/v1/address-reference/provinces/{code}/areas`** are **AllowAnonymous** — no **`X-API-Key`**. Same host as your integration; not under `/store/integrations`. Use them to resolve **`addressProvinceCode`** and **`addressAreaId`** before **`POST /orders`**. Shapes and query params: **`api-design.json`** → **`addressReference`**. ## Scopes - Scopes are read from the **`scope`** claim on the authenticated principal. - If the required scope for an action is missing, the API returns **403** with body: `ApiResponse` with `success: false`, `message`: `Missing scope: `, and `code: 403`. ## Scopes by area The entire Store surface is now available under `/api/v1/store/integrations` with an `X-API-Key`. Each endpoint requires the resource scope below; missing the scope returns **403**. Grant a key only the scopes it needs (least privilege) — `…:read` for GET, `…:write` for create/update/delete. | Scope | Area / use | |--------|-----| | `orders:read` / `orders:write` | Orders, order items, returns, status, QR, storefront OTP, delivery companies | | `catalog:read` / `catalog:write` | Products, product categories, inventory, warehouses, package sizes | | `purchasing:read` / `purchasing:write` | Suppliers, purchase orders (and attachments) | | `customers:read` / `customers:write` | Customer (CRM) records | | `discounts:read` / `discounts:write` | Discount codes (+ validate) and discount rules | | `finance:read` / `finance:write` | Expenses, wallet balance/transactions/bank details | | `analytics:read` | Sales/product/customer/expense/revenue analytics, dashboard summaries | | `printing:read` / `printing:write` | Invoice & label print templates | | `store:read` / `store:write` | Settings, profile, storefront (publish, templates, custom domain, sections, images) | | `pos:read` / `pos:write` | Cashier settings, sessions, pinned items | | `tickets:read` / `tickets:write` | Support tickets | | `messaging:read` / `messaging:write` | Website-chat hub token + conversations; social inbox conversations & channels | | `delivery:read` / `delivery:write` | Delivery providers, Al-Waseet, Boxy (orders, pickups, locations) | | `subscription:read` / `subscription:write` | Subscription, available plans, upgrade requests | | `kyc:write` | Upload KYC documents | | `rbac:read` / `rbac:write` | Staff users, roles, permissions (RBAC) | | `apikeys:read` / `apikeys:write` | List / create / revoke API keys | | `mcp:read` / `mcp:write` | MCP integration info, OAuth redirect URIs | > Security note: `rbac:*`, `apikeys:*`, `kyc:write`, and `subscription:write` are powerful > account-management scopes — only grant them to keys you fully trust, and prefer separate keys > per integration. ## Store JWT permissions (storefront) Storefront endpoints use JWT bearer (not `X-API-Key`) with the following permission gates: | Permission | Routes | |-----------|--------| | `store.storefront.view` | `GET /api/v1/store/storefront`, `GET /api/v1/store/storefront/templates` | | `store.storefront.manage` | `PUT /api/v1/store/storefront`, publish/unpublish, apply template | Both permissions are granted to `store_owner` by default. `store_staff` receives `store.storefront.view` only. ## Public storefront (no auth) `GET /api/v1/public/storefronts/{slug}` and `POST /api/v1/public/storefronts/{slug}/orders` are `[AllowAnonymous]` — no `X-API-Key` or JWT. The store must have `IsStorefrontPublished = true` for these to return data (otherwise **404**). ## Response envelope All integration actions return **`ApiResponse`** in JSON: - **`success`** — `true` on success. - **`data`** — typed payload (see `dtos`, `addressReference`, and `endpoints` in `api-design.json`). - **`message`** — human-readable summary (optional on success, useful on errors). - **`code`** — on errors, mirrors HTTP semantics (400, 401, 403, 404, 409, 422, 429, 500). - **`errors`** — on validation failure, object map of field name → string[] messages. HTTP status is chosen with **`RespondByErrorCode`**: success responses use **200**; errors use the status matching `code` when present. **Model binding / FluentValidation** failures return **400** with `ApiResponse` and `errors` populated. ## Linking If the API key is not linked to a Store, many calls return **400** with a message such as **"No store linked to this API key"** (see `StoreIntegrationsService`). ## Internal tenant claim (JWT and API keys) For password/JWT users and store- or delivery-company-scoped API keys, the server may issue an internal claim **`organization_id`** (UUID) used for database row scoping. It is not required for integration clients to send this claim; it is derived from the authenticated store or delivery company. Platform admin and similar roles do not use this filter. --- ## Store E-Commerce Module Permissions (JWT) All permissions below apply to store JWT endpoints under `/api/v1/store/*`. | Permission | What it gates | `store_owner` | `store_staff` | |-----------|--------------|:---:|:---:| | `store.product_categories.view` | `GET /categories`, `GET /categories/{id}` | ✓ | ✓ | | `store.product_categories.manage` | `POST/PUT/DELETE /categories` | ✓ | — | | `store.customers.view` | `GET /customers`, `GET /customers/{id}` | ✓ | ✓ | | `store.customers.manage` | `POST/PUT/DELETE /customers` | ✓ | ✓ | | `store.suppliers.view` | `GET /suppliers`, `GET /suppliers/{id}` | ✓ | ✓ | | `store.suppliers.manage` | `POST/PUT/DELETE /suppliers` | ✓ | — | | `store.purchase_orders.view` | `GET /purchase-orders`, `GET /purchase-orders/{id}` | ✓ | ✓ | | `store.purchase_orders.manage` | `POST/PUT/DELETE /purchase-orders`, attachments | ✓ | — | | `store.expenses.view` | `GET /expenses`, `GET /expenses/{id}` | ✓ | ✓ | | `store.expenses.manage` | `POST/PUT/DELETE /expenses` | ✓ | — | | `store.cashier.view` | `GET /cashier/settings`, sessions, pinned items | ✓ | ✓ | | `store.cashier.manage` | `PUT /cashier/settings`, open/close sessions, pin items | ✓ | ✓ | | `store.discounts.view` | `GET /discounts/rules`, `GET /discounts/codes`, validate | ✓ | ✓ | | `store.discounts.manage` | `POST/PUT/DELETE /discounts/rules`, codes | ✓ | — | | `store.shipping.view` | `GET /shipping/rules`, `GET /shipping/rules/{id}` | ✓ | ✓ | | `store.shipping.manage` | `POST/PUT/DELETE /shipping/rules` | ✓ | — | | `store.package_sizes.view` | `GET /package-sizes`, `GET /package-sizes/{id}` | ✓ | ✓ | | `store.package_sizes.manage` | `POST/PUT/DELETE /package-sizes` | ✓ | — | | `store.print_templates.view` | `GET /print-templates`, `GET /print-templates/{id}` | ✓ | ✓ | | `store.print_templates.manage` | `POST/PUT/DELETE /print-templates` | ✓ | — | | `store.settings.view` | `GET /settings` | ✓ | ✓ | | `store.settings.manage` | `POST /settings` (init), `PUT /settings` | ✓ | — | | `store.analytics.view` | All `POST /analytics/*` endpoints | ✓ | ✓ | **`store_staff` summary:** receives all `*.view` permissions + `store.customers.manage` + `store.cashier.manage` + `store.analytics.view`. --- ## Social Messaging Permissions (JWT) Permissions for the Social Messaging / CRM feature under `/api/v1/store/social/*`. | Permission | What it gates | `store_owner` | `store_staff` | |-----------|--------------|:---:|:---:| | `store.social_channels.view` | `GET /social/channels` | ✓ | — | | `store.social_channels.manage` | `POST/PATCH/DELETE /social/channels`, toggle-agent, rotate-token | ✓ | — | | `store.conversations.view` | `GET /social/conversations`, `GET /social/conversations/{id}` | ✓ | ✓ | | `store.conversations.manage` | `POST /messages`, resolve, reopen | ✓ | ✓ | `store_staff` can read and reply to conversations but cannot connect, disconnect, or reconfigure channels. ## MCP OAuth Scopes for Social Messaging | Scope | MCP tools that require it | |-------|--------------------------| | `messaging:read` | `list_conversations`, `get_conversation` | | `messaging:write` | `send_conversation_message`, `resolve_conversation` | These scopes are issued via the standard OAuth 2.0 PKCE flow (see `store-mcp-server.md`). --- ## Integration Flows _Setup, purchase, POS and discount flows end-to-end._ # Store integrations — recommended flow ## 1. Prerequisites 1. Obtain **base URL**, **API key**, and required **scopes** from Sendy. 2. Confirm the key is **linked to your store** (otherwise expect 400 from the service layer). 3. Store the key as a secret (env / vault), not in client-side code. 4. Cache or call **`GET /api/v1/address-reference/provinces`** and **`GET /api/v1/address-reference/provinces/{code}/areas`** as needed (no API key) so you can send valid **`addressProvinceCode`** / **`addressAreaId`** on order create — see **`api-design.json`** → **`addressReference`**. ## 2. Orders 1. **Create:** `POST /orders` (or `POST /orders/bulk` for batches). Set **`fulfillmentType`** to match how you fulfill (`from_to` vs `warehouse`). Send **`addressProvinceCode`** and **`addressAreaId`** from **`GET /api/v1/address-reference/provinces`** and **`GET /api/v1/address-reference/provinces/{code}/areas`**. Send **`customerPhone`** as a valid Iraq mobile (see `IraqPhoneValidation` in code). Persist returned **`id`** and **`publicId`** in your system. 2. **Track:** `GET /orders/{orderId}` for status, payment snapshot, **`fulfillmentType`**, and **`addressProvinceCode`** / **`addressAreaId`**. 3. **Adjust delivery details:** `PUT /orders/{orderId}` if the free-text address or map pin needs to change. The request body is still validated like **create** (including province, area, and phone), but the server **only updates** **`customerAddress`**, **`deliveryLat`**, and **`deliveryLng`**. 4. **Cancel:** `POST /orders/{orderId}/cancel` with optional reason. ## 3. Catalog and inventory 1. **Products:** `POST /catalog/products` to upsert by **SKU**; `GET /catalog/products` to reconcile. 2. **Stock:** `PUT /catalog/inventory/{inventoryItemId}` with **`quantityOnHand`**; `GET /catalog/inventory` for full list (includes `warehouseId` when relevant). ## 4. Returns 1. After delivery, `POST /orders/{orderId}/return-request` with optional **reason**. 2. Poll `GET /orders/{orderId}/return-requests` or `GET /return-requests/{returnRequestId}` for **status** (`pending`, `approved`, `rejected`, `completed`). ## 5. Errors and retries - **400** — validation, bad filter, or business rule; inspect `errors` and `message`; fix payload before retrying. - **403** — missing scope; fix key configuration. - **404** — wrong id or resource not visible to this store. - **409** — conflict (e.g. duplicate pending return). - **5xx** — transient; retry with backoff and idempotency awareness on creates (Sendy does not document a separate idempotency key header; treat `externalRef` as your own deduplication key on the client side if needed). For exact JSON properties, always refer to **`api-design.json`** → `dtos`. ## 6. Storefront (social-link order intake) This flow is for stores that want a shareable public URL to paste in their Instagram bio or WhatsApp — customers visit the link, browse products, and place orders without any app login. 1. **Add prices and images to catalog products** (optional but recommended): - `PUT /catalog/products/{productId}` via the integration API, or store JWT `PUT /api/v1/store/products/{id}`. - Products without a `price` are still accepted at checkout (line total = 0). 2. **Customize storefront settings** (store JWT, `store.storefront.manage`): - `PUT /api/v1/store/storefront` with `description`, `coverImageUrl`, `niche`. - Apply a niche template if desired: `GET /api/v1/store/storefront/templates` → `POST /api/v1/store/storefront/templates/{id}/apply`. 3. **Publish the storefront**: - `POST /api/v1/store/storefront/publish`. - Returns `{ slug, is_published: true }` and the `publicUrl`. - Fails with **400** if the store has no slug (contact support to assign one; demo stores have slugs seeded automatically). 4. **Share the public URL**: - Format: `{baseUrl}/api/v1/public/storefronts/{slug}` (e.g. `…/flora-baghdad`). - Paste in Instagram bio, WhatsApp link, or any social post. 5. **Customer flow (no auth)**: - `GET /api/v1/public/storefronts/{slug}` — browse store info + active priced products. - `POST /api/v1/public/storefronts/{slug}/orders` — place an order by SKU + quantity. - Resulting order has `source = "Storefront"`; it appears in the store's normal order list. 6. **Unpublish** when needed: `DELETE /api/v1/store/storefront/publish`. ## 7. Webhook flow (recommended) 1. Register endpoint: `POST /webhooks` with your HTTPS URL. 2. Persist returned subscription id and keep the webhook secret secure. 3. On each delivery, verify `X-Sendy-Signature` against the exact raw body (HMAC SHA-256). 4. De-duplicate by `X-Sendy-Event-Id` (and/or `event_id`) and process asynchronously. 5. Return `2xx` only after persistence/queueing; non-2xx triggers retries with backoff. 6. Rotate secret using `POST /webhooks/{subscriptionId}/rotate-secret` during routine key rotation. --- ## 8. Store Setup Flow Use this flow when onboarding a new store before it starts operating. 1. **Initialize settings** (required first): - `POST /api/v1/store/settings` with `currencyCode`, `timezone`, `industry`, `countryCode`, `language`. - Returns **409** if settings already exist. 2. **Configure storefront** (optional): - `PUT /api/v1/store/storefront` with extended fields: `description`, `coverImageUrl`, `bannerImageUrl`, `contactEmail`, social links, SEO fields, FAQs, policies, `customDomain`. - Apply a niche template: `POST /api/v1/store/storefront/templates/{id}/apply`. 3. **Add package sizes** (optional, for shipping): - `POST /api/v1/store/package-sizes` — mark one as `isDefault: true`. 4. **Configure cashier** (optional, for POS): - `PUT /api/v1/store/cashier/settings` with `requirePin`, `openingCashDefault`, `receiptFooterNote`. 5. **Add product categories** (optional): - `POST /api/v1/store/categories` — supports parent/child hierarchy via `parentCategoryId`. 6. **Publish storefront** (when ready): - `POST /api/v1/store/storefront/publish`. --- ## 9. Purchase Flow Use this flow to track procurement from suppliers. 1. **Add supplier** (optional): - `POST /api/v1/store/suppliers` with `fullName`, contact details. 2. **Create Purchase Order** (starts as `Draft`): - `POST /api/v1/store/purchase-orders` with `supplierId`, `orderDate`, `items[]` (min 1 item required). - Server computes `subTotal` and `totalAmount`. 3. **Advance status** as PO progresses: - `PUT /api/v1/store/purchase-orders/{id}` with `status: "Sent"` / `"PartiallyReceived"` / `"Received"` / `"Cancelled"`. 4. **Attach supporting documents** (max 3): - `POST /api/v1/store/purchase-orders/{id}/attachments` with `fileName`, `fileUrl`, `fileSize`. 5. **Log the expense** after payment: - `POST /api/v1/store/expenses` with `name`, `amount`, `expenseType`, `category`, `expenseDate`. --- ## 10. POS / Cashier Flow Use this flow for point-of-sale sessions. 1. **Pin frequently sold products** (setup step): - `POST /api/v1/store/cashier/pinned` with `storeProductId`, `sortOrder`. Max **30 items**. - Reorder with `PUT /api/v1/store/cashier/pinned/reorder`. 2. **Open a session** at start of shift: - `POST /api/v1/store/cashier/sessions/open` with `openingCash` amount. - Returns **409** if a session is already open. 3. **Sell** (during session): - Create orders via the normal `POST /api/v1/store/orders` flow. 4. **Close session** at end of shift: - `POST /api/v1/store/cashier/sessions/{sessionId}/close` with `closingCash`, optional `notes`. 5. **Review history**: - `GET /api/v1/store/cashier/sessions` for all past sessions. --- ## 11. Discount Flow ### Discount Codes 1. **Create a code**: - `POST /api/v1/store/discounts/codes` with `code` (auto-uppercased), `discountType`, `discountValue`, optional `minOrderValue`, `maxUses`, `startsAt`, `expiresAt`. 2. **Validate at checkout**: - `GET /api/v1/store/discounts/codes/validate?code=SAVE20&orderValue=50000` - Returns `{ isValid, discountType, discountValue }` or `{ isValid: false, errorMessage }`. 3. **Apply the discount** server-side when creating the order (pass the validated code; service deducts from order total). ### Discount Rules 1. **Create a rule** for automatic discounts (no code required): - `POST /api/v1/store/discounts/rules` with `name`, `discountType`, `discountValue`, optional `categoryId` (per-category rule), `channels`. - Rules with `isActive: true` are applied automatically during order processing. ### Shipping Rules 1. **Create a rule**: - `POST /api/v1/store/shipping/rules` with `name`, `ruleType` (`FlatRate`, `FreeShipping`, `WeightBased`, `OrderValueBased`), `cost`, optional `minOrderValue`/`maxOrderValue`. - Rules with `isActive: true` are evaluated during checkout to determine shipping cost. --- ## 12. Social Messaging Agent Setup Use this flow to connect WhatsApp or Instagram and enable the AI-powered CRM inbox. ### A — Connect a channel 1. **Connect the account** (`store.social_channels.manage`): - `POST /api/v1/store/social/channels` - Required: `channelType` (`WhatsApp` or `Instagram`), `externalAccountId`, `displayName`, `accessToken`, `aiProviderCode`, `aiModelCode`. - Optional: `appSecret` (strongly recommended for webhook HMAC verification), `systemPromptOverride`, `isEnabled`, `isAgentEnabled`. - Server encrypts `accessToken` and `appSecret`; returns `webhookVerifyToken` — copy it before leaving this screen. 2. **Register the webhook with Meta** (one-time, in Meta Developer Console): - Callback URL: `https:///api/v1/webhooks/social/whatsapp` (or `/instagram`) - Verify Token: the `webhookVerifyToken` from the connect response - Subscribe to: `messages`, `message_deliveries`, `message_reads` - Meta calls `GET /api/v1/webhooks/social/whatsapp?hub.mode=subscribe&hub.verify_token=...&hub.challenge=...`; Sendy responds with the challenge to confirm. 3. **Rotate the verify token** if compromised: - `POST /api/v1/store/social/channels/{channelId}/rotate-token` — generates a new `WebhookVerifyToken`; update the Meta webhook registration with the new value. ### B — Manage AI agent 4. **Toggle the AI agent on/off** per channel: - `POST /api/v1/store/social/channels/{channelId}/toggle-agent` `{ "enabled": true/false }` - When disabled, inbound messages are still received and stored but no `AiMessageJob` is created — staff must reply manually. 5. **AI dispatch (automatic, background)**: - A Hangfire job (`social.ai.dispatch`) runs every minute. - It picks pending `AiMessageJob` records, calls the configured AI provider (OpenAI or Anthropic) with the conversation history + store context as system prompt, sends the reply via the Meta API, and records token usage. - Failed jobs retry with exponential backoff; `Dead` status is set after `MaxAttempts`. ### C — Staff CRM inbox 6. **View conversations** (`store.conversations.view`): - `GET /api/v1/store/social/conversations?status=Open&page=1` - Returns paginated list of conversations. Filter by `channelId` or `status` (`Open`, `Pending`, `Resolved`). 7. **Read full conversation** (`store.conversations.view`): - `GET /api/v1/store/social/conversations/{conversationId}` - Returns conversation metadata plus the full message history (inbound + outbound, AI-generated or manual). 8. **Send a message manually** (`store.conversations.manage`): - `POST /api/v1/store/social/conversations/{conversationId}/messages` `{ "text": "..." }` - Calls Meta API in real time; persists `SocialMessage` record on success. 9. **Resolve / reopen** (`store.conversations.manage`): - `POST /{conversationId}/resolve` — sets status to `Resolved`. - `POST /{conversationId}/reopen` — sets status back to `Open`. ### D — Token usage and billing - Token consumption is accumulated per subscription period in `StoreAiTokenUsage`. - `GET /api/v1/store/subscription/usage` returns `aiInputTokensUsed`, `aiOutputTokensUsed` alongside the plan's included quota. - Overage beyond `IncludedInputTokensPerPeriod` / `IncludedOutputTokensPerPeriod` is tracked as `OverageInputTokens` / `OverageOutputTokens` and billed at `OverageInputTokenPriceIqd` / `OverageOutputTokenPriceIqd` per 1,000 tokens. - Social Messaging is available on **all subscription plans** (`AllowsSocialMessaging = true`); token quota and overage pricing are configured per plan. --- ## MCP / AI Agent _Connect AI agents via the Streamable-HTTP MCP server and OAuth 2.0._ Use this guide to connect Cursor, Claude Desktop, or `mcp-inspector` to any Sendy MCP endpoint. ## Available endpoints | URL | Tenant context | |-----|---------------| | `https:///api/v1/store/mcp` | Store customer assistant | | `https:///api/v1/admin/mcp` | Platform admin | | `https:///api/v1/delivery-company/mcp` | Delivery company | Transport: Streamable HTTP (`MapMcp`). ## Authentication MCP endpoints require **OAuth 2.0 Bearer** tokens — **not** API keys. The flow is PKCE authorization code (`code_challenge_method=S256`). Steps: 1. **Start authorization** ``` GET /api/v1/oauth/authorize ?response_type=code &client_id= &redirect_uri= &code_challenge= &code_challenge_method=S256 &state= &scope=catalog:read orders:read orders:write ``` If the user is not logged in, the server redirects to your login UI with `?request_id=`. 2. **User logs in** (if not already authenticated) ``` POST /api/v1/oauth/login { "requestId": "", "phone": "...", "password": "..." } ``` Accept: `application/json` to receive `{ "data": { "redirectUrl": "..." } }` instead of a 302. 3. **Exchange code for token** (form-encoded) ``` POST /api/v1/oauth/token grant_type=authorization_code &code= &code_verifier= &client_id= &redirect_uri= ``` Response: ```json { "access_token": "...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "...", "scope": "catalog:read orders:read orders:write" } ``` 4. **Call MCP endpoint** ``` Authorization: Bearer ``` ## Required scopes per tool | Tool | Required scope | |------|---------------| | `get_store_info` | `catalog:read` | | `list_categories` | `catalog:read` | | `list_provinces` | `catalog:read` | | `list_areas` | `catalog:read` | | `list_products` | `catalog:read` | | `search_products` | `catalog:read` | | `get_product` | `catalog:read` | | `send_order_otp` | `orders:write` | | `create_storefront_order` | `orders:write` | | `track_order` | `orders:read` | ## Exposed tools - `get_store_info` — get store name, description, contact details (email, WhatsApp, social links), policies, and FAQs - `list_categories` — list the store's product categories for guided browsing - `list_provinces` — list provinces the store delivers to, with their codes - `list_areas` — list delivery areas/districts within a province (param: `provinceCode`); returns the area ID needed to place an order - `list_products` — list all active products in the store catalog - `search_products` — full-text search by SKU / name / description (max 50 results) - `get_product` — get one product by UUID - `send_order_otp` — send a verification code to the customer's phone via SMS/WhatsApp (param: `phone`); call before `create_storefront_order` - `create_storefront_order` — place an order the same way the storefront website does; requires an OTP code (customer name, phone, address, province code, area ID, items) - `track_order` — fetch order detail and status history by public ID (e.g. `ORD-20260506-ABC123`) ### Ordering flow To place an order the way a customer does on the website: 1. `list_provinces` → customer picks a province (note its `code`) 2. `list_areas` with that `provinceCode` → customer picks an area (note its `id`) 3. `send_order_otp` with the customer's `phone` → a 6-digit code is sent to them 4. `create_storefront_order` with the customer details, `addressProvinceCode`, `addressAreaId`, `items` (SKU + quantity), and the `otpCode` the customer received ## Info endpoint Each area exposes an info endpoint that returns the MCP server URL, tool names, and required scopes: ``` GET /api/v1/store/mcp/integration # requires store.oauth_mcp.redirect_uris.view or store_owner GET /api/v1/admin/mcp/integration # requires admin.oauth_mcp.clients.view GET /api/v1/delivery-company/mcp/integration # requires dc.tickets.view ``` ## Cursor / Claude Desktop config example ```json { "mcpServers": { "sendy-store": { "url": "https:///api/v1/store/mcp", "headers": { "Authorization": "Bearer " } } } } ``` ## Registering redirect URIs Before the OAuth flow can complete, the `redirect_uri` must be registered. Stores register their own: - `POST /api/v1/store/oauth-mcp/redirect-uris` `{ "clientId": "...", "redirectUri": "..." }` — requires `store.oauth_mcp.redirect_uris.manage` - Admin can manage all clients at `/api/v1/admin/oauth-mcp/clients` and `/api/v1/admin/oauth-mcp/redirect-uris` ## Config keys required on server ``` OAuthMcp:SigningKey # 32+ byte base-64 — required OAuthMcp:Issuer OAuthMcp:Audience OAuthMcp:AuthorizationCodeLifetimeSeconds (default 60) OAuthMcp:AccessTokenLifetimeSeconds (default 3600) OAuthMcp:RefreshTokenLifetimeSeconds (default 86400) ``` ## Notes - API keys do **not** work on MCP endpoints — only OAuth bearer tokens. - Tool responses use the same `ApiResponse` envelope as REST controllers. - The same tool surface is exposed on all three MCP URLs; context is resolved from JWT claims. --- ## Create Order Flow _How orders are created, validated, and pushed to delivery._ # Store Create Order Flow Describes the full lifecycle of `POST api/v1/store/orders` — from request validation through inventory allocation, delivery company resolution, optional external provider push, and optional online payment initiation. --- ## 1) Architecture Overview ``` Store JWT → StoreOrdersController │ ▼ StoreOrderService.CreateOrderAsync │ ┌─────────┼─────────────────────┐ ▼ ▼ ▼ SubscriptionGuard InventoryAllocation DeliveryCompanyResolution │ ┌─────────┴──────────┐ ▼ ▼ Internal DC External DC (same org) (StoreDeliveryProviderLink) │ StoreDeliveryProviderService .TryPushOrderAsync │ ┌───────────┴───────────┐ ▼ ▼ Boxy API Al-Waseet API (PushOrderToBoxyAsync) (PushOrderToAlWaseetAsync) │ OrderExternalDelivery ``` **Permission required:** `store.orders.create` --- ## 2) Request Body `POST api/v1/store/orders` ```json { "fulfillmentType": "warehouse", // "warehouse" | "storefront" "customerName": "Ahmed Ali", "customerPhone": "07701234567", "customerAddress": "Al-Karrada, Building 5", "addressProvinceCode": "BGH", // from address reference API "addressAreaId": "", // from address reference API "deliveryLat": 33.3152, "deliveryLng": 44.3661, "items": [ { "sku": "SHIRT-L-RED", "quantity": 2 }, { "sku": "PANTS-32", "quantity": 1 } ], "packageType": "standard", // optional; default "standard" "notes": "Handle with care", // optional "paymentMethod": "cash", // optional; default "cash"; ignored if paymentGateway is set "preferredDeliveryCompanyId": "", // optional; auto-resolved by nearest warehouse if omitted "preferredWarehouseId": "", // optional; warehouse fulfillment only "paymentGateway": "QiCard", // optional; triggers online payment session "returnUrl": "https://mystore.com/ty" // required when paymentGateway is set } ``` --- ## 3) Validation Pipeline Steps execute sequentially; any failure returns early with HTTP 400. ### 3a — Subscription Guard `SubscriptionGuard.EnsureCanCreateOrdersAsync` — verifies the store's active subscription allows creating one more order. If `fulfillmentType == "warehouse"`, a second check (`EnsureCanUseWarehouseFulfillmentAsync`) confirms the plan includes warehouse fulfillment. ### 3b — Address Validation `OrderDeliveryAddressValidation.ValidateProvinceAndAreaAsync` — confirms `addressProvinceCode` exists in `AddressProvinces` and `addressAreaId` exists under that province. ### 3c — Phone Canonicalization `IraqPhoneValidation.TryGetCanonical` — normalizes the customer phone to `+964…` format. Rejects invalid Iraqi numbers with HTTP 400. ### 3d — SKU Validation All requested SKUs are checked against `StoreProducts` for the store (`IsActive && !IsDeleted`). Any unknown or inactive SKU is rejected, listing the offending SKUs in the error response. ### 3e — Inventory Check `InventoryItems` are loaded filtered by: - `StoreId == storeId` - `QuantityOnHand > 0` - `WarehouseId != null` for warehouse fulfillment, `WarehouseId == null` for storefront For each SKU the total available quantity across all matching rows must cover the requested quantity, otherwise HTTP 400. ### 3f — Warehouse Selection (warehouse fulfillment only) If `preferredWarehouseId` is set: the warehouse must exist, be active, and be owned by the store (`WarehouseOwnerType.Store`) and have stock for the requested items. If omitted: all candidate warehouses (from inventory rows) that have coordinates are ranked by distance to `(deliveryLat, deliveryLng)` via `IDistanceService`. The closest one is selected. Fails if no warehouse has coordinates. ### 3g — Delivery Company Resolution ``` preferredDeliveryCompanyId provided? ├── YES → Load DC (IsActive, !IsDeleted) │ ├── dc.OrganizationId == storeOrgId → INTERNAL (allowed) │ └── dc.Slug != null │ && active StoreDeliveryProviderLink for this store → EXTERNAL (allowed) │ └── neither → 400 "not found or inactive" └── NO → ResolveNearestDeliveryCompanyIdAsync (nearest DC-owned warehouse to delivery coordinates; may return null) ``` `ResolveNearestDeliveryCompanyIdAsync` queries all warehouses with `OwnerType == DeliveryCompany` that are active and have coordinates, calculates distance from each to the customer location, and returns the `DeliveryCompanyId` of the closest warehouse. Returns `null` if no DC warehouses exist. --- ## 4) Order & Item Creation After all validations pass: 1. **Allocation plan** is built: for each requested SKU, inventory rows are assigned quantities to cover the requested amount (`BuildAllocationPlan` for warehouse, `BuildStoreFrontAllocationPlan` for storefront). 2. **`Order` entity** is created with: | Field | Source | |-------|--------| | `OrganizationId` | store's organization | | `StoreId` | from JWT | | `PublicId` | generated short ID | | `DeliveryCompanyId` | resolved in §3g | | `AddressProvinceCode` | uppercased from request | | `AddressAreaId` | from request | | `OrderValue` | `sum(qty × unitPrice)` across all items | | `DeliveryFee` | `OrderPricing:DefaultDeliveryFee` config (default `0`) | | `PaymentMethod` | `Online` if `paymentGateway` set; else request value or `Cash` | | `CodAmount` | `OrderValue + DeliveryFee` when Cash; `0` when Online | | `Status` | `Pending` | | `PackageType` | request `packageType` lowercased, or `"standard"` | 3. `db.Orders.Add(order)` → `SaveChangesAsync` (order gets its `Id`). 4. **`OrderItem`** rows are added for each SKU, referencing the first allocated `InventoryItem`. 5. **Inventory** `QuantityOnHand` is decremented by allocated quantities. 6. `SaveChangesAsync` persists items and inventory changes. --- ## 5) External Provider Push `StoreDeliveryProviderService.TryPushOrderAsync(storeId, order, ct)` is called immediately after the order is saved. This never throws — all errors are caught and logged. ``` order.DeliveryCompanyId is null? → skip (no DC assigned) StoreDeliveryProviderLink exists for (storeId, DeliveryCompanyId)? └── NO → skip (internal DC or no link) └── YES → branch on DeliveryCompany.Slug ├── "boxy" → PushOrderToBoxyAsync └── "al-waseet" → PushOrderToAlWaseetAsync ``` An `OrderExternalDelivery` row is written in all cases (Success / Failed) recording: | Field | Meaning | |-------|---------| | `ExternalOrderId` | Provider's order ID (`qr_id` for Al-Waseet, `uid` for Boxy) | | `PushStatus` | `Success`, `Failed` | | `ErrorMessage` | Reason for failure | | `PushedAt` | UTC timestamp when push succeeded | See [al-waseet-delivery-provider-flow.md](/docs/flow-al-waseet/) §4 and [boxy-delivery-provider-flow.md](/docs/flow-boxy/) §4 for the detailed field mappings used during push. A failed push does **not** roll back or cancel the Sendy order — it remains `Pending`. The store can retry via `POST api/v1/store/delivery-providers/orders/{orderId}/sync-status` or by re-pushing manually. --- ## 6) Online Payment Initiation (optional) If `paymentGateway` is set, `InitiateOnlinePaymentAsync` is called after the external push. On success, `payment_url` is returned in the response and the store should redirect the customer there. On failure the endpoint returns HTTP 400 — the order has already been saved but no payment session exists; the store may retry. Supported gateways: `QiCard`, `AsiaPay`, `ZainCash`, `Wayl`. --- ## 7) Success Response ```json { "success": true, "message": "Order created successfully", "data": { "id": "", "public_id": "ORD-XXXX", "status": "pending", "warehouse_id": "", "order_value": 35000, "delivery_fee": 5000, "cod_amount": 40000, "fulfillment_type": "warehouse", "payment_method": "cash", "delivery_company_id": "", "delivery_lat": 33.3152, "delivery_lng": 44.3661, "payment_url": null } } ``` `payment_url` is non-null only when `paymentGateway` was set and initiation succeeded. --- ## 8) Error Cases | Condition | HTTP | Message | |-----------|------|---------| | Subscription limit reached | 400 | subscription message | | Warehouse fulfillment not in plan | 400 | feature message | | Invalid province or area | 400 | "Province/area not found" | | Invalid Iraq phone number | 400 | validation message | | Unknown or inactive SKU | 400 | "One or more SKUs are not in your store catalog…" | | Insufficient stock | 400 | "Insufficient stock for one or more SKUs." | | No warehouse with coordinates (auto-select) | 400 | "Candidate warehouses are missing location coordinates." | | Preferred warehouse not found / wrong store | 400 | "The preferred warehouse was not found…" | | Preferred DC not found / inactive | 400 | "The selected delivery company was not found or is inactive." | | Preferred DC is external but store has no active link | 400 | "The selected delivery company was not found or is inactive." | | Payment initiation failed | 400 | gateway error message | External push failures are **not** surfaced as HTTP errors — the order is created and the failure is recorded in `OrderExternalDelivery.ErrorMessage`. --- ## 9) Fulfillment Types | Value | Inventory Source | Warehouse Required | |-------|------------------|--------------------| | `warehouse` | `InventoryItems` with `WarehouseId != null` | Yes — auto-selected or `preferredWarehouseId` | | `storefront` | `InventoryItems` with `WarehouseId == null` | No | --- ## 10) Source References | Layer | File | |-------|------| | Controller | `Sendy.Api/Controllers/V1/Store/StoreOrdersController.cs` | | Service interface | `Sendy.Application/Interfaces/Services/Store/IStoreOrderService.cs` | | Service implementation | `Sendy.Application/Services/Store/StoreOrderService.cs` | | Request DTO | `Sendy.Application/DTOs/Requests/Store/CreateOrderRequest.cs` | | Order entity | `Sendy.Domain/Entities/Orders/Order.cs` | | External delivery tracking | `Sendy.Domain/Entities/Orders/OrderExternalDelivery.cs` | | Push dispatch | `Sendy.Application/Services/StoreArea/StoreDeliveryProviderService.cs` → `TryPushOrderAsync` | | DC resolution helper | `StoreOrderService.ResolveNearestDeliveryCompanyIdAsync` | | Subscription guard | `Sendy.Application/Services/Store/SubscriptionGuardService.cs` | | Address validation | `Sendy.Application/Validation/OrderDeliveryAddressValidation.cs` | | Phone validation | `Sendy.Application/Validation/IraqPhoneValidation.cs` | | Provider link entity | `Sendy.Domain/Entities/Store/StoreDeliveryProviderLink.cs` | | Al-Waseet push detail | [al-waseet-delivery-provider-flow.md](/docs/flow-al-waseet/) | | Boxy push detail | [boxy-delivery-provider-flow.md](/docs/flow-boxy/) | --- ## Storefront Publish Flow _Publishing a storefront and theme._ # Store Storefront — Preparation & Publishing Flow This document explains how a store prepares its public-facing website (storefront) and publishes it so customers can browse products and place orders. It covers prerequisites, the step-by-step setup sequence, all relevant endpoints, request/response shapes, and the validation rules enforced at each stage. --- ## Overview A storefront is the public webpage that represents a store. Once published, any visitor with the store's URL can view products and create orders — no authentication required. The store owner configures everything (branding, social links, SEO, FAQs, policies) through the authenticated store-area API before flipping the publish switch. ``` Admin creates / approves store │ ▼ Store gets a slug + trial subscription │ ▼ Store configures storefront settings ◄── optional but recommended │ ▼ Store adds products with prices │ ▼ Store publishes storefront │ ▼ Public URL is live ──► customers browse & order ``` --- ## Prerequisites (conditions that must be true before publishing) | # | Condition | How it is satisfied | |---|---|---| | 1 | Store must be **Approved** | Admin approves via `POST /api/v1/admin/stores/{id}/approve`, or the admin created the store directly (auto-approved). | | 2 | Store must have a **slug** | Slug is auto-generated from the store name during provisioning. If missing, contact support. | | 3 | Store must have an **active or trialing subscription** | A `free_trial` subscription is provisioned automatically on store creation. | | 4 | At least one **product with a price** should exist | Products with no price are hidden from the public storefront. | Attempting to publish without a slug returns: ```json HTTP 400 { "success": false, "message": "A slug must be assigned before publishing. Contact support.", "code": 400 } ``` --- ## Registration statuses | Status | Write operations | Publish allowed | |---|---|---| | `Pending` | Blocked | No | | `Approved` | Allowed | Yes (if slug present) | | `Rejected` | Blocked | No | --- ## Step-by-step flow ### Step 1 — Admin creates or approves the store **Option A — Admin creates store directly (pre-approved)** ``` POST /api/v1/admin/stores Permission: admin.stores.create ``` ```json { "name": "string", "phone": "string", "address": "string", "lat": 0.0, "lng": 0.0, "email": "string", "ownerFullName": "string", "ownerPhone": "string", "ownerPassword": "string" } ``` The system automatically: - Sets `RegistrationStatus = Approved` - Generates a unique `slug` from the store name - Provisions a `free_trial` subscription **Option B — Approve a self-registered store** ``` POST /api/v1/admin/stores/{storeId}/approve Permission: admin.stores.approve ``` ```json { "note": "string|null" } ``` Response: ```json { "success": true, "message": "Store approved successfully", "data": { "store_id": "guid", "registration_status": "approved" } } ``` > **Note:** Admin approval does **not** create a subscription. A subscription is only provisioned at store creation time (steps above). If a self-registered store needs a subscription, it must be assigned manually via admin or through the upgrade flow. --- ### Step 2 — (Optional) Pick a template Templates pre-fill the niche, apply a color theme, and define which page sections are supported. They are optional; the store can configure everything manually. **List templates** ``` GET /api/v1/store/storefront/templates Permission: store.storefront.view ``` Response includes full template details including color presets: ```json { "success": true, "data": [ { "id": "guid", "key": "flora", "name": "Flora", "niche": 1, "description": "string", "themeColor": "#hex", "thumbnailUrl": "https://...", "previewImageUrl": "https://...", "previewUrl": "https://live-demo.example.com/flora", "isActive": true, "editableColorSlots": ["primary", "secondary", "accent"], "supportedSections": ["hero", "featured", "categories"], "defaultColorPresetId": "guid|null", "colorPresets": [ { "id": "guid", "name": "Rose Gold", "isDefault": true, "colors": { "primary": "#e8b4a0", "secondary": "#f5e6df", "accent": "#c07a6a", "background": "#ffffff", "surface": "#fdf6f3", "text": "#2c1810" } } ] } ] } ``` **Apply a template** (standalone): ``` POST /api/v1/store/storefront/templates/{templateId}/apply Permission: store.storefront.manage ``` Response: `{ "template_id": "guid", "niche": "string" }` **Or** apply template + color preset as part of `PUT /storefront`: ```json { "templateId": "guid", "colorPresetId": "guid" } ``` --- ### Step 2.5 — (Optional) Configure storefront sections Templates define supported sections (hero, featured products, categories carousel, etc.). Each section can be reordered, toggled, and given per-section content. ``` PUT /api/v1/store/storefront/sections Permission: store.storefront.manage Content-Type: multipart/form-data ``` Request (all section fields optional except `key`): ```json { "sections": [ { "key": "hero", "isEnabled": true, "sortOrder": 1, "title": "Welcome", "titleAr": "أهلاً وسهلاً", "subtitle": "Shop our latest collection", "ctaLabel": "Shop Now", "ctaLink": "/products" }, { "key": "featured", "isEnabled": true, "sortOrder": 2 } ] } ``` Section images are sent as form parts named `sections[n].image`. Only sections included in the request are upserted — sections not mentioned are left unchanged. --- ### Step 3 — Configure storefront settings ``` PUT /api/v1/store/storefront Permission: store.storefront.manage Content-Type: multipart/form-data ``` Request (all fields optional): ```json { "description": "string|null", "niche": 0, "contactEmail": "string|null", "whatsAppNumber": "string|null", "telegramHandle": "string|null", "facebookUrl": "string|null", "instagramUrl": "string|null", "twitterUrl": "string|null", "seoTitle": "string|null", "seoDescription": "string|null", "seoKeywords": "string|null", "faqs": [{ "question": "string", "answer": "string" }], "policies": [{ "title": "string", "content": "string" }], "customDomain": "string|null", "templateId": "guid|null", "colorPresetId": "guid|null", "themeColors": { "primary": "#hex", "secondary": "#hex", "accent": "#hex", "background": "#hex", "surface": "#hex", "text": "#hex" } } ``` Image file parts: `coverImage`, `bannerImage`. All fields are optional. Any field set to `null` clears that value. **Niche values** | Value | Name | |---|---| | 0 | General | | 1 | Flowers | | 2 | Electronics | | 3 | Clothing | | 4 | Food | | 5 | Pharmacy | | 6 | Beauty | | 7 | Books | Response: ```json { "success": true, "message": "Storefront settings updated.", "data": { "id": "guid", "name": "string", "slug": "string", "isPublished": false, "publicUrl": "https://{host}/api/v1/public/storefronts/{slug}", "description": "string|null", "logoUrl": "string|null", "coverImageUrl": "string|null", "niche": 0, "bannerImageUrl": "string|null", "contactEmail": "string|null", "whatsAppNumber": "string|null", "telegramHandle": "string|null", "facebookUrl": "string|null", "instagramUrl": "string|null", "twitterUrl": "string|null", "seoTitle": "string|null", "seoDescription": "string|null", "seoKeywords": "string|null", "customDomain": "string|null", "faqs": [], "policies": [], "templateId": "guid|null", "colorPresetId": "guid|null" } } ``` --- ### Step 4 — Review current storefront state ``` GET /api/v1/store/storefront Permission: store.storefront.view ``` Returns the same shape as the update response above. Use this to verify settings before publishing. --- ### Step 5 — Publish the storefront ``` POST /api/v1/store/storefront/publish Permission: store.storefront.manage ``` No request body required. **Success response:** ```json HTTP 200 { "success": true, "message": "Storefront published.", "data": { "slug": "store-slug", "is_published": true } } ``` **Failure — slug not assigned:** ```json HTTP 400 { "success": false, "message": "A slug must be assigned before publishing. Contact support.", "code": 400 } ``` After a successful publish, the public URL is live: ``` GET /api/v1/public/storefronts/{slug} ``` --- ### Step 6 — (Optional) Unpublish ``` DELETE /api/v1/store/storefront/publish Permission: store.storefront.manage ``` **Response:** ```json { "success": true, "message": "Storefront unpublished.", "data": { "slug": "store-slug", "is_published": false } } ``` The public URL stops responding once unpublished. --- ## Public storefront — customer-facing endpoints These endpoints require **no authentication**. They are accessible by any visitor after the store is published. ### Browse the storefront ``` GET /api/v1/public/storefronts/{slug} ``` Conditions enforced: - `IsStorefrontPublished = true` - `Slug` is not null - Store is not soft-deleted - Only products with `price > 0` are included Response (expanded — all available fields): ```json { "success": true, "data": { "id": "guid", "name": "string", "slug": "string", "description": "string|null", "logoUrl": "string|null", "coverImageUrl": "string|null", "bannerImageUrl": "string|null", "niche": "string|null", "template": { "key": "string", "name": "string", "niche": 0 }, "theme": { "primary": "#hex", "secondary": "#hex", "accent": "#hex", "background": "#hex", "surface": "#hex", "text": "#hex" }, "categories": [ { "id": "guid", "name": "string", "imageUrl": "string|null", "sortOrder": 0 } ], "contact": { "email": "string|null", "whatsAppNumber": "string|null", "telegramHandle": "string|null", "facebookUrl": "string|null", "instagramUrl": "string|null", "twitterUrl": "string|null" }, "faqs": [{ "question": "string", "answer": "string" }], "policies": [{ "title": "string", "content": "string" }], "seo": { "seoTitle": "string|null", "seoDescription": "string|null", "seoKeywords": "string|null" }, "products": [ { "id": "guid", "sku": "string", "name": "string", "description": "string|null", "defaultUnitPrice": 0.0, "salePrice": 0.0, "imageUrl": "string|null", "images": [{ "url": "string" }], "categoryId": "guid|null", "categoryName": "string|null", "brand": "string|null", "unit": "string|null", "isFeatured": false, "isDiscounted": false, "isActive": true, "sortOrder": 0 } ] } } ``` ### Place an order ``` POST /api/v1/public/storefronts/{slug}/orders ``` Request: ```json { "customerName": "string", "customerPhone": "string", "customerAddress": "string", "addressProvinceCode": "string", "addressAreaId": "guid", "items": [ { "sku": "string", "quantity": 1 } ], "notes": "string|null", "paymentMethod": "Cash | Transfer | Card" } ``` Validations applied: - `items` must contain at least one entry - Each `sku` must belong to an active product in this store with `price > 0` - `customerPhone` must be a valid phone format - `addressProvinceCode` and `addressAreaId` must be valid system values Response: ```json { "success": true, "data": { "id": "guid", "publicId": "string", "status": "Pending", "source": "Storefront", "orderValue": 0.0 } } ``` The order appears in the store's order list with `source = "Storefront"`. --- ## Slug generation rules Slugs are generated automatically from the store name during provisioning. They cannot be changed by the store owner. | Rule | Example | |---|---| | Lowercase all characters | `Flora Baghdad` → `flora-baghdad` | | Replace non-alphanumeric characters with `-` | `Store & Co.` → `store---co-` | | Collapse multiple dashes into one | `store---co-` → `store-co` | | Trim leading / trailing dashes | `-store-co-` → `store-co` | | Append `-2`, `-3`, etc. if name is already taken | `flora-baghdad` → `flora-baghdad-2` | --- ## Permissions reference | Permission | Who has it | What it allows | |---|---|---| | `store.storefront.view` | `store_owner`, `store_staff` | Read storefront settings and templates | | `store.storefront.manage` | `store_owner` | Update settings, apply templates, publish / unpublish | | `admin.stores.create` | admin | Create a pre-approved store | | `admin.stores.approve` | admin | Approve a pending store registration | | `admin.stores.reject` | admin | Reject a pending store registration | --- ## Full sequence diagram ``` Admin Store Owner Customer │ │ │ │ POST /admin/stores │ │ │ (or approve existing) │ │ │──────────────────────────► │ │ │ slug + trial sub created │ │ │ │ │ │ │ GET /store/storefront/templates │ │──────────────────────►│ (optional) │ │ │ │ │ PUT /store/storefront │ │ (configure branding, SEO, links) │ │ │ │ │ POST /store/catalog/products │ │ (add products with prices) │ │ │ │ │ POST /store/storefront/publish │ │─────────────────────►│ │ │ { is_published: true } │ │ │ │ │ GET /public/storefronts/{slug} │ │◄─────────────────────│ │ │ │ │ │ POST /public/storefronts/{slug}/orders │ │◄─────────────────────│ │ │ { id, status: Pending, source: Storefront } ``` --- ## Custom Domain Flow _Attaching a custom domain to a storefront._ This document explains how a store connects its own domain (e.g. `mystore.com`) to its Sendy storefront, proves ownership through DNS, and how the public storefront API then resolves the store by that verified domain. It covers the two required DNS records, the step-by-step setup sequence, all relevant endpoints, request/response shapes, error codes, and the infrastructure prerequisite for TLS. --- ## Overview By default a storefront is reachable through its Sendy slug. A store may additionally connect a **custom domain** so customers visit the shop under the store's own brand. Connecting a domain requires **two DNS records**: 1. **Routing record (CNAME / A)** — points the domain at the Sendy server so traffic arrives at the API with `Host: mystore.com`. 2. **Verification record (TXT)** — proves the store owns the domain. Only after this check passes does Sendy begin serving the storefront for that domain. ``` Store enters custom domain (PUT /store/storefront) │ ▼ Backend generates a TXT verification token + returns the CNAME target │ ▼ Store adds 2 DNS records at their domain provider ├─ CNAME mystore.com → api.sendyiq.com (routing) └─ TXT _sendy-verify → sendy-verify={token} (ownership) │ ▼ Store clicks "Verify Domain" (POST /store/storefront/domain/verify) │ ▼ Backend looks up the TXT record via DNS │ ┌────┴─────┐ │ found │ → CustomDomainVerified = true │ not found│ → HTTP 422, retry after DNS propagates └──────────┘ │ ▼ Public storefront resolves by the verified domain GET /public/storefronts/mystore.com ─► same store as the slug ``` --- ## The two DNS records | # | Purpose | Type | Host / Name | Value | Verified by Sendy | |---|---------|------|-------------|-------|-------------------| | 1 | Routing | `CNAME` (subdomain) or `A` (root domain) | the custom domain, e.g. `mystore.com` | `api.sendyiq.com` (the `customDomainTarget` returned by the API) | No — guidance only | | 2 | Ownership | `TXT` | `_sendy-verify.{domain}` | `sendy-verify={customDomainVerificationToken}` | **Yes** — checked on verify | > **Root vs subdomain:** Most DNS providers do not allow a `CNAME` on a root/apex domain (`mystore.com`). For a root domain, use an `A` record pointing at the Sendy server IP, or connect a subdomain (`shop.mystore.com`) with a `CNAME`. The verification (TXT) step is the same in both cases. The verification check only validates record **#2 (TXT)**. Record #1 (CNAME/A) is what actually makes the domain serve traffic; it is shown as setup instructions and is not a gate on verification. --- ## Step-by-step flow ### Step 1 — Enter the custom domain ``` PUT /api/v1/store/storefront Permission: store.storefront.manage Content-Type: multipart/form-data ``` Request (other storefront fields omitted for brevity): ```json { "customDomain": "mystore.com" } ``` Behavior: - The domain is normalized (trimmed + lowercased). - A fresh `customDomainVerificationToken` (32-char hex) is generated. - `customDomainVerified` is reset to `false`. - Setting `customDomain` to `null` / empty clears the domain, the token, and the verified flag. - Changing the domain to a new value regenerates the token and resets verification. Response (storefront fields trimmed to the domain-related ones): ```json { "success": true, "message": "Storefront settings updated.", "data": { "id": "guid", "slug": "string", "customDomain": "mystore.com", "customDomainVerified": false, "customDomainVerificationToken": "9f1c0e7a4b2d4e8f9a0b1c2d3e4f5a6b", "customDomainTarget": "api.sendyiq.com" } } ``` > `customDomainTarget` is a per-deployment constant from configuration (`Application:CustomDomainTarget`, falling back to the `BackendBaseUrl` host). The dashboard displays it as the CNAME "Points to" value so the store knows where to route. If another store has already **verified** the same domain: ```json HTTP 409 { "success": false, "message": "This domain is already in use by another store.", "code": "CUSTOM_DOMAIN_TAKEN" } ``` ### Step 2 — Add the two DNS records At the domain provider, the store creates: ``` Type Host / Name Value CNAME mystore.com api.sendyiq.com TXT _sendy-verify sendy-verify=9f1c0e7a4b2d4e8f9a0b1c2d3e4f5a6b ``` DNS changes can take up to 24 hours to propagate. ### Step 3 — Verify ownership ``` POST /api/v1/store/storefront/domain/verify Permission: store.storefront.manage ``` No request body required. The backend resolves the TXT records for `_sendy-verify.{customDomain}` and checks for a value equal to `sendy-verify={token}`. **Success — TXT record found:** ```json HTTP 200 { "success": true, "message": "Custom domain verified successfully.", "data": { "customDomain": "mystore.com", "customDomainVerified": true, "customDomainVerificationToken": "9f1c0e7a4b2d4e8f9a0b1c2d3e4f5a6b", "customDomainTarget": "api.sendyiq.com" } } ``` `customDomainVerifiedAt` is stamped server-side at this point. **Failure cases:** | Condition | HTTP | Code | Message | |---|---|---|---| | No domain or token configured | 400 | `DOMAIN_NOT_CONFIGURED` | No custom domain or verification token configured. | | Domain already verified | 400 | `DOMAIN_ALREADY_VERIFIED` | Custom domain is already verified. | | TXT record not present yet | 422 | `DNS_TXT_NOT_FOUND` | TXT record not found. DNS propagation can take up to 24 hours. | On a `422`, the store should confirm the TXT record and retry once DNS has propagated. ### Step 4 — Public storefront resolves by the verified domain The public storefront endpoints accept **either the slug or a verified custom domain** as the `{slug}` path identifier: ``` GET /api/v1/public/storefronts/{slug} GET /api/v1/public/storefronts/{slug}/catalog POST /api/v1/public/storefronts/{slug}/orders POST /api/v1/public/storefronts/{slug}/orders/otp ``` Lookup condition (applied in every public storefront query): ``` (Slug == identifier OR (CustomDomain == identifier AND CustomDomainVerified)) AND not soft-deleted AND IsStorefrontPublished ``` So once `mystore.com` is verified and the storefront is published, `GET /api/v1/public/storefronts/mystore.com` returns the same store as its slug. An **unverified** domain never resolves. --- ## Infrastructure prerequisite (out of application scope) The application delivers the DNS instructions, ownership verification, and domain-based lookup. For `https://mystore.com` to actually serve in the browser, the edge / reverse proxy in front of `api.sendyiq.com` must additionally: 1. **Accept the custom `Host` header** and forward it to the API. 2. **Terminate TLS** for the custom domain — e.g. issue an on-demand certificate (Caddy on-demand TLS, an ACME companion, or a managed edge) once the domain is verified. Without (2), the domain resolves at the DNS level but the browser will reject the TLS handshake. Certificate / proxy provisioning is an operations task, not part of this backend flow. --- ## State model | Field | Type | Set when | |---|---|---| | `CustomDomain` | string \| null | Store enters a domain via `PUT /storefront` | | `CustomDomainVerificationToken` | string \| null | Generated on domain save; cleared when domain cleared | | `CustomDomainVerified` | bool | `true` after a successful verify; reset to `false` when the domain changes | | `CustomDomainVerifiedAt` | datetime \| null | Stamped on successful verify | | `CustomDomainTarget` | string (response only) | Read from config (`Application:CustomDomainTarget`) — not stored per-store | > **Backfill note:** stores that had a `CustomDomain` set before verification existed have a `null` token. `GET /api/v1/store/storefront` lazily generates and persists a token for such rows so the dashboard can render the DNS instructions. --- ## Configuration ```jsonc // appsettings.json "Application": { "BackendBaseUrl": "https://api.sendyiq.com", "FrontendBaseUrl": "https://sendyiq.com", "CustomDomainTarget": "api.sendyiq.com" // CNAME "Points to" value shown to stores } ``` If `CustomDomainTarget` is omitted, the API falls back to the host of `BackendBaseUrl`. --- ## Permissions reference | Permission | Who has it | What it allows | |---|---|---| | `store.storefront.view` | `store_owner`, `store_staff` | Read storefront settings (incl. domain status + token) | | `store.storefront.manage` | `store_owner` | Set the custom domain and trigger verification | Public storefront endpoints require **no authentication**. --- ## Endpoints summary | Method | Route | Auth | Purpose | |---|---|---|---| | `PUT` | `/api/v1/store/storefront` | JWT · `store.storefront.manage` | Set/clear custom domain, get token + target | | `POST` | `/api/v1/store/storefront/domain/verify` | JWT · `store.storefront.manage` | Verify ownership via TXT lookup | | `GET` | `/api/v1/store/storefront` | JWT · `store.storefront.view` | Read domain status + token | | `GET` | `/api/v1/public/storefronts/{slug}` | none | Resolve by slug **or** verified custom domain | --- ## Full sequence diagram ``` Store Owner Dashboard / API DNS Provider Public Visitor │ │ │ │ │ PUT /store/storefront│ │ │ │ { customDomain } │ │ │ │──────────────────────►│ │ │ │ token + customDomainTarget │ │ │◄──────────────────────│ │ │ │ │ │ │ │ add CNAME mystore.com → api.sendyiq.com │ │ │ add TXT _sendy-verify → sendy-verify={token} │ │ │────────────────────────────────────────────────►│ │ │ │ │ │ │ POST /store/storefront/domain/verify │ │ │──────────────────────►│ DNS TXT lookup │ │ │ │────────────────────────►│ │ │ │ _sendy-verify value │ │ │ │◄────────────────────────│ │ │ { customDomainVerified: true } │ │ │◄──────────────────────│ │ │ │ │ │ │ │ │ GET /public/storefronts/mystore.com │ │ │◄─────────────────────────────────────────────│ │ │ storefront (resolved by verified domain) │ │ │──────────────────────────────────────────────► ``` --- ## Subscription Flow _Plans, subscriptions and billing._ # Store Subscription Flow This document covers the end-to-end subscription lifecycle for stores: auto-provisioning, self-service upgrades via QiCard, admin management, and the plan limits that gate store operations. --- ## Overview Every store must have an active subscription to perform write operations. The system enforces plan limits via `IStoreSubscriptionGuard`, which is injected into all store-area services. A store without a valid subscription (expired, canceled, or missing) receives a `403` / `422` error before the business logic executes. ### Subscription statuses | Status | Meaning | |---|---| | `Trialing` | Free-trial period; full plan limits apply | | `Active` | Paid and within the billing period | | `PastDue` | Payment overdue; write operations blocked | | `Canceled` | Manually canceled; write operations blocked | | `Expired` | Period ended without renewal; write operations blocked | --- ## 1. Auto-provisioning (trial subscription) A `free_trial` subscription is automatically assigned in two scenarios: 1. **Store self-registration** — after the owner completes OTP registration (`AuthService.StoreRegisterAsync`), `EnsureInitialTrialSubscriptionAsync` is called immediately. 2. **Admin-created store** — when an admin directly creates a pre-approved store (`StoreProvisioningService.CreateApprovedStoreWithOwnerAsync`), `EnsureInitialTrialSubscriptionAsync` is called. In both cases the method looks up the plan with `Code == "free_trial"`, creates a `StoreSubscription` with `Status = Trialing`, and sets `PeriodEnd = now + TrialDays` (falls back to `BillingPeriodDays` if `TrialDays == 0`). Admin approval of a pending store does **not** create a subscription. ``` Store self-registers ──► AuthService.StoreRegisterAsync ──► EnsureInitialTrialSubscriptionAsync │ Admin creates store ──► StoreProvisioningService (pre-approved) ──► EnsureInitialTrialSubscriptionAsync │ StoreSubscription { Status = Trialing } ``` --- ## 2. Store — view subscription and available plans Main area: `GET /api/v1/store` Requires permission: `store.subscription.view` | Endpoint | Description | |---|---| | `GET /api/v1/store/subscription` | Returns current subscription details (plan, status, period, usage snapshot) | | `GET /api/v1/store/subscription/plans` | Lists all public plans the store can upgrade to | --- ## 3. Store — self-service upgrade flow Requires permission: `store.subscription.upgrade` ### Step-by-step 1. **Browse plans** — store calls `GET /api/v1/store/subscription/plans` to see available plans and pricing. 2. **Create upgrade request** — store posts to `POST /api/v1/store/subscription/upgrade-requests` with target plan ID and return URL. The service: - Creates a `StoreSubscriptionUpgradeRequest` with `Status = PendingPayment`. - Initiates a QiCard hosted payment session. - Returns `payment_url` to redirect the user. 3. **User completes payment** — user is redirected to QiCard's hosted page. 4. **QiCard webhook** — on payment completion, QiCard calls `POST /api/v1/payments/qicard/subscription-webhook`. The service: - Matches the notification to the upgrade request via `GatewayRequestId`. - On success: creates a new `StoreSubscription` (status `Active`), sets `ActivatedSubscriptionId`, marks upgrade request `Activated`. - On failure: marks upgrade request `Failed`, stores `FailureReason`. 5. **Store verifies** — store can poll or verify via `POST /api/v1/store/subscription/upgrade-requests/{requestId}/verify` after returning from the payment gateway. 6. **Check status** — `GET /api/v1/store/subscription/upgrade-requests/{requestId}` shows full request state. ``` Store ──► POST /store/subscription/upgrade-requests │ ▼ StoreSubscriptionUpgradeRequest { Status = PendingPayment, payment_url } │ ▼ User redirected ──► QiCard payment page │ ▼ (async) QiCard ──► POST /payments/qicard/subscription-webhook │ ┌───────┴────────┐ Success Failure │ │ New StoreSubscription UpgradeRequest { Status = Failed } { Status = Active } UpgradeRequest { Status = Activated } ``` --- ## 4. Subscription guard — enforced limits `IStoreSubscriptionGuard` is called before every write operation in the store area. It resolves the store's most recent active/trialing subscription and evaluates the plan's limits. | Guard method | Triggered by | Blocks when | |---|---|---| | `EnsureCanWriteAsync` | All update/delete/cancel operations | Subscription expired, canceled, or missing | | `EnsureCanCreateOrdersAsync` | Create order | `MaxOrdersPerPeriod` quota reached | | `EnsureCanUseWarehouseFulfillmentAsync` | Create/update order with `FulfillmentType = Warehouse` | Plan does not allow warehouse fulfillment | | `EnsureCanCreateProductAsync` | Create product | `MaxProducts` limit reached | | `EnsureCanCreateWarehouseAsync` | Create warehouse | `MaxWarehouses` limit reached | | `EnsureCanCreateStoreUserAsync` | Create store-scoped user (RBAC) | `MaxUsers` limit reached | | `EnsureCanCreateApiKeyAsync` | Create API key | `AllowsApiIntegrations = false` or `MaxApiKeys` reached | | `EnsureCanManageWebhookAsync` | Create webhook / rotate secret | `AllowsWebhooks = false` or `MaxWebhookSubscriptions` reached | | `EnsureCanUseIntegrationsAsync` | (called by API key check) | `AllowsApiIntegrations = false` | Quota errors return HTTP `422`; access/status errors return HTTP `403`. --- ## 5. Subscription plan features A `SubscriptionPlan` defines both numeric limits and feature flags. | Field | Type | Meaning | |---|---|---| | `MaxOrdersPerPeriod` | `int?` | Max orders creatable within the subscription period (`null` = unlimited) | | `MaxProducts` | `int?` | Max active products | | `MaxWarehouses` | `int?` | Max active store-owned warehouses | | `MaxUsers` | `int?` | Max active store users | | `MaxApiKeys` | `int?` | Max active API keys | | `MaxWebhookSubscriptions` | `int?` | Max webhook subscriptions | | `AllowsApiIntegrations` | `bool` | Enables API key creation and integration endpoints | | `AllowsWebhooks` | `bool` | Enables webhook management | | `AllowsWarehouseFulfillment` | `bool` | Enables warehouse-type order fulfillment | | `AllowsAdvancedReports` | `bool` | Reserved for advanced reporting features | | `AllowsAiAutomation` | `bool` | Reserved for AI/automation features | | `TrialDays` | `int` | Trial duration for auto-provisioned subscriptions | | `BillingPeriodDays` | `int` | Default billing period (used as trial fallback) | | `MonthlyPriceIqd` | `decimal` | Display price in IQD | --- ## 6. Admin — subscription management Main area: `/api/v1/admin` Requires permission: `admin.subscriptions.view` or `admin.subscriptions.manage` | Endpoint | Permission | Description | |---|---|---| | `GET /api/v1/admin/subscription-plans` | view | List all plans (optionally including inactive) | | `POST /api/v1/admin/subscription-plans` | manage | Create a new plan | | `PUT /api/v1/admin/subscription-plans/{planId}` | manage | Update a plan | | `GET /api/v1/admin/stores/{storeId}/subscription` | view | View a specific store's current subscription | | `POST /api/v1/admin/stores/{storeId}/subscription` | manage | Manually assign a plan to a store (bypasses payment) | | `POST /api/v1/admin/stores/{storeId}/subscription/cancel` | manage | Cancel a store's active subscription | | `GET /api/v1/admin/subscription-upgrade-requests` | view | Paginated list of all upgrade requests (filterable) | | `GET /api/v1/admin/subscription-upgrade-requests/{requestId}` | view | View a single upgrade request | --- ## 7. New permissions | Permission | Scope | Description | |---|---|---| | `admin.subscriptions.view` | admin | View subscription plans and store subscriptions | | `admin.subscriptions.manage` | admin | Manage subscription plans and store subscriptions | | `store.subscription.view` | store | View own subscription status and available plans | | `store.subscription.upgrade` | store | Request and pay for subscription upgrades | `store.subscription.view` is included in both `store_owner` and `store_staff` default permission sets. `store.subscription.upgrade` is included in `store_owner` only. --- ## 8. Payment webhook — `/api/v1/payments/qicard/subscription-webhook` This public endpoint (no auth) is called by QiCard on subscription payment events. It is handled by `IStoreSubscriptionUpgradeService.TryHandleQiCardWebhookAsync`. The handler: 1. Parses and validates the QiCard notification JSON. 2. Matches `GatewayRequestId` to an existing `StoreSubscriptionUpgradeRequest`. 3. On confirmed payment: - Creates a new `StoreSubscription` for the target plan, starting from `now`. - Updates the upgrade request with `Status = Activated`, `PaidAt`, `ActivatedAt`, `ActivatedSubscriptionId`. 4. On failed payment: - Updates the upgrade request with `Status = Failed`, `FailureReason`. --- ## MCP Flow _The MCP authorization and tool-call flow._ # Store MCP Server — Full Feature Flow > Feature area: MCP / AI Agent Integration > Branch: `phase-2` > Last updated: 2026-06-09 --- ## Overview The Store MCP Server exposes Sendy store data and operations as **Model Context Protocol (MCP) tools** so that AI clients (Cursor, Claude Desktop, custom agents) can act on behalf of a store. Unlike the REST API-key integration, MCP uses **OAuth 2.0 PKCE bearer tokens** — the AI client authenticates as the store owner/staff and calls structured tools instead of raw HTTP endpoints. Three MCP endpoints exist, one per tenant area: | URL | Tenant | |-----|--------| | `POST /api/v1/store/mcp` | Store owner / store staff | | `POST /api/v1/admin/mcp` | Platform admin | | `POST /api/v1/delivery-company/mcp` | Delivery company | Transport: Streamable HTTP (`MapMcp`). All three expose the same tool surface; context (which store, which DC) is resolved from the JWT claims of the bearer token. --- ## How It Differs from API-Key Integration | | API-Key Integration | MCP Integration | |---|---|---| | Auth | `X-API-Key` header | OAuth 2.0 Bearer (PKCE) | | Client type | Server-to-server | AI agent / human-in-the-loop | | Interface | REST endpoints | Structured tool calls | | Scope enforcement | Per-endpoint checks in controller | Per-tool `HasScope()` check inside the tool class | | API keys work? | Yes | **No** — only OAuth bearer | | Used for | ERP / POS automation | AI assistants, Cursor, Claude Desktop | --- ## Flow 1 — Register a Redirect URI Before the OAuth flow can complete, the `redirect_uri` must be registered with Sendy. This is a one-time setup step performed by the store owner or admin. **Store registers their own redirect URI:** ``` POST /api/v1/store/oauth-mcp/redirect-uris Authorization: Bearer Permission: store.oauth_mcp.redirect_uris.manage Body: { "clientId": "", "redirectUri": "https://my-agent.example.com/callback" } ``` **Admin can manage all clients and URIs:** ``` GET/POST/DELETE /api/v1/admin/oauth-mcp/clients GET/POST/DELETE /api/v1/admin/oauth-mcp/redirect-uris ``` **Store lists their registered URIs:** ``` GET /api/v1/store/oauth-mcp/redirect-uris?clientId= Permission: store.oauth_mcp.redirect_uris.view ``` --- ## Flow 2 — OAuth PKCE Authorization (Get a Bearer Token) The MCP client must obtain an OAuth 2.0 access token before calling any tool. Sendy implements **PKCE authorization code** (`code_challenge_method=S256`). ### Step 1 — Start authorization ``` GET /api/v1/oauth/authorize ?response_type=code &client_id= &redirect_uri= &code_challenge= &code_challenge_method=S256 &state= &scope=catalog:read orders:read orders:write tickets:write messaging:read messaging:write ``` If the user is not already authenticated, the server redirects to the login UI with `?request_id=`. ### Step 2 — User logs in (if not already authenticated) ``` POST /api/v1/oauth/login Content-Type: application/json Accept: application/json { "requestId": "", "phone": "...", "password": "..." } ``` Response (JSON mode): ```json { "data": { "redirectUrl": "https://my-agent.example.com/callback?code=...&state=..." } } ``` Without `Accept: application/json`, the server issues a `302` redirect directly. ### Step 3 — Exchange code for token ``` POST /api/v1/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code= &code_verifier= &client_id= &redirect_uri= ``` Response: ```json { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "...", "scope": "catalog:read orders:read orders:write tickets:write messaging:read messaging:write" } ``` ### Step 4 — Call the MCP endpoint ``` POST /api/v1/store/mcp Authorization: Bearer Content-Type: application/json { "method": "tools/call", "params": { "name": "list_orders", "arguments": { "page": 1, "perPage": 10 } } } ``` --- ## Flow 3 — Using MCP Tools Once authenticated, the AI client calls tools via the MCP protocol. Each tool enforces its own scope — the server returns `403` if the token lacks the required scope. ### Catalog tools (`catalog:read`) **`list_products`** — list all active products in the store catalog. ``` tools/call → list_products {} ``` **`search_products`** — full-text search by SKU, name, or description (max 50 results). ``` tools/call → search_products { "query": "blue t-shirt" } ``` **`get_product`** — get one product by UUID. ``` tools/call → get_product { "productId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } ``` ### Order tools (`orders:read` / `orders:write`) **`list_orders`** — paginated order list with optional filters. ``` tools/call → list_orders { "page": 1, "perPage": 15, "status": "pending", // optional "search": "ORD-20260506", // optional — searches public id "fulfillmentType": "from_to" // optional: from_to | warehouse } ``` **`track_order`** — fetch order detail by public ID. ``` tools/call → track_order { "publicId": "ORD-20260506-ABC123" } ``` **`create_order`** — create a store order (same shape as the REST integration endpoint). ``` tools/call → create_order { "customerName": "Ali Hassan", "customerPhone": "9647701234567", "addressProvinceCode": "BGH", "addressAreaId": 5, "items": [{ "productId": "...", "quantity": 2 }] } ``` ### Ticket tool (`tickets:write`) **`create_ticket`** — creates a support ticket. Routes to the correct tenant area based on which MCP URL was called (`/store/mcp`, `/admin/mcp`, or `/delivery-company/mcp`). ``` tools/call → create_ticket { "subject": "Missing item in order ORD-20260506-ABC123", "body": "Customer reports item #3 was not delivered." } ``` ### Messaging tools (`messaging:read` / `messaging:write`) **`list_conversations`** — lists open/pending/resolved conversations from connected WhatsApp or Instagram channels. ``` tools/call → list_conversations { "channelType": "WhatsApp", // optional: WhatsApp | Instagram "status": "Open", // optional: Open | Resolved | Pending "page": 1 } ``` **`get_conversation`** — get the full message history for a specific conversation. ``` tools/call → get_conversation { "conversationId": "3fa85f64-..." } ``` **`send_conversation_message`** — send a text message to a customer in an existing conversation. ``` tools/call → send_conversation_message { "conversationId": "3fa85f64-...", "text": "Your order ships tomorrow!" } ``` **`resolve_conversation`** — mark a conversation as resolved/closed. ``` tools/call → resolve_conversation { "conversationId": "3fa85f64-..." } ``` --- ## Flow 4 - Store Links an AI Agent to MCP + Messaging Channels This flow describes how a store connects an external AI agent to Sendy's MCP server, then lets that agent handle messages from WhatsApp or Instagram by reading context and replying through MCP tools. There are two separate links: | Link | Purpose | Auth used | |------|---------|-----------| | Store -> MCP agent | Allows Cursor, Claude Desktop, or a custom agent service to call Sendy MCP tools for this store | OAuth MCP access token | | Store -> social channel | Allows Sendy to receive and send WhatsApp / Instagram messages | Meta channel access token, encrypted by Sendy | The MCP token does not replace the Meta channel token. The MCP token lets the AI agent act inside Sendy; the channel token lets Sendy deliver messages to the customer. ### Step 1 - Store prepares the MCP client 1. Store owner opens the MCP integration page. 2. Frontend calls: ``` GET /api/v1/store/mcp/integration Authorization: Bearer ``` 3. Sendy returns: - MCP URL: `/api/v1/store/mcp` - available tool names - supported OAuth scopes 4. Store registers the agent callback URL: ``` POST /api/v1/store/oauth-mcp/redirect-uris Authorization: Bearer { "clientId": "", "redirectUri": "https://agent.example.com/oauth/callback" } ``` This creates the trust boundary between the store and the external agent client. Only registered redirect URIs can complete the PKCE flow. ### Step 2 - Store authorizes the AI agent The AI agent starts OAuth PKCE and requests only the scopes it needs. For a messaging automation agent, the minimum useful set is usually: ``` catalog:read orders:read tickets:write messaging:read messaging:write ``` If the agent will create orders from chat, add: ``` orders:write ``` After login and consent, the agent exchanges the authorization code for: ```json { "access_token": "eyJ...", "refresh_token": "...", "scope": "catalog:read orders:read tickets:write messaging:read messaging:write" } ``` The agent stores the OAuth token on its side and sends it on every MCP request: ``` Authorization: Bearer ``` ### Step 3 - Store connects a channel Sendy supports three channel types. Each connects differently. All three result in a `SocialChannel` record with `IsAgentEnabled=true` before MCP messaging tools can act on their conversations. --- #### Channel A — WhatsApp WhatsApp uses a Facebook OAuth redirect flow. 1. Call the initiation endpoint — the response is **JSON `{ url }`**, not a redirect. The frontend must navigate to the returned URL: ``` GET /api/v1/store/social/channels/auth/whatsapp Authorization: Bearer Response: { "url": "https://www.facebook.com/dialog/oauth?..." } ``` 2. Store owner completes Meta's permission dialog. 3. Meta redirects back to Sendy's callback. Sendy stores a `SocialChannel`: - `ChannelType = whatsapp` - `ExternalAccountId` (WhatsApp Business Account ID) - Encrypted Meta access token - Webhook verify token - `IsEnabled = true` 4. Enable the AI agent for this channel: ``` POST /api/v1/store/social/channels/{channelId}/toggle-agent Authorization: Bearer { "enabled": true } ``` From this point, inbound messages from `POST /api/v1/webhooks/social/whatsapp` create `AiMessageJob` entries that the agent can handle via MCP. --- #### Channel B — Instagram Instagram follows the exact same Facebook OAuth redirect flow as WhatsApp. 1. Call the initiation endpoint — again JSON `{ url }`, not a redirect: ``` GET /api/v1/store/social/channels/auth/instagram Authorization: Bearer Response: { "url": "https://www.facebook.com/dialog/oauth?..." } ``` 2. Store owner grants Instagram messaging permissions in Meta's dialog. 3. Sendy stores a `SocialChannel`: - `ChannelType = instagram` - `ExternalAccountId` (Instagram Business Account ID) - Encrypted Meta access token - `IsEnabled = true` 4. Enable the agent: ``` POST /api/v1/store/social/channels/{channelId}/toggle-agent Authorization: Bearer { "enabled": true } ``` Inbound messages arrive at `POST /api/v1/webhooks/social/instagram` and follow the same job enqueue path. --- #### Channel C — Website chat Website chat does **not** use Facebook OAuth. The store creates the channel directly and embeds a widget snippet on their site. 1. Create the website channel: ``` POST /api/v1/store/social/channels Authorization: Bearer { "channelType": "website", "name": "Website Chat" } ``` Sendy generates a unique `channelToken` and returns an embed snippet. 2. The store pastes the embed snippet into their website ``. The widget sends and polls messages at: ``` POST /api/v1/widget/chat/{channelToken}/messages ← visitor sends GET /api/v1/widget/chat/{channelToken}/messages ← visitor polls ``` These endpoints are `[AllowAnonymous]` and do not require OAuth. 3. Enable the agent for the website channel: ``` POST /api/v1/store/social/channels/{channelId}/toggle-agent Authorization: Bearer { "enabled": true } ``` > **Important difference:** Website chat uses poll-based delivery — there is no Meta webhook. Inbound messages are persisted directly when the widget POSTs. The `AiMessageJob` is enqueued the same way as for WhatsApp/Instagram, so MCP messaging tools (`list_conversations`, `get_conversation`, `send_conversation_message`) work identically once the job runs. --- #### Channel comparison | | WhatsApp | Instagram | Website chat | |---|---|---|---| | Connect via | Facebook OAuth redirect | Facebook OAuth redirect | Direct API call | | Initiation response | JSON `{ url }` | JSON `{ url }` | Channel record + embed snippet | | Inbound delivery | Meta webhook | Meta webhook | Widget poll endpoint | | Outbound send | WhatsApp Cloud API | Instagram Graph API | Stored in DB, polled by widget | | Toggle agent | Same endpoint | Same endpoint | Same endpoint | | MCP tools work? | Yes | Yes | Yes | From this point, incoming channel messages can be handled by an agent workflow. ### Step 4 - Customer message enters Sendy When a customer sends a WhatsApp or Instagram message: ``` Customer -> WhatsApp / Instagram -> Sendy webhook ``` Webhook endpoints: | Channel | Endpoint | |---------|----------| | WhatsApp | `POST /api/v1/webhooks/social/whatsapp` | | Instagram | `POST /api/v1/webhooks/social/instagram` | Server-side flow: 1. Verify the Meta webhook signature / verify token. 2. Find the connected `SocialChannel`. 3. Upsert `SocialConversation` by `(SocialChannelId, ExternalContactId)`. 4. Deduplicate by `ExternalMessageId`. 5. Persist inbound `SocialMessage`. 6. If `SocialChannel.IsEnabled && SocialChannel.IsAgentEnabled`, create an `AiMessageJob`. At this point the message is inside Sendy and available to MCP tools through `list_conversations` and `get_conversation`. --- ## Flow 5 - Message -> Agent -> MCP Tool Calls -> Channel Response This is the deeper MCP-side flow after the channel message exists in Sendy. ### 1. Agent discovers work The agent can poll or be triggered by Sendy's internal job system. From MCP's perspective, the agent reads the queue of active customer conversations: ```json { "method": "tools/call", "params": { "name": "list_conversations", "arguments": { "status": "Open", "page": 1 } } } ``` MCP server behavior: 1. `MapMcp("/api/v1/store/mcp")` receives the JSON-RPC request. 2. The MCP bearer scheme validates the OAuth token. 3. `StoreMcpTools.ListConversations` checks `HasScope("messaging:read")`. 4. The tool resolves `ISocialConversationService`. 5. The service reads tenant-scoped conversations for the store resolved from token claims. 6. Result is returned as `ApiResponse>`. ### 2. Agent loads the exact conversation The agent fetches the message history before answering: ```json { "method": "tools/call", "params": { "name": "get_conversation", "arguments": { "conversationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } } } ``` MCP server behavior: 1. Checks `messaging:read`. 2. Loads conversation metadata and messages. 3. Returns inbound/outbound message history, channel context, and status. The agent should use this result as the source of truth for: - customer question - previous replies - whether a human already answered - whether the thread is already resolved ### 3. Agent gathers business context through MCP The agent chooses tools based on the customer message. For product questions: ```json { "method": "tools/call", "params": { "name": "search_products", "arguments": { "query": "blue t-shirt" } } } ``` For order status questions: ```json { "method": "tools/call", "params": { "name": "track_order", "arguments": { "publicId": "ORD-20260506-ABC123" } } } ``` For complaints or escalation: ```json { "method": "tools/call", "params": { "name": "create_ticket", "arguments": { "subject": "Customer complaint from WhatsApp", "body": "Customer says item was missing from order ORD-20260506-ABC123." } } } ``` For order creation from chat, if the token has `orders:write`: ```json { "method": "tools/call", "params": { "name": "create_order", "arguments": { "customerName": "Ali Hassan", "customerPhone": "9647701234567", "addressProvinceCode": "BGH", "addressAreaId": 5, "items": [ { "productId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "quantity": 2 } ] } } } ``` Each tool is independently authorized. A token with `messaging:write` cannot read orders unless it also has `orders:read`. ### 4. Agent sends the customer response through MCP When the agent has enough context, it replies through `send_conversation_message`: ```json { "method": "tools/call", "params": { "name": "send_conversation_message", "arguments": { "conversationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "text": "Your order ORD-20260506-ABC123 is currently out for delivery. I will keep this chat open until it is delivered." } } } ``` MCP server behavior: 1. Checks `messaging:write`. 2. Resolves `ISocialConversationService`. 3. Loads the conversation and connected channel. 4. Decrypts the channel access token. 5. Sends the text via the correct provider client: - WhatsApp Cloud API for WhatsApp conversations - Instagram Graph API for Instagram conversations 6. Persists outbound `SocialMessage`. 7. Returns `SocialMessageResponse` to the agent. The MCP response is not the customer-facing message by itself. It is the server acknowledgment that Sendy accepted/sent the outbound message through the linked channel. ### 5. Agent closes the loop If the customer issue is complete, the agent can close the conversation: ```json { "method": "tools/call", "params": { "name": "resolve_conversation", "arguments": { "conversationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } } } ``` MCP server behavior: 1. Checks `messaging:write`. 2. Marks the conversation as resolved. 3. Returns an `ApiResponse`. If a later inbound message arrives from the same customer, the webhook flow can reopen or continue the same conversation depending on the social messaging service behavior. ### End-to-end sequence ``` Store Owner | | 1. Register agent redirect URI v Sendy Store OAuth MCP | | 2. OAuth PKCE consent v AI Agent gets MCP bearer token | | 3. Store connects WhatsApp / Instagram channel v Sendy SocialChannel { IsEnabled=true, IsAgentEnabled=true } | | 4. Customer sends message v Meta webhook -> Sendy SocialInboundService | | 5. Persist SocialConversation + inbound SocialMessage v AI Agent / Dispatcher | | 6. tools/call: get_conversation | 7. tools/call: search_products / track_order / create_ticket / create_order | 8. tools/call: send_conversation_message v Sendy MCP StoreMcpTools | | 9. ISocialConversationService sends via WhatsApp/Instagram client v Customer receives response in the original channel ``` ### Responsibility split | Component | Responsibility | |-----------|----------------| | AI agent | Decides what to say and which MCP tools to call | | MCP endpoint | Authenticates token, validates scopes, routes tool calls | | MCP tools | Thin tool facade over store, order, catalog, ticket, and messaging services | | Social messaging services | Own conversation persistence, channel token use, and outbound provider send | | Meta channel APIs | Deliver inbound and outbound WhatsApp / Instagram messages | ### Failure points to handle | Failure | Expected behavior | |---------|-------------------| | Access token expired | Agent refreshes token or restarts OAuth | | Missing MCP scope | Tool returns `403` with missing scope message | | Conversation not found | Messaging service returns not-found response | | Channel disabled | Message send should fail or be blocked by social service rules | | Meta send fails | Outbound message is not confirmed; service returns error/failure status | | Agent provider fails | `AiMessageJob` retries with backoff when using built-in dispatcher | | Duplicate webhook delivery | Inbound message is deduplicated by external message ID | --- ## Scope Reference | Tool | Required scope | |------|---------------| | `list_products` | `catalog:read` | | `search_products` | `catalog:read` | | `get_product` | `catalog:read` | | `list_orders` | `orders:read` | | `track_order` | `orders:read` | | `create_order` | `orders:write` | | `create_ticket` | `tickets:write` | | `list_conversations` | `messaging:read` | | `get_conversation` | `messaging:read` | | `send_conversation_message` | `messaging:write` | | `resolve_conversation` | `messaging:write` | --- ## Flow 6 — Discover the MCP Endpoint (Info) Each tenant area exposes an info endpoint that returns the MCP server URL, available tool names, and required scopes. AI clients can call this on first connection to self-configure. ``` GET /api/v1/store/mcp/integration Authorization: Bearer Permission: store.oauth_mcp.redirect_uris.view (or store_owner) ``` Response: ```json { "mcpUrl": "https:///api/v1/store/mcp", "tools": ["list_products", "search_products", "get_product", "list_orders", "track_order", "create_order", "create_ticket", "list_conversations", "get_conversation", "send_conversation_message", "resolve_conversation"], "scopes": ["catalog:read", "orders:read", "orders:write", "tickets:write", "messaging:read", "messaging:write"] } ``` --- ## Complete Flow Diagram ``` AI Client (Cursor / Claude Desktop / Agent) Sendy API Store DB │ │ │ │ 1. GET /oauth/authorize?scope=... │ │ ├──────────────────────────────────────────►│ │ │◄─ 302 → login UI with ?request_id= ───────┤ │ │ │ │ │ 2. POST /oauth/login { requestId, ... } │ │ ├──────────────────────────────────────────►│ │ │◄─ { redirectUrl: "...?code=..." } ────────┤ │ │ │ │ │ 3. POST /oauth/token { code, verifier } │ │ ├──────────────────────────────────────────►│ │ │◄─ { access_token, refresh_token } ────────┤ │ │ │ │ │ 4. POST /api/v1/store/mcp │ │ │ Authorization: Bearer │ │ │ tools/call → list_orders │ │ ├──────────────────────────────────────────►│ │ │ ├── HasScope("orders:read")│ │ ├── IStoreOrderService ───►│ │ │◄── PaginatedList ─────────┤ │◄─ ApiResponse> ────────┤ │ │ │ │ │ 5. tools/call → create_order │ │ ├──────────────────────────────────────────►│ │ │ ├── HasScope("orders:write")│ │ ├── IStoreOrderService ───►│ │ │◄── OrderDetail ───────────┤ │◄─ ApiResponse ────────────────────┤ │ ``` --- ## Cursor / Claude Desktop Config Example ```json { "mcpServers": { "sendy-store": { "url": "https:///api/v1/store/mcp", "headers": { "Authorization": "Bearer " } } } } ``` --- ## Server Configuration Keys ``` OAuthMcp:SigningKey # 32+ byte base-64 — required OAuthMcp:Issuer OAuthMcp:Audience OAuthMcp:AuthorizationCodeLifetimeSeconds # default 60 OAuthMcp:AccessTokenLifetimeSeconds # default 3600 OAuthMcp:RefreshTokenLifetimeSeconds # default 86400 ``` --- ## Endpoints Reference ### Store-side management (requires JWT + permission) | Method | Path | Permission | Description | |--------|------|-----------|-------------| | `GET` | `/api/v1/store/mcp/integration` | `store.oauth_mcp.redirect_uris.view` | Returns MCP URL, tool names, and required scopes | | `GET` | `/api/v1/store/oauth-mcp/redirect-uris` | `store.oauth_mcp.redirect_uris.view` | List registered redirect URIs | | `POST` | `/api/v1/store/oauth-mcp/redirect-uris` | `store.oauth_mcp.redirect_uris.manage` | Register a new redirect URI | | `DELETE` | `/api/v1/store/oauth-mcp/redirect-uris/{id}` | `store.oauth_mcp.redirect_uris.manage` | Remove a redirect URI | ### OAuth endpoints (public) | Method | Path | Description | |--------|------|-------------| | `GET` | `/api/v1/oauth/authorize` | Start PKCE authorization; redirects to login if unauthenticated | | `POST` | `/api/v1/oauth/login` | Submit credentials during authorization; returns redirect URL | | `POST` | `/api/v1/oauth/token` | Exchange authorization code for access + refresh token | ### MCP endpoints (OAuth bearer) | Method | Path | Tenant | |--------|------|--------| | `POST` | `/api/v1/store/mcp` | Store owner / store staff | | `POST` | `/api/v1/admin/mcp` | Platform admin | | `POST` | `/api/v1/delivery-company/mcp` | Delivery company | --- ## Source References | Layer | File | |-------|------| | MCP tools (all tools) | Sendy.Api/Mcp/StoreMcpTools.cs | | MCP info controller (store) | Sendy.Api/Controllers/V1/Store/StoreMcpIntegrationController.cs | | Redirect URI controller | Sendy.Api/Controllers/V1/Store/StoreOAuthMcpRedirectUrisController.cs | | MCP info service | Sendy.Application/Services/Mcp/McpIntegrationInfoService.cs | | Ticket creation service (MCP) | Sendy.Application/Services/Mcp/McpTicketCreationService.cs | | OAuth authorization service | Sendy.Infrastructure/Services/Auth/OAuthAuthorizationCodeService.cs | | OAuth client registry | Sendy.Infrastructure/Services/Auth/OAuthMcpClientRegistry.cs | | Redirect URI service | Sendy.Application/Services/Store/StoreOAuthMcpRedirectUriService.cs | | OAuth client entity | Sendy.Domain/Entities/Integration/OAuthMcpClient.cs | | OAuth options | Sendy.Application/Common/OAuthMcpOptions.cs | | Social channel controller | Sendy.Api/Controllers/V1/Store/StoreSocialChannelsController.cs | | Social inbound service | Sendy.Application/Services/Messaging/SocialInboundService.cs | | Social conversation service | Sendy.Application/Services/Messaging/SocialConversationService.cs | | Social channel service | Sendy.Application/Services/Messaging/SocialChannelService.cs | | AI message dispatcher | Sendy.Infrastructure/Jobs/AiMessageDispatchJob.cs | | WhatsApp provider client | Sendy.Infrastructure/Services/Messaging/WhatsAppCloudApiClient.cs | | Instagram provider client | Sendy.Infrastructure/Services/Messaging/InstagramGraphApiClient.cs | For messaging tool internals and AI agent behavior, see social-messaging-flow.md. For the reference guide (config, Cursor setup, scope table), see [DOCS/03-integrations/store-mcp-server.md](/docs/mcp/). --- ## Website Chat Widget Flow _Embedding and using the website chat widget._ # Website Chat Widget — Full Feature Flow > Feature area: Social Messaging / Website Chat > Branch: `phase-2` > Last updated: 2026-06-09 > **Superseded — real-time delivery only.** As of 2026-06-12 the website chat widget uses **SignalR WebSockets** for real-time push in both directions. The poll endpoint (`GET /widget/chat/{token}/messages`) has been removed. See [`website-chat-websocket-flow.md`](/docs/flow-website-chat-websocket/) for the current implementation. This document remains useful for: channel creation, embed snippet, store-side CRM endpoints, and the REST fallback (`POST /widget/chat/{token}/messages`) which is still supported. --- ## Overview The Website Chat Widget lets a store embed a live-chat widget on any website. Unlike WhatsApp and Instagram, there is no OAuth handshake — the store creates a channel via a single API call and receives an embed snippet. Customers interact through the widget; the store replies either manually from the CRM inbox or automatically via the AI agent. ~~Delivery for this channel is **poll-based**: the widget periodically asks the server for new messages.~~ Delivery is now **WebSocket-based** (SignalR). The polling flow described below is superseded. No external API, no webhooks, no Meta credentials required. --- ## How It Differs from WhatsApp / Instagram | | WhatsApp / Instagram | Website Chat | |---|---|---| | Connection | OAuth → Meta | Single API call | | Credential storage | Encrypted token + app secret | None (token is the channel identity) | | Inbound delivery | Meta sends POST to webhook | Widget POSTs directly to Sendy | | Outbound delivery | Graph API call | DB write → widget polls | | AI agent | Yes | Yes | | Manual store reply | Yes | Yes | --- ## Entities Used The same `SocialChannel`, `SocialConversation`, `SocialMessage`, and `AiMessageJob` entities from the social messaging feature are reused without modification. Website channels are identified by `ChannelType = Website` and use `ExternalAccountId` as the channel token (a random `Guid.NewGuid().ToString("N")`). `SocialChannel.ChannelToken` is exposed in API responses only when `ChannelType == Website` — it is the same value as `ExternalAccountId`, surfaced separately for clarity. --- ## Flow 1 — Store Creates a Website Channel ``` POST /api/v1/store/social/channels/website Authorization: Bearer Permission: store.social_channels.manage Body: { "displayName": "Support Chat", "aiProviderCode": "anthropic", // optional, default: anthropic "aiModelCode": "claude-sonnet-4-6", // optional, default: claude-sonnet-4-6 "systemPromptOverride": null // optional } ``` **Server (WebsiteChatService.CreateChannelAsync):** ``` 1. Verify store context (JWT → storeId) 2. EnsureCanConnectChannelAsync → check AllowsSocialMessaging + MaxSocialChannels 3. Check no existing Website channel for this store (409 if one already exists) 4. channelToken = Guid.NewGuid().ToString("N") 5. Create SocialChannel { ChannelType = Website, ExternalAccountId = channelToken, ← this IS the token AccessTokenEncrypted = "" ← no Meta token needed } 6. Return CreateWebsiteChannelResponse ``` **Response:** ```json { "channelId": "3fa85f64-...", "channelToken": "a1b2c3d4e5f6...", "embedSnippet": "" } ``` The store pastes `embedSnippet` into their website's HTML. One channel per store — attempting to create a second returns `409`. > **Note:** `GET /api/v1/store/social/channels/auth/website` returns `400` — Website channels do not use OAuth. The guard lives in `MetaOAuthService.BuildAuthorizationUrlAsync`. --- ## Flow 2 — Customer Sends a Message The widget calls this endpoint directly (no authentication required): ``` POST /api/v1/widget/chat/{channelToken}/messages Body: { "sessionId": "visitor-uuid-or-fingerprint", "text": "Hello, I have a question about my order", "contactDisplayName": "Ahmed" // optional, set once } ``` **Server (WebsiteChatService.SendMessageAsync):** ``` 1. Validate channelToken → load SocialChannel (ChannelType=Website, not deleted) 2. Check channel.IsEnabled (422 if disabled) 3. Upsert SocialConversation keyed on (SocialChannelId, sessionId): - First message: create conversation with Status=Open - Subsequent messages: update ContactDisplayName if not yet set 4. Create SocialMessage { Direction=Inbound, Status=Delivered } 5. If channel.IsEnabled && channel.IsAgentEnabled: Create AiMessageJob { Status=Pending } → AI will reply automatically 6. Return full conversation history (all messages for this sessionId) ``` **Response:** ```json { "conversationId": "guid...", "messages": [ { "id": "...", "direction": "Inbound", "contentText": "Hello...", "isAiGenerated": false, "createdAt": "..." }, { "id": "...", "direction": "Outbound", "contentText": "Hi! How can I help?", "isAiGenerated": true, "createdAt": "..." } ] } ``` The widget uses `sessionId` to tie all messages to the same visitor. The choice of `sessionId` is left to the frontend — a `localStorage` UUID works well. --- ## Flow 3 — Widget Polls for New Messages The widget calls this endpoint on an interval to pick up replies: ``` GET /api/v1/widget/chat/{channelToken}/messages ?sessionId=visitor-uuid &after=2026-06-09T10:05:00Z // optional — only return messages after this timestamp ``` **Server (WebsiteChatService.PollMessagesAsync):** ``` 1. Load channel by token 2. Load conversation by (channelId, sessionId) 3. If no conversation yet → return empty message list (not an error) 4. Query SocialMessages where ConversationId = conv.Id AND CreatedAt > after 5. Return messages ordered by CreatedAt ASC ``` The `after` parameter lets the widget do incremental polling — only fetching messages it hasn't seen yet. On first load, omit `after` to get the full history. --- ## Flow 4 — AI Agent Replies Automatically When `IsAgentEnabled = true`, the Hangfire background job picks up the `AiMessageJob` created in Flow 2: ``` AiMessageDispatchJob (runs every minute): 1. Load pending jobs for Website channels 2. Fetch last 20 messages as conversation history 3. Build system prompt (AiSystemPromptBuilder) 4. Call configured AI provider (Anthropic / OpenAI) 5. On success: - Create SocialMessage { Direction=Outbound, IsAiGenerated=true } - NO external API call (Website channel — DB write is the delivery) - Update AiMessageJob { Status=Sent } - SocialTokenTracker.RecordUsageAsync(inputTokens, outputTokens) 6. On failure: - Exponential backoff; max 3 attempts before Status=Dead ``` The reply appears in the widget on the next poll — typically within seconds. --- ## Flow 5 — Store Staff Replies Manually Store staff open the CRM inbox and send a message from the dashboard: ``` POST /api/v1/store/social/conversations/{conversationId}/messages Authorization: Bearer Permission: store.conversations.manage Body: { "text": "Your order ships tomorrow!" } ``` **Server (SocialConversationService.SendMessageAsync):** ``` 1. Load conversation → verify it belongs to this store 2. Check channel.IsEnabled 3. ChannelType == Website → skip external API call 4. Create SocialMessage { Direction = Outbound, IsAiGenerated = false, SentByUserId = current.Id, Status = Sent } 5. Update conversation.LastMessageAt 6. Return SocialMessageResponse ``` The widget picks up the reply on its next poll — same delivery path as the AI agent. --- ## Complete Flow Diagram ``` Store admin Sendy API Customer widget │ │ │ ├─ POST /channels/website ────────►│ │ │◄──── { channelToken, snippet } ─┤ │ │ │ │ │ (pastes snippet into website) │ │ │ │ │ │ │◄─── POST /widget/chat/{token}/messages ──┤ │ │ { sessionId, text } │ │ │ │ │ ├── upsert Conversation │ │ ├── save SocialMessage (Inbound) │ │ ├── queue AiMessageJob │ │ │──── return messages ──────────────►│ │ │ │ │ [Hangfire - every minute] │ │ │ │ │ ├── call AI provider │ │ ├── save SocialMessage (Outbound) │ │ │ │ │ │◄── GET /widget/chat/{token}/messages?after=... ┤ │ │──── return new outbound message ──►│ │ │ │ ├─ POST /conversations/{id}/messages ►│ │ │ { text: "Manual reply" } │ │ │ ├── save SocialMessage (Outbound) │ │◄──── SocialMessageResponse ─────┤ │ │ │◄── GET /widget/chat/{token}/messages?after=... ┤ │ │──── return manual reply ──────────►│ ``` --- ## Endpoints Reference ### Store-side (requires JWT + permission) | Method | Path | Permission | Description | |--------|------|-----------|-------------| | `POST` | `/api/v1/store/social/channels/website` | `store.social_channels.manage` | Create the website channel, get embed snippet | | `GET` | `/api/v1/store/social/channels` | `store.social_channels.view` | Lists all channels including website; `channelToken` is populated for Website type | | `PATCH` | `/api/v1/store/social/channels/{channelId}` | `store.social_channels.manage` | Update display name, AI model, system prompt | | `POST` | `/api/v1/store/social/channels/{channelId}/toggle-agent` | `store.social_channels.manage` | Enable / disable AI auto-reply | | `DELETE` | `/api/v1/store/social/channels/{channelId}` | `store.social_channels.manage` | Disconnect / soft-delete the channel | | `GET` | `/api/v1/store/social/conversations` | `store.conversations.view` | CRM inbox — lists all conversations across all channels | | `GET` | `/api/v1/store/social/conversations/{conversationId}` | `store.conversations.view` | Full message history for one conversation | | `POST` | `/api/v1/store/social/conversations/{conversationId}/messages` | `store.conversations.manage` | Send a manual reply | | `POST` | `/api/v1/store/social/conversations/{conversationId}/resolve` | `store.conversations.manage` | Mark conversation resolved | | `POST` | `/api/v1/store/social/conversations/{conversationId}/reopen` | `store.conversations.manage` | Reopen a resolved conversation | ### Widget-side (AllowAnonymous — public) | Method | Path | Description | |--------|------|-------------| | `POST` | `/api/v1/widget/chat/{channelToken}/messages` | Customer sends a message; returns full conversation history | | `GET` | `/api/v1/widget/chat/{channelToken}/messages?sessionId=&after=` | Widget polls for new messages | --- ## Database Migration ```bash dotnet ef database update ``` Migration: `AddWebsiteChannelType` Adds `Website = 2` to the `SocialChannelType` enum at the database level. No new tables — the existing `SocialChannels`, `SocialConversations`, and `SocialMessages` tables handle this channel type transparently. --- ## Source References | Layer | File | |-------|------| | Widget controller | Sendy.Api/Controllers/V1/Public/WebsiteChatWidgetController.cs | | Channel creation endpoint | Sendy.Api/Controllers/V1/Store/StoreSocialChannelsController.cs | | Manual reply endpoint | Sendy.Api/Controllers/V1/Store/StoreConversationsController.cs | | Website chat service | Sendy.Application/Services/Messaging/WebsiteChatService.cs | | Website chat interface | Sendy.Application/Interfaces/Services/Messaging/IWebsiteChatService.cs | | Conversation service (manual reply) | Sendy.Application/Services/Messaging/SocialConversationService.cs | | AI dispatcher (Website branch) | Sendy.Infrastructure/Jobs/AiMessageDispatchJob.cs | | Channel type enum | Sendy.Domain/Enums/Messaging/SocialChannelType.cs | | Request DTOs | Sendy.Application/DTOs/Requests/Messaging/WebsiteChatRequests.cs | | Response DTOs | Sendy.Application/DTOs/Responses/Messaging/WebsiteChatResponses.cs | For the AI agent internals, token billing, and CRM inbox details, see social-messaging-flow.md. --- ## Website Chat WebSocket Flow _Real-time chat over SignalR/WebSocket._ # Website Chat — WebSocket (SignalR) Flow > Feature area: Social Messaging / Website Chat > Supersedes: poll-based delivery described in `website-chat-widget-flow.md` > Last updated: 2026-06-12 --- ## Overview The website chatbot was migrated from HTTP polling to **SignalR WebSocket**. Messages are now pushed in real-time in both directions — from the customer widget to the store CRM, and from the store/AI back to the widget — with no polling required. The REST endpoint for sending messages (`POST /api/v1/widget/chat/{token}/messages`) is kept as a fallback. The polling endpoint (`GET /api/v1/widget/chat/{token}/messages`) has been removed. --- ## Hubs | Hub class | Route | Auth | Group format | |---|---|---|---| | `WebsiteChatHub` | `/hubs/widget-chat` | Anonymous | `widget:{sessionId}` | | `StoreConversationHub` | `/hubs/store-conversations` | JWT required | `store:{storeId}` | Both hubs live in `Sendy.Infrastructure/Hubs/`. --- ## Real-Time Push Matrix | Trigger | Widget receives | Store receives | |---|---|---| | Customer sends message (via hub) | `"MessageReceived"` (echo) | `"NewCustomerMessage"` | | Store agent sends reply (REST) | `"MessageReceived"` | — | | AI generates reply (Hangfire) | `"MessageReceived"` | `"NewCustomerMessage"` | --- ## Flow 1 — Widget Connects and Joins Session The customer widget connects once on page load and joins its session group. ``` Widget (browser) WebsiteChatHub │ │ ├─── connect to /hubs/widget-chat ────────────►│ │ │ ├─── JoinSession(channelToken, sessionId) ────►│ │ ├── AnyAsync(ExternalAccountId == channelToken, │ │ ChannelType == Website, IsEnabled) │ ├── Groups.AddToGroupAsync("widget:{sessionId}") │◄─── (no return value, connection ready) ─────┤ ``` **Hub method:** `JoinSession(string channelToken, string sessionId)` **Group joined:** `widget:{sessionId}` **Throws `HubException`** if the channel token is not found or the channel is disabled. --- ## Flow 2 — Customer Sends a Message ``` Widget WebsiteChatHub DB StoreConversationHub │ │ │ │ ├── SendMessage(token, │ │ │ │ sessionId, text, │ │ │ │ displayName?) ───────────►│ │ │ │ ├── IWebsiteChatService │ │ │ │ .SendMessageAsync() │ │ │ │ ─────────────────────►│ │ │ │ upsert Conversation │ │ │ │ save Inbound message │ │ │ │ queue AiMessageJob │ │ │ │◄────────────────────────│ │ │ │ │ │ │◄── "MessageReceived" (echo) ──┤ │ │ │ { inbound message } │ │ │ │ ├── query channel.StoreId │ │ │ ├── query conversation.Id │ │ │ │ │ │ │ ├── IWebsiteChatNotifier │ │ │ │ .NotifyStoreAsync() ──────────────────────────►│ │ │ │ "NewCustomerMessage" │ │ │ │ pushed to store group│ ``` **Hub method:** `SendMessage(string channelToken, string sessionId, string text, string? contactDisplayName)` **Widget receives:** `"MessageReceived"` with the echoed inbound `WidgetMessageResponse` **Store receives:** `"NewCustomerMessage"` with `StoreNewMessageNotification` --- ## Flow 3 — AI Agent Replies (Hangfire) `AiMessageDispatchJob` runs every minute and processes queued `AiMessageJob` records. ``` AiMessageDispatchJob DB Widget Store CRM │ │ │ │ ├── load pending jobs │ │ │ ├── fetch conversation ─────►│ │ │ ├── build system prompt │ │ │ ├── call AI provider │ │ │ │ │ │ │ ├── SocialMessages.Add() ───►│ │ │ │ (Outbound, IsAiGenerated)│ │ │ ├── SaveChangesAsync() ──────►│ │ │ │ │ │ │ ├── NotifyAsync(sessionId) ──────────────────────►│ │ │ "MessageReceived"│ │ │ │ │ ├── NotifyStoreAsync(storeId) ──────────────────────────────────────►│ │ "NewCustomerMessage" │ │ │ │ │ ├── RecordUsageAsync(tokens) │ │ │ ``` The push fires **after** `SaveChangesAsync` so `generatedMessage.Id` and `CreatedAt` are populated. Both pushes are wrapped in a single try/catch — a missed push does not affect the persisted message. --- ## Flow 4 — Store Agent Replies Manually The store agent uses the REST endpoint (`POST /api/v1/store/social/conversations/{id}/messages`). After saving, `SocialConversationService` pushes the reply to the widget. ``` Store agent (REST) SocialConversationService DB Widget │ │ │ │ ├── POST /conversations/ │ │ │ │ {id}/messages ─────────────►│ │ │ │ ├── load conversation │ │ │ ├── SocialMessages.Add()►│ │ │ ├── SaveChangesAsync() ──►│ │ │ │ │ │ │ ├── NotifyAsync(sessionId) ─────────────►│ │◄── 200 SocialMessageResponse ─┤ "MessageReceived"│ ``` **Widget receives:** `"MessageReceived"` with the store agent's `WidgetMessageResponse` The store agent's own CRM view updates synchronously via the HTTP response — no hub push needed for the sender. --- ## Flow 5 — Store Agent Connects to CRM Hub Store agents connect once when the CRM dashboard loads. ``` Store CRM (browser) StoreConversationHub │ │ ├─── connect to /hubs/store-conversations │ │ (Bearer JWT in header/query) ───────►│ [Authorize] → validates JWT │ │ ├─── JoinStore() ───────────────────────►│ │ ├── reads "store_id" claim from Context.User │ ├── Groups.AddToGroupAsync("store:{storeId}") │◄─── (ready to receive pushes) ─────────┤ ``` **Hub method:** `JoinStore()` — reads `store_id` from the JWT claim; no parameter needed. **Group joined:** `store:{storeId:D}` **Throws `HubException`** if the JWT does not contain a valid `store_id` claim. --- ## Complete Diagram ``` Store CRM Sendy API Customer Widget │ │ │ ├── connect /hubs/store-conv─►│ │ ├── JoinStore() ─────────────►│ (joined "store:{id}" group) │ │ │ │ │ │◄── connect /hubs/widget-chat ──┤ │ │◄── JoinSession(token, sid) ────┤ │ │ (joined "widget:{sid}" grp) │ │ │ │ │ │◄── SendMessage(token,sid,text)─┤ │ │ save Inbound + queue AI job │ │ │── "MessageReceived" ──────────►│ (echo) │◄── "NewCustomerMessage" ───┤ │ │ │ │ │ [Hangfire — every minute] │ │ │ │ │ ├── AI call → save Outbound │ │ │── "MessageReceived" ──────────►│ (AI reply) │◄── "NewCustomerMessage" ───┤ │ │ │ │ ├── POST /conversations/{id}/messages │ │ ├── save Outbound │ │◄── 200 ───────────────────┤ │ │ │── "MessageReceived" ──────────►│ (store reply) ``` --- ## SignalR Event Payloads ### `"MessageReceived"` — sent to widget group ```json { "id": "guid", "direction": "Inbound | Outbound", "contentText": "Hello!", "isAiGenerated": false, "createdAt": "2026-06-12T10:05:00Z" } ``` ### `"NewCustomerMessage"` — sent to store group ```json { "conversationId": "guid", "channelId": "guid", "contactDisplayName": "Ahmed", "externalContactId": "visitor-session-uuid", "messageId": "guid", "contentText": "Hello, I have a question", "createdAt": "2026-06-12T10:05:00Z" } ``` --- ## Client Usage — Widget (JavaScript) ```js const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/widget-chat") .withAutomaticReconnect() .build(); connection.on("MessageReceived", message => { renderMessage(message); // { id, direction, contentText, isAiGenerated, createdAt } }); await connection.start(); await connection.invoke("JoinSession", channelToken, sessionId); // Send a message await connection.invoke("SendMessage", channelToken, sessionId, "Hello!", null); ``` `sessionId` should be a stable per-visitor UUID stored in `localStorage`. `channelToken` comes from the `data-channel` attribute on the embed script tag. --- ## Client Usage — Store CRM (JavaScript) ```js const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/store-conversations", { accessTokenFactory: () => localStorage.getItem("jwt") }) .withAutomaticReconnect() .build(); connection.on("NewCustomerMessage", notification => { // { conversationId, channelId, contactDisplayName, externalContactId, // messageId, contentText, createdAt } updateInboxItem(notification); }); await connection.start(); await connection.invoke("JoinStore"); // storeId is read from the JWT on the server ``` --- ## Source References | Layer | File | |-------|------| | Widget hub | Sendy.Infrastructure/Hubs/WebsiteChatHub.cs | | Store CRM hub | Sendy.Infrastructure/Hubs/StoreConversationHub.cs | | Notifier interface | Sendy.Application/Interfaces/Services/Messaging/IWebsiteChatNotifier.cs | | Notifier implementation | Sendy.Infrastructure/Services/Messaging/SignalRWebsiteChatNotifier.cs | | Widget notification DTO | Sendy.Application/DTOs/Responses/Messaging/WidgetMessageResponse.cs | | Store notification DTO | Sendy.Application/DTOs/Responses/Messaging/StoreNewMessageNotification.cs | | AI job (push after save) | Sendy.Infrastructure/Jobs/AiMessageDispatchJob.cs | | Store reply (push to widget) | Sendy.Application/Services/Messaging/SocialConversationService.cs | | Hub mapping | Sendy.Api/Program.cs | | DI registration | Sendy.Api/Hosting/WebApplicationBuilderExtensions.cs | For channel creation, AI agent config, and CRM inbox REST endpoints, see [website-chat-widget-flow.md](/docs/flow-website-chat-widget/). --- ## 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` | --- ## Al-Waseet Delivery Flow _Al-Waseet delivery provider integration flow._ # Al-Waseet Delivery Provider Flow Al-Waseet is an external last-mile delivery provider integrated under Sendy's delivery-provider abstraction. Stores link their Al-Waseet merchant credentials, and the system automatically pushes orders to Al-Waseet when a Sendy order is created or assigned to the Al-Waseet delivery company. ## 1) Architecture Overview ``` Store JWT request │ ▼ StoreDeliveryProviderService resolves store's Al-Waseet credentials │ ▼ IAlWaseetClient (AlWaseetClient) typed HttpClient → Al-Waseet REST API ``` - **Credential storage**: `StoreDeliveryProviderLink` — `UsernameEncrypted` = Al-Waseet username, `PasswordEncrypted` = Al-Waseet password. `TokenEncrypted`/`TokenFetchedAt` = session token (auto-refreshed every 23 hours). - **Dispatch discriminator**: any linked delivery company whose `Slug` is not `"boxy"` or `"hi-express"` routes to Al-Waseet. The expected slug is `"al-waseet"`. - **Auth model**: session-based — `POST /login` (multipart form) returns a bearer-style token. The token is stored encrypted and automatically re-fetched when it is older than 23 hours. - **Serialization**: multipart/form-data for all Al-Waseet request bodies. ## 2) Linking an Al-Waseet Account Stores link/unlink delivery providers via the shared delivery-provider endpoints (not Al-Waseet-specific): ``` POST api/v1/store/delivery-providers/links DELETE api/v1/store/delivery-providers/links/{linkId} GET api/v1/store/delivery-providers/links ``` Request body for linking: ```json { "deliveryCompanyId": "", "username": "", "password": "" } ``` On link, `StoreDeliveryProviderService.LinkProviderAsync` immediately calls `IAlWaseetClient.LoginAsync` to validate the credentials. If login succeeds, the session token is encrypted and saved alongside the credentials. If login fails, the link is rejected with HTTP 400. Credentials and the token are AES-encrypted at rest via `IEncryptionService`. **Permission required:** `store.delivery_providers.link` ## 3) Address Mapping (Admin) Al-Waseet uses numeric city and region IDs. Admins must map Sendy address entities to the corresponding Al-Waseet IDs before orders can be pushed. ``` PUT api/v1/admin/delivery-providers/address-mapping/provinces/{code} body: { "alWaseetCityId": 1 } PUT api/v1/admin/delivery-providers/address-mapping/areas/{areaId} body: { "alWaseetRegionId": 101 } ``` Stored on: - `AddressProvince.AlWaseetCityId` (int?) — maps a Sendy province (e.g., `"BGH"`) to its Al-Waseet `city_id` - `AddressArea.AlWaseetRegionId` (int?) — maps a Sendy area (district/neighborhood) to its Al-Waseet `region_id` Obtain the correct city/region IDs from the Al-Waseet merchant dashboard or their API documentation. **Permission required:** `admin.delivery_providers.manage` ## 4) Order Auto-Push Flow When a Sendy order is created and the store has an active Al-Waseet link, `StoreDeliveryProviderService.TryPushOrderAsync` is called automatically. The push only fires when `order.DeliveryCompanyId` points to the Al-Waseet delivery company record — orders assigned to a Boxy, Hi-Express, or Sendy-internal DC are routed to their respective provider (or skipped if no external link exists). Use `PreferredDeliveryCompanyId` on order create to pin a specific DC. The push flow: 1. Resolves `AlWaseetCityId` from `AddressProvince` matching `order.AddressProvinceCode`. 2. Resolves `AlWaseetRegionId` from `AddressArea` matching `order.AddressAreaId`. 3. If either mapping is missing (`cityId == 0` or `regionId == 0`), the push is recorded as `Failed` with a descriptive error — the order is **not** discarded, only the external delivery record is marked failed. 4. Calls `EnsureFreshTokenAsync` — uses the cached token if less than 23 hours old, otherwise re-authenticates with Al-Waseet and saves the new token. 5. Calls `IAlWaseetClient.CreateOrderAsync` with the following fields: | Al-Waseet field | Source | |----------------|--------| | `client_name` | `order.CustomerName` | | `client_mobile` | `order.CustomerPhone` | | `city_id` | `AddressProvince.AlWaseetCityId` | | `region_id` | `AddressArea.AlWaseetRegionId` | | `location` | `order.CustomerAddress` | | `type_name` | `order.PackageType` (default: `"standard"`) | | `items_number` | `1` (fixed) | | `price` | `order.OrderValue` (integer) | | `package_size` | `1` (fixed) | | `replacement` | `0` (fixed — no replacement by default) | | `merchant_notes` | `order.Notes` (optional) | 6. On success, the Al-Waseet `qr_id` is written to `OrderExternalDelivery.ExternalOrderId` and the `qr_link` (if returned) can be used to access the shipment label. `OrderExternalDelivery` fields after a successful push: | Field | Value | |-------|-------| | `ExternalOrderId` | Al-Waseet `qr_id` | | `ExternalStatusId` | integer status code | | `ExternalStatusLabel` | human-readable status string | | `PushStatus` | `Success` | | `PushedAt` | UTC timestamp of the successful push | ## 5) Status Sync Al-Waseet does not push webhooks — status must be polled manually: ``` POST api/v1/store/delivery-providers/orders/{orderId}/sync-status ``` This calls `IAlWaseetClient.GetOrderStatusAsync` with the stored token and `qr_id`, then updates `OrderExternalDelivery.ExternalStatusId` and `ExternalStatusLabel`. ### Status Codes | ID | Enum | Meaning | |----|------|---------| | 1 | `Active` | Order created, awaiting driver | | 2 | `ReceivedByDriver` | Picked up by driver | | 3 | `InDelivery` | Out for delivery | | 4 | `DeliveredToCustomer` | Delivered successfully | | 5 | `AtBaghdadSortFacility` | At sorting facility (inter-city) | | 7 | `EnRouteToProvince` | In transit to destination province | | 16 | `ReturnInProgress` | Return initiated | | 27 | `Closed` | Order closed | | 29 | `Deferred` | Delivery deferred | ## 6) Token Management Al-Waseet session tokens are valid for approximately 24 hours. Sendy treats them as stale after **23 hours** (`TokenTtl`) to avoid expiry during a push. - On link creation: token is fetched and stored. - On every order push or status sync: `EnsureFreshTokenAsync` checks `TokenFetchedAt`. If stale, it re-authenticates, encrypts the new token, updates `TokenFetchedAt`, and saves to the database before proceeding. - If re-authentication fails, the push/sync is aborted and an error is logged. ## 7) Error Cases | Condition | Outcome | |-----------|---------| | Province mapping missing (`AlWaseetCityId == null`) | Push marked `Failed`; order saved normally | | Area mapping missing (`AlWaseetRegionId == null`) | Push marked `Failed`; order saved normally | | Token refresh fails | Push/sync aborted; HTTP 502 returned on sync | | Al-Waseet API returns `status: false` | Push marked `Failed` with Al-Waseet error message | | No external delivery record found | Sync returns HTTP 404 | | Order not yet pushed successfully | Sync returns HTTP 400 | ## 8) Typical Setup Flow 1. **Admin**: create the Al-Waseet delivery company record via `POST api/v1/admin/delivery-providers` with `slug = "al-waseet"`. 2. **Admin**: map all active provinces via `PUT api/v1/admin/delivery-providers/address-mapping/provinces/{code}` — one call per province with the correct Al-Waseet `city_id`. 3. **Admin**: map all active areas via `PUT api/v1/admin/delivery-providers/address-mapping/areas/{areaId}` — one call per area with the correct Al-Waseet `region_id`. 4. **Store owner**: link their Al-Waseet account via `POST api/v1/store/delivery-providers/links` with `deliveryCompanyId` pointing to the Al-Waseet record, plus their Al-Waseet `username` and `password`. The system validates credentials on link creation. 5. **Sendy order workflow**: when a Sendy order is assigned to the Al-Waseet DC, the system auto-pushes it. The `OrderExternalDelivery` record tracks the `qr_id` and status. 6. **Status updates**: call `POST api/v1/store/delivery-providers/orders/{orderId}/sync-status` periodically or on demand to pull the latest status from Al-Waseet. ## 9) Al-Waseet API Contract Base URL `https://api.alwaseet-iq.net/v1/merchant/` is configured in the `AlWaseetClient` HttpClient registration. All POST bodies use `multipart/form-data`; every endpoint except login takes the session token as a `token` query parameter. Responses share the envelope `{ "status": true|false, "errNum": "...", "msg": "...", "data": ... }` — on `status: false` the error message is in `msg`. Al-Waseet rate-limits all endpoints to **30 requests per 30 seconds** per user. The client covers the full official API ([docs](http://al-waseet.com/apis-main/index)): | Endpoint | Method | Client method | Notes | |----------|--------|---------------|-------| | `/login` | POST | `LoginAsync` | `username` + `password` form fields → `data.token` | | `/citys` | GET | `GetCitiesAsync` | `data: [{ id, city_name }]` | | `/regions?city_id=` | GET | `GetRegionsAsync` | `data: [{ id, region_name }]` | | `/package-sizes` | GET | `GetPackageSizesAsync` | `data: [{ id, size }]` | | `/statuses` | GET | `GetStatusesAsync` | `data: [{ id, status }]` — full status-code catalog | | `/create-order` | POST | `CreateOrderAsync` | order form fields (below) → `qr_id`, `qr_link` | | `/edit-order` | POST | `EditOrderAsync` | same fields as create plus `qr_id`; only while order is still with the merchant | | `/merchant-orders` | GET | `GetMerchantOrdersAsync` | all orders for the merchant account | | `/get-orders-by-ids-bulk` | POST | `GetOrdersByIdsAsync` | `ids` comma-separated form field, **max 25 per call** (client enforces) | | `/check-order?qr_id=` | GET | `GetOrderStatusAsync` | legacy single-order status check — no longer in the official docs | | `/get_merchant_invoices` | GET | `GetMerchantInvoicesAsync` | `data: [{ id, merchant_price, delivered_orders_count, replacement_delivered_orders_count, status, merchant_id, updated_at }]` | | `/get_merchant_invoice_orders?invoice_id=` | GET | `GetMerchantInvoiceOrdersAsync` | `data: { invoice: [...], orders: [...] }` | | `/receive_merchant_invoice?invoice_id=` | GET | `ReceiveMerchantInvoiceAsync` | marks the invoice as received by the merchant | Order form fields (create/edit): `client_name`, `client_mobile` (`+964…` format), `client_mobile2` (optional), `city_id`, `region_id`, `location`, `type_name`, `items_number`, `price`, `package_size`, `replacement` (0/1), `merchant_notes` (optional). ## 10) Store Management API Once a store has linked an Al-Waseet account, the following JWT-authenticated endpoints proxy Al-Waseet API calls on behalf of the store. All routes are under `api/v1/store/` and require role `store_owner | store_staff`. ### Lookups | Method | Route | Permission | Description | |--------|-------|------------|-------------| | `GET` | `al-waseet/cities` | View | List all Al-Waseet cities | | `GET` | `al-waseet/regions?cityId={id}` | View | List regions for a city | | `GET` | `al-waseet/package-sizes` | View | List available package sizes | | `GET` | `al-waseet/statuses` | View | List all status codes with labels | Use `GET al-waseet/cities` + `GET al-waseet/regions?cityId=…` to discover the correct `city_id` / `region_id` values for the admin address mapping endpoints (§3). ### Orders | Method | Route | Permission | Description | |--------|-------|------------|-------------| | `GET` | `al-waseet/orders` | View | All orders for this merchant account | | `POST` | `al-waseet/orders/by-ids` | View | Fetch up to 25 orders by QR ID | | `PATCH` | `al-waseet/orders/{qrId}` | Link | Edit an order (only while still with merchant) | `POST al-waseet/orders/by-ids` body: ```json { "ids": ["qr1", "qr2"] } ``` `PATCH al-waseet/orders/{qrId}` body: same fields as `AlWaseetCreateOrderRequest` (`clientName`, `clientMobile`, `cityId`, `regionId`, `location`, `typeName`, `itemsNumber`, `price`, `packageSize`, `replacement`, `merchantNotes`). ### Invoices | Method | Route | Permission | Description | |--------|-------|------------|-------------| | `GET` | `al-waseet/invoices` | View | List all merchant invoices | | `GET` | `al-waseet/invoices/{invoiceId}` | View | Invoice detail with its orders | | `POST` | `al-waseet/invoices/{invoiceId}/receive` | Link | Mark an invoice as received | ### Error Cases | Condition | HTTP | Message | |-----------|------|---------| | No Al-Waseet link for this store | 404 | "No active Al-Waseet account linked to this store." | | Token refresh fails | 502 | "Al-Waseet returned an error." | | Al-Waseet API returns `status: false` | 502 | Al-Waseet error message | ### Source References | Layer | File | |-------|------| | Controller | `Sendy.Api/Controllers/V1/Store/StoreAlWaseetController.cs` | | Store service interface | `Sendy.Application/Interfaces/Services/StoreArea/IAlWaseetStoreService.cs` | | Store service implementation | `Sendy.Application/Services/StoreArea/AlWaseetStoreService.cs` | ## 11) Source References | Layer | File | |-------|------| | HTTP client interface | Sendy.Application/Interfaces/Services/Integrations/IAlWaseetClient.cs | | HTTP client implementation | Sendy.Infrastructure/Services/External/DeliveryCompanies/AlWaseetClient.cs | | Store management controller | Sendy.Api/Controllers/V1/Store/StoreAlWaseetController.cs | | Store service interface | Sendy.Application/Interfaces/Services/StoreArea/IAlWaseetStoreService.cs | | Store service implementation | Sendy.Application/Services/StoreArea/AlWaseetStoreService.cs | | Delivery provider link service | Sendy.Application/Services/StoreArea/StoreDeliveryProviderService.cs | | Order push logic | `StoreDeliveryProviderService.PushOrderToAlWaseetAsync` | | Token refresh logic | `StoreDeliveryProviderService.EnsureFreshTokenAsync` / `AlWaseetStoreService.EnsureFreshTokenAsync` | | Status sync logic | `StoreDeliveryProviderService.SyncOrderStatusAsync` | | Create order request DTO | Sendy.Application/DTOs/Requests/AlWaseet/AlWaseetCreateOrderRequest.cs | | Login result DTO | Sendy.Application/DTOs/Responses/AlWaseet/AlWaseetLoginResult.cs | | Create order result DTO | Sendy.Application/DTOs/Responses/AlWaseet/AlWaseetCreateOrderResult.cs | | Status result DTO | Sendy.Application/DTOs/Responses/AlWaseet/AlWaseetGetOrderStatusResult.cs | | Status enum | Sendy.Domain/Enums/Orders/AlWaseetOrderStatus.cs | | Province mapping field | `AddressProvince.AlWaseetCityId` — Sendy.Domain/Entities/Location/AddressProvince.cs | | Area mapping field | `AddressArea.AlWaseetRegionId` — Sendy.Domain/Entities/Location/AddressArea.cs | | Admin address mapping endpoints | Sendy.Api/Controllers/V1/Admin/AdminDeliveryProvidersController.cs | | External delivery tracking | Sendy.Domain/Entities/Orders/OrderExternalDelivery.cs | --- ## Boxy Delivery Flow _Boxy delivery provider integration flow._ # Boxy Delivery Provider Flow Boxy is an external last-mile delivery provider integrated under Sendy's delivery-provider abstraction. Stores link their Boxy merchant account, then use the `api/v1/store/boxy/*` surface to manage orders, pick-ups, pick-up locations, and regions — all proxied to the Boxy REST API. ## 1) Architecture Overview ``` Store JWT request │ ▼ StoreBoxyController api/v1/store/boxy/* │ ▼ IBoxyStoreService resolves store's Boxy credentials │ ▼ IBoxyClient (BoxyClient) typed HttpClient → Boxy REST API ``` - **Credential storage**: `StoreDeliveryProviderLink` — `UsernameEncrypted` = `api_key`, `PasswordEncrypted` = `api_secret`. `TokenEncrypted`/`TokenFetchedAt` are unused (Boxy uses static key+secret, no session token). - **Dispatch discriminator**: `DeliveryCompany.Slug == "boxy"`. `StoreDeliveryProviderService` checks this slug when pushing Sendy orders to an external provider. - **Base URL**: `https://api.tryboxy.com/api/v1/` (production) or `https://api-pre.tryboxy.dev/api/v1/` (sandbox). Controlled by `Boxy:Sandbox` in `appsettings.json`. - **Auth headers**: every Boxy request carries `api-key: ` and `api-secret: `. - **Serialization**: snake_case (`JsonNamingPolicy.SnakeCaseLower`) for all Boxy request bodies. ## 2) Linking a Boxy Account Stores link/unlink delivery providers via the shared delivery-provider endpoints (not Boxy-specific): ``` POST api/v1/store/delivery-providers/links DELETE api/v1/store/delivery-providers/links/{linkId} GET api/v1/store/delivery-providers/links ``` Request body for linking: ```json { "deliveryCompanySlug": "boxy", "username": "", "password": "" } ``` On link, `StoreDeliveryProviderService.LinkProviderAsync` calls `IBoxyClient.ValidateCredentialsAsync` (`GET /user`) to confirm the credentials before saving. Credentials are AES-encrypted at rest via `IEncryptionService`. **Permission required:** `store.delivery_providers.link` ## 3) Address Mapping (Admin) Boxy uses text-based province codes and region names (unlike Al-Waseet's numeric IDs). Admins map Sendy address entities to Boxy equivalents: ``` PUT api/v1/admin/delivery-providers/boxy/address-mapping/provinces/{code} body: { "boxyProvinceCode": "BGH" } PUT api/v1/admin/delivery-providers/boxy/address-mapping/areas/{areaId} body: { "boxyRegionName": "Al-Karrada" } ``` Stored on: - `AddressProvince.BoxyProvinceCode` (max 64 chars) - `AddressArea.BoxyRegionName` (max 256 chars) Use `GET api/v1/store/boxy/regions` to fetch all Boxy provinces/regions and their UIDs, then map them to Sendy's address hierarchy. ## 4) Creating a Sendy Order with Boxy Fulfillment The normal Sendy order creation flow (`POST api/v1/store/integrations/orders` or via the store UI) assigns a delivery company. When the assigned DC has `Slug == "boxy"`, `StoreDeliveryProviderService` calls `IBoxyClient.CreateOrderAsync` and writes the result to `OrderExternalDelivery`: | Field | Source | |-------|--------| | `ExternalOrderId` | Boxy `uid` | | `ExternalTrackingLink` | Boxy `tracking_link` | | `ExternalStatusLabel` | Boxy status slug (string) | **Status slugs** are constants in `Sendy.Domain/Enums/Orders/BoxyOrderStatus.cs`: `pending`, `approved`, `picked_up`, `at_hub`, `on_the_way`, `delivered`, `failed_delivery`, `returned`, `cancelled`, and others. Status sync: `POST api/v1/store/delivery-providers/orders/{orderId}/sync-status` triggers `IBoxyClient.GetOrderStatusAsync` and updates `ExternalStatusLabel`. ## 5) Boxy-Direct Store API All endpoints require JWT + `[Authorize]`. `IBoxyStoreService.GetCredentialsAsync` looks up the active `StoreDeliveryProviderLink` with `Slug == "boxy"` for the current store; if none is found, every endpoint returns HTTP 404 "No active Boxy account linked to this store." ### Orders | Method | Route | Permission | Description | |--------|-------|-----------|-------------| | `GET` | `boxy/orders` | `StoreDeliveryProvidersView` | List all Boxy orders for this merchant | | `GET` | `boxy/orders/by-custom-id/{customId}` | `StoreDeliveryProvidersView` | Fetch order by the store's custom ID | | `GET` | `boxy/orders/{orderUid}/label` | `StoreDeliveryProvidersView` | Download single order label (PDF) | | `GET` | `boxy/orders/labels?orderUids=…` | `StoreDeliveryProvidersView` | Bulk label download (PDF); `orderUids` repeatable query param | | `PATCH` | `boxy/orders/{orderUid}` | `StoreDeliveryProvidersLink` | Update order details (same body as create) | | `DELETE` | `boxy/orders/{orderUid}` | `StoreDeliveryProvidersLink` | Delete/void a Boxy order | Label endpoints return raw bytes with `Content-Type` from the Boxy response (typically `application/pdf`), not a JSON envelope. ### Pick-ups | Method | Route | Permission | Description | |--------|-------|-----------|-------------| | `GET` | `boxy/pick-ups` | View | List all pick-ups | | `GET` | `boxy/pick-ups/{pickupUid}` | View | Get single pick-up | | `GET` | `boxy/pick-ups/{pickupUid}/labels` | View | Download pick-up labels (PDF) | | `POST` | `boxy/pick-ups` | Link | Request a pick-up for a list of order UIDs | | `POST` | `boxy/pick-ups/bulk` | Link | Bulk pick-up request; optional `filterUids[]` query param | | `POST` | `boxy/pick-ups/{pickupUid}/cancel` | Link | Cancel a single pick-up | | `POST` | `boxy/pick-ups/bulk-cancel` | Link | Bulk cancel; optional `filterUids[]` query param | Request body for `POST boxy/pick-ups`: ```json { "orderUids": ["uid1", "uid2"] } ``` ### Pick-up Locations | Method | Route | Permission | Description | |--------|-------|-----------|-------------| | `GET` | `boxy/pick-up-locations` | View | List saved pick-up addresses | | `GET` | `boxy/pick-up-locations/cash-dropoff` | View | Get the default cash drop-off location | | `GET` | `boxy/pick-up-locations/{locationUid}` | View | Get a single location | | `POST` | `boxy/pick-up-locations` | Link | Create a new pick-up location | | `PATCH` | `boxy/pick-up-locations/{locationUid}` | Link | Update a pick-up location | | `DELETE` | `boxy/pick-up-locations/{locationUid}` | Link | Delete a pick-up location | Request body for create/update: ```json { "fullName": "Main Warehouse", "type": "WAREHOUSE", "default": true, "regionUid": "", "title": "Baghdad Main", "phone": "07701234567", "addressText": "Al-Karrada, Street 14", "lng": "44.3661", "lat": "33.3152", "email": "warehouse@store.com", "description": "Ground floor" } ``` `regionUid` comes from `GET boxy/regions`. ### Regions | Method | Route | Permission | Description | |--------|-------|-----------|-------------| | `GET` | `boxy/regions` | View | List all Boxy provinces/regions with their UIDs | Use this to discover `regionUid` values needed when creating pick-up locations, and to set up admin address mapping. ## 6) Response Contract Most endpoints return the standard `ApiResponse` envelope: ```json { "success": true, "message": "Orders fetched successfully", "data": { ... } } ``` Label endpoints (`/label`, `/labels`, `pick-ups/{uid}/labels`) return the raw file bytes with the Boxy content-type header. On error they return: ```json { "error": "..." } ``` Error cases: | Condition | HTTP | Message | |-----------|------|---------| | No Boxy link for this store | 404 | "No active Boxy account linked to this store." | | Boxy API returned no data | 502 | "Boxy API returned no data." | | Entity not found on Boxy | 404 | "Order/Pick-up/Location not found." | | Boxy operation failed | 502 | Boxy error message | ## 7) Typical Setup Flow 1. **Admin**: add Boxy as a delivery company with `Slug = "boxy"` via the admin panel. 2. **Admin**: run `GET boxy/regions` (or check Boxy dashboard) and map provinces/areas via `PUT api/v1/admin/delivery-providers/boxy/address-mapping/...`. 3. **Store owner**: link their Boxy account via `POST api/v1/store/delivery-providers/links` with `deliveryCompanySlug = "boxy"`, `username = `, `password = `. 4. **Store owner**: create a pick-up location via `POST api/v1/store/boxy/pick-up-locations`. 5. **Sendy order workflow**: when a Sendy order is assigned to the Boxy DC, the system auto-pushes it to Boxy via `CreateOrderAsync`. The `OrderExternalDelivery` record tracks the Boxy UID, tracking link, and status label. 6. **Pick-up**: store requests pick-up via `POST api/v1/store/boxy/pick-ups` with the relevant Boxy order UIDs. 7. **Labels**: download shipping labels via `GET api/v1/store/boxy/orders/{uid}/label` or in bulk. ## 8) Configuration ```json // appsettings.json { "Boxy": { "Sandbox": true } } ``` Set `Boxy:Sandbox` to `false` in production to point at `https://api.tryboxy.com/api/v1/`. ## 9) Source References | Layer | File | |-------|------| | Controller | `Sendy.Api/Controllers/V1/Store/StoreBoxyController.cs` | | Store service interface | `Sendy.Application/Interfaces/Services/StoreArea/IBoxyStoreService.cs` | | Store service implementation | `Sendy.Application/Services/StoreArea/BoxyStoreService.cs` | | HTTP client interface | `Sendy.Application/Interfaces/Services/IBoxyClient.cs` | | HTTP client implementation | `Sendy.Infrastructure/Services/BoxyClient.cs` | | Status constants | `Sendy.Domain/Enums/Orders/BoxyOrderStatus.cs` | | Create order request DTO | `Sendy.Application/DTOs/Requests/Boxy/BoxyCreateOrderRequest.cs` | | Pick-up request DTO | `Sendy.Application/DTOs/Requests/Boxy/BoxyPickupRequest.cs` | | Pick-up location request DTO | `Sendy.Application/DTOs/Requests/Boxy/BoxyPickupLocationRequest.cs` | | Raw (PDF) result DTO | `Sendy.Application/DTOs/Responses/Boxy/BoxyRawResult.cs` | | Province mapping | `Sendy.Domain/Entities/Location/AddressProvince.cs` → `BoxyProvinceCode` | | Area mapping | `Sendy.Domain/Entities/Location/AddressArea.cs` → `BoxyRegionName` | | Status column | `Sendy.Domain/Entities/Orders/OrderExternalDelivery.cs` → `ExternalStatusLabel` | | Admin mapping endpoints | `Sendy.Api/Controllers/V1/Admin/AdminDeliveryProvidersController.cs` | | DI registration | `Sendy.Api/Hosting/WebApplicationBuilderExtensions.cs` | | EF migration | `Sendy.Infrastructure/Migrations/20260605203917_AddBoxyProviderSupport.cs` | --- # API endpoint catalogue 218 endpoints across 33 resource groups. Format: `METHOD /path — summary [auth]`. Full request/response schemas are in the OpenAPI spec. ## Al Waseet - `GET /api/v1/store/integrations/al-waseet/cities` [API key] - `GET /api/v1/store/integrations/al-waseet/invoices` [API key] - `GET /api/v1/store/integrations/al-waseet/invoices/{invoiceId}` [API key] - `POST /api/v1/store/integrations/al-waseet/invoices/{invoiceId}/receive` [API key] - `GET /api/v1/store/integrations/al-waseet/orders` [API key] - `PATCH /api/v1/store/integrations/al-waseet/orders/{qrId}` [API key] - `POST /api/v1/store/integrations/al-waseet/orders/by-ids` [API key] - `GET /api/v1/store/integrations/al-waseet/package-sizes` [API key] - `GET /api/v1/store/integrations/al-waseet/regions` [API key] - `GET /api/v1/store/integrations/al-waseet/statuses` [API key] ## Analytics - `GET /api/v1/store/integrations/analytics/customers` [API key] - `GET /api/v1/store/integrations/analytics/expenses` [API key] - `GET /api/v1/store/integrations/analytics/products` [API key] - `GET /api/v1/store/integrations/analytics/revenue` [API key] - `GET /api/v1/store/integrations/analytics/sales` [API key] ## API Keys - `GET /api/v1/store/integrations/api-keys` [API key] - `POST /api/v1/store/integrations/api-keys` [API key] - `POST /api/v1/store/integrations/api-keys/{apiKeyId}/revoke` [API key] ## Boxy - `GET /api/v1/store/integrations/boxy/orders` [API key] - `DELETE /api/v1/store/integrations/boxy/orders/{orderUid}` [API key] - `PATCH /api/v1/store/integrations/boxy/orders/{orderUid}` [API key] - `POST /api/v1/store/integrations/boxy/orders/{orderUid}/cancel` [API key] - `GET /api/v1/store/integrations/boxy/orders/{orderUid}/label` [API key] - `GET /api/v1/store/integrations/boxy/orders/{orderUid}/status` [API key] - `GET /api/v1/store/integrations/boxy/orders/by-custom-id/{customId}` [API key] - `GET /api/v1/store/integrations/boxy/orders/labels` [API key] - `GET /api/v1/store/integrations/boxy/pick-up-locations` [API key] - `POST /api/v1/store/integrations/boxy/pick-up-locations` [API key] - `DELETE /api/v1/store/integrations/boxy/pick-up-locations/{locationUid}` [API key] - `GET /api/v1/store/integrations/boxy/pick-up-locations/{locationUid}` [API key] - `PATCH /api/v1/store/integrations/boxy/pick-up-locations/{locationUid}` [API key] - `GET /api/v1/store/integrations/boxy/pick-up-locations/cash-dropoff` [API key] - `GET /api/v1/store/integrations/boxy/pick-ups` [API key] - `POST /api/v1/store/integrations/boxy/pick-ups` [API key] - `GET /api/v1/store/integrations/boxy/pick-ups/{pickupUid}` [API key] - `POST /api/v1/store/integrations/boxy/pick-ups/{pickupUid}/cancel` [API key] - `GET /api/v1/store/integrations/boxy/pick-ups/{pickupUid}/labels` [API key] - `POST /api/v1/store/integrations/boxy/pick-ups/bulk` [API key] - `POST /api/v1/store/integrations/boxy/pick-ups/bulk-cancel` [API key] - `GET /api/v1/store/integrations/boxy/regions` [API key] ## Cashier - `GET /api/v1/store/integrations/cashier/pinned` [API key] - `POST /api/v1/store/integrations/cashier/pinned` [API key] - `DELETE /api/v1/store/integrations/cashier/pinned/{pinnedItemId}` [API key] - `PUT /api/v1/store/integrations/cashier/pinned/reorder` [API key] - `GET /api/v1/store/integrations/cashier/sessions` [API key] - `POST /api/v1/store/integrations/cashier/sessions` [API key] - `PUT /api/v1/store/integrations/cashier/sessions/{sessionId}/close` [API key] - `GET /api/v1/store/integrations/cashier/sessions/active` [API key] - `GET /api/v1/store/integrations/cashier/settings` [API key] - `PUT /api/v1/store/integrations/cashier/settings` [API key] ## Conversations - `GET /api/v1/store/integrations/chat/conversations` [API key] - `GET /api/v1/store/integrations/chat/conversations/{conversationId}` [API key] - `POST /api/v1/store/integrations/chat/conversations/{conversationId}/messages` [API key] - `POST /api/v1/store/integrations/chat/conversations/{conversationId}/reopen` [API key] - `POST /api/v1/store/integrations/chat/conversations/{conversationId}/resolve` [API key] - `POST /api/v1/store/integrations/chat/hub-token` [API key] - `GET /api/v1/store/integrations/social/conversations` [API key] - `GET /api/v1/store/integrations/social/conversations/{conversationId}` [API key] - `POST /api/v1/store/integrations/social/conversations/{conversationId}/messages` [API key] - `POST /api/v1/store/integrations/social/conversations/{conversationId}/reopen` [API key] - `POST /api/v1/store/integrations/social/conversations/{conversationId}/resolve` [API key] ## Customers - `GET /api/v1/store/integrations/customers` [API key] - `POST /api/v1/store/integrations/customers` [API key] - `DELETE /api/v1/store/integrations/customers/{id}` [API key] - `GET /api/v1/store/integrations/customers/{id}` [API key] - `PUT /api/v1/store/integrations/customers/{id}` [API key] ## Dashboard - `GET /api/v1/store/integrations/dashboard` [API key] - `GET /api/v1/store/integrations/dashboard/orders` [API key] ## Delivery Providers - `GET /api/v1/store/integrations/delivery-providers` [API key] - `GET /api/v1/store/integrations/delivery-providers/links` [API key] - `POST /api/v1/store/integrations/delivery-providers/links` [API key] - `DELETE /api/v1/store/integrations/delivery-providers/links/{linkId}` [API key] - `POST /api/v1/store/integrations/delivery-providers/orders/{orderId}/sync-status` [API key] ## Discount Codes - `GET /api/v1/store/integrations/discounts/codes` [API key] - `POST /api/v1/store/integrations/discounts/codes` [API key] - `DELETE /api/v1/store/integrations/discounts/codes/{id}` [API key] - `GET /api/v1/store/integrations/discounts/codes/{id}` [API key] - `PUT /api/v1/store/integrations/discounts/codes/{id}` [API key] - `GET /api/v1/store/integrations/discounts/codes/validate` [API key] ## Discount Rules - `GET /api/v1/store/integrations/discounts/rules` [API key] - `POST /api/v1/store/integrations/discounts/rules` [API key] - `DELETE /api/v1/store/integrations/discounts/rules/{id}` [API key] - `GET /api/v1/store/integrations/discounts/rules/{id}` [API key] - `PUT /api/v1/store/integrations/discounts/rules/{id}` [API key] ## Expenses - `GET /api/v1/store/integrations/expenses` [API key] - `POST /api/v1/store/integrations/expenses` [API key] - `DELETE /api/v1/store/integrations/expenses/{id}` [API key] - `GET /api/v1/store/integrations/expenses/{id}` [API key] - `PUT /api/v1/store/integrations/expenses/{id}` [API key] ## Inventory - `GET /api/v1/store/integrations/catalog/inventory` [API key] - `PUT /api/v1/store/integrations/catalog/inventory/{inventoryItemId}` [API key] - `GET /api/v1/store/integrations/inventory` [API key] - `POST /api/v1/store/integrations/inventory` [API key] - `DELETE /api/v1/store/integrations/inventory/{itemId}` [API key] - `GET /api/v1/store/integrations/inventory/{itemId}` [API key] - `PUT /api/v1/store/integrations/inventory/{itemId}` [API key] ## KYC - `POST /api/v1/store/integrations/kyc/upload` [API key] ## MCP Integration - `GET /api/v1/store/integrations/mcp/integration` [API key] ## OAuth MCP Redirect Uris - `GET /api/v1/store/integrations/oauth-mcp/redirect-uris` [API key] - `POST /api/v1/store/integrations/oauth-mcp/redirect-uris` [API key] - `DELETE /api/v1/store/integrations/oauth-mcp/redirect-uris/{id}` [API key] ## Orders - `GET /api/v1/store/integrations/delivery-companies` [API key] - `GET /api/v1/store/integrations/orders` [API key] - `POST /api/v1/store/integrations/orders` [API key] - `GET /api/v1/store/integrations/orders/{orderId}` [API key] - `PUT /api/v1/store/integrations/orders/{orderId}` [API key] - `POST /api/v1/store/integrations/orders/{orderId}/cancel` [API key] - `GET /api/v1/store/integrations/orders/{orderId}/items` [API key] - `POST /api/v1/store/integrations/orders/{orderId}/qr` [API key] - `POST /api/v1/store/integrations/orders/{orderId}/return-request` [API key] - `GET /api/v1/store/integrations/orders/{orderId}/return-requests` [API key] - `POST /api/v1/store/integrations/orders/{orderId}/status` [API key] - `POST /api/v1/store/integrations/orders/bulk` [API key] - `GET /api/v1/store/integrations/orders/search/{publicId}` [API key] - `POST /api/v1/store/integrations/orders/send-otp` [API key] - `POST /api/v1/store/integrations/orders/storefront` [API key] - `GET /api/v1/store/integrations/return-requests/{returnRequestId}` [API key] - `POST /api/v1/store/integrations/return-requests/{returnRequestId}/approve` [API key] - `POST /api/v1/store/integrations/return-requests/{returnRequestId}/reject` [API key] ## Package Sizes - `GET /api/v1/store/integrations/package-sizes` [API key] - `POST /api/v1/store/integrations/package-sizes` [API key] - `DELETE /api/v1/store/integrations/package-sizes/{id}` [API key] - `GET /api/v1/store/integrations/package-sizes/{id}` [API key] - `PUT /api/v1/store/integrations/package-sizes/{id}` [API key] ## Print Templates - `GET /api/v1/store/integrations/print-templates` [API key] - `POST /api/v1/store/integrations/print-templates` [API key] - `DELETE /api/v1/store/integrations/print-templates/{id}` [API key] - `GET /api/v1/store/integrations/print-templates/{id}` [API key] - `PUT /api/v1/store/integrations/print-templates/{id}` [API key] ## Product Categories - `GET /api/v1/store/integrations/categories` [API key] - `POST /api/v1/store/integrations/categories` [API key] - `DELETE /api/v1/store/integrations/categories/{id}` [API key] - `GET /api/v1/store/integrations/categories/{id}` [API key] - `PUT /api/v1/store/integrations/categories/{id}` [API key] ## Products - `GET /api/v1/store/integrations/catalog/products` [API key] - `POST /api/v1/store/integrations/catalog/products` [API key] - `DELETE /api/v1/store/integrations/catalog/products/{productId}` [API key] - `GET /api/v1/store/integrations/products` [API key] - `POST /api/v1/store/integrations/products` [API key] - `DELETE /api/v1/store/integrations/products/{productId}` [API key] - `GET /api/v1/store/integrations/products/{productId}` [API key] - `PUT /api/v1/store/integrations/products/{productId}` [API key] - `GET /api/v1/store/integrations/products/{productId}/images` [API key] - `POST /api/v1/store/integrations/products/{productId}/images` [API key] ## Profile - `GET /api/v1/store/integrations/profile` [API key] - `PUT /api/v1/store/integrations/profile` [API key] ## Purchase Orders - `GET /api/v1/store/integrations/purchase-orders` [API key] - `POST /api/v1/store/integrations/purchase-orders` [API key] - `DELETE /api/v1/store/integrations/purchase-orders/{id}` [API key] - `GET /api/v1/store/integrations/purchase-orders/{id}` [API key] - `PUT /api/v1/store/integrations/purchase-orders/{id}` [API key] - `POST /api/v1/store/integrations/purchase-orders/{id}/attachments` [API key] - `DELETE /api/v1/store/integrations/purchase-orders/{id}/attachments/{attachmentId}` [API key] ## Rbac - `GET /api/v1/store/integrations/rbac/permissions` [API key] - `GET /api/v1/store/integrations/rbac/roles` [API key] - `POST /api/v1/store/integrations/rbac/roles` [API key] - `DELETE /api/v1/store/integrations/rbac/roles/{roleId}` [API key] - `PUT /api/v1/store/integrations/rbac/roles/{roleId}` [API key] - `GET /api/v1/store/integrations/rbac/roles/{roleId}/permissions` [API key] - `POST /api/v1/store/integrations/rbac/roles/{roleId}/permissions` [API key] - `DELETE /api/v1/store/integrations/rbac/roles/{roleId}/permissions/{permissionId}` [API key] - `GET /api/v1/store/integrations/rbac/users` [API key] - `POST /api/v1/store/integrations/rbac/users` [API key] - `DELETE /api/v1/store/integrations/rbac/users/{userId}` [API key] - `GET /api/v1/store/integrations/rbac/users/{userId}/roles` [API key] - `POST /api/v1/store/integrations/rbac/users/{userId}/roles` [API key] - `DELETE /api/v1/store/integrations/rbac/users/{userId}/roles/{roleId}` [API key] ## Settings - `GET /api/v1/store/integrations/settings` [API key] - `POST /api/v1/store/integrations/settings` [API key] - `PUT /api/v1/store/integrations/settings` [API key] ## Social Channels - `GET /api/v1/store/integrations/social/channels` [API key] - `DELETE /api/v1/store/integrations/social/channels/{channelId}` [API key] - `PATCH /api/v1/store/integrations/social/channels/{channelId}` [API key] - `POST /api/v1/store/integrations/social/channels/{channelId}/rotate-token` [API key] - `POST /api/v1/store/integrations/social/channels/{channelId}/toggle-agent` [API key] - `POST /api/v1/store/integrations/social/channels/website` [API key] ## Storefront - `GET /api/v1/store/integrations/storefront` [API key] - `PUT /api/v1/store/integrations/storefront` [API key] - `POST /api/v1/store/integrations/storefront/domain/verify` [API key] - `DELETE /api/v1/store/integrations/storefront/images/{imageType}` [API key] - `DELETE /api/v1/store/integrations/storefront/publish` [API key] - `POST /api/v1/store/integrations/storefront/publish` [API key] - `PUT /api/v1/store/integrations/storefront/sections` [API key] - `GET /api/v1/store/integrations/storefront/template-entitlements` [API key] - `GET /api/v1/store/integrations/storefront/templates` [API key] - `POST /api/v1/store/integrations/storefront/templates/{templateId}/apply` [API key] ## Subscription - `GET /api/v1/store/integrations/subscription` [API key] - `GET /api/v1/store/integrations/subscription/plans` [API key] - `POST /api/v1/store/integrations/subscription/upgrade-requests` [API key] - `GET /api/v1/store/integrations/subscription/upgrade-requests/{requestId}` [API key] - `POST /api/v1/store/integrations/subscription/upgrade-requests/{requestId}/verify` [API key] ## Suppliers - `GET /api/v1/store/integrations/suppliers` [API key] - `POST /api/v1/store/integrations/suppliers` [API key] - `DELETE /api/v1/store/integrations/suppliers/{id}` [API key] - `GET /api/v1/store/integrations/suppliers/{id}` [API key] - `PUT /api/v1/store/integrations/suppliers/{id}` [API key] ## Tickets - `GET /api/v1/store/integrations/tickets` [API key] - `POST /api/v1/store/integrations/tickets` [API key] - `DELETE /api/v1/store/integrations/tickets/{ticketId}` [API key] - `GET /api/v1/store/integrations/tickets/{ticketId}` [API key] - `PUT /api/v1/store/integrations/tickets/{ticketId}` [API key] ## Wallet - `GET /api/v1/store/integrations/wallet` [API key] - `PUT /api/v1/store/integrations/wallet/bank-details` [API key] - `GET /api/v1/store/integrations/wallet/transactions` [API key] ## Warehouses - `GET /api/v1/store/integrations/warehouses` [API key] - `POST /api/v1/store/integrations/warehouses` [API key] - `DELETE /api/v1/store/integrations/warehouses/{warehouseId}` [API key] - `GET /api/v1/store/integrations/warehouses/{warehouseId}` [API key] - `PUT /api/v1/store/integrations/warehouses/{warehouseId}` [API key] ## Webhooks - `GET /api/v1/store/integrations/webhooks` [API key] - `POST /api/v1/store/integrations/webhooks` [API key] - `DELETE /api/v1/store/integrations/webhooks/{subscriptionId}` [API key] - `GET /api/v1/store/integrations/webhooks/{subscriptionId}/deliveries` [API key] - `POST /api/v1/store/integrations/webhooks/{subscriptionId}/disable` [API key] - `POST /api/v1/store/integrations/webhooks/{subscriptionId}/enable` [API key] - `POST /api/v1/store/integrations/webhooks/{subscriptionId}/rotate-secret` [API key] - `POST /api/v1/store/integrations/webhooks/deliveries/{deliveryId}/retry` [API key] - `GET /api/v1/store/integrations/webhooks/event-types` [API key]