{
  "openapi": "3.1.0",
  "info": {
    "title": "Loyalty+ tenant API",
    "version": "1.0.0",
    "description": "External API for one business (tenant) on the Loyalty+ platform. Every request is authenticated and scoped by the tenant API key (owner portal → Settings → API key). An MCP (Model Context Protocol) endpoint exposing the same capabilities as agent tools is served at POST /v1/api/mcp with the same authentication. A plain-text agent guide is at /llms.txt."
  },
  "servers": [{ "url": "/" }],
  "security": [{ "ApiKeyAuth": [] }],
  "paths": {
    "/v1/api/account": {
      "get": {
        "summary": "Business account summary",
        "description": "Identity, plan, subscription status, and SMS deliverability for the authenticated business.",
        "responses": { "200": { "description": "Account summary" } }
      }
    },
    "/v1/api/customers": {
      "get": {
        "summary": "List/search customers",
        "parameters": [
          { "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Name or phone substring filter" },
          { "name": "statusFilter", "in": "query", "schema": { "type": "string", "enum": ["ALL", "ACTIVE", "BLOCKED"] } },
          { "name": "sortBy", "in": "query", "schema": { "type": "string", "enum": ["points", "lastVisit", "name"] } },
          { "name": "sortOrder", "in": "query", "schema": { "type": "string", "enum": ["asc", "desc"] } },
          { "name": "page", "in": "query", "schema": { "type": "integer", "minimum": 1 } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "maximum": 100 } }
        ],
        "responses": { "200": { "description": "Customers with totalPoints, lastVisit, and pagination" } }
      },
      "post": {
        "summary": "Create a customer",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name"],
                "properties": {
                  "name": { "type": "string" },
                  "phone": { "type": "string" },
                  "notes": { "type": "string" },
                  "initialPoints": { "type": "integer" }
                }
              }
            }
          }
        },
        "responses": {
          "201": { "description": "Created" },
          "409": { "description": "A customer with this phone already exists" }
        }
      }
    },
    "/v1/api/customers/lookup": {
      "get": {
        "summary": "Find a customer by phone and return their points balance",
        "description": "Phone may be in any common human format; it is normalized server-side. Returns the customer, totalPoints, and reward status against the default earning rule.",
        "parameters": [
          { "name": "phone", "in": "query", "required": true, "schema": { "type": "string", "minLength": 5 } }
        ],
        "responses": {
          "200": { "description": "customer, totalPoints, rewardAvailable, rewardThreshold, rewardTitle" },
          "404": { "description": "No customer with this phone" }
        }
      }
    },
    "/v1/api/customers/{id}": {
      "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
      "get": {
        "summary": "Customer detail",
        "description": "Profile, identities, stats (totalPoints/visits/redemptions), points history, recent visits and redemptions.",
        "responses": { "200": { "description": "Customer detail" }, "404": { "description": "Not found" } }
      },
      "patch": {
        "summary": "Update a customer",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": { "type": "string" },
                  "phone": { "type": "string" },
                  "notes": { "type": "string" },
                  "status": { "type": "string", "enum": ["ACTIVE", "BLOCKED"] }
                }
              }
            }
          }
        },
        "responses": { "200": { "description": "Updated" }, "409": { "description": "Phone conflict" } }
      },
      "delete": {
        "summary": "Delete a customer permanently",
        "responses": { "200": { "description": "Deleted (identities, visits, ledger cascade)" } }
      }
    },
    "/v1/api/customers/{id}/points": {
      "post": {
        "summary": "Adjust a customer's points",
        "description": "Writes a MANUAL_ADJUST ledger entry; balances are never edited directly. Negative points deduct; a deduction below zero is rejected with the current balance.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["points", "reason"],
                "properties": {
                  "points": { "type": "integer", "description": "Positive to add, negative to deduct" },
                  "reason": { "type": "string" },
                  "storeId": { "type": "string", "description": "Optional; defaults to the first store" }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "adjustment with previousBalance and newBalance" },
          "400": { "description": "Insufficient points" }
        }
      }
    },
    "/v1/api/reward-rules": {
      "get": { "summary": "List earning rules", "responses": { "200": { "description": "Rules: earningMode VISIT/UNIT/SPEND, points per visit/unit/spend, reward threshold + title, cooldown" } } },
      "post": {
        "summary": "Create an earning rule",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["rewardTitle"],
                "properties": {
                  "storeId": { "type": "string", "description": "Omit for a tenant-default rule" },
                  "earningMode": { "type": "string", "enum": ["VISIT", "UNIT", "SPEND"] },
                  "pointsPerVisit": { "type": "integer" },
                  "pointsPerUnit": { "type": "integer" },
                  "pointsPerSpend": { "type": "integer" },
                  "spendThreshold": { "type": "number" },
                  "pointsRewardThreshold": { "type": "integer" },
                  "rewardTitle": { "type": "string" },
                  "cooldownMinutesPerCheckin": { "type": "integer" }
                }
              }
            }
          }
        },
        "responses": { "201": { "description": "Created" } }
      }
    },
    "/v1/api/reward-rules/{ruleId}": {
      "parameters": [{ "name": "ruleId", "in": "path", "required": true, "schema": { "type": "string" } }],
      "get": { "summary": "Get one rule", "responses": { "200": { "description": "Rule" }, "404": { "description": "Not found" } } },
      "patch": { "summary": "Update a rule (partial)", "responses": { "200": { "description": "Updated" } } },
      "delete": { "summary": "Delete a rule", "responses": { "204": { "description": "Deleted" }, "400": { "description": "Cannot delete the only default rule" } } }
    },
    "/v1/api/reward-tiers": {
      "get": { "summary": "List reward tiers", "description": "Multi-level rewards: pointsRequired and rewardTitle per tier.", "responses": { "200": { "description": "Tiers" } } }
    },
    "/v1/api/dashboard/stats": {
      "get": { "summary": "Business KPIs", "description": "Customer counts, points issued/redeemed, 30-day trend, recent activity, top customers, weekly activation.", "responses": { "200": { "description": "Stats" } } }
    },
    "/v1/api/redemptions": {
      "get": { "summary": "List recent redemptions", "responses": { "200": { "description": "Redemptions with status, customer, store, points" } } }
    },
    "/v1/api/redemptions/by-code/{redeemCode}": {
      "get": {
        "summary": "Look up a redemption by its 8-character code",
        "parameters": [{ "name": "redeemCode", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[A-F0-9]{8}$" } }],
        "responses": { "200": { "description": "Redemption" }, "404": { "description": "Unknown code" } }
      }
    },
    "/v1/api/mcp": {
      "post": {
        "summary": "Model Context Protocol endpoint (streamable HTTP, stateless)",
        "description": "MCP server exposing the API as agent tools: get_account, get_dashboard_stats, list_customers, get_customer, find_customer_by_phone, adjust_customer_points, create_customer, list_reward_rules, list_reward_tiers, list_redemptions. Authenticate with the same x-api-key header.",
        "responses": { "200": { "description": "JSON-RPC response" } }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "Tenant API key from the owner portal (Settings → API key). Scopes every request to that business."
      }
    }
  }
}
