{
  "info": {
    "_postman_id": "aff4b2cf-8240-4064-a266-7914401fb766",
    "name": "Reputation Management API (global)",
    "description": "## Reputation Management Platform API\n\nFull v1 API contract — authentication, brands & locations, plans, review sites, reviews & responses, performance & ranking reports, campaigns, QR codes, and the HiPages s2s ingest path.\n\n### Setup (development sandbox)\n\n1. The `baseUrl` collection variable defaults to `https://development-api.shoutaboutus.com/api/v1`.\n2. Run **§ 1 Authentication → Login** with the sandbox credentials issued to you — the test script captures the token into `bearerToken`.\n3. All other authenticated requests use `{{bearerToken}}` automatically.\n\n### Credentials (development)\n\nCredentials are **not** distributed with this collection. Set the `email` and `password` environment variables to the sandbox account issued to you separately, then run **1.A.1 Login** — its test script captures the bearer token automatically.\n\n| Variable | Value |\n|---|---|\n| `email` | _issued separately_ |\n| `password` | _issued separately; held as a Postman secret variable_ |\n\n### Status indicators in folder/request names\n\n- ✅ live — wired up in `routes/api_v1.php` today.\n\n### Pre-wired collection variables (development sample IDs)\n\n| Variable | Value | Purpose |\n|---|---|---|\n| `baseUrl` | `https://development-api.shoutaboutus.com/api/v1` | API root |\n| `bearerToken` | (captured by Login) | Passport access token |\n| `partnerId` | `9` | HiPages partner company id |\n| `brandId` | `42` | HiPages · Brand 1 |\n| `storeId` | `26` | Demo store #1 under Brand 1 |\n| `planId` | `10` | HiPages Core |\n| `reviewId` | `1546` | Sample HiPages review on store 26 |\n| `reviewSiteId` | `100` | HiPages |\n| `storeReviewSiteId` | `129` | Store 26 ↔ HiPages |\n| `campaignId` | `1` | (no campaigns seeded — create one first via § 8.A.4) |\n| `qrCodeId` | `1` | (no QR codes seeded — create one first via § 9.B.3) |\n| `templateId` | `1` | (no QR templates seeded — create one first via § 9.A.3) |\n\n### Response envelope (all endpoints)\n\nEvery **success** response is wrapped in a single `{ \"data\": <payload> }` envelope. For object endpoints the payload is the object (`{ \"data\": { \"id\": ... } }`); for paginated list endpoints the payload itself carries `data`/`meta`/`kpi`, so the body is `{ \"data\": { \"data\": [...], \"meta\": {...} } }`. **Error** responses use a separate envelope: `{ \"message\": ..., \"status\": \"error\", \"errors\": ... }`. The frontend axiosWrapper unwraps the outer `data` level centrally.\n\nSchema: Postman Collection v2.1.\n",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{bearerToken}}",
        "type": "string"
      }
    ]
  },
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// Collection-wide pre-request: nothing for now."
        ]
      }
    }
  ],
  "variable": [],
  "item": [
    {
      "name": "1. Auth Endpoints",
      "description": "Authentication for partner and account users (login, OAuth password grant, impersonation), plus public pre-login reference lookups.",
      "item": [
        {
          "name": "§ 0. Reference (public lookups)",
          "description": "Public reference catalogs used by the signup wizard, partner provisioning, and any pre-login form that needs geographic or feature-option dropdowns. No bearer token required — these routes sit inside the white-label public group.",
          "item": [
            {
              "name": "0.A.1 Get feature options",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/feature-options?store_id={{storeId}}&company_id={{brandId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "feature-options"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}",
                      "description": "Required without company_id — must exist in store.id"
                    },
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "description": "Required without store_id — must exist in company.id",
                      "disabled": true
                    }
                  ]
                },
                "description": "Resolves a store/company to its owning company and returns plan feature options for feature-gating UI. Note: in the current source the success payload is always an empty array — the method validates the company exists, then returns `successResponse([])`.\n\n**Auth:** Both — no auth middleware (public route under `throttle:100,1`, `auth:api` is NOT applied). Target is scoped purely by the `store_id`/`company_id` query params: `store_id` is resolved to its owning `store.company_id` (only `active`/`pending` stores), or `company_id` is used directly; there is no `BelongsToAuthCompany` or partner-tree check, so any caller can query any company.\n\n**Rate limit:** 100 requests/min (`throttle:100,1`).\n\n**Request**\n- Query: `store_id` (integer, required_without `company_id`, nullable) — must exist in `store.id`; resolved to its `company_id`.\n- Query: `company_id` (integer, required_without `store_id`, nullable) — must exist in `company.id`; used directly when `store_id` is absent.\n\n**Response** — `{ data: ... }` envelope.\n- `data` (array) — always an empty array `[]` on success in current source. Returns an error response (`{ \"message\": \"Company not found!\" }`) if the resolved company id does not exist.",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": []\n}"
                },
                {
                  "name": "400 Company not found",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Company not found!\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "0.A.2 Get countries",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/address/countries",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "address",
                    "countries"
                  ]
                },
                "description": "Returns the full list of countries as `{label, value}` pairs, ordered by name. Result is cached forever under `countries_list_v2`.\n\n**Auth:** Both — no auth middleware. Public reference endpoint under the `throttle:100,1` group (called by the SPA before login); no persona scoping — anyone gets the same global list.\n\n**Rate limit:** 100 requests/min (`throttle:100,1`).\n\n**Request**\n- No path params, query params, or body.\n\n**Response** — `{ data: ... }` envelope.\n- `data` (array of objects) — every country.\n  - `label` (string) — country name (DB column `name`).\n  - `value` (string) — ISO country code (DB column `code`).",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": [\n      { \"label\": \"Australia\", \"value\": \"AU\" },\n      { \"label\": \"Canada\", \"value\": \"CA\" },\n      { \"label\": \"United States\", \"value\": \"US\" }\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "0.A.3 Get states",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/address/states?country_code=US",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "address",
                    "states"
                  ],
                  "query": [
                    {
                      "key": "country_code",
                      "value": "US",
                      "description": "Required — ISO-2 country code"
                    }
                  ]
                },
                "description": "Returns the states/regions for the given `country_code` as `{label, value}` pairs, ordered by name. Result is cached forever under `states_list_v2_{country_code}`.\n\n**Auth:** Both — no auth middleware. Public reference endpoint under the `throttle:100,1` group (called by the SPA before login); no persona scoping — same global list for everyone.\n\n**Rate limit:** 100 requests/min (`throttle:100,1`).\n\n**Request**\n- Query: `country_code` (string, required) — must exist in `countries.code` (`exists:Country,code`).\n\n**Response** — `{ data: ... }` envelope.\n- `data` (array of objects) — states for that country.\n  - `value` (string) — state code (DB column `code`).\n  - `label` (string) — state name (DB column `name`).",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": [\n      { \"value\": \"CA\", \"label\": \"California\" },\n      { \"value\": \"NY\", \"label\": \"New York\" },\n      { \"value\": \"TX\", \"label\": \"Texas\" }\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "0.A.4 Get cities",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/address/cities?country_code=US&state_code=CA",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "address",
                    "cities"
                  ],
                  "query": [
                    {
                      "key": "country_code",
                      "value": "US",
                      "description": "Required — ISO-2 country code"
                    },
                    {
                      "key": "state_code",
                      "value": "CA",
                      "description": "Required — state code within the country"
                    }
                  ]
                },
                "description": "Returns the cities for the given `country_code` and `state_code` as `{label, value}` pairs, ordered by name. Result is cached forever under `cities_list_v2_{country_code}_{state_code}`.\n\n**Auth:** Both — no auth middleware. Public reference endpoint under the `throttle:100,1` group (called by the SPA before login); no persona scoping — same global list for everyone.\n\n**Rate limit:** 100 requests/min (`throttle:100,1`).\n\n**Request**\n- Query: `country_code` (string, required) — must exist in `countries.code` (`exists:Country,code`).\n- Query: `state_code` (string, required) — must exist in `states.code` (`exists:State,code`).\n\n**Response** — `{ data: ... }` envelope.\n- `data` (array of objects) — cities for that state/country.\n  - `value` (integer) — city id (DB column `id`).\n  - `label` (string) — city name (DB column `name`).",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": [\n      { \"value\": 1042, \"label\": \"Los Angeles\" },\n      { \"value\": 1213, \"label\": \"San Francisco\" },\n      { \"value\": 1305, \"label\": \"San Diego\" }\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "0.A.5 Refresh address cache (PUBLIC)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "auth": {
                  "type": "noauth"
                },
                "url": {
                  "raw": "{{baseUrl}}/address/refresh-cache?country_code=US",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "address",
                    "refresh-cache"
                  ],
                  "query": [
                    {
                      "key": "country_code",
                      "value": "US",
                      "disabled": true
                    }
                  ]
                },
                "description": "Clears the forever-cached country / state / city reference lists so the next lookup rebuilds them. Pass `country_code` to scope the flush to a single country (only its state/city cache keys are forgotten); omit it to also forget the global `countries_list_v2` key and every state/city key. Public maintenance endpoint in the pre-login reference group.\n\n## Auth\n- **Public** — no auth. Lives in the `throttle:100,1` reference group.\n\n## Rate limit\n- 100 requests/min per IP (`throttle:100,1`).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `country_code` | string | Optional | `exists:country,code`. Scopes the flush to one country; omit to clear all address caches. |\n\n## Behaviour\n- `200` — `{ data: { message: 'Address caches refreshed.' } }`.\n- `422` — `country_code` supplied but not a known country code.\n\n✅ live ([routes/api_v1.php:188](routes/api_v1.php#L188) → `AddressController@refreshCache`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"message\": \"Address caches refreshed.\"\n  }\n}"
                },
                {
                  "name": "422 Unknown country code",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected country code is invalid.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"country_code\": [\n      \"The selected country code is invalid.\"\n    ]\n  }\n}"
                }
              ]
            }
          ]
        },
        {
          "name": "§ 1. Authentication",
          "description": "# Authentication\n\nThe API uses **OAuth 2.0 Bearer tokens** issued by **Laravel Passport** (RS256-signed JWTs). Every protected endpoint expects an `Authorization: Bearer <token>` header.\n\n## Collection setup (already wired)\n\n- This collection has **top-level Bearer auth** pre-configured — it automatically sends `Authorization: Bearer {{bearerToken}}` on every request.\n- Public endpoints individually override this with **No Auth** (Login, Signup, Discover, OTP, SSO redirect/exchange, webhooks).\n- Run **1.A.1 Login** once — its test script captures `token` from the response into the `{{bearerToken}}` collection variable, so every later request is authenticated automatically (no copy/paste).\n- `{{baseUrl}}` points at the development host by default; change it to target another environment.\n\n## Ways to obtain a token\n\n1. **Password login** — `POST /login` with a JSON body of email, password and rememberMe. Returns `token` (a Passport access token) plus the user company context, location count and branding theme. (1.A.1)\n2. **SSO — JumpCloud (OIDC)** — browser flow: Authorize redirect → JumpCloud → callback → `GET /auth/sso/exchange` redeems the one-time code for a token. (1.D.1 / 1.D.2)\n3. **Discover (optional, pre-login)** — `POST /auth/discover` with an email returns whether that account supports password login, SSO, or both — without revealing whether the email exists (no account enumeration). (Discover auth methods)\n\n## Using the token\n\nSend it on every protected call:\n\n```\nAuthorization: Bearer <token>\n```\n\nProtected route groups (all behind the `auth:api` Passport guard):\n\n| Prefix | Who | Token context |\n| --- | --- | --- |\n| `/admin/*` | Platform super-admin | admin user |\n| `/partner/*` | Partner-admin (hipages service / sales teams) | agency / partner-level access_token |\n| `/account/*` | Brand / location users + current-user self-service (the dashboards/widgets formerly under `/brand/*`, renamed to `/account/*`) | brand/account-level access_token, scoped to its partner |\n| `/sso/*` | OAuth client management | super-admin |\n\n## Account-owner impersonation token\n\nA partner can mint a Passport **personal access token issued AS an account (brand) owner** via `POST /partner/account-owner/token` (formerly `/partner/brand-owner/token`). The collection stores it in `{{brandOwnerToken}}` for requests that must run in the brand's own context.\n\n## Token lifecycle\n\n- Access tokens and personal access tokens expire **1 day** after issue.\n- Refresh tokens are valid for **30 days**.\n- **Verify**: `GET /verify-token` (1.A.2) echoes the authenticated user when the token is still valid.\n- **Revoke / logout**: `GET /logout` (1.A.3) deletes the current token server-side.\n\n## Public vs protected\n\nOnly a small set of endpoints are public (**No Auth**): login, email-OTP verify/resend, the signup wizard, auth discovery, SSO redirect/callback/exchange, pre-login branding and reference lookups (plans, feature-options, countries/states/cities, white-label resolver), campaign tracking + QR landing pages, and provider webhooks (Mandrill, Bandwidth, ReviewData — each verified by its own signature or token). **Every state-changing (write) endpoint requires a valid OAuth Bearer token.**",
          "item": [
            {
              "name": "1.A.1 Login",
              "request": {
                "auth": {
                  "type": "noauth"
                },
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"email\": \"{{email}}\",\n    \"password\": \"{{password}}\",\n    \"rememberMe\": false\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/login",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "login"
                  ]
                },
                "description": "Authenticates an email/password credential and, on success, issues a Passport bearer token plus the user, company, theme, white-label and (for brand companies) plan context. Locked accounts, SSO-only accounts (`sso_required`), inactive/unverified users, users with no company, and companies with `bundle_id < 1` are all rejected before a token is issued.\n\n**Auth:** Both — public route (no `auth:api`); throttled `throttle:10,1` (20/min on local). No partner/account scoping — authenticates any user regardless of persona; the returned `company.bundle_id` (1=partner, 2=location, 3=brand) tells the SPA which persona logged in. The `referer` host is validated against allowed/white-label hosts via `allowHost()`.\n\n**Rate limit:** 20 requests/min (`throttle:20,1`).\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| email | string | yes | `email:rfc`, max 255 |\n| password | string | yes | max 100 |\n| rememberMe | boolean | no | nullable; passed to `Auth::attempt` for long-lived session |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `\"success\"`\n- `token` (string) — Passport access token (`createToken(...)->accessToken`)\n- `user` (object) — `id`, `username`, `email`, `inactive_at` (nullable), `email_verified_at`, `phone_number`, `first_name` (person.firstname), `last_name` (person.lastname), `title`, `role` (person.role() or `\"User\"`), `total_location` (count of stores reachable by the user)\n- `company` (object) — `id`, `name`, `bundle_id`, plus appended `enable_insight_report` (0/1), `enable_competitive_analysis` (0/1), `dormant_accounts_enabled` (0/1), and eager-loaded `company_option` (`company_id`, `who_will_pay`, `enable_generic_alert`, `enable_insight_report`, `enable_competitive_analysis`)\n- `theme_options` (string) — JSON string from `Company::getTheme(...)`\n- `white_label` (object) — `Company::getWhiteLabel(...)`: `logo`, `white_url`, `favicon`, `title`, `status`, `login_logo`, `company_id`, `email_templates_enabled`\n- `plan` (object|null) — only populated when `company.bundle_id == 3` (brand); the assigned plan (`id`, `plan_name`, `status`, `plan_type`, `enforcement_mode`, `is_admin_managed`) with `features` (`id`, `plan_id`, `feature_name`, `enabled`, `hard_stop`). Null otherwise.\n\nError cases (not `{data}`): 401 `\"Invalid email or password!\"` / locked-account message; 403 SSO-required (`{sso_required:true, sso_url}`) or no-company; 400 white-label not allowed / inactive / email not verified / subscription not active."
              },
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const json = pm.response.json();",
                      "  if (json.data.token) { pm.environment.set('bearerToken', json.data.token); }",
                      "}"
                    ]
                  }
                }
              ],
              "response": [
                {
                  "name": "200 Success",
                  "originalRequest": {
                    "method": "POST",
                    "header": [],
                    "body": {
                      "mode": "raw",
                      "raw": "{\"email\":\"{{email}}\",\"password\":\"{{password}}\"}"
                    },
                    "url": {
                      "raw": "{{baseUrl}}/login"
                    }
                  },
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "Content-Type",
                      "value": "application/json"
                    }
                  ],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"token\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...\",\n    \"user\": {\n      \"id\": 8,\n      \"username\": \"partner-admin@example.com\",\n      \"email\": \"partner-admin@example.com\",\n      \"first_name\": \"HiPages\",\n      \"last_name\": \"Admin\",\n      \"role\": \"Owner\",\n      \"total_location\": 6\n    },\n    \"company\": {\n      \"id\": 9,\n      \"name\": \"HiPages\",\n      \"bundle_id\": 1\n    }\n  }\n}"
                },
                {
                  "name": "401 Invalid credentials",
                  "status": "Unauthorized",
                  "code": 401,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "Content-Type",
                      "value": "application/json"
                    }
                  ],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Invalid credentials\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "1.A.2 Verify token",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/verify-token",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "verify-token"
                  ]
                },
                "description": "Validates the supplied Passport bearer token and re-hydrates the SPA session: returns the authenticated user, their company, theme, white-label and (for brand companies) plan context. Mirrors the `login` payload minus the freshly-minted token (it echoes the bearer token from the request).\n\n**Auth:** Both — `auth:api` (Passport bearer). Target is always the authenticated user (`Auth::id()`); no partner/account-tree scoping. The returned `company.bundle_id` reflects whichever persona's token was supplied.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Header: `Authorization: Bearer <token>` (required).\n- No path params, query params, or body.\n\n**Response** — `{ data: ... }` envelope.\n- `token` (string) — echoes `request->bearerToken()`\n- `user` (object) — `id`, `username`, `email`, `inactive_at` (nullable), `email_verified_at`, `phone_number`, `first_name`, `last_name`, `title`, `role` (or `\"User\"`), `total_location`\n- `company` (object) — `id`, `name`, `bundle_id`, plus appended `enable_insight_report` (0/1), `enable_competitive_analysis` (0/1), `dormant_accounts_enabled` (0/1), and `company_option` (`company_id`, `who_will_pay`, `enable_generic_alert`, `enable_insight_report`, `enable_competitive_analysis`)\n- `theme_options` (string) — JSON string\n- `white_label` (object) — `logo`, `white_url`, `favicon`, `title`, `status`, `login_logo`, `company_id`, `email_templates_enabled`\n- `plan` (object|null) — only when `company.bundle_id == 3`; same plan+features shape as `login`. Null otherwise.\n\n401 `\"Unauthenticated.\"` if the token is missing/invalid."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "Content-Type",
                      "value": "application/json"
                    }
                  ],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"user\": {\n      \"id\": 8,\n      \"email\": \"partner-admin@example.com\"\n    }\n  }\n}"
                }
              ]
            },
            {
              "name": "1.A.3 Logout",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/logout",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "logout"
                  ]
                },
                "description": "Revokes the caller's current Passport access token and records the logout: deletes the active token, stamps `logout_time` on the latest `RsAuthEvent` for the user, and writes an `AUTH_LOGOUT` audit log. The token cannot be reused afterward.\n\n**Auth:** Both — `auth:api` (Passport bearer). Acts only on the authenticated user (`Auth::id()`); no partner/account scoping.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Header: `Authorization: Bearer <token>` (required).\n- No path params, query params, or body. (Method is GET despite being a state-changing action.)\n\n**Response** — `{ data: ... }` envelope.\n- `success` (boolean) — always `true` on success\n\n401 `\"Unauthenticated.\"` if the token is missing/invalid."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"success\": true\n  }\n}"
                }
              ]
            },
            {
              "name": "1.D.1 SSO — Reputation Management login (OAuth 2.0)",
              "description": "## Reputation Management login (OAuth 2.0)\n\nStandard OAuth Authorization Code grant. Use this option when your app needs the access token server-side. The token never leaves your backend except for the one-shot handoff URL.\n\nAll callable requests below use the `{{baseUrl}}` Postman variable so the same collection works against dev, staging, and production environments. Set `{{baseUrl}}` to your target environment's `…/api/v1` URL in your Postman environment.\n\n### What you need before you start\n\n| Item | How to get it |\n| --- | --- |\n| `client_id` | Create OAuth credentials on the App Credentials page. |\n| `client_secret` | Returned once when the client is created. Store securely. |\n| `redirect_uri` | A URL on your app, e.g. `https://yourapp.com/callback`. Register it when creating credentials — it must match exactly (including http/https and trailing slash). |\n\n### Flow\n\n1. **Send the user to Reputation Management** — top-level browser redirect to `{{baseUrl}}/../oauth/authorize` with the params shown in the sub-request below. `state` is a random string you generate and persist in session (CSRF protection).\n2. **Receive the code** — Reputation Management bounces the user back to your registered `redirect_uri` with `?code=…&state=…`. Verify `state` matches what you stored.\n3. **Exchange the code for a token** — call the \"Step 3\" sub-request.\n4. **Use the token** — either call RM APIs with `Authorization: Bearer <access_token>`, or hand off to the frontend at `<frontend_url>/rs-sso-login/?token=<access_token>`. The frontend picks up the token and starts the user's session.\n\n### Token revocation\n\nSee the \"Revoke token\" sub-request.\n\n### Token expiry & refresh\n\nThe token-response `expires_in` is returned in seconds (default ~1 year). Use the `refresh_token` against `POST /oauth/token` with `grant_type=refresh_token` to get a new access token before expiry.\n\n### A note on `{{baseUrl}}/../oauth/...`\n\nPassport's OAuth endpoints sit at the host root (not under `/api/v1`). The `..` in the path is a relative segment that Postman normalizes before sending — `<host>/api/v1/../oauth/token` becomes `<host>/oauth/token`. This keeps the collection environment-portable.\n\n### Pre-go-live checklist\n\n- You have `client_id` and `client_secret`.\n- Your `redirect_uri` is registered and exactly matches what you send.\n- You verify `state` on the OAuth callback (CSRF protection).\n- You store the access token server-side. The only legitimate URL use is the one-shot handoff to `rs-sso-login`.\n- You never log or expose `client_secret`.",
              "item": [
                {
                  "name": "Step 1 — Authorize (browser redirect, documentation)",
                  "request": {
                    "auth": {
                      "type": "noauth"
                    },
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{oauthBaseUrl}}/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=https://yourapp.com/callback&response_type=code&scope=*&state=RANDOM_STRING",
                      "host": [
                        "{{oauthBaseUrl}}"
                      ],
                      "path": [
                        "oauth",
                        "authorize"
                      ],
                      "query": [
                        {
                          "key": "client_id",
                          "value": "YOUR_CLIENT_ID"
                        },
                        {
                          "key": "redirect_uri",
                          "value": "https://yourapp.com/callback"
                        },
                        {
                          "key": "response_type",
                          "value": "code"
                        },
                        {
                          "key": "scope",
                          "value": "*"
                        },
                        {
                          "key": "state",
                          "value": "RANDOM_STRING"
                        }
                      ]
                    },
                    "description": "Browser-initiated top-level redirect — NOT a fetch/XHR call from your app. The user must hit this URL in their browser so Reputation Management can present the consent screen and bounce back to your registered `redirect_uri` with a `code`."
                  },
                  "response": []
                },
                {
                  "name": "Step 2 — Receive code on your callback (browser leg, no Postman action)",
                  "request": {
                    "auth": {
                      "type": "noauth"
                    },
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "https://yourapp.com/callback?code=THE_CODE_FROM_REPUTATION_MANAGEMENT&state=RANDOM_STRING",
                      "protocol": "https",
                      "host": [
                        "yourapp",
                        "com"
                      ],
                      "path": [
                        "callback"
                      ],
                      "query": [
                        {
                          "key": "code",
                          "value": "THE_CODE_FROM_REPUTATION_MANAGEMENT",
                          "description": "One-time authorization code Reputation Management appended to your registered redirect_uri."
                        },
                        {
                          "key": "state",
                          "value": "RANDOM_STRING",
                          "description": "Echoed back verbatim — your server MUST compare this against the value it stored in step 1 to prevent CSRF."
                        }
                      ]
                    },
                    "description": "## ⚠️ Browser leg — not a Postman-callable request\n\nThis step happens entirely **in the user's browser**. Reputation Management redirects the browser back to the `redirect_uri` you registered with us, appending two query params:\n\n- `code` — a short-lived authorization code (single-use, expires in ~10 minutes).\n- `state` — the exact random string your server sent in Step 1.\n\n### What your server must do when the callback hits\n\n1. **Verify `state` matches** the value you stored in the user's session before the Step-1 redirect. If it doesn't match, abort the flow — it's a CSRF attempt.\n2. **Capture `code`** and immediately call **Step 3** (Exchange code for token) from your backend. Do NOT send the code from the browser.\n3. `code` is single-use and short-lived — exchange it within seconds of receipt.\n\n### Why no Postman request here\n\nThere is no API call for this step. The browser receives the 302 response from Reputation Management's `/oauth/authorize` and follows it to *your* `redirect_uri`. Your application is the receiver — Reputation Management has no further role until you call Step 3.\n\n*This entry exists for documentation continuity between Step 1 and Step 3 — it cannot be executed.*"
                  },
                  "response": []
                },
                {
                  "name": "Step 3 — Exchange code for token",
                  "request": {
                    "auth": {
                      "type": "noauth"
                    },
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/x-www-form-urlencoded"
                      }
                    ],
                    "body": {
                      "mode": "urlencoded",
                      "urlencoded": [
                        {
                          "key": "grant_type",
                          "value": "authorization_code"
                        },
                        {
                          "key": "client_id",
                          "value": "YOUR_CLIENT_ID"
                        },
                        {
                          "key": "client_secret",
                          "value": "YOUR_CLIENT_SECRET"
                        },
                        {
                          "key": "redirect_uri",
                          "value": "https://yourapp.com/callback"
                        },
                        {
                          "key": "code",
                          "value": "THE_CODE_FROM_STEP_2"
                        }
                      ]
                    },
                    "url": {
                      "raw": "{{oauthBaseUrl}}/oauth/token",
                      "host": [
                        "{{oauthBaseUrl}}"
                      ],
                      "path": [
                        "oauth",
                        "token"
                      ]
                    },
                    "description": "Server-to-server call from your backend. `client_secret` MUST NOT leave your server. Returns an access token + refresh token. ✅ live (Passport `oauth/token`)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [
                        {
                          "key": "Content-Type",
                          "value": "application/json"
                        }
                      ],
                      "body": "{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 31536000,\n  \"access_token\": \"eyJ0eXAiOiJKV1QiLCJhbGciOi...\",\n  \"refresh_token\": \"def502...\"\n}"
                    },
                    {
                      "name": "400 Invalid grant",
                      "status": "Bad Request",
                      "code": 400,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"error\": \"invalid_grant\",\n  \"error_description\": \"The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.\"\n}"
                    }
                  ]
                },
                {
                  "name": "Revoke token",
                  "request": {
                    "auth": {
                      "type": "bearer",
                      "bearer": [
                        {
                          "key": "token",
                          "value": "{{accessToken}}",
                          "type": "string"
                        }
                      ]
                    },
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/sso/revoking-token",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "sso",
                        "revoking-token"
                      ]
                    },
                    "description": "Revokes the Passport access token presented in the request's `Authorization: Bearer` header (SSO sign-out). Calls `$request->user()->token()->revoke()` on the current token.\n\n**Auth:** Both — `auth:api` (Passport). The endpoint sits in the admin-only `sso` route group, but the method itself has no extra persona gate: it scopes purely to the caller's *own* presented token via `$request->user()->token()`, so any authenticated principal (Partner or Account) holding a valid token revokes only that token. No partner-tree walk, no `BelongsToAuthCompany`, no invite hash.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- No path params.\n- No query params.\n- No request body (GET).\n\n**Response** — `{ data: ... }` envelope (via `ApiResponses::successResponse`).\n- `status` (string) — always `\"success\"` on success.\n- `message` (string) — `\"Token revoked successfully!\"`.\n\nOn failure the catch path returns `defaultErrorResponse()` (generic error envelope) and writes an emergency debug log."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"status\": \"success\", \"message\": \"Token revoked successfully!\" } }"
                    }
                  ]
                }
              ]
            },
            {
              "name": "1.D.2 SSO — JumpCloud handoff",
              "description": "## JumpCloud SSO (full handoff)\n\nHandoff flow: you redirect the user, Reputation Management handles the JumpCloud round-trip, and the user lands signed-in on the frontend. **Your app does NOT receive the token or the OIDC code.** Use this option when your users sign in via JumpCloud and your app's only role is launching the login.\n\nAll callable requests below use the `{{baseUrl}}` Postman variable so the same collection works against dev, staging, and production environments. Set `{{baseUrl}}` to your target environment's `…/api/v1` URL in your Postman environment.\n\nJumpCloud SSO does **not** require a `client_id` or `client_secret` from your side — the JumpCloud OIDC client is configured on the backend.\n\n### Flow\n\n1. You send the user to the JumpCloud entrypoint (sub-request \"Begin SSO redirect\").\n2. Reputation Management issues a Socialite redirect to JumpCloud; the user signs in there.\n3. JumpCloud returns to Reputation Management with an OIDC code.\n4. Reputation Management validates the user, mints a single-use exchange code, and redirects the browser to the globally-configured landing URL (`JUMPCLOUD_FRONTEND_CALLBACK_URL` on the backend — not per-client).\n5. The frontend redeems the code against `{{baseUrl}}/auth/sso/exchange` and starts the user's session.\n\n**Your app's role ends at step 1.** There is no callback, token exchange, or credential handling on your side.\n\n### Pre-conditions for a test user\n\n- Account exists with the same email as their JumpCloud account.\n- User belongs to a partner or account (`bundle_id >= 1` — super-admins are hard-blocked from SSO).\n- `users.auth_methods` includes `jumpcloud`.\n- Account is not locked or inactive.\n\n### Error codes\n\nIf SSO fails, the user lands on the FE callback URL with `?error=<code>`. The frontend renders the message — your app does not need to handle these.\n\n| Code | Meaning |\n| --- | --- |\n| `sso_no_email` | JumpCloud didn't return an email. |\n| `no_user` | No Reputation Management account matches the user's JumpCloud email. |\n| `sso_not_enabled` | `auth_methods` doesn't include `jumpcloud` for this user. |\n| `no_company` | The user has no company set up. |\n| `role_not_allowed` | User is a super-admin (`bundle_id = 0`); SSO blocked for that tier. |\n| `account_locked` | Too many failed logins — progressive lockout still applies to SSO. |\n| `account_inactive` | Account is marked inactive. |\n| `sso_failed` | Generic JumpCloud / Socialite failure. Check the JumpCloud OIDC app config. |\n\n### FAQ\n\n**Can I receive the JumpCloud token in my own callback?**\n\nNo. JumpCloud SSO lands on a single globally-configured frontend URL. Use Option 1 (Reputation Management login) if you need full client control.\n\n**Can I switch to Reputation Management login if JumpCloud isn't enabled for a user?**\n\nYes — the flows are independent. Show both buttons, or call the \"Discover auth methods\" sub-request with the user's email to see what is supported for that specific user.\n\n**Do JumpCloud-issued sessions expire?**\n\nYes. The frontend receives a Passport access token from the exchange step — the same token shape as Option 1, with the same `expires_in` (default ~1 year).",
              "item": [
                {
                  "name": "Begin SSO (browser redirect, documentation)",
                  "request": {
                    "auth": {
                      "type": "noauth"
                    },
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/auth/sso/jumpcloud/redirect",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "auth",
                        "sso",
                        "jumpcloud",
                        "redirect"
                      ]
                    },
                    "description": "Kicks off the external JumpCloud OIDC sign-in by issuing a stateless Socialite 302 redirect to JumpCloud's authorize endpoint with scopes `openid`, `profile`, `email`. This is a top-level browser navigation (not an XHR/fetch call). After the user authenticates, JumpCloud bounces back to `GET /auth/sso/jumpcloud/callback`, which mints a one-time `SsoExchangeCode` and redirects the SPA to the frontend callback URL with `?code=<raw>` (or `?error=<reason>`); the SPA then redeems that code at `GET /auth/sso/exchange` for a Passport access token.\n\n**Auth:** Both — public/unauthenticated (no `auth:api`); runs under the `web` middleware group so Socialite can store and round-trip the OIDC `state` CSRF parameter in the session (JumpCloud requires a non-empty state ≥ 8 chars). No persona scoping happens at this step — it only redirects to the IdP. Persona eligibility is enforced later in the callback: super-admins (`company.bundle_id` 0/null) are hard-blocked, and only customer-tier users (`bundle_id` ≥ 1 — agency/Partner, brand/Account, store) with `jumpcloud` in their `auth_methods` (`User::canUseJumpCloud()`) and an associated company are allowed through.\n\n**Rate limit:** 20 requests/min (`throttle:20,1`).\n\n**Request**\n- No path params, query params, or body.\n\n**Response** — not a `{ data: ... }` envelope. Returns an HTTP **302 Found** redirect to the JumpCloud authorize URL (`Laravel\\Socialite` `RedirectResponse`).\n- `Location` (header) — JumpCloud OIDC authorize endpoint URL including `client_id`, `redirect_uri`, `response_type=code`, `scope=openid profile email`, and a generated `state`.\n\n**Success example**\n```\nHTTP/1.1 302 Found\nLocation: https://oauth.id.jumpcloud.com/oauth2/auth?client_id=<client_id>&redirect_uri=<callback>&scope=openid+profile+email&response_type=code&state=<random>\n```\n\n_Note: this endpoint is for documentation only — it is meant to be opened directly in a browser, not invoked from the SPA's HTTP layer, since the response is a redirect rather than JSON._"
                  },
                  "response": [
                    {
                      "name": "302 Redirect to JumpCloud",
                      "status": "Found",
                      "code": 302,
                      "_postman_previewlanguage": "text",
                      "header": [
                        {
                          "key": "Location",
                          "value": "https://oauth.id.jumpcloud.com/oauth2/auth?response_type=code&scope=openid+profile+email&client_id=...&state=...&redirect_uri=%7B%7BbaseUrl%7D%7D%2Fauth%2Fsso%2Fjumpcloud%2Fcallback"
                        }
                      ],
                      "body": ""
                    }
                  ]
                },
                {
                  "name": "Discover auth methods for an email",
                  "request": {
                    "auth": {
                      "type": "noauth"
                    },
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"email\": \"user@example.com\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/auth/discover",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "auth",
                        "discover"
                      ]
                    },
                    "description": "Email-first auth-method discovery for the login form: given an email, returns which sign-in methods the user can use (`password` and/or `jumpcloud`), their preferred method, and a JumpCloud SSO redirect URL when SSO is available. Unknown emails always return `[\"password\"]` so the endpoint cannot be used to enumerate accounts.\n\n**Auth:** Both — public route (no `auth:api`); throttled `throttle:10,1` (20/min on local). No persona scoping — purely email lookup; SSO branching is driven by the user's `sso_required` flag, `auth_methods`, and `preferred_auth_method`.\n\n**Rate limit:** 20 requests/min (`throttle:20,1`).\n\n**Request**\n- Body (validated inline via `$request->validate`, not a Form Request):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| email | string | yes | `email` rule |\n\n**Response** — `{ data: ... }` envelope.\n- `methods` (string[]) — subset/ordering of `[\"password\", \"jumpcloud\"]`. If `sso_required`, exactly `[\"jumpcloud\"]`; otherwise built from `canUsePassword()`/`canUseJumpCloud()`, falling back to `[\"password\"]` if empty. Unknown email → `[\"password\"]`.\n- `preferred` (string) — `user.preferred_auth_method` or `\"password\"` fallback (`\"password\"` for unknown email)\n- `sso_url` (string|null) — `.../api/v1/auth/sso/jumpcloud/redirect` when `jumpcloud` is in `methods`, else `null`"
                  },
                  "response": [
                    {
                      "name": "200 Both methods available",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"methods\": [\n        \"password\",\n        \"jumpcloud\"\n      ],\n      \"preferred\": \"password\",\n      \"sso_url\": \"{{baseUrl}}/auth/sso/jumpcloud/redirect\"\n    }\n  }\n}"
                    },
                    {
                      "name": "200 SSO-only user",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"methods\": [\n        \"jumpcloud\"\n      ],\n      \"preferred\": \"jumpcloud\",\n      \"sso_url\": \"{{baseUrl}}/auth/sso/jumpcloud/redirect\"\n    }\n  }\n}"
                    },
                    {
                      "name": "200 Unknown email (no enumeration)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"methods\": [\n        \"password\"\n      ],\n      \"preferred\": \"password\",\n      \"sso_url\": null\n    }\n  }\n}"
                    }
                  ]
                }
              ]
            },
            {
              "name": "1.B.1 Verify email OTP",
              "request": {
                "auth": {
                  "type": "noauth"
                },
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"type\": \"forgot_password\",\n    \"email\": \"jane@example.com\",\n    \"otp\": \"123456\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/verify/email/otp",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "verify",
                    "email",
                    "otp"
                  ]
                },
                "description": "Verifies the email OTP previously sent for a forgot-password / change-password / signup flow; on success for `forgot_password` it returns a short-lived Passport access token used to complete the password reset.\n\n**Auth:** Both — public, no auth middleware. Sits in the `throttle:10,1` (20/min local) group with no `auth:api`. Target is scoped by `email` + `type`: it looks up the latest `User` (for `change_password`/`forgot_password`) or latest `SignupActivation` (for `rs_signup`) by email, then matches the newest unexpired, unverified `Otp` row of that category. No company/persona scoping — any caller who knows the email and a valid OTP can verify; persona only differs in that the returned `token` is only included for the `forgot_password` type.\n\n**Rate limit:** 20 requests/min (`throttle:20,1`).\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `type` | string | yes | One of `change_password`, `forgot_password`, `rs_signup`. |\n| `email` | string | yes | `email:rfc,dns`. Must belong to a registered User (change/forgot) or SignupActivation (rs_signup), else 400 \"Email address is not registered\". |\n| `otp` | string | yes | The code emailed to the user. Compared with `hash_equals`; 3 failed attempts invalidates the OTP. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — always `success` on a verified OTP.\n- `response` (string) — `The one time password has been verified successfully`.\n- `token` (string) — Passport personal access token; present ONLY when `type=forgot_password` (used to authorize the subsequent `users/reset/password` call). Absent for `change_password` and `rs_signup`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"response\": \"The one time password has been verified successfully\", \"token\": \"eyJ0eXAiOiJKV1QiLCJ...\" } }"
                }
              ]
            },
            {
              "name": "1.B.2 Resend email OTP",
              "request": {
                "auth": {
                  "type": "noauth"
                },
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"type\": \"forgot_password\",\n    \"email\": \"jane@example.com\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/resend/email/otp",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "resend",
                    "email",
                    "otp"
                  ]
                },
                "description": "Resends an email OTP for a change-password / forgot-password / signup flow. Always returns the same uniform \"if registered, an OTP has been sent\" message to avoid email enumeration.\n\n**Auth:** Both — public, no auth middleware. Sits in a separate slow-burn `throttle:3,3` group (3 per 3 min; 1000 local). No `auth:api` and no company scoping. The target is found by `email`: for `change_password`/`forgot_password` the latest `User` (its `person.company_id` is used for branding); for `rs_signup` the latest `SignupActivation` (company resolved via its referral code). An additional in-handler guard returns the uniform message without sending if 10+ OTPs were created for that email in the last hour (enumeration suspected).\n\n**Rate limit:** 1000 requests/3 min (`throttle:1000,3`).\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `type` | string | yes | One of `change_password`, `forgot_password`, `rs_signup`. |\n| `email` | string | yes | `email:rfc,dns`. Unknown / unregistered emails still return the uniform success message (no enumeration). |\n\n**Response** — `{ data: ... }` envelope. Uniform regardless of whether the email exists or an OTP was actually sent.\n- `status` (string) — always `success`.\n- `response` (string) — `If the email address is registered, a one time password has been sent.`"
              },
              "response": [
                {
                  "name": "201 Created",
                  "status": "Created",
                  "code": 201,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"response\": \"If the email address is registered, a one time password has been sent.\" } }"
                }
              ]
            },
            {
              "name": "1.C.1 Reset password",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"password\": \"NewP@ss123\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/users/reset/password",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "users",
                    "reset",
                    "password"
                  ]
                },
                "description": "Resets the authenticated user's password to the supplied value, revokes all of their existing tokens, and returns a fresh access token.\n\n**Auth:** Both — `auth:api`, inside the `users/` prefix of the authenticated group. Target is implicitly the caller: it operates on `$request->user()` only (no id param, no company scoping), so any authenticated persona resets their own password. Typically called with the short-lived token returned by `verify/email/otp` (`type=forgot_password`).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `password` | string | yes | `Password::min(8)->max(100)->letters()->mixedCase()->numbers()` plus a regex requiring at least 1 special character and forbidding `\"`, `'`, `\\`, whitespace, and control characters. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `response` (string) — `Password has been updated successfully`.\n- `token` (string) — a newly minted Passport access token (all prior tokens are revoked via `AuditLogger::revokeTokens`)."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"response\": \"Password has been updated successfully\", \"token\": \"eyJ0eXAiOiJKV1QiLCJ...\" } }"
                }
              ]
            },
            {
              "name": "1.C.2 Change password",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"current_password\": \"OldP@ss123\",\n    \"password\": \"NewP@ss456\",\n    \"password_confirmation\": \"NewP@ss456\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/users/change/password",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "users",
                    "change",
                    "password"
                  ]
                },
                "description": "Initiates an authenticated password change: verifies the current password, then emails a `change_password` OTP rather than changing the password directly. (Completion happens via `verify/email/otp` + `users/reset/password`.)\n\n**Auth:** Both — `auth:api`, inside the `users/` prefix of the authenticated group. Target is the caller only: it checks and acts on `$request->user()` (no id param, no company scoping). The OTP email is branded using the caller's `person.company_id`. Any authenticated persona may change their own password.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `current_password` | string | yes | Must match the caller's current password (`Hash::check`), else 400 with a `current_password` error. |\n| `password` | string | yes | New password. Rules: `confirmed` (requires `password_confirmation`), `different:current_password`, `Password::min(8)->max(100)->letters()->mixedCase()->numbers()`, plus regex requiring 1 special char and forbidding `\"`, `'`, `\\`, whitespace, control chars. Note: not persisted here — only validated; the OTP is sent. |\n| `password_confirmation` | string | yes | Required by the `confirmed` rule on `password`; must equal `password`. |\n\n**Response** — `{ data: ... }` envelope. No token returned (password is not yet changed).\n- `status` (string) — `success`.\n- `response` (string) — `Otp has been sent successfully!`"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"response\": \"Otp has been sent successfully!\" } }"
                }
              ]
            }
          ]
        },
        {
          "name": "§ 1.C OAuth 2.0 — Password Grant (access + refresh tokens)",
          "description": "# OAuth 2.0 — Password Grant\n\nThe **OAuth 2.0 Resource Owner Password Credentials** grant, served by Laravel Passport at **`POST /oauth/token`**. This endpoint lives at the **app root**, not under `/api/v1` — that is why the requests below hit `{{baseUrl}}/../oauth/token` (`{{baseUrl}}` ends in `/api/v1`, so `/..` steps back to the host root).\n\nUse this when a trusted first-party client needs to exchange a username + password for an **access token AND a refresh token**. (The `/login` endpoint in §1 issues a *personal access* token with **no** refresh token — the password grant is the refreshable alternative.)\n\n## One-time setup\n\n- `{{oauthClientId}}` is pre-filled with the dev password-grant client id (a public identifier, safe to share).\n- `{{oauthClientSecret}}` is intentionally **blank** — paste the client secret into the collection variable before running. The secret is shown only once, when the client is created with `php artisan passport:client --password`, and must never be committed to source control or exposed to a browser.\n\n## 1 — Get a token  (`1.C.1`)\n\n`POST {{baseUrl}}/../oauth/token` with form fields `grant_type=password`, `client_id`, `client_secret`, `username` (the user's email), `password`, and `scope` (`*` = full access). On success it returns `token_type`, `expires_in`, `access_token`, and `refresh_token`. The request's **test script auto-saves** `access_token` → `{{bearerToken}}` and `refresh_token` → `{{refreshToken}}`, so every other request in the collection is immediately authenticated.\n\n## 2 — Verify a token  (`1.C.2`)\n\n`GET {{baseUrl}}/verify-token` with `Authorization: Bearer {{bearerToken}}` (inherited from the collection). Returns the authenticated user + company when the token is still valid, or **401 Unauthenticated** when it is expired or revoked. Call it to check whether you still hold a usable session before firing a real request — if it 401s, refresh (step 3) or log in again.\n\n## 3 — Refresh a token  (`1.C.3`)\n\nAccess tokens expire **1 day** after issue; refresh tokens last **30 days**. When the access token expires, `POST {{baseUrl}}/../oauth/token` with `grant_type=refresh_token`, the stored `refresh_token`, `client_id`, `client_secret`, and `scope`. You receive a **brand-new access token AND a brand-new refresh token** — the old refresh token is consumed, so always persist the new one (the test script does this). Once the refresh token itself expires (30 days), the user must log in again via `1.C.1`.\n\n## Lifecycle at a glance\n\n| Token | Lifetime | How to renew |\n| --- | --- | --- |\n| `access_token` | 1 day | refresh-token grant (`1.C.3`) |\n| `refresh_token` | 30 days | re-run password grant (`1.C.1`) |\n\n**Security:** the password grant (ROPC) is for **trusted first-party clients only**. Never ship `client_secret` to a browser/SPA or to third parties; for those, use the SSO / authorization-code flow in §1.",
          "item": [
            {
              "name": "1.E.1 Get token (password grant)",
              "request": {
                "auth": {
                  "type": "noauth"
                },
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/x-www-form-urlencoded"
                  }
                ],
                "body": {
                  "mode": "urlencoded",
                  "urlencoded": [
                    {
                      "key": "grant_type",
                      "value": "password"
                    },
                    {
                      "key": "client_id",
                      "value": "{{oauthClientId}}"
                    },
                    {
                      "key": "client_secret",
                      "value": "{{oauthClientSecret}}"
                    },
                    {
                      "key": "username",
                      "value": "{{email}}"
                    },
                    {
                      "key": "password",
                      "value": "{{password}}"
                    },
                    {
                      "key": "scope",
                      "value": "*"
                    }
                  ]
                },
                "url": {
                  "raw": "{{oauthBaseUrl}}/oauth/token",
                  "host": [
                    "{{oauthBaseUrl}}"
                  ],
                  "path": [
                    "oauth",
                    "token"
                  ]
                },
                "description": "Exchange a user's email + password for an access token + refresh token. `username` is the user's email. `scope=*` requests full access. Set the client secret in `{{oauthClientSecret}}` first. On 200 the test script stores `{{bearerToken}}` + `{{refreshToken}}`. ✅ live"
              },
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const json = pm.response.json();",
                      "  if (json.access_token) { pm.environment.set('bearerToken', json.access_token); }",
                      "  if (json.refresh_token) { pm.environment.set('refreshToken', json.refresh_token); }",
                      "}"
                    ]
                  }
                }
              ],
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "Content-Type",
                      "value": "application/json"
                    }
                  ],
                  "body": "{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 86400,\n  \"access_token\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...\",\n  \"refresh_token\": \"def50200c8a1f...\"\n}"
                },
                {
                  "name": "400 Invalid credentials",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"error\": \"invalid_grant\",\n  \"error_description\": \"The user credentials were incorrect.\"\n}"
                }
              ]
            },
            {
              "name": "1.E.2 Verify token",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/verify-token",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "verify-token"
                  ]
                },
                "description": "Identical route to idx=5 — `GET {{baseUrl}}/verify-token` backed by `ApiController::verifyToken` (this collection lists it twice, e.g. under a partner/account folder). Validates the Passport bearer token and re-hydrates the SPA session: returns the authenticated user, company, theme, white-label and (brand-only) plan context, echoing the supplied bearer token.\n\n**Auth:** Both — `auth:api` (Passport bearer). Target is always the authenticated user (`Auth::id()`); no partner/account-tree scoping. `company.bundle_id` reflects the token's persona.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Header: `Authorization: Bearer <token>` (required).\n- No path params, query params, or body.\n\n**Response** — `{ data: ... }` envelope.\n- `token` (string) — echoes `request->bearerToken()`\n- `user` (object) — `id`, `username`, `email`, `inactive_at` (nullable), `email_verified_at`, `phone_number`, `first_name`, `last_name`, `title`, `role` (or `\"User\"`), `total_location`\n- `company` (object) — `id`, `name`, `bundle_id`, plus `enable_insight_report` (0/1), `enable_competitive_analysis` (0/1), `dormant_accounts_enabled` (0/1), and `company_option` (`company_id`, `who_will_pay`, `enable_generic_alert`, `enable_insight_report`, `enable_competitive_analysis`)\n- `theme_options` (string) — JSON string\n- `white_label` (object) — `logo`, `white_url`, `favicon`, `title`, `status`, `login_logo`, `company_id`, `email_templates_enabled`\n- `plan` (object|null) — only when `company.bundle_id == 3`; plan (`id`, `plan_name`, `status`, `plan_type`, `enforcement_mode`, `is_admin_managed`) with `features`. Null otherwise.\n\n401 `\"Unauthenticated.\"` if the token is missing/invalid."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "Content-Type",
                      "value": "application/json"
                    }
                  ],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"user\": {\n      \"id\": 8,\n      \"email\": \"partner-admin@example.com\"\n    }\n  }\n}"
                },
                {
                  "name": "401 Unauthenticated",
                  "status": "Unauthorized",
                  "code": 401,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Unauthenticated.\"\n}"
                }
              ]
            },
            {
              "name": "1.E.3 Refresh token",
              "request": {
                "auth": {
                  "type": "noauth"
                },
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/x-www-form-urlencoded"
                  }
                ],
                "body": {
                  "mode": "urlencoded",
                  "urlencoded": [
                    {
                      "key": "grant_type",
                      "value": "refresh_token"
                    },
                    {
                      "key": "refresh_token",
                      "value": "{{refreshToken}}"
                    },
                    {
                      "key": "client_id",
                      "value": "{{oauthClientId}}"
                    },
                    {
                      "key": "client_secret",
                      "value": "{{oauthClientSecret}}"
                    },
                    {
                      "key": "scope",
                      "value": "*"
                    }
                  ]
                },
                "url": {
                  "raw": "{{oauthBaseUrl}}/oauth/token",
                  "host": [
                    "{{oauthBaseUrl}}"
                  ],
                  "path": [
                    "oauth",
                    "token"
                  ]
                },
                "description": "Exchange the stored `{{refreshToken}}` for a fresh access token + refresh token once the access token has expired (1-day lifetime). The old refresh token is consumed; the test script saves the new pair. ✅ live"
              },
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const json = pm.response.json();",
                      "  if (json.access_token) { pm.environment.set('bearerToken', json.access_token); }",
                      "  if (json.refresh_token) { pm.environment.set('refreshToken', json.refresh_token); }",
                      "}"
                    ]
                  }
                }
              ],
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "Content-Type",
                      "value": "application/json"
                    }
                  ],
                  "body": "{\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 86400,\n  \"access_token\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...(new)\",\n  \"refresh_token\": \"def50200f9b2e...(new)\"\n}"
                },
                {
                  "name": "401 Invalid refresh token",
                  "status": "Unauthorized",
                  "code": 401,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"error\": \"invalid_request\",\n  \"error_description\": \"The refresh token is invalid.\"\n}"
                }
              ]
            }
          ]
        },
        {
          "name": "§ P.1 Auth — brand-owner impersonation",
          "item": [
            {
              "name": "1.A.4 Mint brand-owner token (partner-only, rotate-on-mint)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"account_id\": 4521\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/account-owner/token",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account-owner",
                    "token"
                  ]
                },
                "description": "Mints a Passport personal access token issued AS the owner user of a given brand (brand-owner impersonation), for server-to-server calls to brand-scoped endpoints on behalf of a tenant brand. Rotates: every other non-revoked token of the same name for that owner is revoked on each mint (at most one active brand-owner token per owner).\n\n**Auth:** Partner — `auth:api`, inside the `partner/` prefix; partner-agnostic (any partner, not just hipages — no `EnsureHipagesPartner` on this route). The caller is gated in-controller: the auth user's `person.company` must exist and have `bundle_id === 1` (partner), else 403 `Only partner users can mint brand-owner tokens.` The target `account_id` must resolve to a `Company` with `bundle_id === 3` (a brand) — else 404 `Company is not a brand.` — and must descend from the caller's partner tree via `Company::isBelongsToParentCompany(authCompany->id, brand->id)`, else 403 `Brand is not under your partner tree.` The brand must have an owner user (`getOwnerUser()`), else 404.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `account_id` | integer | yes | The brand company id (must `exist` in `company.id` with `deleted_at` null). Despite the field name, it must be a brand-level company (`bundle_id=3`) under the caller's partner tree. Required for the only persona that can call this (partner). |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `token` (string) — the issued Passport access token (`$tokenResult->accessToken`), usable as a Bearer token for the brand owner.\n- `account_owner` (object):\n  - `user_id` (integer) — the brand owner's user id (`owner->id`).\n  - `account_id` (integer) — the brand company id (`brand->id`).\n  - `expires_at` (string|null) — ISO-8601 token expiry, or null when no Passport TTL is configured.\n  - `prior_tokens_revoked` (integer) — count of older same-name tokens revoked by this mint (rotation)."
              },
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const json = pm.response.json();",
                      "  if (json.data.token) { pm.environment.set('brandOwnerToken', json.data.token); }",
                      "}"
                    ]
                  }
                }
              ],
              "response": [
                {
                  "name": "200 Success — fresh mint (no prior token)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "Content-Type",
                      "value": "application/json"
                    }
                  ],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"token\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...\",\n    \"brand_owner\": {\n      \"user_id\": 5,\n      \"account_id\": 42,\n      \"expires_at\": \"2026-11-15T00:00:00+10:00\",\n      \"prior_tokens_revoked\": 0\n    }\n  }\n}"
                },
                {
                  "name": "200 Success — rotation (prior token revoked)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "Content-Type",
                      "value": "application/json"
                    }
                  ],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"token\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...\",\n    \"brand_owner\": {\n      \"user_id\": 5,\n      \"account_id\": 42,\n      \"expires_at\": \"2026-11-15T00:00:00+10:00\",\n      \"prior_tokens_revoked\": 1\n    }\n  }\n}"
                },
                {
                  "name": "403 Caller is not a partner",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Only partner users can mint brand-owner tokens.\",\n  \"errors\": []\n}"
                },
                {
                  "name": "403 Brand not in caller's tree",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Brand is not under your partner tree.\",\n  \"errors\": []\n}"
                },
                {
                  "name": "404 Brand not found / not a brand / no owner",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Brand has no owner user.\",\n  \"errors\": []\n}"
                },
                {
                  "name": "422 Validation",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"The brand id field is required.\",\n  \"errors\": {\n    \"account_id\": [\"The brand id field is required.\"]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Partner-only token mint. Returns a Passport personal access token issued AS the brand owner so server-to-server callers can drive brand-scoped endpoints on behalf of a tenant brand. Pattern B: every mint also revokes any previously-active token under the same name (one active token per partner-brand pair)."
        },
        {
          "name": "§ 0.S Signup (public onboarding)",
          "item": [
            {
              "name": "0.S.1 Initiate signup (create/resume activation + email OTP)",
              "request": {
                "method": "POST",
                "auth": {
                  "type": "noauth"
                },
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"email\": \"jane@example.com\",\n  \"term_and_condition\": 1,\n  \"plan_id\": 3,\n  \"referral_code\": \"REF123\",\n  \"query_string\": \"utm_source=google\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/signup",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "signup"
                  ]
                },
                "description": "Step 1 of self-service signup. Creates a new `SignupActivation` for the email (or resumes an in-progress one), optionally tagging it with a plan and referral code, and emails a one-time verification code (OTP, category `rs_signup`, 5-minute expiry). Rejects an email that already has a registered `User`. **Side effect:** sends an OTP email.\n\n## Auth\n- Public — no bearer token (`auth: noauth`). Route lives in the public login/signup throttle group.\n\n## Rate limit\n- 10 requests/min per IP (`throttle:10,1` in production; `20,1` in `local`). Public route.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `email` | string | Required | Valid email (`rfc,dns`); must be unique in `users.email` — already-registered emails are rejected 403. |\n| `term_and_condition` | integer | Required | Must be `1` (terms accepted). |\n| `plan_id` | integer | Optional | Existing `plans.id`; stored on the activation. |\n| `company_industry_id` | integer | Optional | Existing `company_industries.id`. |\n| `referral_code` | string | Optional | Referral / partner code; ties the signup to a partner or brand company. |\n| `query_string` | string | Optional | Raw marketing query string (e.g. UTM params) kept for attribution. |\n\n## Behaviour\n- Synchronous. `200` returns the activation snapshot (`data.account`, plus `data.business` / `data.feature_options` / `data.meta_data` once later steps run, and `data.user` once a `User` exists).\n- `403` when the email already has a registered account.\n- `400` when the activation is already `Active` (`You have already registered.`).\n- `422` on validation failure.\n\n✅ live ([routes/api_v1.php:153](routes/api_v1.php#L153) → `SignUpController@index`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"message\": null,\n    \"account\": {\n      \"id\": 101,\n      \"email\": \"jane@example.com\",\n      \"email_verified_at\": null,\n      \"first_name\": null,\n      \"last_name\": null,\n      \"mobile\": null,\n      \"plan_id\": 3,\n      \"plan_group_name\": null,\n      \"referral_code\": \"REF123\",\n      \"referral_company_id\": 5,\n      \"referral_company_name\": \"Acme Partner\",\n      \"step\": \"account_activation\",\n      \"who_will_pay\": \"brand\",\n      \"hear_about_us\": null,\n      \"hear_others\": null,\n      \"hide_password\": null,\n      \"show_skip_google_connect_button\": 0\n    },\n    \"business\": null,\n    \"feature_options\": null,\n    \"meta_data\": null\n  }\n}"
                },
                {
                  "name": "403 Already registered",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"This email has already been registered with our system. Please use a new email address or contact support@responsescribe.com.\"\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The email field is required.\",\n  \"errors\": {\n    \"email\": [\n      \"The email field is required.\"\n    ],\n    \"term_and_condition\": [\n      \"The selected term and condition is invalid.\"\n    ]\n  }\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  if (j.data && j.data.account) { pm.environment.set('signupActivationId', String(j.data.account.id)); }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "0.S.2 Save account details (step 2)",
              "request": {
                "method": "POST",
                "auth": {
                  "type": "noauth"
                },
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"signup_account_id\": 101,\n  \"first_name\": \"Jane\",\n  \"last_name\": \"Doe\",\n  \"mobile\": \"+11234567890\",\n  \"password\": \"Secret@123\",\n  \"hear_about_us\": \"Referral\",\n  \"hear_others\": null\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/signup/account",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "signup",
                    "account"
                  ]
                },
                "description": "Step 2 of self-service signup. Persists the account holder's profile (name, mobile, optional password, and how they heard about us) against an in-progress activation. Requires the activation email to already be verified and a `plan_id` to be set.\n\n## Auth\n- Public — no bearer token (`auth: noauth`).\n\n## Rate limit\n- 10 requests/min per IP (`throttle:10,1` in production; `20,1` in `local`). Public route.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `signup_account_id` | integer | Required | The `signup_activations.id` returned by step 1. |\n| `first_name` | string | Required | 2–100 chars; no angle brackets. |\n| `last_name` | string | Required | ≤100 chars; no angle brackets. |\n| `mobile` | string | Required | Contact mobile number. |\n| `password` | string | Optional | 8–100 chars, mixed case + number + special char, no spaces/quotes. |\n| `hear_about_us` | string | Required | One of `Website or Blog`, `Social Media`, `Referral`, `Other`. |\n| `hear_others` | string | Optional | 10–255 chars; free text (typically when `hear_about_us = Other`). |\n\n## Behaviour\n- Synchronous. `200` returns the refreshed activation snapshot with name/mobile populated.\n- `403` when the email is not yet verified (`Email not verified`) or no plan is set (`Plan not found`).\n- `422` on validation failure.\n\n✅ live ([routes/api_v1.php:155](routes/api_v1.php#L155) → `SignUpController@account`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"message\": null,\n    \"account\": {\n      \"id\": 101,\n      \"email\": \"jane@example.com\",\n      \"email_verified_at\": \"2026-07-09T08:15:00.000000Z\",\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"mobile\": \"+11234567890\",\n      \"plan_id\": 3,\n      \"plan_group_name\": \"Starter\",\n      \"referral_code\": \"REF123\",\n      \"referral_company_id\": 5,\n      \"referral_company_name\": \"Acme Partner\",\n      \"step\": \"account_activation\",\n      \"who_will_pay\": \"brand\",\n      \"hear_about_us\": \"Referral\",\n      \"hear_others\": null,\n      \"hide_password\": null,\n      \"show_skip_google_connect_button\": 0\n    },\n    \"business\": null,\n    \"feature_options\": null,\n    \"meta_data\": null\n  }\n}"
                },
                {
                  "name": "403 Email not verified",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Email not verified\"\n}"
                }
              ]
            },
            {
              "name": "0.S.3 Save Google Business location (step 3 — Google path)",
              "request": {
                "method": "POST",
                "auth": {
                  "type": "noauth"
                },
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"signup_account_id\": 101,\n  \"google_access_token_id\": 5,\n  \"business_name\": \"Acme Plumbing\",\n  \"account_name\": \"accounts/123456789\",\n  \"address1\": \"123 Main St\",\n  \"address2\": \"Suite 4\",\n  \"country_code\": \"US\",\n  \"state\": \"California\",\n  \"city\": \"San Diego\",\n  \"postal_code\": \"92014\",\n  \"google_place_id\": \"ChIJN1t_tDeuEmsRUsoyG83frY4\",\n  \"location_name\": \"locations/987654321\",\n  \"mapsUri\": \"https://maps.google.com/?cid=123\",\n  \"newReviewUri\": \"https://search.google.com/local/writereview?placeid=ChIJN1t_tDeuEmsRUsoyG83frY4\",\n  \"website_uri\": \"https://acmeplumbing.com\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/signup/account/business",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "signup",
                    "account",
                    "business"
                  ]
                },
                "description": "Step 3 (Google path) of self-service signup. Attaches the selected Google Business Profile location to the activation, resolves the city/state/country, then provisions the company, location-account, store, plan and blank store-review-sites and marks the activation `Active`. **Side effects:** creates Company/Store/Plan rows, may dispatch review-pull jobs, and sends a referral-notify email.\n\n## Auth\n- Public — no bearer token (`auth: noauth`).\n\n## Rate limit\n- 10 requests/min per IP (`throttle:10,1` in production; `20,1` in `local`). Public route.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `signup_account_id` | integer | Required | The `signup_activations.id`. |\n| `google_access_token_id` | integer | Required | `google_access_tokens.id` of the connected Google account. |\n| `business_name` | string | Required | ≤100 chars; no angle brackets. |\n| `account_name` | string | Required | ≤100 chars; Google account resource name. |\n| `address1` | string | Optional | ≤255 chars. Defaults to `Address Missing` when empty. |\n| `address2` | string | Optional | ≤255 chars. |\n| `country_code` | string | Optional | ISO country code; when omitted the store falls back to a default city. |\n| `state` | string | Optional | Resolves the city row together with `country_code`/`city`. |\n| `city` | string | Optional | Resolves the city row together with `country_code`/`state`. |\n| `postal_code` | string | Optional | Defaults to `92014` when empty. |\n| `google_place_id` | string | Required | Google Place ID. |\n| `location_name` | string | Required | Google location resource name. |\n| `mapsUri` | string | Required | Google Maps URI. |\n| `newReviewUri` | string | Required | Google \"write a review\" URI. |\n| `website_uri` | string | Optional | Business website URL. |\n\n## Behaviour\n- Synchronous (runs company/store provisioning in a DB transaction). `200` returns the activation snapshot now including `data.business`.\n- `400` when the GMB location has no resolvable city/state/country.\n- `500` on provisioning failure (transaction rolled back).\n\n✅ live ([routes/api_v1.php:156](routes/api_v1.php#L156) → `SignUpController@accountBusiness`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"message\": \"The account has been successfully created. You can either log in to the platform or proceed to the feature options.\",\n    \"account\": {\n      \"id\": 101,\n      \"email\": \"jane@example.com\",\n      \"email_verified_at\": \"2026-07-09T08:15:00.000000Z\",\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"mobile\": \"+11234567890\",\n      \"plan_id\": 3,\n      \"plan_group_name\": \"Starter\",\n      \"referral_code\": \"REF123\",\n      \"referral_company_id\": 5,\n      \"referral_company_name\": \"Acme Partner\",\n      \"step\": \"feature_options\",\n      \"who_will_pay\": \"brand\",\n      \"hear_about_us\": \"Referral\",\n      \"hear_others\": null,\n      \"hide_password\": null,\n      \"show_skip_google_connect_button\": 0\n    },\n    \"user\": {\n      \"id\": 4021\n    },\n    \"business\": {\n      \"id\": 55,\n      \"signup_activation_id\": 101,\n      \"business_name\": \"Acme Plumbing\",\n      \"account_name\": \"accounts/123456789\",\n      \"address1\": \"123 Main St\",\n      \"address2\": \"Suite 4\",\n      \"city_id\": 133682,\n      \"postal_code\": \"92014\",\n      \"google_place_id\": \"ChIJN1t_tDeuEmsRUsoyG83frY4\",\n      \"location_name\": \"locations/987654321\",\n      \"store_id\": 8801,\n      \"google_access_token\": \"ya29.a0Af...\",\n      \"google_access_token_id\": 5\n    },\n    \"feature_options\": null,\n    \"meta_data\": null\n  }\n}"
                },
                {
                  "name": "400 Missing city/state/country",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": \"Missing city/state/country from the GMB location.\"\n}"
                }
              ]
            },
            {
              "name": "0.S.4 Save manual business address (step 3 — manual path)",
              "request": {
                "method": "POST",
                "auth": {
                  "type": "noauth"
                },
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"signup_account_id\": 101,\n  \"business_name\": \"Acme Plumbing\",\n  \"address1\": \"123 Main St\",\n  \"address2\": \"Suite 4\",\n  \"city_id\": 133682,\n  \"postal_code\": \"92014\",\n  \"website_uri\": \"https://acmeplumbing.com\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/signup/account/business/info",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "signup",
                    "account",
                    "business",
                    "info"
                  ]
                },
                "description": "Step 3 (manual path) of self-service signup, used when the user does not connect a Google Business Profile. Saves a manually entered business name and address (by `city_id`) and provisions the company/store/plan the same way as the Google path.\n\n## Auth\n- Public — no bearer token (`auth: noauth`).\n\n## Rate limit\n- 10 requests/min per IP (`throttle:10,1` in production; `20,1` in `local`). Public route.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `signup_account_id` | integer | Required | The `signup_activations.id`. |\n| `business_name` | string | Required | ≤100 chars; no angle brackets. |\n| `address1` | string | Required | ≤255 chars. |\n| `address2` | string | Optional | ≤255 chars. |\n| `city_id` | integer | Required | Existing `cities.id`. |\n| `postal_code` | string | Required | 3–12 chars. |\n| `website_uri` | string | Optional | Valid URL. |\n\n## Behaviour\n- Synchronous. `200` returns the activation snapshot including `data.business`.\n- `422` on validation failure.\n- `500` on provisioning failure (transaction rolled back).\n\n✅ live ([routes/api_v1.php:157](routes/api_v1.php#L157) → `SignUpController@accountBusinessInfo`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"message\": null,\n    \"account\": {\n      \"id\": 101,\n      \"email\": \"jane@example.com\",\n      \"email_verified_at\": \"2026-07-09T08:15:00.000000Z\",\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Doe\",\n      \"mobile\": \"+11234567890\",\n      \"plan_id\": 3,\n      \"plan_group_name\": \"Starter\",\n      \"referral_code\": null,\n      \"referral_company_id\": null,\n      \"referral_company_name\": \"\",\n      \"step\": \"feature_options\",\n      \"who_will_pay\": \"brand\",\n      \"hear_about_us\": \"Referral\",\n      \"hear_others\": null,\n      \"hide_password\": null,\n      \"show_skip_google_connect_button\": 0\n    },\n    \"user\": {\n      \"id\": 4021\n    },\n    \"business\": {\n      \"id\": 55,\n      \"signup_activation_id\": 101,\n      \"business_name\": \"Acme Plumbing\",\n      \"account_name\": \"Acme Plumbing\",\n      \"address1\": \"123 Main St\",\n      \"address2\": \"Suite 4\",\n      \"city_id\": 133682,\n      \"postal_code\": \"92014\",\n      \"store_id\": 8801,\n      \"google_access_token\": null,\n      \"google_access_token_id\": null\n    },\n    \"feature_options\": null,\n    \"meta_data\": null\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The city id field is required.\",\n  \"errors\": {\n    \"city_id\": [\n      \"The city id field is required.\"\n    ],\n    \"postal_code\": [\n      \"The postal code field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "0.S.5 Submit multi-location interest form (lead capture)",
              "request": {
                "method": "POST",
                "auth": {
                  "type": "noauth"
                },
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"first_name\": \"John\",\n  \"last_name\": \"Doe\",\n  \"phone\": \"5551234567\",\n  \"email\": \"john.doe@example.com\",\n  \"company\": \"Acme Corp\",\n  \"number_of_locations\": \"10\",\n  \"notes\": \"We have locations across 5 states.\",\n  \"hear_about_us\": \"Referral\",\n  \"hear_others\": null,\n  \"token_v3\": \"03AGdBq26...\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/signup/multi-location-signup",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "signup",
                    "multi-location-signup"
                  ]
                },
                "description": "Public lead-capture form for prospects with multiple locations. Validates a Google reCAPTCHA v3 token and records the enquiry for sales follow-up. **Does not create an account** (the multi-location interest email has been retired; the lead is still captured for the sales team).\n\n## Auth\n- Public — no bearer token (`auth: noauth`).\n\n## Rate limit\n- 10 requests/min per IP (`throttle:10,1` in production; `20,1` in `local`). Public route.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `first_name` | string | Required | 2–100 chars; no angle brackets. |\n| `last_name` | string | Required | ≤100 chars; no angle brackets. |\n| `phone` | string | Optional | 9–15 chars. |\n| `email` | string | Required | Valid email; unique across `users` and `multi_location_sign_ups`. |\n| `company` | string | Required | ≤255 chars; no angle brackets. |\n| `number_of_locations` | string | Optional | Free-text count of locations. |\n| `notes` | string | Optional | ≤4000 chars; no angle brackets. |\n| `hear_about_us` | string | Required | One of `Website or Blog`, `Social Media`, `Referral`, `Other`. |\n| `hear_others` | string | Optional | ≤255 chars. |\n| `token_v3` | string | Required | Google reCAPTCHA v3 token; verified server-side. |\n\n## Behaviour\n- Synchronous. `200` on success.\n- `422` on validation failure (including duplicate email or a failed reCAPTCHA check).\n\n✅ live ([routes/api_v1.php:154](routes/api_v1.php#L154) → `MultiLocationSignUpController@multiLocationSignup`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": \"Thanks for reaching out! Our team will get back to you within one business day.\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"This email has already been used.\",\n  \"errors\": {\n    \"email\": [\n      \"This email has already been used.\"\n    ],\n    \"token_v3\": [\n      \"The token v3 field is required.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Public, unauthenticated self-service onboarding wizard (no bearer token). Step 1 `POST /signup` creates or resumes a signup activation and emails an OTP; step 2 `POST /signup/account` saves the account holder's name/password/contact; step 3 `POST /signup/account/business` (Google Business Profile path) or `POST /signup/account/business/info` (manual address path) attaches the business location and provisions the company/store/plan and marks the activation Active. `POST /signup/multi-location-signup` is a separate reCAPTCHA-gated lead-capture form for multi-location prospects (records the enquiry only, no account). All routes throttle at 10/min per IP in production (20/min in local)."
        },
        {
          "name": "§ 1.E SSO — OAuth client management",
          "item": [
            {
              "name": "1.E.1 Create OAuth client (authorization code grant)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"My App\",\n  \"redirect_callback\": [\n    \"https://example.com/callback\"\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/sso/create/client",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sso",
                    "create",
                    "client"
                  ]
                },
                "description": "Creates a Passport **authorization-code grant** OAuth client (name + redirect URLs) owned by the authenticated user. This app acts as an OAuth Identity Provider. The plaintext `client_secret` is returned **once, on creation only** — it is hashed at rest (Passport v13) and can never be retrieved later. Emits audit `OAUTH_CLIENT_CREATED`.\n\n## Auth\n- Bearer token (`auth:api`). The route file groups SSO client management under admin/super-admin, but the controller enforces **owner-scoping**: the creating user owns the client and only its creator may view / update / delete it.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `name` | string | Required | Human-readable client name. |\n| `redirect_callback` | array&lt;string&gt; | Required | One or more redirect URLs; each must be a valid `http`/`https` URL. |\n\n## Behaviour\n- 200 with `client_id`, one-time `client_secret`, and the echoed `redirect_callback`. 422 on validation failure; 500 on unexpected error.\n\n> Test script saves `data.client_id` into `{{ssoClientId}}` for the view/update/delete requests.\n\n✅ live ([routes/api_v1.php:377](routes/api_v1.php#L377) → `SsoController@createClient`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"client_id\": \"019eeeca-2288-73a4-ac88-7c3f105bcc22\",\n    \"client_secret\": \"Rk8kNvFE4ecavLBEH8uqUaN9eS4MFKZrcgwEebe1\",\n    \"redirect_callback\": [\n      \"https://example.com/callback\"\n    ]\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The name field is required.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"name\": [\n      \"The name field is required.\"\n    ],\n    \"redirect_callback\": [\n      \"The redirect callback field is required.\"\n    ]\n  }\n}"
                },
                {
                  "name": "401 Unauthenticated",
                  "status": "Unauthorized",
                  "code": 401,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Unauthenticated.\"\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  if (j.data && j.data.client_id) {",
                      "    pm.environment.set('ssoClientId', String(j.data.client_id));",
                      "  }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "1.E.2 Create password-grant client",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Example Name\",\n  \"user_id\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/sso/create/password-client",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sso",
                    "create",
                    "password-client"
                  ]
                },
                "description": "Creates a Passport **password-grant** client used to mint tokens via the password flow. Emits audit `OAUTH_CLIENT_PASSWORD_GRANT_CREATED`.\n\n## Auth\n- Bearer token (`auth:api`). Grouped under admin/super-admin in the route file.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `name` | string | Required | Client name (passed to `createPasswordGrantClient`). |\n| `user_id` | integer | Required | Required by validation; not otherwise consumed by the controller. |\n\n## Behaviour\n- 200 with `client_id` + `client_secret` (plaintext, returned once). 422 on validation failure.\n\n✅ live ([routes/api_v1.php:381](routes/api_v1.php#L381) → `SsoController@createPasswordClient`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"client_id\": \"019eeeca-3391-71b2-9d4c-2f7c9a1e5abc\",\n    \"client_secret\": \"aQ9xPmR2LtVsKcE7HnUdWbZfGyJ3oT1lN8sYvXq0\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The name field is required.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"name\": [\n      \"The name field is required.\"\n    ],\n    \"user_id\": [\n      \"The user id field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "1.E.3 List OAuth clients",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sso/list/client",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sso",
                    "list",
                    "client"
                  ]
                },
                "description": "Lists the OAuth clients owned by the caller, ordered by name.\n\n## Auth\n- Bearer token (`auth:api`). Only the caller's own clients (`owner_id` = caller) are returned.\n\n## Rate limit\n- No rate limit.\n\n## Behaviour\n- 200 with a nested `data.data` array of clients (`id`, `name`, `grant_types`, `redirect_uris`, `revoked`, timestamps). Secrets are never listed here.\n\n✅ live ([routes/api_v1.php:375](routes/api_v1.php#L375) → `SsoController@listClient`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"id\": \"019eeeca-2288-73a4-ac88-7c3f105bcc22\",\n        \"name\": \"test 2\",\n        \"grant_types\": [\n          \"authorization_code\",\n          \"refresh_token\"\n        ],\n        \"redirect_uris\": [\n          \"https://fb.com\"\n        ],\n        \"revoked\": false,\n        \"created_at\": \"2026-06-22T10:04:45.000000Z\",\n        \"updated_at\": \"2026-06-22T10:05:43.000000Z\"\n      }\n    ]\n  }\n}"
                },
                {
                  "name": "401 Unauthenticated",
                  "status": "Unauthorized",
                  "code": 401,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Unauthenticated.\"\n}"
                }
              ]
            },
            {
              "name": "1.E.4 View OAuth client",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"client_id\": \"{{ssoClientId}}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/sso/view/client",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sso",
                    "view",
                    "client"
                  ]
                },
                "description": "Returns the details of a single OAuth client owned by the caller. The `secret` is partially masked (middle third replaced with `*`). Returns 403 if the caller is not the client's creator.\n\n## Auth\n- Bearer token (`auth:api`). Owner-scoped — only the creator may view details.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `client_id` | string | Required | Must exist in `oauth_clients.id`. |\n\n## Behaviour\n- 200 with the client (masked secret). 403 if not owner. 422 if `client_id` does not exist.\n\n✅ live ([routes/api_v1.php:380](routes/api_v1.php#L380) → `SsoController@viewClient`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"client\": {\n      \"id\": \"019eeecc-ebbe-705e-94be-687f68bd7454\",\n      \"owner_type\": \"App\\\\Models\\\\User\",\n      \"owner_id\": 3,\n      \"name\": \"test\",\n      \"provider\": null,\n      \"redirect_uris\": [\n        \"https://google.com\",\n        \"https://google2.com\"\n      ],\n      \"grant_types\": [\n        \"authorization_code\",\n        \"refresh_token\"\n      ],\n      \"revoked\": false,\n      \"created_at\": \"2026-06-22T10:07:47.000000Z\",\n      \"updated_at\": \"2026-06-22T10:07:47.000000Z\",\n      \"secret\": \"$2y$12$DU********************frY4\"\n    }\n  }\n}"
                },
                {
                  "name": "403 Forbidden (not owner)",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Permission denied! The creator has the ability to view details.\"\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected client id is invalid.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"client_id\": [\n      \"The selected client id is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "1.E.5 Update OAuth client",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"client_id\": \"{{ssoClientId}}\",\n  \"name\": \"My Renamed App\",\n  \"redirect_callback\": [\n    \"https://example.com/callback\",\n    \"https://example.com/callback2\"\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/sso/update/client",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sso",
                    "update",
                    "client"
                  ]
                },
                "description": "Updates the name and redirect URLs of an existing OAuth client. Returns 403 unless the caller is the client's creator. Emits audit `OAUTH_CLIENT_UPDATED` (SUCCESS or DENIED).\n\n## Auth\n- Bearer token (`auth:api`). Owner-scoped.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `client_id` | string | Required | Must exist in `oauth_clients.id`. |\n| `name` | string | Required | New client name. |\n| `redirect_callback` | array&lt;string&gt; | Required | Replacement redirect URLs; each a valid `http`/`https` URL. |\n\n## Behaviour\n- 200 with `data.status` (boolean update result). 403 if not owner. 422 if `client_id` invalid.\n\n✅ live ([routes/api_v1.php:378](routes/api_v1.php#L378) → `SsoController@updateClient`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": true\n  }\n}"
                },
                {
                  "name": "403 Forbidden (not owner)",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Permission denied! The creator has the ability to make updates.\"\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected client id is invalid.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"client_id\": [\n      \"The selected client id is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "1.E.6 Delete OAuth client",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"client_id\": \"{{ssoClientId}}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/sso/delete/client",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sso",
                    "delete",
                    "client"
                  ]
                },
                "description": "Revokes every token issued for the client and deletes it. Idempotent — an already-revoked/missing client returns `Already deleted!`. Returns 403 unless the caller is the creator. Emits audit `OAUTH_CLIENT_DELETED` (SUCCESS or DENIED).\n\n## Auth\n- Bearer token (`auth:api`). Owner-scoped.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `client_id` | string | Required | Must exist in `oauth_clients.id`. |\n\n## Behaviour\n- 200 `Successfully deleted!` on delete, or `Already deleted!` when already revoked. 403 if not owner. 422 if `client_id` invalid.\n\n✅ live ([routes/api_v1.php:379](routes/api_v1.php#L379) → `SsoController@deleteClient`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Successfully deleted!\"\n  }\n}"
                },
                {
                  "name": "200 Already deleted",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Already deleted!\"\n  }\n}"
                },
                {
                  "name": "403 Forbidden (not owner)",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Permission denied! The creator has the ability to delete.\"\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected client id is invalid.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"client_id\": [\n      \"The selected client id is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "1.E.7 SSO login (verify token, return session)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sso/rs-login",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sso",
                    "rs-login"
                  ]
                },
                "description": "SSO callback: verifies the presented Bearer token and returns the login session payload (user, company, theme, white-label). Used by the SPA after an SSO handoff. Returns 400 for an inactive account, and a 200 `signup_pending` status when email is unverified or signup is incomplete.\n\n## Auth\n- Bearer token (`auth:api`). Persona: **partner (`bundle_id`=1) or brand (`bundle_id`=3) user** (plus one legacy admin). Any other role gets 403.\n\n## Rate limit\n- No rate limit.\n\n## Behaviour\n- 200 with `status`/`token`/`user`/`company`/`theme_options`/`white_label`. 400 if the account is inactive. 403 if the user is neither a partner/brand user nor the legacy admin.\n\n✅ live ([routes/api_v1.php:374](routes/api_v1.php#L374) → `SsoController@ssoUser`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"token\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.DUMMY.DO_NOT_USE\",\n    \"user\": {\n      \"id\": 3,\n      \"username\": \"demo+yelp@example.com\",\n      \"email\": \"demo+yelp@example.com\",\n      \"email_verified_at\": \"2026-06-11T10:34:52.000000Z\",\n      \"phone_number\": \"123-456-7890\",\n      \"first_name\": \"John\",\n      \"last_name\": \"Doe\",\n      \"title\": \"Mr.\",\n      \"role\": \"Owner\",\n      \"total_location\": 3,\n      \"show_review_feed_widget\": 0\n    },\n    \"company\": {\n      \"id\": 5,\n      \"name\": \"Yelp partner\",\n      \"bundle_id\": 1,\n      \"company_option\": {\n        \"company_id\": 5,\n        \"who_will_pay\": \"partner\",\n        \"enable_generic_alert\": false\n      }\n    },\n    \"theme_options\": \"{\\\"mode\\\":\\\"system\\\",\\\"skin\\\":\\\"default\\\",\\\"primaryColor\\\":\\\"#f1874c\\\"}\",\n    \"white_label\": {\n      \"logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/logo/example.png\",\n      \"white_url\": \"https://hipages.manage-myreviews.com\",\n      \"favicon\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/favicon/example.png\",\n      \"title\": \"hipages\",\n      \"status\": \"active\",\n      \"login_logo\": \"\",\n      \"company_id\": 5,\n      \"email_templates_enabled\": true\n    }\n  }\n}"
                },
                {
                  "name": "200 Signup pending",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"signup_pending\",\n    \"message\": \"Please complete signup process.\"\n  }\n}"
                },
                {
                  "name": "400 Inactive account",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"status\": \"error\",\n    \"message\": \"Your account is inactive. Please contact support for assistance.\",\n    \"path\": \"#\"\n  }\n}"
                },
                {
                  "name": "403 Forbidden (role not allowed)",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Response scribe brand/location user can access this portal.\"\n}"
                }
              ]
            },
            {
              "name": "1.E.8 JumpCloud OIDC callback (issue exchange code)",
              "request": {
                "method": "GET",
                "header": [],
                "auth": {
                  "type": "noauth"
                },
                "url": {
                  "raw": "{{baseUrl}}/auth/sso/jumpcloud/callback?code=<oidc_code>&state=<state>",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "auth",
                    "sso",
                    "jumpcloud",
                    "callback"
                  ],
                  "query": [
                    {
                      "key": "code",
                      "value": "<oidc_code>",
                      "description": "Required. OAuth authorization code returned by JumpCloud."
                    },
                    {
                      "key": "state",
                      "value": "<state>",
                      "description": "Optional. Socialite CSRF state, validated via the session cookie."
                    }
                  ]
                },
                "description": "JumpCloud redirects the **browser** here after sign-in. Validates the IdP user (email match, `jumpcloud` enabled in `auth_methods`, company present, `bundle_id` ≥ 1, not locked/inactive), mints a single-use `SsoExchangeCode`, then **302-redirects** to `FRONTEND_CALLBACK_URL` with `?code=<raw>` on success or `?error=<reason>` on failure. Never returns JSON — the user-agent is the browser. The route carries `web` session middleware so Socialite can round-trip its OAuth `state`.\n\n## Auth\n- Public (no Bearer). Socialite validates the OAuth `state`; identity comes from JumpCloud.\n\n## Rate limit\n- 20 requests/min per IP (public).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `code` | string | Required | OAuth authorization code from JumpCloud. |\n| `state` | string | Optional | Socialite CSRF state (session-validated). |\n\n## Behaviour\n- Always 302. Failure `error` reasons: `sso_failed`, `sso_no_email`, `no_user`, `sso_not_enabled`, `no_company`, `role_not_allowed`, `account_locked`, `account_inactive`. Emits audit `SSO_LOGIN_SUCCESS` / `SSO_LOGIN_FAILED` / `SSO_LOGIN_BLOCKED_*`.\n\n✅ live ([routes/api_v1.php:148](routes/api_v1.php#L148) → `JumpCloudController@callback`)"
              },
              "response": [
                {
                  "name": "302 Redirect (success)",
                  "status": "Found",
                  "code": 302,
                  "_postman_previewlanguage": "text",
                  "header": [
                    {
                      "key": "Location",
                      "value": "https://app.example.com/sso/callback?code=9f3a...raw"
                    }
                  ],
                  "body": ""
                },
                {
                  "name": "302 Redirect (error)",
                  "status": "Found",
                  "code": 302,
                  "_postman_previewlanguage": "text",
                  "header": [
                    {
                      "key": "Location",
                      "value": "https://app.example.com/sso/callback?error=no_user"
                    }
                  ],
                  "body": ""
                }
              ]
            },
            {
              "name": "1.E.9 Exchange SSO code for access token",
              "request": {
                "method": "GET",
                "header": [],
                "auth": {
                  "type": "noauth"
                },
                "url": {
                  "raw": "{{baseUrl}}/auth/sso/exchange?code=<one_time_code>",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "auth",
                    "sso",
                    "exchange"
                  ],
                  "query": [
                    {
                      "key": "code",
                      "value": "<one_time_code>",
                      "description": "Required. One-time SSO exchange code (32–128 chars) from the callback redirect."
                    }
                  ]
                },
                "description": "The SPA hits this to redeem the one-time SSO code issued by the JumpCloud callback. Validates and **single-use-redeems** the code under a DB lock, re-checks eligibility, issues a Passport access token, fires `UserLoggedIn`, and **302-redirects** to `{FRONTEND_URL}/rs-sso-login/?token=<accessToken>`. An invalid / expired / already-redeemed code returns a 400 JSON error. Emits audit `SSO_EXCHANGE_CODE_REDEEMED` + `AUTH_LOGIN` on success, `SSO_EXCHANGE_CODE_INVALID` on failure.\n\n## Auth\n- Public. The one-time `code` is the credential.\n\n## Rate limit\n- 20 requests/min per IP (public).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `code` | string | Required | One-time SSO exchange code, 32–128 chars, from the callback redirect. |\n\n## Behaviour\n- On success: **302** to the frontend with `?token=<accessToken>` (the token is delivered via the redirect URL, not a JSON body). On failure: 400 JSON `Invalid or expired SSO code.`\n\n✅ live ([routes/api_v1.php:150](routes/api_v1.php#L150) → `JumpCloudController@exchange`)"
              },
              "response": [
                {
                  "name": "302 Redirect (success)",
                  "status": "Found",
                  "code": 302,
                  "_postman_previewlanguage": "text",
                  "header": [
                    {
                      "key": "Location",
                      "value": "https://app.example.com/rs-sso-login/?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.DUMMY.DO_NOT_USE"
                    }
                  ],
                  "body": ""
                },
                {
                  "name": "400 Invalid or expired code",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Invalid or expired SSO code.\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                }
              ]
            }
          ],
          "description": "Admin-scoped Passport OAuth **client registration** (this app acting as an OAuth Identity Provider) plus the external **JumpCloud OIDC** SSO handoff. Client-management endpoints (create / list / view / update / delete client, create password-grant client) require a Bearer token and are owner-scoped — a client can only be managed by the user who created it. `rs-login` verifies a Bearer token and returns the SSO session payload for partner/brand users. `auth/sso/jumpcloud/callback` (browser 302) and `auth/sso/exchange` (one-time-code redemption) are the public JumpCloud OIDC leg — no token; they issue/redeem a single-use exchange code and hand back a Passport access token."
        }
      ]
    },
    {
      "name": "2. Partner Endpoints",
      "description": "Endpoints runnable with a PARTNER access token — account/brand CRUD, plans, webhooks, provisioning, error notifications. Reports live under 'Report Endpoints'; hipages-specific endpoints under 'hipages Endpoints'.",
      "item": [
        {
          "name": "§ P.2 Brands (tenant CRUD)",
          "item": [
            {
              "name": "2.A.1 List brands",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/account/list?limit=10&page=1&sort_field=company_name&sort_by=ASC",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "list"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "10"
                    },
                    {
                      "key": "page",
                      "value": "1"
                    },
                    {
                      "key": "sort_field",
                      "value": "company_name"
                    },
                    {
                      "key": "sort_by",
                      "value": "ASC"
                    },
                    {
                      "key": "search_field",
                      "value": "company_name",
                      "disabled": true
                    },
                    {
                      "key": "search_value",
                      "value": "Acme",
                      "disabled": true
                    },
                    {
                      "key": "active_plan",
                      "value": "1",
                      "disabled": true
                    },
                    {
                      "key": "call_for",
                      "value": "dropdown",
                      "disabled": true
                    },
                    {
                      "key": "account_id",
                      "value": "7",
                      "description": "Optional. Filter to a single brand by its internal account id. Scoped to your own brands, so an id outside your tree returns an empty page. Either-or with client_account_id (if both are sent they must agree).",
                      "disabled": true
                    },
                    {
                      "key": "client_account_id",
                      "value": "EXT-BRAND-001",
                      "description": "Optional. Filter to a single brand by your own external id (client_account_id, unique per partner). Scoped to your own brands; an unknown/cross-tenant value returns an empty page. Either-or with account_id.",
                      "disabled": true
                    }
                  ]
                },
                "description": "> 🧭 **E2E journey 2/9 — List Brands (verify).** Confirms the brand from step 1 (`id == {{brandId}}`) persisted.\n\nReturns a paginated list of brand accounts (bundle_id=3) under the authenticated partner, with owner email, status, location/active-plan counts and current plan name; pass `call_for=dropdown` for a flat label/value list instead.\n\n**Auth:** Partner — `auth:api` under the `partner/account` group; controller hard-requires the caller's company to be `bundle_id == 1` (partner) else 403. Scope is the partner's own brand tree (`Company::getAllBrandIdsOfCompanyIds`), further narrowed by the user's `assign_brand`/`assign_location` assignments. Optional `account_id`/`client_account_id` filters are re-checked by `BelongsToAuthCompany` so a value outside the partner's brands yields an empty page.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `limit` (integer, optional) — page size, default 10.\n- Query: `page` (integer, optional) — page number.\n- Query: `sort_field` (string, optional) — one of `company_name`, `email`, `status`, `plan_name`; default `company_name`.\n- Query: `sort_by` (string, optional) — `asc`/`ASC`/`desc`/`DESC`; default `ASC`.\n- Query: `search_field` (string, optional) — `company_name`, `email`, `status`, or `plan_name` (pairs with `search_value`).\n- Query: `search_value` (string, optional) — like-match value (exact status_id when `search_field=status`).\n- Query: `active_plan` (in:0,1, optional) — when 1, restrict to brands with an active store plan.\n- Query: `call_for` (string, optional) — `dropdown` returns a flat label/value list (prepends `{label:'All',value:''}` when more than one brand).\n- Query: `account_id` (integer, optional) — filter to one brand by internal company id; must belong to the auth partner.\n- Query: `client_account_id` (string, max 255, optional) — partner-supplied external id, resolved to the brand within the partner only.\n\n**Response** — `{ data: ... }` envelope. Default: a paginated collection of `PartnerBrandListResource` (Laravel paginator: `current_page`, `data[]`, `per_page`, `total`, `links`, etc.). Each item:\n- `id` (integer) — brand company id.\n- `company_name` (string) — brand name.\n- `email` (string) — owner user email.\n- `account_status` (string) — `Active` / `Inactive` / `Paused` (derived from person.status_id).\n- `status` (integer) — raw person.status_id.\n- `total_locations` (integer) — count of store ids under the brand.\n- `store_ids` (integer[]) — active/pending store ids.\n- `total_active_plan` (integer) — distinct stores with active/expired store_plans.\n- `plan_name` (string|null) — brand-level plan name.\n- `client_account_id` (string|null) — partner-supplied external id.\n\nWhen `call_for=dropdown`, the envelope instead carries `{ status: 'success', response: [{label, value}, ...] }`."
              },
              "response": [
                {
                  "name": "200 Paginated",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"id\": 7,\n      \"company_name\": \"Acme Brand\",\n      \"email\": \"owner@acme.com\",\n      \"account_status\": \"Active\",\n      \"status\": 1,\n      \"total_locations\": 5,\n      \"store_ids\": [15, 16, 17],\n      \"plan_name\": \"Pro Monthly\"\n    }\n  ],\n  \"current_page\": 1,\n  \"last_page\": 3,\n  \"per_page\": 10,\n  \"total\": 24\n}"
                }
              ]
            },
            {
              "name": "2.A.2 Create brand",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"name\": \"Acme Brand\",\n    \"website\": \"https://acmebrand.com\",\n    \"billing_id\": \"BILL-001\",\n    \"client_account_id\": \"ACC-001\",\n    \"phone\": \"1234567890\",\n    \"address\": \"123 Brand Street\",\n    \"city_id\": {{cityId}},\n    \"companyindustry_id\": {{companyIndustryId}},\n    \"postal_code\": \"10001\",\n    \"first_name\": \"John\",\n    \"last_name\": \"Doe\",\n    \"owner_email\": \"demo.owner+{{$timestamp}}@example.com\",\n    \"owner_phone\": \"9876543210\",\n    \"password\": \"Secret@123\",\n    \"plan_id\": {{planId}},\n    \"with_location\": 0\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/account/create",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "create"
                  ]
                },
                "description": "> 🧭 **E2E journey 1/9 — Create Brand.** Uses `{{cityId}}`, `{{planId}}`; captures `data.accountId.value` → `{{brandId}}`. `with_location=0` (the journey creates the location in step 3). `owner_email` is uniquified with `{{$timestamp}}` to avoid the `unique:users,email` 422 on re-runs.\n\nCreates a new brand (bundle_id=3) account under the authenticated partner together with its owner user/person, links a brand-level plan, sends the owner a set-password welcome email, and (when `with_location=1`) auto-creates one location-account + store on that plan.\n\n**Auth:** Partner — `auth:api` under `partner/account`. The new brand's parent is `parent_company_id` when supplied, otherwise the caller's own partner company (`person.company.bundle_id == 1`). `parent_company_id` and `plan_id` are both validated against the auth partner: `parent_company_id` must pass `BelongsToAuthCompany`, and `plan_id` must exist in `company_plans` for the resolved partner company.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (JSON):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| name | string (max 100) | yes | No angle brackets. Brand/company name. |\n| website | string (url) | no | |\n| billing_id | string (max 100) | no | No angle brackets. |\n| client_account_id | string (max 100) | no | Partner-supplied external id. No angle brackets. |\n| phone | string (9–15) | no | Business phone. |\n| address | string (max 255) | yes | No angle brackets. |\n| city_id | integer | yes | `exists:city,id`. |\n| companyindustry_id | integer | no | `exists:companyindustry,id` (active industries only). Optional industry tag; if the owning partner has an industry set it cascades and overrides this on save (`Company::resolveAccountIndustryId`). |\n| postal_code | string (3–12) | yes | No angle brackets. |\n| first_name | string (max 100) | yes | Owner first name. |\n| last_name | string (max 100) | no | Owner last name. |\n| owner_email | email (rfc,dns) | yes | Unique across users. |\n| owner_phone | string (9–15) | no | |\n| password | string | yes | Min 8, max 100, mixed case + letters + numbers + symbol; no quotes/backslash/whitespace. |\n| plan_id | integer | yes | Must exist in `plans` AND be assigned to the resolving partner via `company_plans`. |\n| parent_company_id | integer | no | Must be a bundle_id 1/2/3 company belonging to the auth partner tree; defaults to the caller's partner company. |\n| with_location | in:0,1 | no | When 1, auto-creates a default location + store on the chosen plan. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Account has been created successfully!`.\n- `accountId.label` (string) — created brand name.\n- `accountId.value` (integer) — created brand company id."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Brand account has been created successfully!\",\n    \"accountId\": {\n      \"label\": \"Acme Brand\",\n      \"value\": 12\n    }\n  }\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  if (j.data && j.data.accountId && j.data.accountId.value) {",
                      "    pm.environment.set('brandId', String(j.data.accountId.value));",
                      "    console.log('journey: brandId =', j.data.accountId.value);",
                      "  }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "2.A.3 Edit brand",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"name\": \"Acme Brand\",\n    \"website\": \"https://acmebrand.com\",\n    \"billing_id\": \"BILL-001\",\n    \"client_account_id\": \"ACC-001\",\n    \"phone\": \"1234567890\",\n    \"address\": \"123 Brand Street\",\n    \"city_id\": 1,\n    \"companyindustry_id\": {{companyIndustryId}},\n    \"postal_code\": \"10001\",\n    \"first_name\": \"John\",\n    \"last_name\": \"Doe\",\n    \"owner_email\": \"john@acmebrand.com\",\n    \"owner_phone\": \"9876543210\",\n    \"override_locations\": [\n        \"plan_id\"\n    ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/account/{{brandId}}/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "{{brandId}}",
                    "update"
                  ]
                },
                "description": "Updates an existing brand company row plus its owner user/person and brand company_options; field names passed in `override_locations` are cascaded down to the brand's child stores (only `who_will_pay` is actually mirrored onto each store's company_options).\n\n**Auth:** Partner — `auth:api` under `partner/account`. Target resolved by path id; controller 404s if the row is missing or not `bundle_id=3`, then walks `getParentCompany(brand,'partner')` and calls `canManageBrand` (super-admin, or the caller's partner == the brand's partner) else 403. `owner_email` uniqueness ignores the brand's current owner user.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — brand company id (bundle_id=3).\n- Body (JSON):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| name | string (max 100) | yes | No angle brackets. |\n| website | string (url) | no | |\n| billing_id | string (max 100) | no | No angle brackets. |\n| client_account_id | string (max 100) | no | No angle brackets. |\n| phone | string (9–15) | no | |\n| address | string (max 255) | yes | No angle brackets. |\n| city_id | integer | yes | `exists:city,id`. |\n| companyindustry_id | integer | no | `exists:companyindustry,id` (active industries only). Optional industry tag; a partner-level industry, when set, cascades and overrides this on save (`Company::resolveAccountIndustryId`). |\n| postal_code | string (3–12) | yes | No angle brackets. |\n| first_name | string (max 100) | yes | Owner first name. |\n| last_name | string (max 100) | no | Owner last name. |\n| owner_email | email (rfc,dns) | yes | Unique, ignoring this brand's current owner. |\n| owner_phone | string (9–15) | no | |\n| override_locations | array | no | Cascade field list. |\n| override_locations.* | string in:plan_id | no | Only `plan_id` is an accepted value. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Account has been updated successfully!`.\n- `accountId.label` (string) — brand name.\n- `accountId.value` (integer) — brand company id.\n- `stores_updated` (integer) — count of child stores touched by the cascade.\n- `overrides_applied` (string[]) — the override field names received."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Brand account has been updated successfully!\",\n    \"accountId\": { \"label\": \"Acme Brand\", \"value\": 42 },\n    \"stores_updated\": 0,\n    \"overrides_applied\": []\n  }\n}"
                },
                {
                  "name": "403 Out of partner tree",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"You do not have access to this brand.\",\n  \"errors\": []\n}"
                },
                {
                  "name": "404 Brand not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Brand not found.\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "2.A.4 Get assigned plan for brand",
              "request": {
                "method": "GET",
                "header": [
                  {
                    "key": "Accept",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/account/{{brandId}}/assigned-plan",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "{{brandId}}",
                    "assigned-plan"
                  ]
                },
                "description": "Returns the brand company record plus the plan currently assigned to it (with the plan's features and review-site profile eager-loaded), or `plan: null` when no plan is assigned.\n\n**Auth:** Partner — `auth:api` under `partner/account`. The route id is constrained to `[0-9]+`. Note: this method only checks the company exists (404 if not) and does NOT call `canManageBrand`, so it performs no partner-tree ownership check beyond authentication.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — brand company id.\n\n**Response** — `{ data: ... }` envelope wrapping an inner `data` object.\n- `data.account` (object) — full `Company` model row for the brand (id, name, code, company_id, bundle_id, billing_model, contract_model, client_account_id, timestamps, etc.).\n- `data.plan` (object|null) — the assigned `Plan` (latest `company_plans` row) with:\n  - `id`, `plan_name`, `price`, `status`, `plan_type`, `enforcement_mode`, `is_admin_managed`, `short_info`, timestamps.\n  - `features` (array) — `plan_features` rows: `feature_name`, `enabled`, `service_limit`, `limit_type`, `included_units`, `overage_rate`, `hard_stop`.\n  - `plan_review_sites` (array) — each with `review_site_id`, `pull_frequency`, `pull_interval`, `pull_times`, `pull_days` and a nested `review_site` object.\n  - `null` if the brand has no `company_plans.plan_id`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": {\n      \"account\": { \"id\": 12, \"name\": \"Acme Brand\", \"bundle_id\": 3, \"company_id\": 5 },\n      \"plan\": {\n        \"id\": 4,\n        \"plan_name\": \"Gold\",\n        \"status\": \"active\",\n        \"is_admin_managed\": true,\n        \"features\": [\n          { \"feature_name\": \"ai_response\", \"enabled\": true, \"overage_rate\": \"0.0800\", \"hard_stop\": false }\n        ],\n        \"plan_review_sites\": [\n          { \"review_site_id\": 1, \"pull_frequency\": \"daily\", \"review_site\": { \"id\": 1, \"name\": \"Yelp\" } }\n        ]\n      }\n    }\n  }\n}"
                }
              ]
            },
            {
              "name": "2.A.6 Get brand details",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/account/{{brandId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "{{brandId}}"
                  ],
                  "query": [
                    {
                      "key": "client_account_id",
                      "value": "EXT-BRAND-001",
                      "description": "Optional. Resolve the account by your own external id instead of the path id. When present it TAKES PRECEDENCE over {{brandId}}. Scoped to your own brands — a bogus or cross-tenant value returns 404.",
                      "disabled": true
                    }
                  ]
                },
                "description": "Loads a single brand with everything the brand-edit form needs: brand row + company_options + city/state/country, the owner (name/email/phone), and the brand's current plan_id. Returns 404 if the target is missing or not a brand (bundle_id != 3).\n\n**Auth:** Partner — `auth:api` under `partner/account`. The path id is validated with `exists:company,id` + `BelongsToAuthCompany`; a partner-supplied `client_account_id` query param (if present) takes precedence and is resolved to the brand within the caller's partner only. After load, controller walks `getParentCompany(brand,'partner')` and enforces `canManageBrand` (super-admin or matching partner) else 403.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — brand company id (bundle_id=3).\n- Query: `client_account_id` (string, optional) — partner-supplied external id; overrides the path id (404 if it resolves to none within the partner).\n\n**Response** — `{ data: ... }` envelope wrapping an inner `data` object.\n- `data.account` (object) — selected brand fields: `id`, `name`, `company_id`, `site_url`, `address`, `city_id`, `zip`, `company_phone`, `billing_id`, `client_account_id`, `phone`, `bundle_id`, `website`, `created_at`; plus `company_option` (`id`, `company_id`, `who_will_pay`, `enable_generic_alert`, `enable_insight_report`, `enable_competitive_analysis`) and a nested `city.state.country`.\n- `data.owner` (object|null) — `first_name`, `last_name` (from person), `email`, `phone` (from user); null if no owner.\n- `data.plan_id` (integer|null) — latest `company_plans.plan_id` for the brand."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": {\n      \"account\": { \"id\": 42, \"name\": \"Acme Brand\", \"bundle_id\": 3 },\n      \"owner\": { \"first_name\": \"John\", \"email\": \"john@acmebrand.com\" },\n      \"plan_id\": 10\n    }\n  }\n}"
                },
                {
                  "name": "403 Out of partner tree",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"You do not have access to this brand.\",\n  \"errors\": []\n}"
                },
                {
                  "name": "404 Brand not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Brand not found.\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "2.A.7 List brands with inactive plans",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/account/inactive-plan-list?",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "inactive-plan-list"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "10",
                      "description": "Optional — items per page",
                      "disabled": true
                    },
                    {
                      "key": "page",
                      "value": "1",
                      "description": "Optional — page number",
                      "disabled": true
                    }
                  ]
                },
                "description": "Returns a paginated list of brands (bundle_id=3) under the authenticated partner that have NO active store-plan subscription, with owner email, cancelled-location count and last plan name.\n\n**Auth:** Partner — `auth:api` under `partner/account`; controller hard-requires the caller's company to be `bundle_id == 1` (partner) else 403. Scope is the partner's own brand tree, matched via `partner.id = company.id OR partner.company_id = company.id`; the result excludes any brand id that currently has an active store_plan.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `limit` (integer, optional) — page size, default 10.\n- Query: `page` (integer, optional) — page number.\n\n**Response** — `{ data: ... }` envelope = a Laravel paginator (`current_page`, `per_page`, `total`, `links`, `data[]`, etc.). Each item:\n- `id` (integer) — brand company id.\n- `company_name` (string) — brand name.\n- `email` (string|null) — owner user email.\n- `store_ids` (string|null) — comma-separated cancelled store ids (GROUP_CONCAT).\n- `total_cancelled_location` (integer) — count of cancelled stores.\n- `plan_name` (string|null) — last brand plan name."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"current_page\": 1,\n    \"data\": [\n      { \"id\": 42, \"company_name\": \"Acme Brand\", \"email\": \"john@acmebrand.com\", \"total_cancelled_location\": 3, \"plan_name\": \"ResponseScribe\" }\n    ],\n    \"total\": 1,\n    \"per_page\": 10,\n    \"last_page\": 1\n  }\n}"
                },
                {
                  "name": "403 Not a partner",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Only partner can access this endpoint!\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "2.A.8 Assign plan to a brand",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/account/{{brandId}}/assign-plan",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "{{brandId}}",
                    "assign-plan"
                  ]
                },
                "description": "Assigns a plan to a brand and propagates it to every store under that brand: existing active/expired store_plans are closed and a fresh active store_plan (running to month-end) is created per store, and the brand-level `company_plans` link is updated/created.\n\n**Auth:** Partner — `auth:api` under `partner/account`. Path id must resolve to a `bundle_id=3` company (404 if missing, 403 if not a brand), with a partner (`getParentCompany`) and pass `canManageBrand` else 403. The plan must be available to that partner via `company_plans`, and must match the partner billing model: plan-based partners may only assign `is_admin_managed=1` plans; usage-based partners only `is_admin_managed=0` (else 403).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — brand company id (bundle_id=3).\n- Body (JSON):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| plan_id | integer | yes | `exists:plans,id`; further checked for partner availability + billing-model match in the controller. |\n\n**Response** — `{ data: ... }` envelope wrapping an inner `data` object.\n- `data.account` (object) — fresh `Company` model row for the brand.\n- `data.plan` (object) — the assigned `Plan` with `features` and `planReviewSites.reviewSite` eager-loaded (same shape as 2.A.4).\n- `data.stores_assigned` (integer) — number of stores the plan was propagated to.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"plan_id\": 3\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": {\n      \"account\": { \"id\": 42, \"name\": \"Acme Brand\" },\n      \"plan\": { \"id\": 10, \"plan_name\": \"ResponseScribe\", \"features\": [], \"plan_review_sites\": [] },\n      \"stores_assigned\": 4\n    }\n  }\n}"
                },
                {
                  "name": "403 Billing-model mismatch",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Plan-based companies can only be assigned admin-managed plans.\",\n  \"errors\": []\n}"
                },
                {
                  "name": "404 Plan not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Plan not found.\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "2.A.9 Delete a store (soft delete)",
              "request": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/store/{{storeId}}/delete",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "store",
                    "{{storeId}}",
                    "delete"
                  ]
                },
                "description": "Soft-deletes a store and its paired location-account `company` row, closes any active/expired `store_plans` (sets status `close`, `plan_end` + `cancel_subscription_at` = now), and cascade-soft-deletes the store's `reviews`, `response_reviews`, `review_flag`, and `store_review_sites`. All in one DB transaction.\n\n**Auth:** Partner — `auth:api`, under the `partner/` group (partner-admin scope). The URL `id` is validated by the `StoreDeleteRequest` form request: `BelongsToAuthCompany` resolves the store to its `company_id` and confirms it sits inside the caller's company hierarchy (partner-tree walk via `Company::isBelongsToParentCompany`), so a partner can only delete its own stores.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — Store id. Mapped to `store_id` in `validationData()`; must `exists:store,id` and pass `BelongsToAuthCompany`.\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Location has been deleted successfully!`.\n- `storeId` (integer) — the deleted store's id."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Location has been deleted successfully!\",\n    \"storeId\": 26\n  }\n}"
                },
                {
                  "name": "400 Out of scope",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Validation failed\",\n  \"errors\": { \"store_id\": [\"The selected store is not within your scope.\"] }\n}"
                },
                {
                  "name": "404 Store not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Store not found.\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "2.A.9 Delete account (cascade + dormant if enabled)",
              "request": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/account/{{brandId}}/delete",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "{{brandId}}",
                    "delete"
                  ]
                },
                "description": "Soft-deletes a brand and cascades to everything beneath it — its location-account companies, their stores, and each store's reviews/responses/flags/review-site rows; open plans are closed. If the owning partner has dormant archiving enabled, every affected store is archived to the dormant connection before removal.\n\n**Auth:** Partner — `auth:api` under `partner/account`. The `BrandDeleteRequest` injects the path id as `account_id` and validates it with `exists:company,id` + `BelongsToAuthCompany`. Controller then 404s if missing/not bundle_id=3, walks `getParentCompany(brand,'partner')` and enforces `canManageBrand` (super-admin or matching partner) else 403. The dormant toggle is read BEFORE the cascade because the parent walk no longer resolves once the brand is soft-deleted.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — brand (account) company id (bundle_id=3).\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Account has been deleted successfully!`.\n- `account_id` (integer) — the deleted brand id (from `AccountDeletionService` result)."
              },
              "response": [
                {
                  "name": "200 Deleted",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Account has been deleted successfully!\",\n    \"account_id\": 7\n  }\n}"
                },
                {
                  "name": "422 Unknown / cross-tenant id",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected account id does not belong to your company hierarchy.\",\n  \"errors\": {\n    \"account_id\": [\"The selected account id does not belong to your company hierarchy.\"]\n  }\n}"
                },
                {
                  "name": "404 Not a brand",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Account not found.\",\n  \"status\": \"error\"\n}"
                }
              ]
            },
            {
              "name": "2.A.10 Bulk import accounts + locations (CSV upload)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Accept",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    {
                      "key": "file",
                      "type": "file",
                      "src": null,
                      "description": "Import CSV (one account + one location per row). Headers must match the 20-column contract. Samples: storage/app/samples/account_bulk_import_sample.csv (US) and account_bulk_import_sample_au.csv (AU)."
                    }
                  ]
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/account/bulk-import",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "bulk-import"
                  ]
                },
                "description": "Bulk-imports accounts from a single CSV — **one account (brand) + one location (store) per row** — designed for up to ~100K rows. The upload is validated for shape/size, stored, and queued; processing runs asynchronously on the `bulk` queue (prepare → parallel chunk jobs → finalize). Returns an `import_id` immediately — poll **2.A.11** for progress.\n\nPer created row the system provisions the full cascade: brand company (bundle_id=3), `company_plans`, `company_options`, owner user + person, location-account company (bundle_id=2), `store`, `store_options`, `store_plans`, and `store_review_sites` (one per the plan's review-site set). Welcome/notification emails are suppressed for bulk imports; every owner is created with the shared password `Account@123!` (rotation expected on first login).\n\n## Auth\n- Partner — `auth:api`. Caller must be a partner company (`person.company.bundle_id == 1`); the import is scoped to that partner. Non-partner callers get **403**.\n\n## Rate limit\n- No rate limit (bulk import — the `bulk` worker controls back-pressure).\n\n## Body (multipart/form-data)\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `file` | file (.csv) | Required | CSV only, max 100 MB. Header row must contain every required column below. xlsx is rejected (streamed row-by-row for memory safety). |\n\n### CSV columns — one account+location per row\n| Column | Required | Notes |\n|---|---|---|\n| `account_name` | Required | Brand name (max 100, no `<>`). |\n| `client_account_id` | Required | Partner-unique external account id (`^[A-Za-z0-9_-]+$`, max 100). Duplicate (in-file or existing) → skipped `duplicate_client_account_id`. Also the re-run idempotency key. |\n| `plan_id` | Required | Numeric plan id assigned to the partner (`company_plans`). Unassigned → skipped `plan_unavailable`. |\n| `owner_first_name` | Required | Brand owner first name. |\n| `owner_email` | Required | Globally unique on `users`. Duplicate → skipped `duplicate_email`. |\n| `location_name` | Required | Location/store name (max 100, no `<>`). |\n| `client_location_id` | Required | Partner-unique external location id (same charset). Duplicate → skipped `duplicate_client_location_id`. |\n| `address` | Required | max 255, no `<>`. |\n| `country` | Required | Country code or name; **must already exist** (unknown → skipped `unknown_country`). Countries are never auto-created. |\n| `state` | Required | State code or name; **created** under the country when missing. |\n| `city` | Required | City name; **created** under (country, state) when missing. |\n| `postal_code` | Required | 3–12 chars. |\n| `website` | Optional | URL — brand website. |\n| `billing_id` | Optional | max 100. |\n| `owner_last_name` | Optional | Brand owner last name. |\n| `owner_phone` | Optional | 9–15 digits, no spaces/symbols. |\n| `storeid` | Optional | Store label; defaults to the resolved city name when blank. |\n| `business_phone` | Optional | 9–15 digits. |\n| `site_url` | Optional | URL — location website. |\n| `companyindustry_id` | Optional | Industry id; **blank → defaults to Home Services**. Non-existent id → skipped `invalid_row`. A partner-level industry still overrides the per-row value when set. |\n\n## Behaviour\n- **202 Accepted** → `{ data: { import_id, status: \"queued\" } }`.\n- Validation, de-dupe (in-file + against the partner's existing accounts/locations) and geo resolve/create all happen in the single-threaded prepare stage; invalid/duplicate rows are recorded and **skipped** while the rest continue (skip-and-report).\n- **Idempotent re-run:** re-uploading the same file only creates rows whose `client_account_id` / `client_location_id` don't already exist for the partner.\n- **403** if the caller is not a partner; **422** if the file is missing, not a `.csv`, or larger than 100 MB.\n\n> Test script saves `data.import_id` → `{{bulkImportId}}` for use by **2.A.11**.\n\n✅ live ([routes/api_v1.php](routes/api_v1.php) → `AccountBulkImportController::store`)"
              },
              "response": [
                {
                  "name": "202 Queued",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"import_id\": 2,\n    \"status\": \"queued\",\n    \"message\": \"Import received. Processing has been queued.\"\n  }\n}"
                },
                {
                  "name": "403 Not a partner",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Only a partner account may run a bulk import.\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                },
                {
                  "name": "422 Invalid file",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The import file must be a .csv file.\",\n  \"errors\": {\n    \"file\": [\n      \"The import file must be a .csv file.\"\n    ]\n  }\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 202) {",
                      "  const j = pm.response.json();",
                      "  if (j.data && j.data.import_id) {",
                      "    pm.environment.set('bulkImportId', String(j.data.import_id));",
                      "  }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "2.A.11 Bulk import status",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/account/bulk-import/{{bulkImportId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "bulk-import",
                    "{{bulkImportId}}"
                  ]
                },
                "description": "Returns live progress and the final outcome of a bulk account+location import (**2.A.10**). Poll until `status` is terminal.\n\n## Auth\n- Partner — `auth:api`. Returns the import only when it belongs to the caller's partner (else **404**); **403** if the caller is not a partner.\n\n## Rate limit\n- No rate limit.\n\n## Path\n- `id` (integer, Required) — the `import_id` returned by **2.A.10**.\n\n## Response — `{ data: ... }`\n| Field | Type | Notes |\n|---|---|---|\n| `status` | string | `queued` → `preparing` → `processing` → terminal `completed` / `completed_with_errors` / `failed`. |\n| `total_rows` | integer | CSV data rows read. |\n| `processed_rows` | integer | Rows that reached a terminal outcome (created + skipped). |\n| `created_rows` | integer | Accounts successfully provisioned. |\n| `skipped_rows` | integer | Rows skipped (invalid / duplicate / plan_unavailable / unknown_country). |\n| `created_cities` | integer | New `city` reference rows minted during geo resolve. |\n| `created_states` | integer | New `state` reference rows minted during geo resolve. |\n| `skip_breakdown` | object | Map of skip `reason` → count, e.g. `{ \"duplicate_email\": 1, \"plan_unavailable\": 2 }`. |\n| `error_message` | string\\|null | Set only when `status = failed`. |\n| `queued_at` / `started_at` / `completed_at` | timestamp\\|null | Lifecycle timestamps. |\n\n✅ live ([routes/api_v1.php](routes/api_v1.php) → `AccountBulkImportController::show`)"
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"import_id\": 2,\n    \"status\": \"completed_with_errors\",\n    \"total_rows\": 22,\n    \"processed_rows\": 22,\n    \"created_rows\": 21,\n    \"skipped_rows\": 1,\n    \"created_cities\": 1,\n    \"created_states\": 0,\n    \"skip_breakdown\": {\n      \"duplicate_email\": 1\n    },\n    \"error_message\": null,\n    \"queued_at\": \"2026-06-30T07:02:03.000000Z\",\n    \"started_at\": \"2026-06-30T07:02:04.000000Z\",\n    \"completed_at\": \"2026-06-30T07:02:07.000000Z\"\n  }\n}"
                },
                {
                  "name": "404 Not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Import not found.\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                }
              ]
            }
          ],
          "description": "Partner-side CRUD on the brands the partner owns. Brands are top-level tenant resources — only partners create / edit / archive them."
        },
        {
          "name": "§ P.3 Plans (partner-managed)",
          "item": [
            {
              "name": "3.A.0 Plan-builder context (partner)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/plan-builder/context",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "plan-builder",
                    "context"
                  ]
                },
                "description": "Returns the authenticated partner's billing model, contract model, billing config, per-feature rates, and review-site universe — everything the Plan Builder UI needs to render.\n\n**Auth:** Partner. Route is in the `auth:api` group with no extra route-level middleware; persona is enforced in the controller. `partnerPlanBuilderContext()` calls `getAuthPartner()` → `Company::getParentCompany(user.person.company_id, 'partner')`, walking the company tree up to the `bundle_id=1` partner. **403** if no partner resolves. Any user inside a partner tree (partner/brand/location) resolves to and is scoped by their owning partner.\n\n**Rate limit:** No rate limit.\n\n**Request** — no path/query/body params.\n\n**Response** (`data.data` object + `data.meta`)\n- `partner` — `{ id, name, billing_model, contract_model }`\n- `config` — partner `CompanyBillingConfig` (`location_rate`, `one_time_fee`, …) or `null`\n- `feature_rates[]` — `{ feature_name, rate_per_unit, enabled }`\n- `review_site_universe[]` — `{ review_site_id, name, color, pull_frequency, pull_interval, pull_times[], pull_days[] }`\n- `meta.plans_read_only` — `true` for plan-based partners, `false` for usage-based"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": {\n      \"partner\": { \"id\": 5, \"name\": \"Yelp partner\", \"billing_model\": \"plan-based\", \"contract_model\": \"model-a\" },\n      \"config\": { \"id\": 1, \"company_id\": 5, \"contract_model\": \"model-a\", \"location_rate\": \"4.01\", \"one_time_fee\": \"0.00\" },\n      \"feature_rates\": [ { \"feature_name\": \"per_location_cost\", \"rate_per_unit\": \"4.0100\", \"enabled\": true }, { \"feature_name\": \"ai_response\", \"rate_per_unit\": \"0.0800\", \"enabled\": true } ],\n      \"review_site_universe\": [ { \"review_site_id\": 44, \"name\": \"Google\", \"color\": \"#3774ee\", \"pull_frequency\": \"daily\", \"pull_interval\": 1, \"pull_times\": [\"06:00\"], \"pull_days\": [] } ]\n    },\n    \"meta\": { \"plans_read_only\": true }\n  }\n}"
                }
              ]
            },
            {
              "name": "3.A.1 List partner plans",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/plans/list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "plans",
                    "list"
                  ]
                },
                "description": "Lists the plans available to the authenticated partner. Usage-based partners get their partner-managed usage plans (editable); plan-based partners get the admin-managed plans assigned to them (read-only).\n\n**Auth:** Partner. `auth:api` group, no extra route-level middleware. `getPartnerPlanList()` resolves the partner via `getAuthPartner()` (**403** if none). **403** (`Partner billing model is not configured.`) unless `billing_model` is `usage-based` or `plan-based`. Scoped to plans linked via `company_plans` (non-deleted): usage-based → `plan_type=usage-based AND is_admin_managed=0`; plan-based → `plan_type=plan-based AND is_admin_managed=1`.\n\n**Rate limit:** No rate limit.\n\n**Request** — no path/query/body params.\n\n**Response** (`data.data` array + `data.meta`) — each plan eager-loads `features` + `planReviewSites.reviewSite`.\n- Plan: `id, plan_name, price, original_price, status, plan_type, tier_slug, enforcement_mode, is_admin_managed, description, plan_end_date, short_info, …`\n- `features[]` — `{ feature_name, enabled, service_limit, limit_type, included_units, overage_rate, hard_stop, partner_billing_enabled }`\n- `plan_review_sites[]` — `{ review_site_id, pull_frequency, pull_interval, pull_times[], pull_days, review_site:{…} }`\n- `meta` — `{ billing_model, read_only }` (`read_only=true` for plan-based)"
              },
              "response": [
                {
                  "name": "200 — Usage-Based partner (editable)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": [\n      {\n        \"id\": 8,\n        \"plan_name\": \"Growth — Unlimited\",\n        \"price\": 5,\n        \"original_price\": 6,\n        \"status\": \"active\",\n        \"plan_type\": \"usage-based\",\n        \"tier_slug\": null,\n        \"enforcement_mode\": \"per-location\",\n        \"is_admin_managed\": false,\n        \"description\": null,\n        \"plan_end_date\": null,\n        \"short_info\": null,\n        \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n        \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n        \"deleted_at\": null,\n        \"features\": [\n          {\n            \"id\": 48,\n            \"plan_id\": 8,\n            \"feature_name\": \"ai_response\",\n            \"enabled\": true,\n            \"service_limit\": 12,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": \"0.0800\",\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 49,\n            \"plan_id\": 8,\n            \"feature_name\": \"review_flag\",\n            \"enabled\": true,\n            \"service_limit\": 11,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": \"0.0800\",\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 50,\n            \"plan_id\": 8,\n            \"feature_name\": \"response_to_past_reviews\",\n            \"enabled\": true,\n            \"service_limit\": null,\n            \"limit_type\": \"monthly\",\n            \"included_units\": 10,\n            \"overage_rate\": \"0.0500\",\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 51,\n            \"plan_id\": 8,\n            \"feature_name\": \"response_posting\",\n            \"enabled\": true,\n            \"service_limit\": 9,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": \"0.0400\",\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 52,\n            \"plan_id\": 8,\n            \"feature_name\": \"review_widgets\",\n            \"enabled\": true,\n            \"service_limit\": 8,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": \"0.1000\",\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 53,\n            \"plan_id\": 8,\n            \"feature_name\": \"review_solicitation_email\",\n            \"enabled\": true,\n            \"service_limit\": 7,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": \"0.0200\",\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 54,\n            \"plan_id\": 8,\n            \"feature_name\": \"review_solicitation_sms\",\n            \"enabled\": true,\n            \"service_limit\": 6,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": \"0.0600\",\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 55,\n            \"plan_id\": 8,\n            \"feature_name\": \"insight_report\",\n            \"enabled\": false,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T10:45:00.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": false\n          },\n          {\n            \"id\": 56,\n            \"plan_id\": 8,\n            \"feature_name\": \"competitive_analysis\",\n            \"enabled\": false,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T10:45:00.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": false\n          },\n          {\n            \"id\": 57,\n            \"plan_id\": 8,\n            \"feature_name\": \"per_pull_data\",\n            \"enabled\": true,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          }\n        ],\n        \"plan_review_sites\": [\n          {\n            \"id\": 12,\n            \"plan_id\": 8,\n            \"review_site_id\": 1,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n              \"06:00\"\n            ],\n            \"pull_days\": [],\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"review_site\": {\n              \"id\": 1,\n              \"name\": \"Yelp\",\n              \"link\": \"http://www.yelp.com\",\n              \"review_site_button\": \"img-yelp.png\",\n              \"review_site_color\": \"#d32200\",\n              \"max_rate\": 5,\n              \"status\": 0,\n              \"show\": true,\n              \"use_sau_scrapper\": \"Y\",\n              \"publisher\": \"yelp.com\",\n              \"auth_type\": \"cookie\",\n              \"scraping_enabled\": true,\n              \"posting_enabled\": true,\n              \"self_form_enabled\": \"N\",\n              \"email_on_reply\": \"\",\n              \"deleted_at\": null,\n              \"created_at\": \"2015-05-26T09:18:10.000000Z\",\n              \"updated_at\": \"2020-05-04T02:44:57.000000Z\"\n            }\n          },\n          {\n            \"id\": 13,\n            \"plan_id\": 8,\n            \"review_site_id\": 3,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n              \"06:00\"\n            ],\n            \"pull_days\": [],\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"review_site\": {\n              \"id\": 3,\n              \"name\": \"Facebook\",\n              \"link\": \"http://www.facebook.com\",\n              \"review_site_button\": \"img-facebook.png\",\n              \"review_site_color\": \"#45619d\",\n              \"max_rate\": 5,\n              \"status\": 0,\n              \"show\": true,\n              \"use_sau_scrapper\": \"Y\",\n              \"publisher\": \"facebook.com\",\n              \"auth_type\": \"oauth\",\n              \"scraping_enabled\": true,\n              \"posting_enabled\": true,\n              \"self_form_enabled\": \"N\",\n              \"email_on_reply\": \"\",\n              \"deleted_at\": null,\n              \"created_at\": \"2015-05-26T09:18:10.000000Z\",\n              \"updated_at\": \"2020-05-04T02:44:57.000000Z\"\n            }\n          },\n          {\n            \"id\": 14,\n            \"plan_id\": 8,\n            \"review_site_id\": 8,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n              \"06:00\"\n            ],\n            \"pull_days\": [],\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"review_site\": {\n              \"id\": 8,\n              \"name\": \"Tripadvisor\",\n              \"link\": \"tripadvisor.com\",\n              \"review_site_button\": \"img-tripadvisor.png\",\n              \"review_site_color\": \"#00af87\",\n              \"max_rate\": 5,\n              \"status\": 0,\n              \"show\": true,\n              \"use_sau_scrapper\": \"Y\",\n              \"publisher\": \"tripadvisor.com\",\n              \"auth_type\": \"cookie\",\n              \"scraping_enabled\": true,\n              \"posting_enabled\": true,\n              \"self_form_enabled\": \"N\",\n              \"email_on_reply\": \"N\",\n              \"deleted_at\": null,\n              \"created_at\": \"2015-05-26T09:20:06.000000Z\",\n              \"updated_at\": \"2020-05-04T02:44:57.000000Z\"\n            }\n          },\n          {\n            \"id\": 15,\n            \"plan_id\": 8,\n            \"review_site_id\": 16,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n              \"06:00\"\n            ],\n            \"pull_days\": [],\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"review_site\": {\n              \"id\": 16,\n              \"name\": \"OpenTable\",\n              \"link\": \"opentable.com\",\n              \"review_site_button\": \"img-opentable.png\",\n              \"review_site_color\": \"#da3643\",\n              \"max_rate\": 5,\n              \"status\": 0,\n              \"show\": true,\n              \"use_sau_scrapper\": \"Y\",\n              \"publisher\": \"opentable.com\",\n              \"auth_type\": \"cookie\",\n              \"scraping_enabled\": true,\n              \"posting_enabled\": true,\n              \"self_form_enabled\": \"N\",\n              \"email_on_reply\": \"N\",\n              \"deleted_at\": null,\n              \"created_at\": \"2015-10-16T16:39:17.000000Z\",\n              \"updated_at\": \"2020-05-04T02:44:57.000000Z\"\n            }\n          },\n          {\n            \"id\": 16,\n            \"plan_id\": 8,\n            \"review_site_id\": 44,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n              \"06:00\"\n            ],\n            \"pull_days\": [],\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"review_site\": {\n              \"id\": 44,\n              \"name\": \"Google\",\n              \"link\": \"http://maps.google.com\",\n              \"review_site_button\": \"img-google.png\",\n              \"review_site_color\": \"#3774ee\",\n              \"max_rate\": 5,\n              \"status\": 0,\n              \"show\": true,\n              \"use_sau_scrapper\": \"Y\",\n              \"publisher\": \"maps.google.com\",\n              \"auth_type\": \"oauth\",\n              \"scraping_enabled\": true,\n              \"posting_enabled\": true,\n              \"self_form_enabled\": \"N\",\n              \"email_on_reply\": \"N\",\n              \"deleted_at\": null,\n              \"created_at\": \"2017-01-09T11:33:00.000000Z\",\n              \"updated_at\": \"2020-05-04T02:44:57.000000Z\"\n            }\n          },\n          {\n            \"id\": 17,\n            \"plan_id\": 8,\n            \"review_site_id\": 54,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n              \"06:00\"\n            ],\n            \"pull_days\": [],\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"review_site\": {\n              \"id\": 54,\n              \"name\": \"Apartments\",\n              \"link\": \"https://www.apartments.com/\",\n              \"review_site_button\": \"img-apartments.png\",\n              \"review_site_color\": \"#44e89f\",\n              \"max_rate\": 5,\n              \"status\": 0,\n              \"show\": true,\n              \"use_sau_scrapper\": \"Y\",\n              \"publisher\": \"apartments.com\",\n              \"auth_type\": \"cookie\",\n              \"scraping_enabled\": true,\n              \"posting_enabled\": true,\n              \"self_form_enabled\": \"N\",\n              \"email_on_reply\": \"N\",\n              \"deleted_at\": null,\n              \"created_at\": \"2019-05-03T10:50:48.000000Z\",\n              \"updated_at\": \"2020-05-04T02:44:57.000000Z\"\n            }\n          },\n          {\n            \"id\": 18,\n            \"plan_id\": 8,\n            \"review_site_id\": 85,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n              \"06:00\"\n            ],\n            \"pull_days\": [],\n            \"created_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"updated_at\": \"2026-06-19T06:21:21.000000Z\",\n            \"deleted_at\": null,\n            \"review_site\": {\n              \"id\": 85,\n              \"name\": \"Doordash\",\n              \"link\": \"doordash.com\",\n              \"review_site_button\": \"img-doordash.png\",\n              \"review_site_color\": \"#fb0000\",\n              \"max_rate\": 5,\n              \"status\": 0,\n              \"show\": true,\n              \"use_sau_scrapper\": \"N\",\n              \"publisher\": \"doordash.com\",\n              \"auth_type\": \"cookie\",\n              \"scraping_enabled\": true,\n              \"posting_enabled\": true,\n              \"self_form_enabled\": \"N\",\n              \"email_on_reply\": \"N\",\n              \"deleted_at\": null,\n              \"created_at\": \"2024-10-11T08:32:26.000000Z\",\n              \"updated_at\": \"2024-10-11T08:32:26.000000Z\"\n            }\n          }\n        ]\n      }\n    ],\n    \"meta\": {\n      \"billing_model\": \"usage-based\",\n      \"read_only\": false\n    }\n  }\n}"
                },
                {
                  "name": "200 — Plan-Based partner (read-only)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": [\n      {\n        \"id\": 4,\n        \"plan_name\": \"RM Core\",\n        \"price\": 0,\n        \"original_price\": null,\n        \"status\": \"active\",\n        \"plan_type\": \"plan-based\",\n        \"tier_slug\": \"core\",\n        \"enforcement_mode\": \"per-location\",\n        \"is_admin_managed\": true,\n        \"description\": \"Display + manual reply (hipages reviews only) + flagging (hipages only) + per-pull data. No solicitation, no AI features. Auto-applies to migrated tradies through November 2027.\",\n        \"plan_end_date\": null,\n        \"short_info\": \"Free transition tier (through Nov 2027)\",\n        \"created_at\": \"2026-05-24T11:28:25.000000Z\",\n        \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n        \"deleted_at\": null,\n        \"features\": [\n          {\n            \"id\": 88,\n            \"plan_id\": 4,\n            \"feature_name\": \"ai_response\",\n            \"enabled\": true,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": true,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 89,\n            \"plan_id\": 4,\n            \"feature_name\": \"review_flag\",\n            \"enabled\": true,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": true,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 90,\n            \"plan_id\": 4,\n            \"feature_name\": \"response_to_past_reviews\",\n            \"enabled\": true,\n            \"service_limit\": null,\n            \"limit_type\": \"monthly\",\n            \"included_units\": 8,\n            \"overage_rate\": null,\n            \"hard_stop\": true,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 91,\n            \"plan_id\": 4,\n            \"feature_name\": \"response_posting\",\n            \"enabled\": true,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": true,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 92,\n            \"plan_id\": 4,\n            \"feature_name\": \"review_widgets\",\n            \"enabled\": false,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 93,\n            \"plan_id\": 4,\n            \"feature_name\": \"review_solicitation_email\",\n            \"enabled\": false,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 94,\n            \"plan_id\": 4,\n            \"feature_name\": \"review_solicitation_sms\",\n            \"enabled\": false,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 95,\n            \"plan_id\": 4,\n            \"feature_name\": \"insight_report\",\n            \"enabled\": false,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 96,\n            \"plan_id\": 4,\n            \"feature_name\": \"competitive_analysis\",\n            \"enabled\": false,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": false,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          },\n          {\n            \"id\": 97,\n            \"plan_id\": 4,\n            \"feature_name\": \"per_pull_data\",\n            \"enabled\": true,\n            \"service_limit\": null,\n            \"limit_type\": null,\n            \"included_units\": null,\n            \"overage_rate\": null,\n            \"hard_stop\": true,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"partner_billing_enabled\": true\n          }\n        ],\n        \"plan_review_sites\": [\n          {\n            \"id\": 23,\n            \"plan_id\": 4,\n            \"review_site_id\": 44,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n              \"18:00\"\n            ],\n            \"pull_days\": null,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"review_site\": {\n              \"id\": 44,\n              \"name\": \"Google\",\n              \"link\": \"http://maps.google.com\",\n              \"review_site_button\": \"img-google.png\",\n              \"review_site_color\": \"#3774ee\",\n              \"max_rate\": 5,\n              \"status\": 0,\n              \"show\": true,\n              \"use_sau_scrapper\": \"Y\",\n              \"publisher\": \"maps.google.com\",\n              \"auth_type\": \"oauth\",\n              \"scraping_enabled\": true,\n              \"posting_enabled\": true,\n              \"self_form_enabled\": \"N\",\n              \"email_on_reply\": \"N\",\n              \"deleted_at\": null,\n              \"created_at\": \"2017-01-09T11:33:00.000000Z\",\n              \"updated_at\": \"2020-05-04T02:44:57.000000Z\"\n            }\n          },\n          {\n            \"id\": 24,\n            \"plan_id\": 4,\n            \"review_site_id\": 100,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n              \"06:00\"\n            ],\n            \"pull_days\": null,\n            \"created_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"updated_at\": \"2026-06-19T11:45:35.000000Z\",\n            \"deleted_at\": null,\n            \"review_site\": {\n              \"id\": 100,\n              \"name\": \"hipages\",\n              \"link\": \"https://www.hipages.com.au\",\n              \"review_site_button\": \"img-hipages.png\",\n              \"review_site_color\": \"#FF6B00\",\n              \"max_rate\": 5,\n              \"status\": 0,\n              \"show\": true,\n              \"use_sau_scrapper\": \"N\",\n              \"publisher\": null,\n              \"auth_type\": \"restapi\",\n              \"scraping_enabled\": true,\n              \"posting_enabled\": true,\n              \"self_form_enabled\": \"Y\",\n              \"email_on_reply\": \"Y\",\n              \"deleted_at\": null,\n              \"created_at\": \"2026-05-14T00:00:00.000000Z\",\n              \"updated_at\": \"2026-05-14T00:00:00.000000Z\"\n            }\n          }\n        ]\n      }\n    ],\n    \"meta\": {\n      \"billing_model\": \"plan-based\",\n      \"read_only\": true\n    }\n  }\n}"
                }
              ]
            },
            {
              "name": "3.A.2 Get partner plan",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/plans/{{planId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "plans",
                    "{{planId}}"
                  ]
                },
                "description": "Loads a single plan for the authenticated partner (usage-based: for editing; plan-based: read-only).\n\n**Auth:** Partner. `auth:api` group, no extra route-level middleware. `getPartnerPlan()` resolves the partner via `getAuthPartner()` (**403** if none). The plan must be linked to the partner via `company_plans` (`planBelongsToCompany`) else **403**; usage-based → must be `plan_type=usage-based AND is_admin_managed=0` else **403**; plan-based → must be `plan_type=plan-based AND is_admin_managed=1` else **403**. **404** if the plan id doesn't exist.\n\n**Rate limit:** No rate limit.\n\n**Request**\n\n| Param | In | Type | Required | Notes |\n|---|---|---|---|---|\n| `id` | path | integer | Yes | Plan ID (route `[0-9]+`). Must belong to the partner. |\n\n**Response** (`data.data` = `Plan` with `features` + `planReviewSites.reviewSite`; `data.meta.read_only`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": { \"id\": 4, \"plan_name\": \"Gold\", \"price\": 4, \"status\": \"active\", \"plan_type\": \"plan-based\", \"is_admin_managed\": true, \"features\": [ { \"feature_name\": \"ai_response\", \"enabled\": true, \"overage_rate\": \"0.0800\", \"hard_stop\": false, \"partner_billing_enabled\": true } ], \"plan_review_sites\": [ { \"review_site_id\": 1, \"pull_frequency\": \"daily\", \"review_site\": { \"id\": 1, \"name\": \"Yelp\", \"auth_type\": \"cookie\" } } ] },\n    \"meta\": { \"read_only\": true }\n  }\n}"
                }
              ]
            },
            {
              "name": "3.A.3 Create plan (Usage-Based only)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"plan_name\": \"ResponseScribe Basic\",\n    \"price\": 49.99,\n    \"original_price\": 59.99,\n    \"status\": \"active\",\n    \"plan_end_date\": \"2027-12-31\",\n    \"short_info\": \"Up to 50 responses/month\",\n    \"description\": \"Full AI-powered response service.\",\n    \"enforcement_mode\": \"per-location\",\n    \"features\": [\n        {\n            \"feature_name\": \"ai_response\",\n            \"enabled\": true,\n            \"hard_stop\": true,\n            \"service_limit\": 50,\n            \"limit_type\": null\n        },\n        {\n            \"feature_name\": \"response_to_past_reviews\",\n            \"enabled\": true,\n            \"hard_stop\": false,\n            \"limit_type\": \"monthly\"\n        },\n        {\n            \"feature_name\": \"per_pull_data\",\n            \"enabled\": true\n        }\n    ],\n    \"review_sites\": [\n        {\n            \"review_site_id\": 1,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n                \"06:00\"\n            ],\n            \"pull_days\": []\n        }\n    ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/plans/create",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "plans",
                    "create"
                  ]
                },
                "description": "Creates a new usage-based plan owned by the authenticated partner and links it via `company_plans`. Features + review sites are persisted from the payload; `plan_type` is forced `usage-based`, `is_admin_managed=0`.\n\n**Auth:** Partner (usage-based only). `auth:api` group, no extra route-level middleware. `createPartnerPlan()` resolves the partner via `getAuthPartner()` (**403** if none) and returns **403** (`Partner plans can only be created for usage-based companies.`) unless `partner.billing_model === 'usage-based'`. `overage_rate` is never accepted — it is server-derived from the partner billing config.\n\n**Validation** (`PartnerPlanRequest`) branches on the partner `contract_model`: **model-a** → `hard_stop` required per enabled feature, `service_limit` required only when `hard_stop=true` (else unlimited + overage); **model-b** → `hard_stop` forced true at persist (ignored here), `service_limit` optional. `per_pull_data` is exempt (quota derives from review-site cadence). ≥1 feature must be enabled.\n\n**Request body**\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `plan_name` | string | Yes | max 100 |\n| `price` | number | Yes | min 0 |\n| `original_price` | number | No | nullable, min 0 |\n| `status` | string | No | `active`\\|`inactive` (default `active`) |\n| `plan_end_date` | date | No | nullable |\n| `short_info` | string | No | nullable, max 255 |\n| `description` | string | No | nullable |\n| `enforcement_mode` | string | No | `per-location`\\|`aggregate-pool` (default `per-location`) |\n| `features` | array | Yes | min 1, ≥1 enabled |\n| `features.*.feature_name` | string | Yes | `ai_response, review_flag, response_to_past_reviews, response_posting, review_widgets, review_solicitation_email, review_solicitation_sms, review_qr_code, insight_report, competitive_analysis, per_pull_data` |\n| `features.*.enabled` | boolean | No | default true |\n| `features.*.hard_stop` | boolean | model-a: Yes (per enabled, non-`per_pull_data`) | model-b: ignored |\n| `features.*.service_limit` | integer | conditional | model-a: required when `hard_stop=true`; min 1 |\n| `features.*.limit_type` | string | conditional | `monthly`\\|`quantity`; **required if** `response_to_past_reviews` |\n| `review_sites` | array | Yes | min 1 |\n| `review_sites.*.review_site_id` | integer | Yes | exists in `review_sites` |\n| `review_sites.*.pull_frequency` | string | Yes | `hourly`\\|`daily`\\|`weekly`\\|`monthly` |\n| `review_sites.*.pull_interval` | integer | No | 1–30 |\n| `review_sites.*.pull_times` | array | No | each `HH:MM` |\n| `review_sites.*.pull_days` | array | No | nullable; each int 0–6 |\n\n**Rate limit:** No rate limit.\n\n**Response** — `data` is the created `Plan` with `features` + `plan_review_sites`."
              },
              "response": [
                {
                  "name": "200 — Plan created",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": {\n      \"id\": 11,\n      \"plan_name\": \"ResponseScribe Basic\",\n      \"price\": 49.99,\n      \"original_price\": 59.99,\n      \"status\": \"active\",\n      \"description\": \"Full AI-powered response service.\",\n      \"plan_end_date\": \"2027-12-31T00:00:00.000000Z\",\n      \"short_info\": \"Up to 50 AI responses/month\",\n      \"plan_type\": \"usage-based\",\n      \"tier_slug\": null,\n      \"enforcement_mode\": \"per-location\",\n      \"is_admin_managed\": false,\n      \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n      \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n      \"deleted_at\": null,\n      \"features\": [\n        {\n          \"id\": 88,\n          \"plan_id\": 11,\n          \"feature_name\": \"ai_response\",\n          \"enabled\": true,\n          \"service_limit\": 50,\n          \"limit_type\": null,\n          \"included_units\": null,\n          \"overage_rate\": null,\n          \"hard_stop\": true,\n          \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"deleted_at\": null,\n          \"partner_billing_enabled\": true\n        },\n        {\n          \"id\": 89,\n          \"plan_id\": 11,\n          \"feature_name\": \"response_to_past_reviews\",\n          \"enabled\": true,\n          \"service_limit\": null,\n          \"limit_type\": \"monthly\",\n          \"included_units\": 10,\n          \"overage_rate\": \"0.0500\",\n          \"hard_stop\": false,\n          \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"deleted_at\": null,\n          \"partner_billing_enabled\": true\n        },\n        {\n          \"id\": 90,\n          \"plan_id\": 11,\n          \"feature_name\": \"response_posting\",\n          \"enabled\": true,\n          \"service_limit\": null,\n          \"limit_type\": null,\n          \"included_units\": null,\n          \"overage_rate\": \"0.0400\",\n          \"hard_stop\": false,\n          \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"deleted_at\": null,\n          \"partner_billing_enabled\": true\n        },\n        {\n          \"id\": 91,\n          \"plan_id\": 11,\n          \"feature_name\": \"review_widgets\",\n          \"enabled\": true,\n          \"service_limit\": 5,\n          \"limit_type\": null,\n          \"included_units\": null,\n          \"overage_rate\": null,\n          \"hard_stop\": true,\n          \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"deleted_at\": null,\n          \"partner_billing_enabled\": true\n        },\n        {\n          \"id\": 92,\n          \"plan_id\": 11,\n          \"feature_name\": \"per_pull_data\",\n          \"enabled\": true,\n          \"service_limit\": null,\n          \"limit_type\": null,\n          \"included_units\": null,\n          \"overage_rate\": null,\n          \"hard_stop\": false,\n          \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"deleted_at\": null,\n          \"partner_billing_enabled\": true\n        }\n      ],\n      \"plan_review_sites\": [\n        {\n          \"id\": 30,\n          \"plan_id\": 11,\n          \"review_site_id\": 44,\n          \"pull_frequency\": \"daily\",\n          \"pull_interval\": 1,\n          \"pull_times\": [\n            \"06:00\"\n          ],\n          \"pull_days\": [],\n          \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"deleted_at\": null,\n          \"review_site\": {\n            \"id\": 44,\n            \"name\": \"Google\",\n            \"link\": \"http://maps.google.com\",\n            \"review_site_button\": \"img-google.png\",\n            \"review_site_color\": \"#3774ee\",\n            \"max_rate\": 5,\n            \"status\": 0,\n            \"show\": true,\n            \"use_sau_scrapper\": \"Y\",\n            \"publisher\": \"maps.google.com\",\n            \"auth_type\": \"oauth\",\n            \"scraping_enabled\": true,\n            \"posting_enabled\": true,\n            \"self_form_enabled\": \"N\",\n            \"email_on_reply\": \"N\",\n            \"deleted_at\": null,\n            \"created_at\": \"2017-01-09T11:33:00.000000Z\",\n            \"updated_at\": \"2020-05-04T02:44:57.000000Z\"\n          }\n        }\n      ]\n    }\n  }\n}"
                }
              ]
            },
            {
              "name": "3.A.4 Update plan (Usage-Based only)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"plan_name\": \"ResponseScribe Pro\",\n    \"price\": 79.99,\n    \"original_price\": 89.99,\n    \"status\": \"active\",\n    \"plan_end_date\": \"2027-12-31\",\n    \"short_info\": \"Up to 200 responses/month\",\n    \"description\": \"Full AI-powered response service.\",\n    \"enforcement_mode\": \"per-location\",\n    \"features\": [\n        {\n            \"feature_name\": \"ai_response\",\n            \"enabled\": true,\n            \"hard_stop\": true,\n            \"service_limit\": 200,\n            \"limit_type\": null\n        },\n        {\n            \"feature_name\": \"response_to_past_reviews\",\n            \"enabled\": true,\n            \"hard_stop\": false,\n            \"limit_type\": \"monthly\"\n        },\n        {\n            \"feature_name\": \"per_pull_data\",\n            \"enabled\": true\n        }\n    ],\n    \"review_sites\": [\n        {\n            \"review_site_id\": 1,\n            \"pull_frequency\": \"daily\",\n            \"pull_interval\": 1,\n            \"pull_times\": [\n                \"06:00\"\n            ],\n            \"pull_days\": []\n        }\n    ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/plans/{{planId}}/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "plans",
                    "{{planId}}",
                    "update"
                  ]
                },
                "description": "Updates an existing partner-managed usage-based plan. Features + review sites are **fully replaced** each call (old `PlanFeature`/`PlanReviewSite` rows deleted and re-inserted).\n\n**Auth:** Partner (usage-based only). `auth:api` group, no extra route-level middleware. `updatePartnerPlan()` resolves the partner via `getAuthPartner()` (**403** if none); **403** unless `billing_model === 'usage-based'`; **404** if the plan id doesn't exist; **403** unless `plan_type=usage-based AND is_admin_managed=0`; **403** unless linked to the partner (`planBelongsToCompany`). `overage_rate` read-only (same as create).\n\n**Validation** — identical `PartnerPlanRequest` rules as Create (same contract-model branching).\n\n**Rate limit:** No rate limit.\n\n**Request**\n\n| Param | In | Type | Required | Notes |\n|---|---|---|---|---|\n| `id` | path | integer | Yes | Plan ID (route `[0-9]+`); a usage-based plan owned by the partner. |\n\nBody params are the same as **3.A.3 Create**. On update, omitted `status`/`enforcement_mode` fall back to the plan's existing values.\n\n**Response** — `data` is the updated `Plan` with `features` + `plan_review_sites`."
              },
              "response": [
                {
                  "name": "200 — Plan updated",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": {\n      \"id\": 7,\n      \"plan_name\": \"ResponseScribe Basic v2\",\n      \"price\": 59.99,\n      \"original_price\": null,\n      \"status\": \"active\",\n      \"description\": null,\n      \"plan_end_date\": null,\n      \"short_info\": null,\n      \"plan_type\": \"usage-based\",\n      \"tier_slug\": null,\n      \"enforcement_mode\": \"per-location\",\n      \"is_admin_managed\": false,\n      \"created_at\": \"2026-06-01T09:00:00.000000Z\",\n      \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n      \"deleted_at\": null,\n      \"features\": [\n        {\n          \"id\": 70,\n          \"plan_id\": 7,\n          \"feature_name\": \"ai_response\",\n          \"enabled\": true,\n          \"service_limit\": 100,\n          \"limit_type\": null,\n          \"included_units\": null,\n          \"overage_rate\": null,\n          \"hard_stop\": true,\n          \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"deleted_at\": null,\n          \"partner_billing_enabled\": true\n        },\n        {\n          \"id\": 71,\n          \"plan_id\": 7,\n          \"feature_name\": \"response_posting\",\n          \"enabled\": true,\n          \"service_limit\": null,\n          \"limit_type\": null,\n          \"included_units\": null,\n          \"overage_rate\": \"0.0400\",\n          \"hard_stop\": false,\n          \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"deleted_at\": null,\n          \"partner_billing_enabled\": true\n        },\n        {\n          \"id\": 72,\n          \"plan_id\": 7,\n          \"feature_name\": \"per_pull_data\",\n          \"enabled\": true,\n          \"service_limit\": null,\n          \"limit_type\": null,\n          \"included_units\": null,\n          \"overage_rate\": null,\n          \"hard_stop\": false,\n          \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"deleted_at\": null,\n          \"partner_billing_enabled\": true\n        }\n      ],\n      \"plan_review_sites\": [\n        {\n          \"id\": 31,\n          \"plan_id\": 7,\n          \"review_site_id\": 44,\n          \"pull_frequency\": \"daily\",\n          \"pull_interval\": 1,\n          \"pull_times\": [\n            \"06:00\"\n          ],\n          \"pull_days\": [],\n          \"created_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"updated_at\": \"2026-06-19T11:00:00.000000Z\",\n          \"deleted_at\": null,\n          \"review_site\": {\n            \"id\": 44,\n            \"name\": \"Google\",\n            \"link\": \"http://maps.google.com\",\n            \"review_site_button\": \"img-google.png\",\n            \"review_site_color\": \"#3774ee\",\n            \"max_rate\": 5,\n            \"status\": 0,\n            \"show\": true,\n            \"use_sau_scrapper\": \"Y\",\n            \"publisher\": \"maps.google.com\",\n            \"auth_type\": \"oauth\",\n            \"scraping_enabled\": true,\n            \"posting_enabled\": true,\n            \"self_form_enabled\": \"N\",\n            \"email_on_reply\": \"N\",\n            \"deleted_at\": null,\n            \"created_at\": \"2017-01-09T11:33:00.000000Z\",\n            \"updated_at\": \"2020-05-04T02:44:57.000000Z\"\n          }\n        }\n      ]\n    }\n  }\n}"
                }
              ]
            },
            {
              "name": "3.A.5 Delete plan (Usage-Based only)",
              "request": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/plans/{{planId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "plans",
                    "{{planId}}"
                  ]
                },
                "description": "Soft-deletes a partner-managed usage-based plan and removes its `company_plans` link to the partner (one DB transaction).\n\n**Auth:** Partner (usage-based-managed plan only). `auth:api` group, no extra route-level middleware. `deletePartnerPlan()` resolves the partner via `getAuthPartner()` (**403** if none); **404** if the plan id doesn't exist; **403** unless `plan_type=usage-based AND is_admin_managed=0`; **403** unless linked to the partner (`planBelongsToCompany`). The plan-type + ownership checks (not a billing-model gate) restrict deletion.\n\n**Rate limit:** No rate limit.\n\n**Request**\n\n| Param | In | Type | Required | Notes |\n|---|---|---|---|---|\n| `id` | path | integer | Yes | Plan ID (route `[0-9]+`); must exist in `plans` and belong to the partner. |\n\n**Response** — `data.message`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"message\": \"Plan deleted.\" } }"
                }
              ]
            },
            {
              "name": "3.A.6 Account upgrade — change account plan",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"plan_id\": 3,\n    \"account_ids\": [\n        12,\n        15\n    ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/account/plan/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "plan",
                    "update"
                  ]
                },
                "description": "Assigns the selected plan to one or more brand accounts and (re)applies it to every store under each account. Per account the `CompanyPlan` pointer is updated/created, then each store gets a new active `StorePlan` from now → end-of-month; previously active/expired `StorePlan`s without an issued next-invoice are closed. Accounts already on the requested plan are skipped. DB-transactional; a `planChanged` billing webhook fires per updated account.\n\n**Auth:** **Both (Partner or Account).** `auth:api` group under the `account` prefix, no extra route-level middleware — scoping is entirely in `AccountProratedPlanRequest`: `plan_id` must exist in `company_plans` for the caller's owning partner (resolved via `user.person.company.planAssignmentCompanyId()`, which walks up to the plan-owning partner) and each `account_ids.*` must be a `Company` id passing `BelongsToAuthCompany` (inside the auth user's hierarchy). A **Partner** can target any account in its tree; an **Account** caller can only target accounts within its own hierarchy. Same params for both personas — only the reachable account set differs.\n\n**Request body**\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `plan_id` | integer | Yes | Must be assigned (via `company_plans`, non-deleted) to the caller's plan-assignment partner. Else: \"The selected plan is not available for your account.\" |\n| `account_ids` | array | Yes | Brand account (Company) IDs. |\n| `account_ids.*` | integer | Yes | Exists in `company` + within auth hierarchy (`BelongsToAuthCompany`). |\n\n**Rate limit:** No rate limit.\n\n**Response** — `data.status` + `data.message`. `success` with a count summary when ≥1 account updated; `error` when nothing changed (already active everywhere / no active stores)."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Plan updated successfully for 1 account(s) and 4 location(s). Charges will appear on next month's invoice.\"\n  }\n}"
                },
                {
                  "name": "200 No-op (already on requested plan)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"error\",\n    \"message\": \"No accounts were updated since the selected plan is already active for all specified accounts or no active stores were found under those accounts. If you expected accounts to be updated, please verify that the account IDs are correct and that the selected plan is different from the currently active plan for those accounts.\"\n  }\n}"
                },
                {
                  "name": "422 Plan not in partner catalog",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected plan is not available for your account.\",\n  \"errors\": {\n    \"plan_id\": [\"The selected plan is not available for your account.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "3.A.7 Cancel account subscription (all stores)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/plans/cancel-account-subscription",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "plans",
                    "cancel-account-subscription"
                  ]
                },
                "description": "Cancels every active store plan under the given brand account and notifies an admin via a `brandPlanCancelled` billing webhook. All matching `StorePlan`s (status `active`/`expired`) are set to `close` with `plan_end`, `cancel_subscription_at`, and the reason stamped.\n\n**Auth:** **Both (Partner or Account), within the brand's own tree.** `auth:api` group under the `plans` prefix, no extra route-level middleware. Two-layer scoping: (1) `CancelBrandSubscriptionRequest` requires `company_id` to be an existing `bundle_id=3` (brand/account) Company passing `BelongsToAuthCompany`; (2) the controller additionally requires the auth user's `company_id` to equal the brand id **or** the brand's parent company id — else **403** (`Permission denied`). So an Account user cancels their own brand; the parent Partner can cancel a child brand. **404** if the brand has no locations (`Location not found.`) or no payable active plans (`No active plan found.`).\n\n**Request body**\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | Yes | Brand account ID; existing `bundle_id=3` Company in the caller's hierarchy. |\n| `cancelling_reason` | string | Yes | One of: `Price too high`, `Response quality not good enough`, `Another solution in place`, `Prefer to respond myself`, `Do not get enough reviews to justify it`, `Responses are not important to our business`, `Other`. |\n| `cancelling_reason_other` | string | No | nullable, max 255. |\n\n**Rate limit:** No rate limit.\n\n**Response** — `data.message`.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 12,\n    \"cancelling_reason\": \"Price too high\",\n    \"cancelling_reason_other\": \"Switching to a competitor\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": { \"message\": \"Subscription cancelled successfully.\" }\n}"
                },
                {
                  "name": "403 Permission denied",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Permission denied\",\n  \"errors\": []\n}"
                },
                {
                  "name": "404 Location not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Location not found.\",\n  \"errors\": []\n}"
                }
              ]
            }
          ],
          "description": "Partner-managed plan templates + assignment surface. Usage-Based partners own their plans here; Plan-Based partners get an admin-managed catalogue via 3.A.6–8."
        },
        {
          "name": "§ P.7 Webhook subscriptions",
          "description": "**Partner-admin outbound webhook subscriptions.**\n\nEvery action captured in the `review_history` audit trail can be fanned out to a partner-owned webhook endpoint. The partner owns the subscription URL, an optional event filter, and the HMAC secret used to verify deliveries.\n\n## Two-slot delivery model (current — replaces the legacy 3-tier model)\n\nEach partner can have at most **one IMMEDIATE subscription** and **one BATCH subscription** at the same time (independent URLs / secrets / event filters). The slot is chosen at create time via `delivery_kind`:\n\n| `delivery_kind` | When it fires | Required fields | Notes |\n|---|---|---|---|\n| `immediate` | Per event — fan-out happens inside `ReviewHistoryObserver` the moment the audit row is written | `events` (optional filter), `payload_mode`, `suppress_native_notifications` apply | Use this slot for time-critical signals (flag actions, profanity, post failures, removals) |\n| `batch` | Every `batch_interval_hours` hours by the scheduler — coalesces all review-impacting activity since `last_batch_dispatched_at` | `batch_interval_hours` ∈ {1, 2, 4, 6, 12} | Event filter / payload mode / suppress flag are ignored — batch always sends full envelopes for every review-impacting event |\n\nRe-creating a slot is blocked while the existing one is non-soft-deleted: delete it first or `POST /update` the existing row. After a soft-delete the slot frees up immediately.\n\n## Lifecycle\n\n1. Partner-admin POSTs `/partner/webhooks` with at minimum `{label, webhook_url, delivery_kind}` (+ `batch_interval_hours` if `batch`).\n2. Server generates a 256-bit raw secret, returns it ONCE in the response, and persists only the encrypted ciphertext + an 8-byte display fingerprint.\n3. For `immediate` subs: every matching `review_history` insert triggers `ReviewHistoryObserver` → `DispatchPartnerWebhooks` → `DeliverWebhook`.\n4. For `batch` subs: the scheduler wakes up every `batch_interval_hours`, dispatches one job per qualifying review since `last_batch_dispatched_at`, then advances the cursor.\n5. Each attempt writes a row to `partner_webhook_deliveries`. On 2xx the subscription's `consecutive_failure_count` resets to 0; on permanent failure it increments. After 20 consecutive permanent failures the subscription auto-disables.\n\n## Delivery envelope (received by your endpoint)\n\n**Method:** `POST` — JSON body, `Content-Type: application/json`.\n\n**Headers:**\n- `X-SAU-Event` — event value (e.g. `profanity_detected`, `response_post_succeeded`, `webhook.test`)\n- `X-SAU-Delivery` — ULID unique to this event (idempotency key)\n- `X-SAU-Attempt` — current attempt number (1..3)\n- `X-SAU-Signature` — `sha256=<hex>` of `hash_hmac('sha256', RAW_BODY, RAW_SECRET)`\n\n**Body:** Canonical envelope —\n```json\n{\n  \"event\": \"profanity_detected\",\n  \"event_id\": \"01HXYZ...\",\n  \"occurred_at\": \"2026-05-28T09:42:01+00:00\",\n  \"delivered_at\": \"2026-05-28T09:42:01+00:00\",\n  \"partner\":  {\"id\": 5},\n  \"actor\":    {\"type\": \"system\", \"id\": null, \"name\": \"profanity-scan\"},\n  \"review\":   {\"id\": 769, \"external_review_id\": \"hp-12345\", \"store_id\": 2, \"review_site_id\": 1, \"source_platform\": \"hipages\", \"rate\": 1, \"reviewer\": \"Anon\"},\n  \"metadata\": {\"any\": \"action-specific\"}\n}\n```\n\n### Site-connection event envelope (review_pull_* / response_post_*)\n\nConnection events use a different body shape (no `review` / `actor` embed). The same `X-SAU-*` headers and HMAC signing apply.\n\n```json\n{\n  \"event\": \"review_pull_disconnected\",\n  \"event_id\": \"01HXYZ...\",\n  \"occurred_at\": \"2026-06-22T09:42:01+00:00\",\n  \"delivered_at\": \"2026-06-22T09:42:01+00:00\",\n  \"partner\": {\"id\": 5},\n  \"store\": {\"id\": 812},\n  \"review_site\": {\n    \"store_review_site_id\": 4410,\n    \"review_site_id\": 1,\n    \"name\": \"Google\",\n    \"connection_status\": \"disconnected\",\n    \"disconnect_reason\": \"Google connection has expired. Please reconnect this location.\"\n  },\n  \"axis\": \"pull\",\n  \"error_code\": \"E-002\",\n  \"reason\": \"Google connection has expired. Please reconnect this location.\"\n}\n```\n\n- `axis` - `pull` (data pulling / scraping) or `post` (response posting). The two axes are independent: a site can keep pulling reviews while posting access is lost, so you may receive `response_post_disconnected` without `review_pull_disconnected`.\n- `error_code` - stable catalog code, present on the disconnected events (e.g. E-001 no URL configured, E-002 expired credentials, E-007 two-factor required, E-008 data pulling paused). Null on the connected events.\n- `reason` - layman-friendly explanation safe to surface to an end user. Null on connected events.\n- An event fires only on a per-axis transition (connected vs disconnected), never on every write.\n\n## Verifying the signature (server side)\n```python\nimport hmac, hashlib\nexpected = 'sha256=' + hmac.new(SECRET.encode(), RAW_BODY_BYTES, hashlib.sha256).hexdigest()\nassert hmac.compare_digest(expected, request.headers['X-SAU-Signature'])\n```\n\n## Retry & auto-disable\n- 3 attempts per event: immediate, +1m, +5m, +30m\n- One `partner_webhook_deliveries` row per attempt (full per-attempt audit trail)\n- 20 consecutive permanent failures → subscription auto-disabled, re-enable manually\n\n## Auth\nAll endpoints require the top-level collection bearer (`Authorization: Bearer {{bearerToken}}`, set automatically by **1.A.1 Login** using the `email` / `password` environment variables). The token user MUST belong to a company with `bundle_id=1` (partner-admin). Brand-admins and tradies receive 403 with audit-logged `AUTHZ_DENIED`. Cross-tenant subscription IDs return 404 (not 403) — least-information leak.\n\n## Spec v4.26 fields (immediate-slot only)\n\n- **`payload_mode`** (`full` | `minimal`, default `full`) — §10.4. `minimal` ships only the cache-invalidation envelope (event, event_id, occurred_at, partner.id, review.id, review_history_id) so the partner uses it as a refetch ping. `full` keeps the original contract with the full review embed.\n- **`suppress_native_notifications`** (boolean, default `false`) — §10.18 (broadened 2026-06). When `true` on an active subscription, SAU skips every native partner-action email for the partner tree: homeowner notify on response posted, flag-status changes to brand owner, and future review-action emails as they are added. The original per-event opt-in check (requiring `homeowner_notified_of_response` in `events`) is no longer enforced — the webhook fan-out fires reliably for every relevant audit row, so the partner is guaranteed a replacement signal.\n\nThese two fields are accepted on `batch` subscriptions but pinned to safe defaults server-side (`full` / `false`) — only the immediate slot honours them. The legacy `delivery_tier_override` field has been REMOVED — use `delivery_kind` + `batch_interval_hours` instead.\n",
          "item": [
            {
              "name": "7.A.1 List my subscriptions",
              "request": {
                "method": "GET",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{bearerToken}}"
                  },
                  {
                    "key": "Accept",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/webhooks",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "webhooks"
                  ]
                },
                "description": "Lists every active (non-soft-deleted) webhook subscription owned by the caller's partner company, newest first. Raw HMAC secret material is never returned — only the fingerprint.\n\n**Auth:** Partner — `auth:api`; no extra route middleware. The controller calls `requirePartnerCompanyId()`, which 401s if unauthenticated and 403s unless `request.user().person.company.bundle_id === 1` (partner-admin). Rows are scoped to that resolved `partner_company_id`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- No path/query/body params.\n\n**Response** — `{ data: { data: [...] } }` envelope; `data.data` is the array of subscriptions.\n- `id` (int)\n- `partner_company_id` (int)\n- `label` (string)\n- `webhook_url` (string)\n- `events` (array<string>|null) — null = wildcard (all events)\n- `payload_mode` (string) — `full` | `minimal`; defaults to `full`\n- `delivery_kind` (string) — `immediate` | `batch`\n- `batch_interval_hours` (int|null) — null on immediate subs\n- `delivery_label` (string) — human label, e.g. `immediate` or `every 4 hours`\n- `last_batch_dispatched_at` (string ISO-8601|null)\n- `is_active` (bool)\n- `hmac_secret_fingerprint` (string)\n- `last_succeeded_at` (string ISO-8601|null)\n- `last_failed_at` (string ISO-8601|null)\n- `last_failure_reason` (string|null)\n- `consecutive_failure_count` (int)\n- `created_at` / `updated_at` (string ISO-8601)"
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"data\": [\n  {\n    \"id\": 1,\n    \"partner_company_id\": 5,\n    \"label\": \"Review-site connection\",\n    \"webhook_url\": \"https://webhook.site/59ae5174-d893-41c5-af9f-0c52a20cf90f\",\n    \"events\": [\"review_pull_connected\", \"review_pull_disconnected\", \"usage.limit_reached\"],\n    \"payload_mode\": \"full\",\n    \"delivery_kind\": \"immediate\",\n    \"batch_interval_hours\": null,\n    \"delivery_label\": \"immediate\",\n    \"last_batch_dispatched_at\": null,\n    \"is_active\": true,\n    \"hmac_secret_fingerprint\": \"5373187189f86daf\",\n    \"last_succeeded_at\": \"2026-06-15T13:41:47+00:00\",\n    \"last_failed_at\": null,\n    \"last_failure_reason\": null,\n    \"consecutive_failure_count\": 0,\n    \"created_at\": \"2026-06-11T11:30:50+00:00\",\n    \"updated_at\": \"2026-06-15T13:41:47+00:00\"\n  }\n] } }"
                }
              ]
            },
            {
              "name": "7.A.2 Create subscription (raw secret returned ONCE)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{bearerToken}}"
                  },
                  {
                    "key": "Accept",
                    "value": "application/json"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/webhooks",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "webhooks"
                  ]
                },
                "description": "Creates an outbound webhook subscription for the caller's partner; the server generates a 256-bit HMAC secret and returns the raw value ONCE in `hmac_secret` (never recoverable afterward except via reveal/rotate). Each partner may hold at most one non-deleted subscription per `delivery_kind` slot (one immediate + one batch).\n\n**Auth:** Partner — `auth:api`; `requirePartnerCompanyId()` enforces `bundle_id === 1` (403 otherwise). `partner_company_id` is taken from the auth user, not the body.\n\n**Rate limit:** No rate limit.\n\n**Request** (validated by `PartnerWebhookCreateRequest`)\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `label` | string | yes | max 255 |\n| `webhook_url` | string (url) | yes | `url:http,https`, max 2048 |\n| `delivery_kind` | string | yes | `immediate` \\| `batch`; unique-slot rule — fails 422 if a non-deleted sub of the same kind already exists for this partner |\n| `batch_interval_hours` | int\\|null | required if `delivery_kind=batch` | one of `[1,2,4,6,12]`; nullable/ignored for immediate |\n| `events` | array<string>\\|null | no | omit or `[]` = all events (persisted as null). Batch subs ignore it (pinned null). On immediate slot, batch-kind events are rejected; hipages-only events rejected for non-hipages partners |\n| `events.*` | string | — | must be in `WebhookEventCatalog::values()` |\n| `payload_mode` | string | no | `full` \\| `minimal`; defaults `full`; forced `full` on batch subs |\n| `is_active` | bool | no | defaults `true` |\n\n**Response** — `{ data: { data: {...} } }` envelope (HTTP 201). Same fields as the show/list presenter, PLUS the once-only secret block:\n- `hmac_secret` (string) — raw secret, shown ONCE\n- `hmac_secret_warning` (string) — store-it-now warning\n- (no `events` filter persisted for batch subs → `events: null`; `payload_mode` forced `full`; `last_batch_dispatched_at` set to creation time for batch, null for immediate)",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"label\": \"Production webhook\",\n    \"webhook_url\": \"https://partner.example.com/hooks/sau\",\n    \"delivery_kind\": \"immediate\",\n    \"is_active\": true,\n    \"events\": [\n        \"review_pull_connected\",\n        \"review_pull_disconnected\",\n        \"usage.limit_reached\"\n    ],\n    \"payload_mode\": \"full\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201 || pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  if (j.data && j.data.id) { pm.environment.set('webhookSubscriptionId', String(j.data.id)); }",
                      "  if (j.data && j.data.hmac_secret) {",
                      "    pm.environment.set('webhookHmacSecret', j.data.hmac_secret);",
                      "    console.log('Saved raw HMAC secret to {{webhookHmacSecret}} — visible ONLY in this run');",
                      "  }",
                      "}"
                    ]
                  }
                }
              ],
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"data\": {\n  \"id\": 2,\n  \"partner_company_id\": 5,\n  \"label\": \"Production webhook\",\n  \"webhook_url\": \"https://partner.example.com/hooks/sau\",\n  \"events\": [\"review_pull_connected\", \"review_pull_disconnected\", \"usage.limit_reached\"],\n  \"payload_mode\": \"full\",\n  \"delivery_kind\": \"immediate\",\n  \"batch_interval_hours\": null,\n  \"delivery_label\": \"immediate\",\n  \"last_batch_dispatched_at\": null,\n  \"is_active\": true,\n  \"hmac_secret_fingerprint\": \"587647e874083b23\",\n  \"last_succeeded_at\": null,\n  \"last_failed_at\": null,\n  \"last_failure_reason\": null,\n  \"consecutive_failure_count\": 0,\n  \"created_at\": \"2026-06-16T10:23:23+00:00\",\n  \"updated_at\": \"2026-06-16T10:23:23+00:00\",\n  \"hmac_secret\": \"whsec_DUMMYsecretFORdocsONLYdoNOTuse00000000\",\n  \"hmac_secret_warning\": \"Store this value securely — it will not be shown again. Use it to verify the X-SAU-Signature header on every incoming delivery.\"\n} } }"
                }
              ]
            },
            {
              "name": "7.A.3 List subscribable events",
              "request": {
                "method": "GET",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{bearerToken}}"
                  },
                  {
                    "key": "Accept",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/webhooks/events",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "webhooks",
                    "events"
                  ]
                },
                "description": "Returns the full catalog of events the caller's partner can subscribe to, plus the allowed `batch_interval_hours` choices. The catalog is partner-scoped: hipages-only events (flagging / rating-gate / moderation family) are included only for hipages partners.\n\n**Auth:** Partner — `auth:api`; `requirePartnerCompanyId()` enforces `bundle_id === 1`. The resolved `partner_company_id` is passed to `HipagesPolicy::isHipagesPartnerStore()` to decide whether hipages events are included.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- No path/query/body params.\n\n**Response** — `{ data: { data: [...], meta: {...} } }` envelope. `data.data` is the event array; `data.meta` carries cadence options.\n- `data.data[].value` (string) — event name (e.g. `review_ingested`, `review_pull_connected`)\n- `data.data[].category` (string) — e.g. `ingest`, `response`, `connection`, `usage`, `billing`\n- `data.data[].description` (string)\n- `data.data[].default_kind` (string) — `immediate` | `batch`; the slot the event belongs to\n- `data.meta.batch_interval_hours_options` (array<int>) — `[1, 2, 4, 6, 12]`"
              },
              "response": [
                {
                  "name": "200 — events catalog (full: ingest / hipages / response / connection / usage / billing)",
                  "originalRequest": {
                    "method": "GET",
                    "header": [
                      {
                        "key": "Authorization",
                        "value": "Bearer {{bearerToken}}"
                      },
                      {
                        "key": "Accept",
                        "value": "application/json"
                      }
                    ],
                    "url": {
                      "raw": "{{baseUrl}}/partner/webhooks/events",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "webhooks",
                        "events"
                      ]
                    }
                  },
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "Content-Type",
                      "value": "application/json"
                    }
                  ],
                  "cookie": [],
                  "body": "{\n  \"data\": {\n    \"data\": [\n      {\n        \"value\": \"review_ingested\",\n        \"category\": \"ingest\",\n        \"description\": \"A new review was ingested via partner submission or seed import.\",\n        \"default_kind\": \"batch\"\n      },\n      {\n        \"value\": \"review_pulled_from_platform\",\n        \"category\": \"ingest\",\n        \"description\": \"A new review was pulled from a connected platform (Google / Facebook / Caring).\",\n        \"default_kind\": \"batch\"\n      },\n      {\n        \"value\": \"review_campaign_collected\",\n        \"category\": \"ingest\",\n        \"description\": \"A review was collected through a solicitation campaign flow.\",\n        \"default_kind\": \"batch\"\n      },\n      {\n        \"value\": \"review_updated\",\n        \"category\": \"ingest\",\n        \"description\": \"An existing review’s content was overwritten via re-submission.\",\n        \"default_kind\": \"batch\"\n      },\n      {\n        \"value\": \"review_hard_deleted\",\n        \"category\": \"ingest\",\n        \"description\": \"A review was permanently removed (typically OAuth disconnect cleanup).\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"platform_removal_detected\",\n        \"category\": \"ingest\",\n        \"description\": \"A backward scrape detected the review is no longer present on the source platform.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"profanity_detected\",\n        \"category\": \"ingest\",\n        \"description\": \"A profanity scan matched the review body (logged only on hit, content auto-redacted).\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"tradie_flagged\",\n        \"category\": \"hipages\",\n        \"description\": \"A tradie flagged a review for moderation review.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"flag_denied\",\n        \"category\": \"hipages\",\n        \"description\": \"The service team rejected a tradie flag; the review stays visible.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"flag_approved\",\n        \"category\": \"hipages\",\n        \"description\": \"The service team approved a flag and excluded the review from ratings.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"flag_approved_removed\",\n        \"category\": \"hipages\",\n        \"description\": \"The service team approved a flag and removed the review entirely.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"suppressed\",\n        \"category\": \"hipages\",\n        \"description\": \"A partner-admin hid the review from the public profile and ratings.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"restored\",\n        \"category\": \"hipages\",\n        \"description\": \"A previously suppressed review was brought back into the public profile and ratings.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"excluded_from_rating\",\n        \"category\": \"hipages\",\n        \"description\": \"The review was excluded from rating calculations but remains publicly visible.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"included_in_rating\",\n        \"category\": \"hipages\",\n        \"description\": \"The review is once again counted in rating calculations.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"hidden_from_public\",\n        \"category\": \"hipages\",\n        \"description\": \"The review was hidden from the public profile but still counts toward ratings.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"shown_to_public\",\n        \"category\": \"hipages\",\n        \"description\": \"The review was made visible on the public profile again.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"released_to_public\",\n        \"category\": \"hipages\",\n        \"description\": \"The review was released early from the 7-day hold and is now publicly visible.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"qualified_toggled\",\n        \"category\": \"hipages\",\n        \"description\": \"The review’s qualifying category (A ↔ B) was reclassified.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"soft_deleted\",\n        \"category\": \"hipages\",\n        \"description\": \"A partner-admin soft-deleted the review; it can be restored later.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"response_drafted\",\n        \"category\": \"response\",\n        \"description\": \"A response draft was created on a review.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"response_updated\",\n        \"category\": \"response\",\n        \"description\": \"A response draft’s content was edited.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"response_deleted\",\n        \"category\": \"response\",\n        \"description\": \"A response was removed (soft or hard delete).\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"ai_response_generated\",\n        \"category\": \"response\",\n        \"description\": \"An AI-suggested response was drafted for the review.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"response_submitted_to_platform\",\n        \"category\": \"response\",\n        \"description\": \"A response was dispatched to the source platform (Google / Facebook / Caring).\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"response_post_succeeded\",\n        \"category\": \"response\",\n        \"description\": \"The source platform accepted and published the response.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"response_post_failed\",\n        \"category\": \"response\",\n        \"description\": \"The source platform rejected the response (reason in metadata).\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"response_status_changed\",\n        \"category\": \"response\",\n        \"description\": \"A response moved between lifecycle statuses (draft → submitted → posted / failed).\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"response_resubmitted\",\n        \"category\": \"response\",\n        \"description\": \"A previously failed response was re-fired to the source platform.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"homeowner_notified_of_response\",\n        \"category\": \"hipages\",\n        \"description\": \"A homeowner notification email was sent informing them of the response.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"review_pull_connected\",\n        \"category\": \"connection\",\n        \"description\": \"Review pulling (scraping) for a location's review site started or resumed.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"review_pull_disconnected\",\n        \"category\": \"connection\",\n        \"description\": \"Review pulling (scraping) for a location's review site is unavailable — connection lost, no URL configured, or paused (reason + error_code carry which).\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"response_post_connected\",\n        \"category\": \"connection\",\n        \"description\": \"Response posting for a location's review site became active.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"response_post_disconnected\",\n        \"category\": \"connection\",\n        \"description\": \"Response posting for a location's review site is unavailable — disconnected, awaiting approval, or no URL configured.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"usage.threshold_warning\",\n        \"category\": \"usage\",\n        \"description\": \"A location crossed a usage warning threshold (75% / 80%) for a metered plan feature this period.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"usage.limit_reached\",\n        \"category\": \"usage\",\n        \"description\": \"A location reached 100% of its included units for a metered plan feature this period.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"plan.cancelled\",\n        \"category\": \"billing\",\n        \"description\": \"A location cancelled its subscription and will no longer be billed.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"brand_plan.cancelled\",\n        \"category\": \"billing\",\n        \"description\": \"A brand (or partner) cancelled its subscription across its locations.\",\n        \"default_kind\": \"immediate\"\n      },\n      {\n        \"value\": \"plan.changed\",\n        \"category\": \"billing\",\n        \"description\": \"A location or brand subscription plan was changed (upgrade, downgrade or proration).\",\n        \"default_kind\": \"immediate\"\n      }\n    ],\n    \"meta\": {\n      \"batch_interval_hours_options\": [\n        1,\n        2,\n        4,\n        6,\n        12\n      ]\n    }\n  }\n}"
                }
              ]
            },
            {
              "name": "7.A.4 Show one subscription",
              "request": {
                "method": "GET",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{bearerToken}}"
                  },
                  {
                    "key": "Accept",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/webhooks/{{webhookSubscriptionId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "webhooks",
                    "{{webhookSubscriptionId}}"
                  ]
                },
                "description": "Returns a single webhook subscription scoped to the caller's partner. Returns 404 (not 403) when the row exists but belongs to another partner — minimises ID leakage. No raw secret material is exposed.\n\n**Auth:** Partner — `auth:api`; `findCallerSubscription()` calls `requirePartnerCompanyId()` (403 unless `bundle_id === 1`) then loads the row WHERE `id` AND `partner_company_id` = caller's company; missing → 404.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (int, required) — subscription id; route constrained to `[0-9]+`.\n\n**Response** — `{ data: { data: {...} } }` envelope. Same presenter fields as the list endpoint (idx=38): `id`, `partner_company_id`, `label`, `webhook_url`, `events`, `payload_mode`, `delivery_kind`, `batch_interval_hours`, `delivery_label`, `last_batch_dispatched_at`, `is_active`, `hmac_secret_fingerprint`, `last_succeeded_at`, `last_failed_at`, `last_failure_reason`, `consecutive_failure_count`, `created_at`, `updated_at`. No `hmac_secret`."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"data\": {\n  \"id\": 2,\n  \"partner_company_id\": 5,\n  \"label\": \"example_label\",\n  \"webhook_url\": \"https://example.com/webhook\",\n  \"events\": null,\n  \"payload_mode\": \"full\",\n  \"delivery_kind\": \"batch\",\n  \"batch_interval_hours\": 4,\n  \"delivery_label\": \"every 4 hours\",\n  \"last_batch_dispatched_at\": \"2026-06-16T10:23:23+00:00\",\n  \"is_active\": true,\n  \"hmac_secret_fingerprint\": \"587647e874083b23\",\n  \"last_succeeded_at\": null,\n  \"last_failed_at\": null,\n  \"last_failure_reason\": null,\n  \"consecutive_failure_count\": 0,\n  \"created_at\": \"2026-06-16T10:23:23+00:00\",\n  \"updated_at\": \"2026-06-16T10:23:23+00:00\"\n} } }"
                }
              ]
            },
            {
              "name": "7.A.5 Update subscription (no secret)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{bearerToken}}"
                  },
                  {
                    "key": "Accept",
                    "value": "application/json"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/webhooks/{{webhookSubscriptionId}}/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "webhooks",
                    "{{webhookSubscriptionId}}",
                    "update"
                  ]
                },
                "description": "Partially updates a subscription owned by the caller's partner — every field is optional; send only what changes. The HMAC secret cannot be changed here (use rotate-secret). `delivery_kind` may be switched but must not collide with the partner's other slot. Returns the updated row (no secret).\n\n**Auth:** Partner — `auth:api`; `findCallerSubscription()` enforces `bundle_id === 1` and scopes by `partner_company_id` (404 if not owned). Validated by `PartnerWebhookUpdateRequest`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (int, required) — route constrained to `[0-9]+`.\n- Body (all `sometimes`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `label` | string | no | `sometimes`+`required` when present; max 255 |\n| `webhook_url` | string (url) | no | `url:http,https`, max 2048 |\n| `is_active` | bool | no | enable/disable |\n| `delivery_kind` | string | no | `immediate` \\| `batch`; unique-slot rule excludes the edited row by id — 422 on collision with the other slot |\n| `batch_interval_hours` | int\\|null | required if `delivery_kind=batch` | one of `[1,2,4,6,12]` |\n| `events` | array<string>\\|null | no | null/`[]` = all events; on immediate effective kind, batch-kind events rejected; hipages events rejected for non-hipages partners |\n| `events.*` | string | — | must be in `WebhookEventCatalog::values()` |\n| `payload_mode` | string | no | `full` \\| `minimal` |\n\n**Response** — `{ data: { data: {...} } }` envelope (HTTP 200). Same presenter fields as show (idx=41); no `hmac_secret`.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"label\": \"Updated webhook label\",\n    \"webhook_url\": \"https://partner.example.com/hooks/sau-v2\",\n    \"is_active\": true,\n    \"payload_mode\": \"minimal\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"data\": {\n  \"id\": 3,\n  \"partner_company_id\": 5,\n  \"label\": \"Updated webhook label\",\n  \"webhook_url\": \"https://partner.example.com/hooks/sau-v2\",\n  \"events\": null,\n  \"payload_mode\": \"minimal\",\n  \"delivery_kind\": \"immediate\",\n  \"batch_interval_hours\": null,\n  \"delivery_label\": \"immediate\",\n  \"last_batch_dispatched_at\": null,\n  \"is_active\": true,\n  \"hmac_secret_fingerprint\": \"335b947fe611b6b2\",\n  \"last_succeeded_at\": null,\n  \"last_failed_at\": null,\n  \"last_failure_reason\": null,\n  \"consecutive_failure_count\": 0,\n  \"created_at\": \"2026-06-16T10:25:29+00:00\",\n  \"updated_at\": \"2026-06-16T10:26:02+00:00\"\n} } }"
                }
              ]
            },
            {
              "name": "7.A.6 Rotate HMAC secret",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{bearerToken}}"
                  },
                  {
                    "key": "Accept",
                    "value": "application/json"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/webhooks/{{webhookSubscriptionId}}/rotate-secret",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "webhooks",
                    "{{webhookSubscriptionId}}",
                    "rotate-secret"
                  ]
                },
                "description": "Generates a new 256-bit HMAC secret and atomically replaces the stored ciphertext + fingerprint. The OLD secret immediately stops verifying — the partner's verifier must be updated in lock-step. The new raw secret is returned ONCE in `hmac_secret`. No request body required.\n\n**Auth:** Partner — `auth:api`; `findCallerSubscription()` enforces `bundle_id === 1` and scopes by `partner_company_id` (404 if not owned).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (int, required) — route constrained to `[0-9]+`.\n- No body.\n\n**Response** — `{ data: { data: {...} } }` envelope (HTTP 200). All presenter fields (with the rotated `hmac_secret_fingerprint`), PLUS:\n- `hmac_secret` (string) — new raw secret, shown ONCE\n- `hmac_secret_warning` (string) — notes the previous secret is no longer accepted",
                "body": {
                  "mode": "raw",
                  "raw": "{}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  if (j.data && j.data.hmac_secret) {",
                      "    pm.environment.set('webhookHmacSecret', j.data.hmac_secret);",
                      "  }",
                      "}"
                    ]
                  }
                }
              ],
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"data\": {\n  \"id\": 3,\n  \"partner_company_id\": 5,\n  \"label\": \"dummy_label\",\n  \"webhook_url\": \"https://dummy.url/webhook\",\n  \"events\": null,\n  \"payload_mode\": \"full\",\n  \"delivery_kind\": \"batch\",\n  \"batch_interval_hours\": 4,\n  \"delivery_label\": \"every 4 hours\",\n  \"last_batch_dispatched_at\": \"2026-06-16T10:25:29+00:00\",\n  \"is_active\": true,\n  \"hmac_secret_fingerprint\": \"011d12e18d77edfb\",\n  \"last_succeeded_at\": null,\n  \"last_failed_at\": null,\n  \"last_failure_reason\": null,\n  \"consecutive_failure_count\": 0,\n  \"created_at\": \"2026-06-16T10:25:29+00:00\",\n  \"updated_at\": \"2026-06-16T10:26:29+00:00\",\n  \"hmac_secret\": \"whsec_DUMMYsecretFORdocsONLYdoNOTuse00000000\",\n  \"hmac_secret_warning\": \"Store this value securely — it will not be shown again. Update your verifier immediately; the previous secret is no longer accepted.\"\n} } }"
                }
              ]
            },
            {
              "name": "7.A.6b Reveal current HMAC secret",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{bearerToken}}"
                  },
                  {
                    "key": "Accept",
                    "value": "application/json"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/webhooks/{{webhookSubscriptionId}}/reveal-secret",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "webhooks",
                    "{{webhookSubscriptionId}}",
                    "reveal-secret"
                  ]
                },
                "description": "Returns the CURRENT raw HMAC secret (decrypted) so a partner-admin can re-copy it if the value shown at create/rotate time was lost. Does not change the secret. Every call writes a `PARTNER_WEBHOOK_SECRET_REVEALED` audit row. No request body required.\n\n**Auth:** Partner — `auth:api`; `findCallerSubscription()` enforces `bundle_id === 1` and scopes by `partner_company_id` (404 if not owned).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (int, required) — route constrained to `[0-9]+`.\n- No body.\n\n**Response** — `{ data: { data: {...} } }` envelope (HTTP 200). All presenter fields (idx=41), PLUS:\n- `hmac_secret` (string) — the current raw secret (decrypted from `hmac_secret_encrypted`). No `hmac_secret_warning` field on this endpoint.",
                "body": {
                  "mode": "raw",
                  "raw": "{}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  if (j.data && j.data.hmac_secret) {",
                      "    pm.environment.set('webhookHmacSecret', j.data.hmac_secret);",
                      "  }",
                      "}"
                    ]
                  }
                }
              ],
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"data\": {\n  \"id\": 3,\n  \"partner_company_id\": 5,\n  \"label\": \"dummy_label\",\n  \"webhook_url\": \"https://dummy.url/webhook\",\n  \"events\": null,\n  \"payload_mode\": \"full\",\n  \"delivery_kind\": \"batch\",\n  \"batch_interval_hours\": 4,\n  \"delivery_label\": \"every 4 hours\",\n  \"last_batch_dispatched_at\": \"2026-06-16T10:25:29+00:00\",\n  \"is_active\": true,\n  \"hmac_secret_fingerprint\": \"011d12e18d77edfb\",\n  \"last_succeeded_at\": null,\n  \"last_failed_at\": null,\n  \"last_failure_reason\": null,\n  \"consecutive_failure_count\": 0,\n  \"created_at\": \"2026-06-16T10:25:29+00:00\",\n  \"updated_at\": \"2026-06-16T10:26:29+00:00\",\n  \"hmac_secret\": \"whsec_DUMMYsecretFORdocsONLYdoNOTuse00000000\"\n} } }"
                }
              ]
            },
            {
              "name": "7.A.7 Fire a test delivery",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{bearerToken}}"
                  },
                  {
                    "key": "Accept",
                    "value": "application/json"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/webhooks/{{webhookSubscriptionId}}/test",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "webhooks",
                    "{{webhookSubscriptionId}}",
                    "test"
                  ]
                },
                "description": "Dispatches a synthetic `webhook.test` delivery to the subscription's URL (via the same `DeliverWebhook` job + per-attempt retries as production) so partners can verify their endpoint and HMAC verifier. Returns 400 if the subscription is inactive. The resulting `partner_webhook_deliveries` row is identical in shape to a production attempt — poll the deliveries endpoint ~60s later. No request body required.\n\n**Auth:** Partner — `auth:api`; `findCallerSubscription()` enforces `bundle_id === 1` and scopes by `partner_company_id` (404 if not owned).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (int, required) — route constrained to `[0-9]+`.\n- No body.\n\n**Response** — `{ data: { data: {...} } }` envelope (HTTP 200).\n- `data.data.subscription_id` (int)\n- `data.data.status` (string) — `queued`\n- `data.data.note` (string) — hint pointing to the deliveries endpoint",
                "body": {
                  "mode": "raw",
                  "raw": "{}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"data\": {\n  \"subscription_id\": 3,\n  \"status\": \"queued\",\n  \"note\": \"Test event queued — check /partner/webhooks/3/deliveries within ~60s.\"\n} } }"
                }
              ]
            },
            {
              "name": "7.A.8 Recent delivery attempts",
              "request": {
                "method": "GET",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{bearerToken}}"
                  },
                  {
                    "key": "Accept",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/webhooks/{{webhookSubscriptionId}}/deliveries?limit=50",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "webhooks",
                    "{{webhookSubscriptionId}}",
                    "deliveries"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "50"
                    }
                  ]
                },
                "description": "Returns recent delivery attempts for the subscription, newest first (`orderByDesc('id')`). Includes the signed-payload signature and HTTP/error details so partners can re-verify a missed delivery. Bounded by `limit` (default 50, clamped to 1..200).\n\n**Auth:** Partner — `auth:api`; `findCallerSubscription()` enforces `bundle_id === 1` and scopes by `partner_company_id` (404 if not owned).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (int, required) — route constrained to `[0-9]+`.\n- Query: `limit` (int, optional) — default 50, max 200; out-of-range values are clamped, not rejected.\n\n**Response** — `{ data: { data: [...] } }` envelope; `data.data` is the attempt array.\n- `id` (int)\n- `event` (string) — from `event_action` (e.g. `webhook.test`)\n- `event_id` (string)\n- `review_history_id` (string|null) — null for test deliveries\n- `attempt_number` (int)\n- `http_status` (int|null)\n- `error_message` (string|null)\n- `response_body_snippet` (string|null)\n- `signature` (string) — e.g. `sha256=...`\n- `attempted_at` (string ISO-8601|null)\n- `delivered_at` (string ISO-8601|null)\n- `next_retry_at` (string ISO-8601|null)"
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"data\": [\n  {\n    \"id\": 7,\n    \"event\": \"webhook.test\",\n    \"event_id\": \"01KV7ZHF5KYNK2Q2AXN8KQFS5R\",\n    \"review_history_id\": null,\n    \"attempt_number\": 3,\n    \"http_status\": null,\n    \"error_message\": \"cURL error 6: Could not resolve host: dummy.url\",\n    \"response_body_snippet\": null,\n    \"signature\": \"sha256=0000000000000000000000000000000000000000000000000000000000000000\",\n    \"attempted_at\": \"2026-06-16T10:27:30+00:00\",\n    \"delivered_at\": null,\n    \"next_retry_at\": null\n  }\n] } }"
                }
              ]
            },
            {
              "name": "7.A.9 Delete subscription (soft + auto-disable)",
              "request": {
                "method": "DELETE",
                "header": [
                  {
                    "key": "Authorization",
                    "value": "Bearer {{bearerToken}}"
                  },
                  {
                    "key": "Accept",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/webhooks/{{webhookSubscriptionId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "webhooks",
                    "{{webhookSubscriptionId}}"
                  ]
                },
                "description": "Soft-deletes the subscription (sets `deleted_at`) and forces `is_active=false` in one transaction so any already-queued retry attempts exit silently. Delivery-log rows are retained. Soft-deleting frees the `delivery_kind` slot, so a new subscription of the same kind can be created afterward. No request body required.\n\n**Auth:** Partner — `auth:api`; `findCallerSubscription()` enforces `bundle_id === 1` and scopes by `partner_company_id` (404 if not owned).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (int, required) — route constrained to `[0-9]+`.\n- No body.\n\n**Response** — `{ data: { data: {...} } }` envelope (HTTP 200).\n- `data.data.subscription_id` (int)\n- `data.data.status` (string) — `deleted`"
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"data\": {\n  \"subscription_id\": 2,\n  \"status\": \"deleted\"\n} } }"
                }
              ]
            }
          ]
        },
        {
          "name": "§ P.12 Error notifications",
          "description": "Operational-monitoring inbox for the partner: failed scrapes, missing credentials, throttled API quotas. Notifications can be listed (filtered by status bucket), inspected in detail, annotated with notes, bulk-transitioned, and re-emailed to the on-call admin.",
          "item": [
            {
              "name": "P.12.1 List error notifications",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/error-notifications?type=open",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "error-notifications"
                  ],
                  "query": [
                    {
                      "key": "type",
                      "value": "open",
                      "description": "Required — one of `open` / `acknowledged` / `resolved`"
                    },
                    {
                      "key": "limit",
                      "value": "10",
                      "description": "Optional",
                      "disabled": true
                    },
                    {
                      "key": "page",
                      "value": "1",
                      "description": "Optional",
                      "disabled": true
                    },
                    {
                      "key": "sort_by",
                      "value": "desc",
                      "description": "Optional — asc/desc",
                      "disabled": true
                    },
                    {
                      "key": "search_value",
                      "value": "",
                      "description": "Optional free-text search",
                      "disabled": true
                    }
                  ]
                },
                "description": "Returns a paginated, sortable, searchable list of the partner's error/monitoring notifications, filtered by lifecycle state (open / acknowledged / resolved).\n\n**Auth:** Partner — `auth:api`, under the `partner` prefix. Scoped by the caller's own company: the list is built for `[$authUser->person->company_id]` via `EmailNotification::getEmailNotificationList(...)`, so a partner only sees notifications under their company. The `type` query param maps to the SQL filter (`resolved=1` for resolved, `status='acknowledged'` for acknowledged, else open).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `type` (string, required) — one of `open`, `acknowledged`, `resolved`.\n- Query: `limit` (integer, optional) — page size, default 5.\n- Query: `page` (integer, optional) — page number, default 1.\n- Query: `sort_field` (string, optional) — one of `notificationDate`, `latest_note_date`, `accountName`, `storeName`, `reviewSiteName`, `subject`, `resolved`. Default `notificationDate`. (Not in the Form Request rules; read from input directly.)\n- Query: `sort_by` (string, optional) — `asc`/`desc`/`ASC`/`DESC`. Default `desc`.\n- Query: `search_field` (string, optional) — one of `notificationDate`, `accountName`, `storeName`, `subject`, `reviewSiteName`. (Not in Form Request rules; read from input directly.)\n- Query: `search_value` (string, optional) — value to search for.\n\n**Response** — paginated `ErrorNotificationResource` collection (`{ data: [...], links, meta }`). Each item:\n- `id` (int) — notification id.\n- `is_badge` (string) — `Yes` when unread (`notified != 1`), else `No`.\n- `notificationDate` (string) — created_at formatted `m/d/y`.\n- `accountName` (string|null) — brand/account company name (`com.name`).\n- `storeName` (string) — `CONCAT_WS(' - ', store.name, store.storeid)`.\n- `reviewSiteName` (string) — review site name; `Maps.Google` normalized to `Google`; `#N/A` when empty.\n- `subject` (string) — notification subject, or `#N/A`.\n- `isResolved` (string) — `Yes`/`No`.\n- `latestNote` (string) — latest note content, or `#N/A`.\n- `lastUpdated` (string) — latest note date formatted `m/d/y`.\n- `show_acknowledge_button` (string) — `yes`/`no` (yes when not acknowledged and not resolved).\n- `show_resolved_button` (string) — `yes`/`no` (yes when not resolved).\n- `store_id` (int|null) — `store.id`.\n- `review_site_id` (int|null) — `review_sites.id`.\n- `store_review_site_id` (int|null) — matching `store_review_sites.id` for the store+site, or null.\n- `show_connect_button` (string) — `yes`/`no` (yes when `review_site_id > 0` and not resolved)."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    { \"id\": 1215, \"subject\": \"Google scrape failed\", \"accountName\": \"Acme Brand\", \"storeName\": \"Sydney CBD\", \"reviewSiteName\": \"Google\", \"notificationDate\": \"05/29/26\", \"show_acknowledge_button\": \"yes\", \"show_resolved_button\": \"yes\" }\n  ]\n}"
                }
              ]
            },
            {
              "name": "P.12.2 Get notification details",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/error-notifications/details?notification_id={{errorNotificationId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "error-notifications",
                    "details"
                  ],
                  "query": [
                    {
                      "key": "notification_id",
                      "value": "{{errorNotificationId}}",
                      "description": "Required"
                    }
                  ]
                },
                "description": "Returns the full raw record of a single error notification and, if it belongs to the caller's company and is still unread, marks it as notified.\n\n**Auth:** Partner — `auth:api`, under the `partner` prefix. The Form Request only validates `notification_id` exists in `email_notifications`; there is no partner-tree / `BelongsToAuthCompany` gate on lookup (any valid id is fetched). The auto-mark-as-notified side effect is gated: it only fires when `$authUser->person->company_id == $notification->company_id`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `notification_id` (integer, required) — must exist in `email_notifications.id`.\n\n**Response** — `{ data: ... }` envelope wrapping the raw `EmailNotification` model (not a Resource):\n- `status` (string) — literal `success`.\n- `data` (object) — the EmailNotification row. Fields include `id`, `company_id`, `store_id`, `review_site_id`, `type`, `recipients`, `subject`, `content`, `grouped_ids`, `resolved` (bool), `resolved_at`, `notified` (bool), `notified_at`, `status`, `created_at`, `updated_at`, plus appended `account_id` (int|null, from brand_id). Note: `brand_id` is hidden by the model's `$hidden`."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": { \"id\": 1215, \"subject\": \"Google scrape failed\", \"notified\": 1, \"company_id\": 42 }\n  }\n}"
                }
              ]
            },
            {
              "name": "P.12.3 List notes on a notification",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/error-notifications/notes?notification_id={{errorNotificationId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "error-notifications",
                    "notes"
                  ],
                  "query": [
                    {
                      "key": "notification_id",
                      "value": "{{errorNotificationId}}",
                      "description": "Required"
                    },
                    {
                      "key": "limit",
                      "value": "10",
                      "description": "Optional, default 10",
                      "disabled": true
                    },
                    {
                      "key": "page",
                      "value": "1",
                      "description": "Optional",
                      "disabled": true
                    }
                  ]
                },
                "description": "Returns a paginated list of notes attached to a given error notification, newest-updated first.\n\n**Auth:** Partner — `auth:api`, under the `partner` prefix. The Form Request validates `notification_id` is an integer (no `exists` rule); the controller looks up the notification by id and returns a 400 (`No notification is available`) if not found. No partner-tree / company scoping on the lookup — notes for any existing notification id are returned.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `notification_id` (integer, required) — the parent notification id.\n- Query: `limit` (integer, optional) — page size, default 10.\n- Query: `page` (integer, optional) — page number.\n\n**Response** — paginated `EmailNotificationNoteResource` collection (`{ data: [...], links, meta }`). Each item:\n- `id` (int) — note id.\n- `notification_id` (int) — parent notification id (`email_notification_id`).\n- `note_content` (string) — the note text.\n- `created_at` (string) — formatted `m/d/Y`.\n- `updated_at` (string) — formatted `Y-m-d H:i:s`."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    { \"id\": 1, \"notification_id\": 1215, \"note_content\": \"Reached out to Google support\", \"created_at\": \"05/29/2026\", \"updated_at\": \"2026-05-29 14:30:00\" }\n  ]\n}"
                }
              ]
            },
            {
              "name": "P.12.4 Add a note",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/error-notifications/notes/add",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "error-notifications",
                    "notes",
                    "add"
                  ]
                },
                "description": "Adds a free-text note to an error notification.\n\n**Auth:** Partner — `auth:api`, under the `partner` prefix. The Form Request validates `notification_id` exists in `email_notifications`; no partner-tree / `BelongsToAuthCompany` gate (a note may be attached to any existing notification id).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `notification_id` | integer | Yes | Must exist in `email_notifications.id`. |\n| `note_content` | string | Yes | The note text. |\n\n**Response** — `{ data: ... }` envelope:\n- `status` (string) — `success`.\n- `message` (string) — `Notification note saved successfully.`",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"notification_id\": 1215,\n    \"note_content\": \"Escalated to vendor; awaiting reconnect.\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": { \"status\": \"success\", \"message\": \"Notification note saved successfully.\" }\n}"
                }
              ]
            },
            {
              "name": "P.12.5 Delete a note",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/error-notifications/notes/delete",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "error-notifications",
                    "notes",
                    "delete"
                  ]
                },
                "description": "Deletes a single note from an error notification.\n\n**Auth:** Partner — `auth:api`, under the `partner` prefix. The Form Request validates `id` exists in `email_notification_notes`; no partner-tree / company scoping (any existing note id can be deleted).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `id` | integer | Yes | Note id; must exist in `email_notification_notes.id`. |\n\n**Response** — `{ data: ... }` envelope:\n- `status` (string) — `success`.\n- `message` (string) — `Notification note deleted successfully.`",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"id\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": { \"status\": \"success\", \"message\": \"Notification note deleted successfully.\" }\n}"
                }
              ]
            },
            {
              "name": "P.12.6 Bulk update notification status",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/error-notifications/status/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "error-notifications",
                    "status",
                    "update"
                  ]
                },
                "description": "Bulk-updates the lifecycle status of one or more error notifications, marking them `acknowledged` or `resolved`.\n\n**Auth:** Partner — `auth:api`, under the `partner` prefix. The Form Request validates each `id.*` exists in `email_notifications`; the controller loads all rows by id and updates them (no partner-tree / company scoping on which ids may be updated). `resolved` sets `resolved=1`, `status='none'`, `resolved_at=now`; `acknowledged` sets `status='acknowledged'`. Each updated row is also stamped `notified=1`/`notified_at` (only when the caller's `company_id` is present and the row was not already notified).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `id` | array of integers | Yes | Notification ids; each must exist in `email_notifications.id`. |\n| `status` | string | Yes | One of `resolved`, `acknowledged`. |\n\n**Response** — `{ data: ... }` envelope:\n- `message` (string) — `Selected notifications have been marked as Acknowledged.` / `... Resolved.` (status is ucfirst-ed), or `Some notifications not found.` if any save failed.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"id\": [\n        1,\n        2,\n        3\n    ],\n    \"status\": \"acknowledged\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": { \"message\": \"Selected notifications have been marked as Acknowledged.\" }\n}"
                }
              ]
            },
            {
              "name": "P.12.7 Send alert email",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/partner/error-notifications/send-email-notification",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "error-notifications",
                    "send-email-notification"
                  ]
                },
                "description": "Sends the error-notification alert email, either to the store's configured response-posting error recipients (`email_to=admin`) or to a custom address (`email_to=other`).\n\n**Auth:** Partner — `auth:api`, under the `partner` prefix. The Form Request validates `notification_id` exists; no partner-tree / company scoping on which notification is emailed. The controller 400s if the notification is missing or has empty `content` (`No notification is available`), if the linked store is missing (`Location does not exist.`), or if no recipient resolves (`User does not exist.`). For `admin`, recipients come from `CompanyOption::getResponsePostingErrorRecipients($store->id)`; for `other`, the `email` field is used. Mail sent via `SendEmailNotificationMail($notification, $store->company_id)`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `notification_id` | integer | Yes | Must exist in `email_notifications.id`. |\n| `email_to` | string | Yes | One of `admin`, `other`. |\n| `email` | string (email) | Conditional | Required only when `email_to=other`; validated `email:rfc,dns`. |\n\n**Response** — `{ data: ... }` envelope:\n- `status` (string) — `success`.\n- `message` (string) — `Email sent successfully!`",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"notification_id\": 1215,\n    \"email_to\": \"other\",\n    \"email\": \"ops@acme.com\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": { \"status\": \"success\", \"message\": \"Email notification sent successfully.\" }\n}"
                }
              ]
            }
          ]
        },
        {
          "name": "§ P.15 Users & roles (partner)",
          "item": [
            {
              "name": "P.15.1 List company users (roles, locations, brands)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/users/list?sort_field=last_name&sort_by=ASC",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "users",
                    "list"
                  ],
                  "query": [
                    {
                      "key": "sort_field",
                      "value": "last_name"
                    },
                    {
                      "key": "sort_by",
                      "value": "ASC"
                    }
                  ]
                },
                "description": "Lists every non-deleted user in the authenticated caller's company with their role, assigned locations and assigned brands/accounts. Only partner (`bundle_id=1`) and brand (`bundle_id=3`) companies may call it.\n\n## Auth\n- Partner / brand company-admin — collection bearer token (`auth:api`). In-controller gate: the caller's `person.company.bundle_id` must be 1 or 3, else `403 Only Brand users are allowed.`\n\n## Rate limit\n- No rate limit (no `throttle` middleware on the route; inside the `auth:api` group).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `sort_field` | string | Optional | One of `email`, `first_name`, `last_name`, `role`. |\n| `sort_by` | string | Optional | `ASC` / `DESC` (case-insensitive). Only applied when `sort_field` is present. |\n\n## Behaviour\n- `200` returns a `data` array of users (id, email, verification/active state, name, role, `locations`, `accounts`). Not paginated — returns the full company user list.\n- `403` when the caller is not a partner/brand company.\n\n✅ live ([routes/api_v1.php:1129](routes/api_v1.php#L1129) → `UserController@users`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"email\": \"alex@example.com\",\n      \"inactive_at\": null,\n      \"email_verified_at\": \"2026-06-12T06:28:19.000000Z\",\n      \"first_name\": \"Alex\",\n      \"last_name\": \"Smith\",\n      \"role\": \"Basic User\",\n      \"locations\": [],\n      \"accounts\": []\n    },\n    {\n      \"id\": 3,\n      \"email\": \"john@example.com\",\n      \"inactive_at\": null,\n      \"email_verified_at\": \"2026-06-11T10:34:52.000000Z\",\n      \"first_name\": \"John\",\n      \"last_name\": \"Doe\",\n      \"role\": \"Owner\",\n      \"locations\": [],\n      \"accounts\": [\n        \"Acme Brand\"\n      ]\n    }\n  ]\n}"
                },
                {
                  "name": "403 Forbidden",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Only Brand users are allowed.\"\n}"
                }
              ]
            },
            {
              "name": "P.15.2 Create partner user",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"user_type\": \"assign_brand\",\n  \"first_name\": \"Jane\",\n  \"last_name\": \"Smith\",\n  \"title\": \"Manager\",\n  \"email\": \"jane@example.com\",\n  \"password\": \"Secret@123\",\n  \"account\": [\n    10\n  ],\n  \"assign_all_accounts\": 1,\n  \"send_default_reports\": 1,\n  \"inactive_at\": null\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/users/partner/add",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "users",
                    "partner",
                    "add"
                  ]
                },
                "description": "Creates a new user under the authenticated partner company, sets their role (`admin` / `assign_brand` / `basic`), optionally assigns brand/account access and default reports, and emails first-login credentials via the white-label-aware welcome email. **Side effects:** creates User + Person (+ UserBrand rows), sends a welcome email, writes a `USER_CREATED` audit event.\n\n## Auth\n- Partner — collection bearer token (`auth:api`). The new user is created under the caller's company.\n\n## Rate limit\n- No rate limit (no `throttle` middleware on the route; inside the `auth:api` group).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user_type` | string | Required | `admin`, `assign_brand`, or `basic`. |\n| `first_name` | string | Required | ≤255 chars; no angle brackets. |\n| `last_name` | string | Optional | ≤255 chars. |\n| `title` | string | Optional | ≤50 chars. |\n| `email` | string | Required | Valid email; unique in `users`. |\n| `password` | string | Required | 8–100 chars, mixed case + number + special char, no spaces/quotes. |\n| `account` | integer[] | Required if `user_type=assign_brand`, or `basic` with `assign_all_accounts=0` | Brand/account company ids under the caller (validated by `BelongsToAuthCompany`). |\n| `assign_all_accounts` | integer | Required if `user_type=basic` | `0` or `1`. |\n| `send_default_reports` | integer | Optional | `0`/`1`; when `1` subscribes default activity + review-response reports. |\n| `inactive_at` | string | Optional | `Y-m-d H:i:s` deactivation date. |\n\n## Behaviour\n- Synchronous (DB transaction). `200` on success.\n- `422` on validation failure.\n- `500` on save failure (transaction rolled back).\n\n✅ live ([routes/api_v1.php:1130](routes/api_v1.php#L1130) → `UserController@addPartnerUser`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": \"User has been created successfully\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The email has already been taken.\",\n  \"errors\": {\n    \"email\": [\n      \"The email has already been taken.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "P.15.3 Edit partner user",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"user\": 12,\n  \"user_type\": \"assign_brand\",\n  \"first_name\": \"Jane\",\n  \"last_name\": \"Smith\",\n  \"title\": \"Manager\",\n  \"password\": null,\n  \"account\": [\n    10\n  ],\n  \"assign_all_accounts\": 1,\n  \"send_default_reports\": 1,\n  \"inactive_at\": null\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/users/partner/edit",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "users",
                    "partner",
                    "edit"
                  ]
                },
                "description": "Updates an existing partner user's name/title, role, brand/account assignments, optional password and deactivation date. Password changes and deactivations revoke the target's live tokens (SOC 2 CC6.2) and are audited. The target must belong to the caller's company.\n\n## Auth\n- Partner — collection bearer token (`auth:api`). In-controller gate: the target's `company_id` must equal the caller's, else `403 Permission denied.`\n\n## Rate limit\n- No rate limit (no `throttle` middleware on the route; inside the `auth:api` group).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user` | integer | Required | Id of the user to edit (`users.id`). |\n| `user_type` | string | Required | `admin`, `assign_brand`, or `basic`. |\n| `first_name` | string | Required | ≤255 chars; no angle brackets. |\n| `last_name` | string | Optional | ≤255 chars. |\n| `title` | string | Optional | ≤50 chars. |\n| `password` | string | Optional | 8–100 chars, complexity rules; blank leaves the password unchanged. Changing it revokes the user's tokens. |\n| `account` | integer[] | Required if `user_type=assign_brand`, or `basic` with `assign_all_accounts=0` | Brand/account company ids under the caller. |\n| `assign_all_accounts` | integer | Required if `user_type=basic` | `0`/`1`. |\n| `send_default_reports` | integer | Optional | `0`/`1`. |\n| `inactive_at` | string | Optional | `Y-m-d H:i:s`; setting a past/imminent date deactivates the user and revokes their tokens. |\n\n## Behaviour\n- Synchronous (DB transaction). `200` on success.\n- `403` when the target is not in the caller's company (`Permission denied.`).\n- `422` on validation failure; `500` on save failure.\n\n✅ live ([routes/api_v1.php:1131](routes/api_v1.php#L1131) → `UserController@userPartnerEdit`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": \"User information has been updated successfully\"\n  }\n}"
                },
                {
                  "name": "403 Permission denied",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Permission denied.\"\n}"
                }
              ]
            },
            {
              "name": "P.15.4 Get partner user details",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"user_id\": 9\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/users/partner/details",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "users",
                    "partner",
                    "details"
                  ]
                },
                "description": "Returns the full detail of a single partner user (identified by `user_id`) including derived `user_type`, role, assigned locations and assigned brands/accounts. Only partner/brand companies may call it, and the target must belong to the caller's company tree (`BelongsToAuthCompany`).\n\n## Auth\n- Partner / brand — collection bearer token (`auth:api`). Gate: caller `bundle_id` ∈ {1,3}; `user_id` must belong to the caller's company (validation rule).\n\n## Rate limit\n- No rate limit (no `throttle` middleware on the route; inside the `auth:api` group).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user_id` | integer | Required | Id of the user to fetch (`users.id`); must belong to the caller's company. |\n\n## Behaviour\n- `200` returns `data.response` (the user object). `user_type` is derived: `basic` → basic, else `assign_brand` → brand-assigned, else `admin`.\n- `404 User not found.` when the id resolves to no non-deleted person.\n- `403` when the caller is not a partner/brand company; `422` when `user_id` is missing or outside scope.\n\n✅ live ([routes/api_v1.php:1132](routes/api_v1.php#L1132) → `UserController@partnerUserDetails`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": {\n      \"id\": 9,\n      \"first_name\": \"John\",\n      \"last_name\": \"Doe\",\n      \"title\": \"Manager\",\n      \"email\": \"john@example.com\",\n      \"inactive_at\": null,\n      \"email_verified_at\": \"2026-06-16T12:26:09.000000Z\",\n      \"user_type\": \"assign_brand\",\n      \"role\": \"Brand Assigned\",\n      \"location\": [],\n      \"accounts\": [\n        \"Acme Brand\"\n      ]\n    }\n  }\n}"
                },
                {
                  "name": "404 User not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Not Found\",\n  \"status\": \"error\",\n  \"errors\": \"User not found.\"\n}"
                }
              ]
            },
            {
              "name": "P.15.5 Impersonate user or company owner",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"user_id\": 9,\n  \"company_id\": null\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/users/impersonate",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "users",
                    "impersonate"
                  ]
                },
                "description": "Mints a login token that lets the caller act as another user or a company's owner. Partners (`bundle_id=1`) may impersonate users/owners of their child companies; brands (`bundle_id=3`) may impersonate users of their parent partner. Every attempt (grant + denial) is audited (`AUTH_IMPERSONATE`). Provide exactly one of `user_id` or `company_id`.\n\n## Auth\n- Partner or brand — collection bearer token (`auth:api`). Scope is enforced via `Company::isBelongsToParentCompany`; unauthorized targets return 403.\n\n## Rate limit\n- No rate limit (no `throttle` middleware on the route; inside the `auth:api` group).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user_id` | integer | Required without `company_id` | Id of the user to impersonate (`users.id`). |\n| `company_id` | integer | Required without `user_id` | Id of the company whose owner to impersonate (`company.id`). |\n\n## Behaviour\n- `200` returns a full login payload for the impersonated identity (`token`, `login_type=impersonate`, `user`, `company`, `theme_options`, `white_label`).\n- `403` when the target is not within the caller's permitted tree.\n- `404` when the user/company/owner cannot be found; `422` when neither id is supplied.\n\n> Test script saves `data.token` into `{{impersonateToken}}`.\n\n✅ live ([routes/api_v1.php:1133](routes/api_v1.php#L1133) → `UserController@impersonate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"token\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...DUMMY_TOKEN\",\n    \"login_type\": \"impersonate\",\n    \"user\": {\n      \"id\": 9,\n      \"username\": \"John\",\n      \"email\": \"john@example.com\",\n      \"phone_number\": null,\n      \"first_name\": \"John\",\n      \"last_name\": \"Doe\",\n      \"title\": \"Manager\",\n      \"role\": \"Brand Assigned\",\n      \"total_location\": 0\n    },\n    \"company\": {\n      \"id\": 5,\n      \"name\": \"Acme Partner\",\n      \"bundle_id\": 1,\n      \"client_account_id\": null,\n      \"company_option\": {\n        \"company_id\": 5,\n        \"who_will_pay\": \"partner\",\n        \"enable_generic_alert\": false\n      }\n    },\n    \"theme_options\": \"{\\\"mode\\\":\\\"system\\\",\\\"skin\\\":\\\"default\\\",\\\"layout\\\":\\\"vertical\\\",\\\"primaryColor\\\":\\\"#f1874c\\\"}\",\n    \"white_label\": {\n      \"logo\": \"https://cdn.example.com/logo.png\",\n      \"white_url\": \"https://portal.example.com\",\n      \"favicon\": \"https://cdn.example.com/favicon.png\",\n      \"title\": \"Acme\",\n      \"status\": \"active\",\n      \"login_logo\": \"\",\n      \"company_id\": 5,\n      \"email_templates_enabled\": true\n    }\n  }\n}"
                },
                {
                  "name": "403 Not authorized",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"You are not authorized to impersonate this user\"\n}"
                },
                {
                  "name": "404 Not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Not Found\",\n  \"status\": \"error\",\n  \"errors\": \"User not found\"\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  if (j.data && j.data.token) { pm.environment.set('impersonateToken', j.data.token); }",
                      "}"
                    ]
                  }
                }
              ]
            }
          ],
          "description": "Partner/brand company-admin user management (bearer token whose user's company is a partner `bundle_id=1` or brand `bundle_id=3`). List all users in the company with roles/locations/brands (`GET users/list`), create (`POST users/partner/add`) and edit (`POST users/partner/edit`) partner users, fetch a single partner user's details (`POST users/partner/details`), and impersonate a child company/user or parent partner user (`POST users/impersonate`). No throttle; scope enforced in-controller and via BelongsToAuthCompany."
        },
        {
          "name": "§ P.16 Store provisioning",
          "item": [
            {
              "name": "16.A.1 Bulk-provision stores into a tier",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tier\": \"mgmt\",\n  \"store_ids\": [{{storeId}}, 27]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/provisioning/bulk",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "provisioning",
                    "bulk"
                  ]
                },
                "description": "Idempotent bulk operation (spec §10.4) that moves up to 1,000 stores into a named hipages tier in a single call — used for the July 1 four-pool launch kickoff. For every in-scope store it cancels the currently-active StorePlan (when it is on a different plan) and starts a new one at `now()`; upgrades are immediate with no proration. Stores outside the caller's partner company subtree are silently skipped and returned in `skipped_out_of_scope`. Side effect: writes/updates `store_plans` rows inside a DB transaction (rolled back on any error).\n\n## Auth\n- Bearer — **partner** persona (`auth:api`). The caller's partner ancestor is resolved via `Company::getParentCompany(company_id, 'partner')`; only stores inside that subtree are provisioned. 403 if the partner scope cannot be resolved.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `tier` | string | Required | One of `core`, `mgmt`, `plus`, `dormant`. Maps to plan names hipages Core / Review Management / Review Management Plus / Dormant. |\n| `store_ids` | integer[] | Required | 1–1000 store ids. |\n| `store_ids.*` | integer | Required | Each entry must be an integer. |\n\n## Behaviour\n- 200 with `{ provisioned, skipped_out_of_scope }` (arrays of store ids). Idempotent: re-running on a store already on the target tier just clears any `cancel_subscription_at` marker.\n- 400 unknown tier slug · 403 partner scope unresolvable · 422 validation (missing tier / store_ids).\n\n✅ live ([routes/api_v1.php:676](routes/api_v1.php#L676) → `PartnerProvisioningController@bulk`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"provisioned\": [\n      26,\n      27\n    ],\n    \"skipped_out_of_scope\": [\n      99\n    ]\n  }\n}"
                },
                {
                  "name": "403 Forbidden",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Partner scope could not be resolved for this user.\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The tier field is required.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"tier\": [\n      \"The tier field is required.\"\n    ],\n    \"store_ids\": [\n      \"The store ids field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "16.A.2 Provision a single store into a tier",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tier\": \"plus\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/provisioning/store/{{storeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "provisioning",
                    "store",
                    "{{storeId}}"
                  ]
                },
                "description": "Upgrades or downgrades a single store to any named tier — including `dormant`. Per spec §10.4 upgrades take effect immediately with no proration: the store's active StorePlan is cancelled and a new one is started at `now()`. Runs in a DB transaction. Scoped to the caller's partner company subtree.\n\n## Auth\n- Bearer — **partner** persona (`auth:api`). The store's `company_id` must fall inside the caller's partner subtree, else 403.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `tier` | string | Required | One of `core`, `mgmt`, `plus`, `dormant`. |\n\n## Behaviour\n- 200 with `{ store_id, tier, plan_id, plan_start }`.\n- 400 unknown tier · 403 store out of scope / partner unresolvable · 404 store not found · 422 validation.\n\n✅ live ([routes/api_v1.php:677](routes/api_v1.php#L677) → `PartnerProvisioningController@provisionStore`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"store_id\": 26,\n    \"tier\": \"plus\",\n    \"plan_id\": 7,\n    \"plan_start\": \"2026-07-09T10:00:00.000000Z\"\n  }\n}"
                },
                {
                  "name": "403 Forbidden",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"This store is outside your partner scope.\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                },
                {
                  "name": "404 Not Found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Store not found.\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected tier is invalid.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"tier\": [\n      \"The selected tier is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "16.A.3 Reclassify a store as dormant",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/provisioning/store/{{storeId}}/dormant",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "provisioning",
                    "store",
                    "{{storeId}}",
                    "dormant"
                  ]
                },
                "description": "Moves a store to the **hipages Dormant** tier. Per spec §10.4 cancellations go to dormant only — there is no account pause. The active StorePlan is cancelled and a new dormant plan is started immediately. No request body is required. Scoped to the caller's partner company subtree.\n\n## Auth\n- Bearer — **partner** persona (`auth:api`). Store must be inside the caller's partner subtree.\n\n## Rate limit\n- No rate limit.\n\n## Body\n- None.\n\n## Behaviour\n- 200 with `{ store_id, tier: \"dormant\", plan_id, plan_start }`.\n- 403 store out of scope / partner unresolvable · 404 store not found.\n\n✅ live ([routes/api_v1.php:679](routes/api_v1.php#L679) → `PartnerProvisioningController@reclassifyDormant`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"store_id\": 26,\n    \"tier\": \"dormant\",\n    \"plan_id\": 10,\n    \"plan_start\": \"2026-07-09T10:00:00.000000Z\"\n  }\n}"
                },
                {
                  "name": "403 Forbidden",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"This store is outside your partner scope.\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                },
                {
                  "name": "404 Not Found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Store not found.\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                }
              ]
            },
            {
              "name": "16.A.4 Reactivate a dormant store onto an active tier",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"tier\": \"core\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/provisioning/store/{{storeId}}/reactivate",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "provisioning",
                    "store",
                    "{{storeId}}",
                    "reactivate"
                  ]
                },
                "description": "Pulls a dormant store back onto an active tier (`core`, `mgmt` or `plus`). The `dormant` slug is **not** accepted here — use `/dormant` to move to dormant and this endpoint to come back. Cancels the current dormant StorePlan and starts a new active one at `now()`. Scoped to the caller's partner company subtree.\n\n## Auth\n- Bearer — **partner** persona (`auth:api`). Store must be inside the caller's partner subtree.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `tier` | string | Required | One of `core`, `mgmt`, `plus`. `dormant` is rejected (422). |\n\n## Behaviour\n- 200 with `{ store_id, tier, plan_id, plan_start }`.\n- 400 unknown/disallowed tier · 403 store out of scope / partner unresolvable · 404 store not found · 422 validation.\n\n✅ live ([routes/api_v1.php:681](routes/api_v1.php#L681) → `PartnerProvisioningController@reactivate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"store_id\": 26,\n    \"tier\": \"core\",\n    \"plan_id\": 5,\n    \"plan_start\": \"2026-07-09T10:00:00.000000Z\"\n  }\n}"
                },
                {
                  "name": "403 Forbidden",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"This store is outside your partner scope.\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                },
                {
                  "name": "404 Not Found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Store not found.\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected tier is invalid.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"tier\": [\n      \"The selected tier is invalid.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Partner-token endpoints (spec §10.4) that move stores between the four hipages tiers — Core, Review Management (mgmt), Review Management Plus (plus) and Dormant. Bulk launch kickoff plus per-store upgrade/downgrade, reclassify-to-dormant and reactivate. Each call cancels the store's active StorePlan and starts a new one at now() (upgrades are immediate, no proration). All scoped to the caller's partner company subtree; out-of-scope stores are skipped (bulk) or 403 (per-store)."
        },
        {
          "name": "§ P.17 Partner reference & GBP events",
          "item": [
            {
              "name": "17.A.1 List account industries (dropdown)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/account/industries",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "account",
                    "industries"
                  ]
                },
                "description": "Returns the active industry list for the account (brand) create/edit dropdown. Each row is `{ value, label }` where `value` = `companyindustry.id` — mirroring the address/countries shape the same form already consumes. Only industries with `status = 1` are returned, ordered by name.\n\n## Auth\n- Bearer — **partner** persona (`auth:api`). Declared under the `partner` route group.\n\n## Rate limit\n- No rate limit.\n\n## Query\n- None.\n\n## Behaviour\n- 200. The list is nested one level deeper than usual: the payload is `successResponse(['data' => $list])`, so the response is `{ \"data\": { \"data\": [ ... ] } }`.\n\n✅ live ([routes/api_v1.php:435](routes/api_v1.php#L435) → `BrandController@industryList`). Its `value`s are what the account create/edit endpoints accept as `companyindustry_id`; this request also captures the first row into `{{companyIndustryId}}` for those calls."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": [\n      {\n        \"value\": 1,\n        \"label\": \"Automotive\"\n      },\n      {\n        \"value\": 2,\n        \"label\": \"Home Services\"\n      },\n      {\n        \"value\": 3,\n        \"label\": \"Trades & Construction\"\n      }\n    ]\n  }\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  const list = j.data && j.data.data;",
                      "  if (Array.isArray(list) && list.length && list[0].value) {",
                      "    pm.environment.set('companyIndustryId', String(list[0].value));",
                      "    console.log('companyIndustryId =', list[0].value);",
                      "  }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "17.A.2 Google connection activity (feed + KPIs)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/gbp-connection-events?from=2026-06-01&to=2026-06-30&page=1&limit=10",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "gbp-connection-events"
                  ],
                  "query": [
                    {
                      "key": "from",
                      "value": "2026-06-01"
                    },
                    {
                      "key": "to",
                      "value": "2026-06-30"
                    },
                    {
                      "key": "page",
                      "value": "1"
                    },
                    {
                      "key": "limit",
                      "value": "10"
                    },
                    {
                      "key": "event",
                      "value": "connection.connected",
                      "disabled": true
                    },
                    {
                      "key": "source",
                      "value": "invite_link",
                      "disabled": true
                    },
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "disabled": true
                    },
                    {
                      "key": "store_id",
                      "value": "{{storeId}}",
                      "disabled": true
                    },
                    {
                      "key": "search",
                      "value": "owner@example.com",
                      "disabled": true
                    }
                  ]
                },
                "description": "Google Business Profile connection-activity report for the caller's account tree. Returns the event feed (sign-ins, failures, connects, disconnects) newest first, plus funnel KPI totals for the same filter window. Reads `gbp_connection_events` scoped by `partner_id` to the caller's partner company.\n\n## Auth\n- Bearer — **partner** persona, and the caller MUST be a partner-level user (`company.bundle_id = 1`), re-asserted in the controller. 403 (with an `AUTHZ_DENIED` audit event) for any non-partner caller; 401 if unauthenticated.\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `from` | date | Optional | Include events on/after this day (start of day). |\n| `to` | date | Optional | Include events up to/including this day; must be `>= from`. |\n| `event` | string | Optional | Single event type, e.g. `connection.connected`, `oauth.started`, `token.exchange_failed`. |\n| `source` | string | Optional | Portal section: `invite_link`, `connect_google_page`, `registration`, `settings`, `debugger`, `app`, `system`, `unknown`. |\n| `company_id` | integer | Optional | Filter to one account — includes every location under it. |\n| `store_id` | integer | Optional | Filter to a single location. |\n| `search` | string | Optional | Match the connected Google account email (`like`). Max 255. |\n| `page` | integer | Optional | Page number, min 1. |\n| `limit` | integer | Optional | Page size 1–100 (default 10). |\n\n## Behaviour\n- 200 with `{ kpis, events }`. `events` is a Laravel length-aware paginator; each row is a transformed shape (`id, event, source, store_id, store_name, google_email, error_code, message, details, user_email, created_at`). `details` is a human-readable summary synthesised from the event type + metadata.\n- 401 unauthenticated · 403 caller is not partner-level.\n\n✅ live ([routes/api_v1.php:455](routes/api_v1.php#L455) → `GbpConnectionEventController@partnerEvents`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"kpis\": {\n      \"logins_started\": 42,\n      \"connections\": 30,\n      \"failed_logins\": 7,\n      \"no_locations_found\": 3,\n      \"disconnects\": 2\n    },\n    \"events\": {\n      \"current_page\": 1,\n      \"data\": [\n        {\n          \"id\": 101,\n          \"event\": \"connection.connected\",\n          \"source\": \"invite_link\",\n          \"store_id\": 26,\n          \"store_name\": \"Board & Brew\",\n          \"google_email\": \"owner@example.com\",\n          \"error_code\": null,\n          \"message\": null,\n          \"details\": \"Location connected successfully\",\n          \"user_email\": \"admin@example.com\",\n          \"created_at\": \"2026-07-07T09:15:00+00:00\"\n        },\n        {\n          \"id\": 100,\n          \"event\": \"token.exchange_failed\",\n          \"source\": \"connect_google_page\",\n          \"store_id\": 27,\n          \"store_name\": \"Sunrise Cafe\",\n          \"google_email\": null,\n          \"error_code\": \"invalid_grant\",\n          \"message\": \"Auth code expired\",\n          \"details\": \"invalid_grant \\u2014 Auth code expired\",\n          \"user_email\": null,\n          \"created_at\": \"2026-07-07T08:02:11+00:00\"\n        }\n      ],\n      \"first_page_url\": \"https://development-api.shoutaboutus.com/api/v1/partner/gbp-connection-events?page=1\",\n      \"from\": 1,\n      \"last_page\": 9,\n      \"last_page_url\": \"https://development-api.shoutaboutus.com/api/v1/partner/gbp-connection-events?page=9\",\n      \"next_page_url\": \"https://development-api.shoutaboutus.com/api/v1/partner/gbp-connection-events?page=2\",\n      \"path\": \"https://development-api.shoutaboutus.com/api/v1/partner/gbp-connection-events\",\n      \"per_page\": 10,\n      \"prev_page_url\": null,\n      \"to\": 10,\n      \"total\": 84\n    }\n  }\n}"
                },
                {
                  "name": "403 Forbidden",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Only partner-level users can view Google connection activity.\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                }
              ]
            }
          ],
          "description": "Partner-token read endpoints: the active industry list for the account create/edit dropdown, and the Google Business Profile connection-activity report (event feed + funnel KPIs) for every location under the caller's partner tree. The GBP report requires a partner-level (bundle_id=1) user."
        },
        {
          "name": "§ P.18 Referral",
          "item": [
            {
              "name": "P.18.1 Track referral link click (PUBLIC)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"code\": \"AbCdEf1234\",\n  \"ip\": \"192.168.1.1\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/referral/click",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "referral",
                    "click"
                  ]
                },
                "description": "Public attribution endpoint: records a click on a referral link identified by its referral `code` and increments that code's `total_click` counter. Called by the SPA/landing page before login. Returns the referring company's public identity so the sign-up flow can pre-attribute the lead.\n\n## Auth\n- **Public** — no auth. Lives in the pre-login `throttle:100,1` reference group.\n\n## Rate limit\n- 100 requests/min per IP (`throttle:100,1`).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `code` | string | Required | `exists:referral_codes,code`. The referral code that was clicked. |\n| `ip` | string | Optional | Client IP recorded against the click for attribution/dedup. |\n\n## Behaviour\n- `200` — `{ data: { status, message, company } }`; `company` is `{ id, name, bundle_id }` of the referring company (or `null`).\n- `422` — `code` missing or not found in `referral_codes`.\n- `400` — `{ status: error }` if the click row fails to persist ('Something went wrong! please try again.').\n\n✅ live ([routes/api_v1.php:169](routes/api_v1.php#L169) → `ReferralController@referralClick`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Click added successfully\",\n    \"company\": {\n      \"id\": 1,\n      \"name\": \"Example Name\",\n      \"bundle_id\": 1\n    }\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected code is invalid.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"code\": [\n      \"The selected code is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "P.18.2 Get partner referral info + referred list",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/referral/partner-info?sort_field=id&sort_by=DESC&limit=10",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "referral",
                    "partner-info"
                  ],
                  "query": [
                    {
                      "key": "call_for",
                      "value": "",
                      "disabled": true
                    },
                    {
                      "key": "search_field",
                      "value": "account_name",
                      "disabled": true
                    },
                    {
                      "key": "search_value",
                      "value": "",
                      "disabled": true
                    },
                    {
                      "key": "sort_field",
                      "value": "id"
                    },
                    {
                      "key": "sort_by",
                      "value": "DESC"
                    },
                    {
                      "key": "limit",
                      "value": "10"
                    }
                  ]
                },
                "description": "Returns the authenticated partner's referral-programme details. By default it lazily creates the partner's own referral code (if none exists) and returns a **paginated list of companies that signed up through it**, plus the partner's own referral code, total click count and white-label URL as top-level additional keys. When `call_for` is set it instead returns the URL-widget info (also lazily creating the widget row). Side effect: may create a `referral_codes` row and/or a `widgets` row on first call.\n\n## Auth\n- Bearer **partner** token (`auth:api`). The referral list is scoped to the caller's own company (`referral_company_id = auth company id`).\n\n## Rate limit\n- No rate limit (no `throttle` middleware on this route).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `call_for` | string | Optional | When present (e.g. `widget`), returns the URL-widget payload instead of the referral list. |\n| `search_field` | string | Optional | One of `account_name`, `code`, `total_click`. Chooses which column `search_value` filters. |\n| `search_value` | string | Optional | Filter value for `search_field` (LIKE for `account_name`, exact otherwise). |\n| `sort_field` | string | Optional | `id` or `total_click` (anything else falls back to `id`). Default `id`. |\n| `sort_by` | string | Optional | `ASC` or `DESC` (default `DESC`). |\n| `limit` | integer | Optional | Page size. Default `10`. |\n\n## Behaviour\n- `200` (default) — paginated `ReferralListResource` collection: `{ data: [...], links, meta }` plus top-level `white_label_url`, `referral_code`, `total_click`. Each row: `id`, `account_name`, `company_id`, `referral_company_id`, `landing_page_url` (Google landing page for bundle_id=2 accounts), `code`, `total_click`, `total_location`, `created_at`.\n- `200` (`call_for` set) — `{ data: { status, message, referral_code, widget_text } }`.\n\n✅ live ([routes/api_v1.php:1006](routes/api_v1.php#L1006) → `ReferralController@getPartnerReferralInfo`)"
              },
              "response": [
                {
                  "name": "200 OK (referral list)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"id\": 1,\n      \"account_name\": \"Example Name\",\n      \"company_id\": 12,\n      \"referral_company_id\": 10,\n      \"landing_page_url\": \"https://example.com/sample\",\n      \"code\": \"ABC123\",\n      \"total_click\": 10,\n      \"total_location\": 3,\n      \"created_at\": \"2026-06-16T09:48:56.000000Z\"\n    }\n  ],\n  \"links\": {\n    \"first\": \"{{baseUrl}}/referral/partner-info?page=1\",\n    \"last\": \"{{baseUrl}}/referral/partner-info?page=1\",\n    \"prev\": null,\n    \"next\": null\n  },\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 10,\n    \"total\": 1\n  },\n  \"white_label_url\": \"https://app.example.com\",\n  \"referral_code\": \"AbCdEf1234\",\n  \"total_click\": 42\n}"
                },
                {
                  "name": "200 OK (call_for=widget)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Referral code has been retrieved successfully\",\n    \"referral_code\": \"AbCdEf1234\",\n    \"widget_text\": \"<p>Need Help responding to your reviews?</p>\"\n  }\n}"
                }
              ]
            }
          ],
          "description": "Partner referral programme. `P.18.1 Track referral link click` is a PUBLIC (no-auth) attribution endpoint that records a click on a referral code; `P.18.2 Get partner referral info` is a partner-token endpoint returning the partner's own referral code + paginated list of companies referred through it (or, with `call_for`, the URL-widget payload)."
        }
      ]
    },
    {
      "name": "3. Account Endpoints",
      "description": "Endpoints runnable with an ACCOUNT (brand) access token — locations, plans, review sites, reviews & responses, campaigns, QR codes, FAQ/widgets, white-label, email templates. Reports live under 'Report Endpoints'.",
      "item": [
        {
          "name": "§ B.1 Locations",
          "item": [
            {
              "name": "2.B.1.a Partner-wide location list",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/partner/store/list?limit=10&page=1&active_plan=1&plan_subscription=active",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "store",
                    "list"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "10"
                    },
                    {
                      "key": "page",
                      "value": "1"
                    },
                    {
                      "key": "active_plan",
                      "value": "1"
                    },
                    {
                      "key": "plan_subscription",
                      "value": "active"
                    },
                    {
                      "key": "account_id",
                      "value": "{{brandId}}",
                      "disabled": true
                    },
                    {
                      "key": "call_for",
                      "value": "dropdown",
                      "disabled": true
                    },
                    {
                      "key": "location_id",
                      "value": "15",
                      "description": "Optional. Filter to a single location by its internal store id. Scoped to your own stores. Either-or with client_location_id.",
                      "disabled": true
                    },
                    {
                      "key": "client_location_id",
                      "value": "EXT-LOC-001",
                      "description": "Optional. Filter to a single location by your own external id (client_location_id, unique per partner). Scoped to your own stores; an unknown/cross-tenant value returns an empty page. Either-or with location_id.",
                      "disabled": true
                    }
                  ]
                },
                "description": "> 🧭 **E2E journey 4/9 — List Locations (capture store id).** Matches the row whose `storeid == \"LOC-001\"` (created in step 3) and captures its `id` → `{{storeId}}`.\n\nReturns a paginated list of all store locations under the authenticated partner (aggregated across every brand the partner owns), each row joined to its brand/account name, address, and active-plan name + dates. When `call_for` is set it instead returns a flat label/value list for dropdowns (with an `All` option prepended when more than one store).\n\n**Auth:** Partner — `auth:api`, under the `partner/` group. Scope is baked into `Store::getSubPartnerStoresList($user->person->company->id, ...)` (the caller's partner company id); filter params (`account_id`, `client_account_id`, `location_id`, `client_location_id`) are all additionally validated by `BelongsToAuthCompany`, so values outside the partner's tree just yield an empty page.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `limit` (integer, optional) — page size, default 10.\n- Query: `page` (integer, optional) — page number.\n- Query: `plan_subscription` (string, optional) — one of `all` (default), `active`, `inactive`.\n- Query: `active_plan` (integer, optional) — passed in the example URL; the listing branch filters via `plan_subscription`, so `active` ≈ active_plan=1.\n- Query: `account_id` (integer, optional) — brand/account filter; must `exists:company,id` + `BelongsToAuthCompany`.\n- Query: `client_account_id` (string, optional, max 255) — external account id, resolved to `account_id`; `BelongsToAuthCompany`.\n- Query: `location_id` (integer, optional) — single store filter; must `exists:store,id`.\n- Query: `client_location_id` (string, optional, max 255) — external location id, resolved to a store; `BelongsToAuthCompany`.\n- Query: `call_for` (string, optional) — any value returns a flat label/value dropdown list instead of the paginated table.\n- Query: `sort_field` (string, optional) — default `store_name`.\n- Query: `sort_by` (string, optional) — `ASC` (default) or `DESC`.\n- Query: `search_field` / `search_value` (string, optional) — column + term to filter on.\n\n**Response** — `{ data: ... }` envelope. Paginated `SubPartnerStoreList` resource collection (`data[]`, `links`, `meta`) in the default branch; `{ status, response: [...] }` flat list when `call_for` is set.\n- `data[].id` (integer) — store id.\n- `data[].name` (string) — store name.\n- `data[].storeid` (string) — store external id.\n- `data[].client_location_id` (string) — partner-supplied location id.\n- `data[].account_name` (string) — parent brand/account name.\n- `data[].store_name` (string) — `name - storeid` composite.\n- `data[].full_address`, `address1`, `address2`, `zip` (string) — location address fields.\n- `data[].plan_name` (string) — active plan name.\n- `data[].activation_date`, `expiry_date`, `cancellation_date` (string|null) — plan window."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": [ { \"id\": 2, \"name\": \"Demo Brew\", \"storeid\": \"Alameda County\", \"client_location_id\": \"\", \"account_name\": \"Shout About Us\", \"store_name\": \"Demo Brew - Alameda County\", \"full_address\": \"123 Main St\", \"address1\": \"123 Main St\", \"address2\": null, \"zip\": \"92014\", \"plan_name\": \"Gold\", \"activation_date\": \"2026-06-16 09:48:56\", \"expiry_date\": \"2026-06-30 23:59:59\", \"cancellation_date\": null } ], \"links\": { \"first\": \"...?page=1\", \"last\": \"...?page=1\", \"prev\": null, \"next\": null }, \"meta\": { \"current_page\": 1, \"per_page\": 10, \"total\": 1 } }"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  const rows = (j.data && (j.data.data || j.data)) || [];",
                      "  const match = Array.isArray(rows) ? rows.find(function(r){ return r.storeid === 'LOC-001'; }) : null;",
                      "  if (match && match.id) {",
                      "    pm.environment.set('storeId', String(match.id));",
                      "    console.log('journey: storeId =', match.id);",
                      "  }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "2.B.1.b Brand-user location list (POST)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"sort_field\": \"store_name\",\n    \"sort_by\": \"ASC\",\n    \"search_field\": \"store_name\",\n    \"search_value\": \"Main St\",\n    \"active_plan\": 1,\n    \"company_id\": 5,\n    \"limit\": 10\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store/list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store",
                    "list"
                  ]
                },
                "description": "Returns a paginated list of stores accessible to the authenticated user, each row joined out to full location, brand, and geographic detail (city → state → country) plus the active `store_plan` with its plan features, and a generated `connect_page_url`. Backs `getUserStoreWithLocation`. (idx=56 and idx=73 are the same `POST /store/list` endpoint documented under two collection folders.)\n\n**Auth:** Both (Partner or Account/brand user) — `auth:api`, role-agnostic `store/` group. Default scope is `Store::getStoreListByUser($user)` (the caller's own accessible stores); when `company_id` is supplied it scopes to `Store::getStoreListByCompany` for that company, and when `store_id` is supplied it narrows to that one store. Both `company_id` and `store_id` are validated by `BelongsToAuthCompany`, so a partner can reach any store under its tree while a brand user is confined to its own brand's stores.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `page` (integer, optional) — pagination page.\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| sort_field | string | optional | one of store_name, address, zip, city_name, state_code, company_phone, country_name, state_name, storeid, account_name (default store_name) |\n| sort_by | string | optional | asc / ASC / desc / DESC (default ASC) |\n| search_field | string | optional | same column set as sort_field |\n| search_value | string | optional | term; validated by NameNoAngleBrackets |\n| active_plan | int (0\\|1) | optional | 1 = only stores with an active plan |\n| company_id | int | optional | must exist (bundle_id 1/2/3, not deleted) + BelongsToAuthCompany |\n| store_id | int | optional | must exist:store + BelongsToAuthCompany |\n| limit | int | optional | page size (default 10) |\n\n**Response** — `{ data: ... }` envelope; `data` is the raw Laravel paginator (`current_page`, `data[]`, `per_page`, `total`, page urls, `links`).\n- `data.data[].id` (integer) — store id.\n- `data.data[].account_name` (string) — parent brand name.\n- `data.data[].store_name`, `store_full_name`, `storeid`, `client_location_id` (string) — store identity.\n- `data.data[].address`, `address1`, `address2`, `zip` (string|null) — location address.\n- `data.data[].country_id/_name/_code`, `state_id/_name/_code`, `city_id/_name` — geo joins.\n- `data.data[].company_phone` (string|null).\n- `data.data[].brand_company_id` (integer) — parent brand company id.\n- `data.data[].connect_page_url` (string) — generated connect-review-sites URL.\n- `data.data[].active_store_plan` (object|null) — `{ id, store_id, plan_id, status, plan_start, plan_end, cancel_subscription_at, plan: { id, plan_name, features: [{ id, plan_id, feature_name, options }] } }`."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"current_page\": 1,\n    \"data\": [\n      {\n        \"id\": 34,\n        \"account_name\": \"HiPages Brand 1\",\n        \"store_name\": \"Jean Test Location\",\n        \"store_full_name\": \"Jean Test Location - JEAN-1778215061\",\n        \"storeid\": \"JEAN-1778215061\",\n        \"client_location_id\": \"demo-hp-34\",\n        \"address\": \"1 Test Street\",\n        \"address1\": \"1 Test Street\",\n        \"address2\": null,\n        \"zip\": \"2000\",\n        \"country_id\": 1,\n        \"country_name\": \"Afghanistan\",\n        \"country_code\": \"AF\",\n        \"state_id\": 3901,\n        \"state_name\": \"Badakhshan\",\n        \"state_code\": \"BDS\",\n        \"city_id\": 1,\n        \"city_name\": \"Ashkāsham\",\n        \"company_phone\": null,\n        \"active_store_plan\": {\n          \"id\": 21,\n          \"store_id\": 34,\n          \"plan_id\": 11,\n          \"status\": \"active\",\n          \"plan_start\": \"2026-05-08T04:37:42.000000Z\",\n          \"plan_end\": \"2026-05-31T23:59:59.000000Z\",\n          \"cancel_subscription_at\": null,\n          \"plan\": {\n            \"id\": 11,\n            \"plan_name\": \"HiPages Review Management\",\n            \"features\": [\n              {\n                \"id\": 72,\n                \"plan_id\": 11,\n                \"feature_name\": \"ai_response\",\n                \"options\": []\n              },\n              {\n                \"id\": 73,\n                \"plan_id\": 11,\n                \"feature_name\": \"review_flag\",\n                \"options\": []\n              }\n            ]\n          }\n        }\n      }\n    ],\n    \"per_page\": 10,\n    \"total\": 12\n  }\n}"
                }
              ]
            },
            {
              "name": "2.B.1.c Brand-user location list (GET dropdown)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store/list?active_plan=1&appendAll=1",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store",
                    "list"
                  ],
                  "query": [
                    {
                      "key": "active_plan",
                      "value": "1"
                    },
                    {
                      "key": "appendAll",
                      "value": "1"
                    }
                  ]
                },
                "description": "Returns a flat label/value list of the stores the authenticated user can access, for dropdowns. An `All` option is prepended when there are 2+ stores, or whenever `appendAll` is truthy. Backs `getUserStoreList`.\n\n**Auth:** Both (Partner or Account/brand user) — `auth:api`, role-agnostic `store/` group. Scope is `Store::getStoreListByUser($user)` keyed off the caller's `person->company`, so a partner sees every store under its tree and a brand user only its own brand's stores. No per-row ownership params on this route.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `active_plan` (integer, optional) — `0` (default, all) or `1` (only stores with an active plan).\n- Query: `appendAll` (integer/any, optional) — when truthy, forces the `All` option even with a single store.\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `response` (array) — list of `{ label, value }`; `label` is `name` or `name - storeid, STATE` and `value` is the store id. `All` row (`value: \"\"`) prepended per the count/appendAll rule. Empty array when the user has no stores."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"response\": [ { \"label\": \"All\", \"value\": \"\" }, { \"label\": \"Demo Brew - Alameda County, CA\", \"value\": 2 }, { \"label\": \"GSLoc - SID123\", \"value\": 14 } ] } }"
                }
              ]
            },
            {
              "name": "2.B.2 Create location",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"name\": \"Downtown Location\",\n    \"storeid\": \"LOC-001\",\n    \"client_location_id\": \"CLIENT-LOC-001\",\n    \"address\": \"123 Main Street\",\n    \"city_id\": {{cityId}},\n    \"postal_code\": \"10001\",\n    \"site_url\": \"https://downtown.acmebrand.com\",\n    \"company_phone\": \"1234567890\",\n    \"phone\": \"9876543210\",\n    \"account_id\": {{brandId}}\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/store/create",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "store",
                    "create"
                  ]
                },
                "description": "> 🧭 **E2E journey 3/9 — Create Location.** `account_id={{brandId}}` (a `bundle_id=3` brand). Auto-seeds blank review-site rows from the brand's plan. Returns no id — capture it in step 4.\n\nCreates a new store location under a brand (account) in one transaction: a bundle_id=2 location-account `company` row + its `company` options, the `store` row, and a `store_options` row (inheriting `who_will_pay`). If the brand has a most-recent `company_plan`, the store is auto-attached to that plan and blank `store_review_site` rows are seeded. Rejects duplicates (same name+address+zip+city under bundle_id=2). Logs an audit event and emails a location-added notification.\n\n**Auth:** Partner — `auth:api`, under the `partner/` group. The target brand is `account_id`, validated by `StoreCreateRequest`: must `exists` in `company` with `bundle_id=3` and not deleted, AND pass `BelongsToAuthCompany` (must be a brand inside the caller's partner tree).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| name | string | required | max 255; NameNoAngleBrackets (no `<`/`>`) |\n| storeid | string | optional | max 100; NameNoAngleBrackets |\n| client_location_id | string | optional | max 100; NameNoAngleBrackets |\n| address | string | required | max 255; NameNoAngleBrackets |\n| city_id | int | required | must exist:city,id |\n| postal_code | string | required | min 3, max 12; NameNoAngleBrackets |\n| site_url | string | optional | must be a valid URL |\n| company_phone | string | optional | min 9, max 15 digits — primary business phone |\n| phone | string | optional | min 9, max 15 digits — secondary line |\n| account_id | int | required | parent brand company id; bundle_id=3, not deleted, + BelongsToAuthCompany |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Location has been created successfully!`.\n\n(Error `{ status, message }` returned with HTTP 200-error envelope on duplicate: `This location is already registered!`.)"
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Location has been created successfully!\"\n  }\n}"
                },
                {
                  "name": "400 Duplicate",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"status\": \"error\", \"message\": \"This location is already registered!\", \"errors\": [] }"
                }
              ]
            },
            {
              "name": "2.B.3 Edit location",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"name\": \"Acme Brand - NYC01\",\n    \"storeid\": \"NYC01\",\n    \"client_location_id\": \"CLI-7755\",\n    \"address\": \"123 Main St\",\n    \"city_id\": 1,\n    \"postal_code\": \"10001\",\n    \"site_url\": \"https://acmebrand-nyc.com\",\n    \"phone\": \"9876543210\",\n    \"company_phone\": \"1234567890\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/partner/store/{{storeId}}/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "partner",
                    "store",
                    "{{storeId}}",
                    "update"
                  ]
                },
                "description": "Updates a store and its paired location-account `company` row together (name, storeid, client_location_id, address, zip, city, site_url, phone, company_phone). Self-heals by backfilling any missing `store_review_site` rows for the store (idempotent) and reports how many were created. Rejects duplicates (same name+address+zip+city under another bundle_id=2 row). Cannot move a store between brands.\n\n**Auth:** Partner — `auth:api`, under the `partner/` group. Ownership is enforced inside the controller (not the form request): it walks `Company::getParentCompany(store.company_id, 'partner')` and `canManageStore()` confirms the caller's partner matches (super admin bypasses); otherwise 403. `StoreEditRequest` carries no ownership rule — only field validation.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — Store id (route constrained to `[0-9]+`). 404 if the store or its location company is missing.\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| name | string | required | max 255; NameNoAngleBrackets |\n| storeid | string | optional | max 100; NameNoAngleBrackets |\n| client_location_id | string | optional | max 100; NameNoAngleBrackets (defaults to '' if null) |\n| address | string | required | max 255; NameNoAngleBrackets |\n| city_id | int | required | must exist:city,id |\n| postal_code | string | required | min 3, max 12; NameNoAngleBrackets (saved to location zip) |\n| site_url | string | optional | must be a valid URL |\n| phone | string | optional | min 9, max 15 digits — secondary line |\n| company_phone | string | optional | min 9, max 15 digits — primary business phone |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Location has been updated successfully!`.\n- `storeId` (integer) — the updated store id.\n- `review_sites_backfilled` (integer) — count of `store_review_site` rows created by the self-heal (often 0)."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"message\": \"Location has been updated successfully!\", \"storeId\": 15, \"review_sites_backfilled\": 0 } }"
                }
              ]
            },
            {
              "name": "Store Details",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store/{{storeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store",
                    "{{storeId}}"
                  ]
                },
                "description": "Returns a single store with its brand/account name, location detail, and active subscription. Top-level fields cover store identity; address, phone, and billing identifiers are nested under `company` (with `city → state → country`); the active subscription is under `active_store_plan` including the plan's feature list (with `enabled`/`hard_stop`).\n\n**Auth:** Both (Partner or Account/brand user) — `auth:api`, role-agnostic `store/` group. Access is gated by membership: the id must be in `Store::getStoreListByUser($user, true)` (the caller's accessible store ids) or it returns 404 — partners reach any store in their tree, brand users only their own.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — Store id (route constrained to `[0-9]+`). 404 if not in the user's accessible store list.\n\n**Response** — `{ data: ... }` envelope; under it `status` plus a nested `data` store object.\n- `data.status` (string) — `success`.\n- `data.data.id` (integer) — store id.\n- `data.data.account_name` (string) — parent brand name.\n- `data.data.store_name`, `store_full_name`, `storeid`, `client_location_id` (string) — identity.\n- `data.data.company_id` (integer) — location-account company id.\n- `data.data.active_store_plan` (object|null) — `{ id, store_id, plan_id, status, plan_start, plan_end, cancel_subscription_at, plan: { id, plan_name, features: [{ id, plan_id, feature_name, enabled, hard_stop, options }] } }`.\n- `data.data.company` (object) — `{ id, address, address2, zip, city_id, site_url, company_phone, billing_id, phone, client_account_id, city: { id, name, state_id, state: { id, name, country_id, code, country: { id, name, code } } } }`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 412,\n      \"account_name\": \"Acme Coffee Co\",\n      \"store_name\": \"Acme Coffee – Downtown\",\n      \"store_full_name\": \"Acme Coffee – Downtown - DT-001\",\n      \"storeid\": \"DT-001\",\n      \"client_location_id\": \"loc_8842\",\n      \"company_id\": 1530,\n      \"active_store_plan\": {\n        \"id\": 88,\n        \"store_id\": 412,\n        \"plan_id\": 14,\n        \"status\": 1,\n        \"plan_start\": \"2026-06-01\",\n        \"plan_end\": \"2026-06-30\",\n        \"cancel_subscription_at\": null,\n        \"plan\": {\n          \"id\": 14,\n          \"plan_name\": \"Growth\",\n          \"features\": [\n            {\n              \"id\": 301,\n              \"plan_id\": 14,\n              \"feature_name\": \"ai_response\",\n              \"enabled\": 1,\n              \"hard_stop\": 0,\n              \"options\": [\n                {\n                  \"id\": 9001,\n                  \"feature_value\": \"AI Response\",\n                  \"plan_feature_id\": 301,\n                  \"feature_key\": \"title\"\n                }\n              ]\n            }\n          ]\n        }\n      },\n      \"company\": {\n        \"id\": 1530,\n        \"address\": \"42 Market Street\",\n        \"address2\": \"Suite 5\",\n        \"zip\": \"90210\",\n        \"city_id\": 77,\n        \"site_url\": \"https://acmecoffee.example\",\n        \"company_phone\": \"3105551234\",\n        \"billing_id\": null,\n        \"phone\": \"3105551234\",\n        \"city\": {\n          \"id\": 77,\n          \"name\": \"Beverly Hills\",\n          \"state_id\": 5,\n          \"state\": {\n            \"id\": 5,\n            \"name\": \"California\",\n            \"country_id\": 1,\n            \"code\": \"CA\",\n            \"country\": {\n              \"id\": 1,\n              \"name\": \"United States\",\n              \"code\": \"US\"\n            }\n          }\n        }\n      }\n    }\n  }\n}"
                },
                {
                  "name": "404 Not Found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Store not found\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "Bulk Upload Location Sample File",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store/bulk-upload/sample/location-file",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store",
                    "bulk-upload",
                    "sample",
                    "location-file"
                  ]
                },
                "description": "Returns a download URL for the sample spreadsheet used by the bulk location upload. Checks S3 for `assets/sample/Bulk Upload Location Sample.xlsx`; if absent, uploads the bundled `public/` copy to S3 first, then returns its S3 URL with a cache-busting `?v=<timestamp>`. 400 if the sample file is missing from both S3 and local storage.\n\n**Auth:** Both (Partner or Account/brand user) — `auth:api`, role-agnostic `store/` group. No request-level scoping; any authenticated user gets the same static sample-file URL.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- (no path, query, or body parameters)\n\n**Response** — `{ data: ... }` envelope.\n- `path` (string) — S3 download URL for the sample file with a `?v=YmdHis` cache-buster."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"path\": \"https://d2ny6zb7otrnhl.cloudfront.net/assets/sample/Bulk Upload Location Sample.xlsx?v=20260624090758\"\n  }\n}"
                },
                {
                  "name": "400 Sample File Not Found",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"error\": \"Sample file not found\"\n  }\n}"
                }
              ]
            },
            {
              "name": "Import Locations Excel (Brand – update existing)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store/import-locations-excel",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store",
                    "import-locations-excel"
                  ]
                },
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    {
                      "key": "excel_file",
                      "type": "file",
                      "src": [],
                      "description": "Required. Excel file (xlsx/xls, mimetypes application/vnd.ms-excel or application/vnd.openxmlformats-officedocument.spreadsheetml.sheet). Max ~99 MB (101376 KB). Rows are matched to EXISTING stores by `location_id` (= store.saucode) within the caller's brand, then store name / address / city / zip are updated."
                    }
                  ]
                },
                "description": "Uploads an Excel file (first sheet only) and updates existing locations belonging to the authenticated brand — matching each row by `location_id` against `store.saucode`, then writing the store name/storeid plus the location-account company's name/address/city/zip. Returns a success message plus a list of per-row errors that were skipped (state/city/store not found in the brand).\n\n**Auth:** Account — `auth:api` (route is inside the authenticated group). `ImportLocationsExcelRequest::authorize()` requires the user's company `bundle_id === 3` (BRAND); a non-brand user gets 403. Scope is the caller's own brand: each matched store is INNER-joined `store → company (location-account) → company brand` and filtered to `brand.bundle_id = 3`, `brand.deleted_at IS NULL`, and `brand.id = <auth user's company id>`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (`multipart/form-data`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `excel_file` | file (xlsx/xls) | Yes | `mimetypes:application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `max:101376` KB (~99 MB). First worksheet only. |\n\nPer-row columns validated inside the controller (each row of the sheet): `location_id` (required, must exist in `store.saucode`), `country_code` (required, must exist in `country.code`), `state_code` (required string), `city_name` (required string), `zip_code` (required), `store_name` (required string). Optional row columns also read: `store_id`, `address`. Any row-level validation failure returns a 400 with the full list of messages and processes nothing.\n\n**Response** — `{ data: ... }` envelope.\n- `data.status` (string) — `\"success\"`.\n- `data.message` (string) — `\"File has been uploaded.\"`.\n- `data.errors` (array<string>) — per-row skip messages (e.g. `\"Row #N - State code not found\"`, `\"Row #N - City name not found\"`, `\"Row #N - Location not found in your brand\"`); empty when all rows applied."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"File has been uploaded.\",\n    \"errors\": [\n      \"Row #3 - City name not found\",\n      \"Row #7 - Location not found in your brand\"\n    ]\n  }\n}"
                },
                {
                  "name": "400 Empty / Invalid Rows",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    \"Row #2: ABC-999 - Location ID not found\",\n    \"Row #4: XX - Country code not found\"\n  ]\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The excel file field is required.\",\n  \"errors\": {\n    \"excel_file\": [\n      \"The excel file field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Bulk Upload Locations (request verification)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/bulk-upload/locations",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "bulk-upload",
                    "locations"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "formdata": [
                    {
                      "key": "account_id",
                      "value": "{{brandId}}",
                      "type": "text",
                      "description": "Required. Brand company id — must exist in `company.id` where bundle_id = 3 and not soft-deleted, and must belong to the auth user's company hierarchy (BelongsToAuthCompany rule)."
                    },
                    {
                      "key": "location_file",
                      "type": "file",
                      "src": [],
                      "description": "Required. Excel file (xlsx/xls). Max 10240 KB. Header row must match the template exactly: location_name, store_id, gbp_name, website, address, city, state, zip, country_code, business_phone, location_user_first_name_optional, location_user_last_name_optional, location_user_email_address_optional, location_user_phone_number_optional."
                    }
                  ],
                  "raw": "{\n    \"account_id\": 1,\n    \"location_file\": \"<file: locations.xlsx>\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Accepts an Excel file of locations and an `account_id` (Brand), validates the header row and every data row, stores the file to S3, and emails the admin team to verify before any locations are created. Does NOT create locations itself.\n\n**Auth:** Both — `auth:api`. Lives in the top-level authenticated group (no `partner/*`, `admin/*`, or `EnsureHipagesPartner`). The target is scoped by the `account_id` rule: it must be a `company` row with `bundle_id=3` (Brand/Account), not soft-deleted, and pass `BelongsToAuthCompany`, which walks the auth user's company hierarchy via `Company::isBelongsToParentCompany`. A Partner (bundle_id=1) can target any Brand under their tree; a Brand/Account user can target Brands in their own hierarchy.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (`multipart/form-data`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `account_id` | integer | yes | Must exist in `company` where `bundle_id=3` and `deleted_at` is null, and belong to the auth user's company hierarchy (`BelongsToAuthCompany`). |\n| `location_file` | file (xlsx/xls) | yes | `mimetypes:application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, max 10240 KB. Header row must exactly match the template: `location_name, store_id, gbp_name, website, address, city, state, zip, country_code, business_phone, location_user_first_name_optional, location_user_last_name_optional, location_user_email_address_optional, location_user_phone_number_optional`. |\n\nPer-row data is validated by `validateData()`: `address`, `city`, `state`, `zip`, `business_phone` (9-10 digits, no formatting), `country_code` (must exist in `countries.code`), and `location_name` are required; `gbp_name`, `location_user_phone_number_optional`, `location_user_email_address_optional` (valid email, unique in `users`, distinct) are optional. Formula values (`#NAME?`) are rejected.\n\n**Response** — `{ data: ... }` envelope. Always HTTP 200 on the happy/validation paths; bad header/empty file returns a `badRequest` error envelope instead.\n- `status` (string) — `\"success\"` on accepted upload, `\"error\"` when per-row validation fails.\n- `message` (string) — human-readable status text.\n- `errors` (object, only when `status=error`) — map keyed by spreadsheet row number (data row index + 2) to an array of error message strings."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Thank you. Upload complete. We will confirm with you once data and locations are live.\"\n  }\n}"
                },
                {
                  "name": "200 Row Validation Errors",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"message\": \"Please correct the errors and re-upload the file.\",\n    \"status\": \"error\",\n    \"errors\": {\n      \"3\": [\n        \"The address field is required.\",\n        \"Country code must be a valid country code.\"\n      ],\n      \"5\": [\n        \"The Business phone field is required. Do not enter ( ) or - in the phone number field Please only enter the 10 digit numbers with no country code.\"\n      ]\n    }\n  }\n}"
                },
                {
                  "name": "400 Bad Header / Empty File",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"location_file\": \"The Excel file is invalid. The header names and order must match those in the template file.\"\n  }\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The account is invalid or deleted.\",\n  \"errors\": {\n    \"account_id\": [\n      \"The account is invalid or deleted.\"\n    ],\n    \"location_file\": [\n      \"The location file field must be a file of type Excel\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Import Locations (admin – create verified)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/import/locations",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "import",
                    "locations"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "formdata": [
                    {
                      "key": "account_id",
                      "value": "{{brandId}}",
                      "type": "text",
                      "description": "Required. Brand company id — must exist in `company.id` where bundle_id = 3 and not soft-deleted, and must belong to the auth user's company hierarchy (BelongsToAuthCompany rule)."
                    },
                    {
                      "key": "location_file",
                      "type": "file",
                      "src": [],
                      "description": "Required. Excel file (xlsx/xls). Max 10240 KB. Same template / header order as the bulk-upload endpoint. Rows are imported into the platform as RsImportLocation records and locations are created."
                    }
                  ],
                  "raw": "{\n    \"account_id\": 1,\n    \"location_file\": \"<file: locations.xlsx>\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Admin \"create verified\" import: validates the same Excel file/header/rows, then for each row persists an `RsImportLocation` record (`company_id` = `account_id`, `location_name`, raw `row_data` JSON) and dispatches a `CreateLocation` job to actually create the location asynchronously.\n\n**Auth:** Both — `auth:api`. In the top-level authenticated group (no `admin/*` prefix or admin middleware on the route despite the \"admin access only\" doc note). The target is scoped by the `account_id` rule: must be a `company` row with `bundle_id=3` (Brand/Account), not soft-deleted, and pass `BelongsToAuthCompany` (hierarchy walk via `Company::isBelongsToParentCompany`). A Partner (bundle_id=1) reaches Brands under their tree; a Brand/Account user reaches Brands in their own hierarchy.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (`multipart/form-data`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `account_id` | integer | yes | Must exist in `company` where `bundle_id=3` and `deleted_at` is null, and belong to the auth user's hierarchy (`BelongsToAuthCompany`). Used as `RsImportLocation.company_id`. |\n| `location_file` | file (xlsx/xls) | yes | `mimetypes:application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, max 10240 KB. Same 14-column header template as bulk-upload. |\n\nPer-row rules are identical to `validateData()` (see idx 63): required `address`, `city`, `state`, `zip`, `business_phone` (9-10 digits), `country_code` (exists in `countries.code`), `location_name`; optional `gbp_name`, `location_user_phone_number_optional`, `location_user_email_address_optional` (valid/unique/distinct email); `#NAME?` formula values rejected.\n\n**Response** — `{ data: ... }` envelope. Validation-failure path returns HTTP 200; bad header/empty file returns an `errorResponse` (message-only) at HTTP 200.\n- `status` (string) — `\"success\"` once rows are saved and jobs dispatched, `\"error\"` when per-row validation fails.\n- `message` (string) — human-readable status text.\n- `errors` (object, only when `status=error`) — map keyed by spreadsheet row number (data row index + 2) to arrays of error message strings."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Thank you very much. File has been uploaded. Location creation is currently underway\"\n  }\n}"
                },
                {
                  "name": "200 Row Validation Errors",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"message\": \"Please correct the errors and re-upload the file.\",\n    \"status\": \"error\",\n    \"errors\": {\n      \"2\": [\n        \"The location name field is required.\"\n      ],\n      \"6\": [\n        \"The User email already registered. Please enter a different email address\"\n      ]\n    }\n  }\n}"
                },
                {
                  "name": "200 Bad Header / Empty File",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The Excel file is invalid. The header names and order must match those in the template file.\",\n  \"errors\": \"\"\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The account is invalid or deleted. (and 1 more error)\",\n  \"errors\": {\n    \"account_id\": [\n      \"The account is invalid or deleted.\",\n      \"The selected account id does not belong to your company hierarchy.\"\n    ],\n    \"location_file\": [\n      \"The location file field is required.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Location CRUD + brand-user listing. Partners can also call these via brand-owner impersonation; brand owners use them directly with their own login."
        },
        {
          "name": "§ B.2 Plans (brand view)",
          "item": [
            {
              "name": "3.B.1 Plan list (brand)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/plans/list?company_id={{brandId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "plans",
                    "list"
                  ],
                  "query": [
                    {
                      "key": "company_id",
                      "value": "{{brandId}}"
                    }
                  ]
                },
                "description": "Returns every plan assigned to a given company (account/brand) via the `company_plans` pivot, eager-loaded with each plan's features, feature options, and plan-level review sites, ordered cheapest-first.\n\n**Auth:** Both — **no auth middleware**. The route lives in the public `throttle:100,1` group (\"reference data called by the SPA before login\"); `GetPlanListRequest::authorize()` returns `true`. The target company is scoped purely by the `company_id` query param. When `company_id` is omitted, it falls back to the authenticated user's `person->company_id` (`auth('api')->user()?->person?->company_id`), and finally to `1` if no user is present. Reachable scope is therefore identical for partner and account personas — whatever `company_id` is passed (validated only as `exists:company,id`, no hierarchy/`BelongsToAuthCompany` check). For idx=65 the brand company id is passed via `?company_id={{brandId}}`.\n\n**Rate limit:** 100 requests/min (`throttle:100,1`).\n\n**Request**\n- Query: `company_id` (integer, optional) — company (account/brand) id to list plans for; must exist in `company`. If omitted, defaults to the logged-in user's company, else `1`.\n\n**Response** — `{ data: ... }` envelope. Note: the controller returns `successResponse(['data' => $plans])`, so the payload is **double-nested** as `{ \"data\": { \"data\": [ ...plans ] } }`. Each array element is a raw `Plan` model (no API Resource) with all `plans.*` columns plus the eager-loaded relations:\n- `data.data[].id` (integer)\n- `data.data[].plan_name` (string)\n- `data.data[].price` (float) — cast to float; list ordered by `price` ascending\n- `data.data[].original_price` (float|null)\n- `data.data[].status` (string enum)\n- `data.data[].plan_type` (string enum)\n- `data.data[].tier_slug` (string|null)\n- `data.data[].enforcement_mode` (string enum|null)\n- `data.data[].is_admin_managed` (boolean) — cast to bool\n- `data.data[].description` (string|null)\n- `data.data[].plan_end_date` (datetime|null) — cast to datetime\n- `data.data[].short_info` (string|null)\n- `data.data[].created_at` / `updated_at` / `deleted_at` (datetime|null)\n- `data.data[].features[]` (array) — selected cols only: `id`, `plan_id`, `feature_name`, `enabled`, `included_units`, `limit_type`, `hard_stop`\n- `data.data[].features[].options[]` (array) — selected cols only: `id`, `plan_feature_id`, `feature_key`, `feature_value`\n- `data.data[].plan_review_sites[]` (array, relation `planReviewSites`) — selected cols only: `id`, `plan_id`, `review_site_id`, `pull_frequency`, `pull_interval`\n- `data.data[].plan_review_sites[].review_site` (object, relation `reviewSite`) — selected cols only: `id`, `name`\n\nReturns `404` (`notFoundResponse('Company not found.')`) when the resolved company id does not exist; `422` on `company_id` validation failure; `500` (`defaultErrorResponse`) on a thrown exception (also logged via `writeDebugLog` at `emergency`)."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": [\n      {\n        \"id\": 14,\n        \"plan_name\": \"Starter\",\n        \"price\": 49.0,\n        \"original_price\": 59.0,\n        \"status\": \"active\",\n        \"plan_type\": \"usage-based\",\n        \"tier_slug\": \"starter\",\n        \"enforcement_mode\": \"soft\",\n        \"is_admin_managed\": false,\n        \"description\": \"Entry plan\",\n        \"plan_end_date\": null,\n        \"short_info\": \"Best for single locations\",\n        \"created_at\": \"2026-05-01T10:00:00.000000Z\",\n        \"updated_at\": \"2026-05-01T10:00:00.000000Z\",\n        \"deleted_at\": null,\n        \"features\": [\n          {\n            \"id\": 81,\n            \"plan_id\": 14,\n            \"feature_name\": \"ai_response\",\n            \"enabled\": 1,\n            \"included_units\": 100,\n            \"limit_type\": \"monthly\",\n            \"hard_stop\": 0,\n            \"options\": [\n              { \"id\": 5, \"plan_feature_id\": 81, \"feature_key\": \"tone\", \"feature_value\": \"professional\" }\n            ]\n          }\n        ],\n        \"plan_review_sites\": [\n          {\n            \"id\": 22,\n            \"plan_id\": 14,\n            \"review_site_id\": 3,\n            \"pull_frequency\": \"weekly\",\n            \"pull_interval\": 1,\n            \"review_site\": { \"id\": 3, \"name\": \"Google\" }\n          }\n        ]\n      }\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "3.B.2 Plan details (current store plan)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store/current-plan?store_id={{storeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store",
                    "current-plan"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}"
                    }
                  ]
                },
                "description": "Returns the currently active plan for the store identified by `store_id` (via `Store::getStorePlanDetails()`), plus its `who_will_pay` billing flag from `store_options`. Backs `getCurrentPlan`.\n\n**Auth:** Both (Partner or Account/brand user) — `auth:api`, role-agnostic `store/` group. `store_id` is validated by `GetCurrentPlanRequest`: required, must `exists:store,id`, and must pass `BelongsToAuthCompany` (resolves the store to its company and confirms it is inside the caller's hierarchy) — partner reaches any store in its tree, brand user only its own.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, required) — Store id; exists:store + BelongsToAuthCompany.\n\n**Response** — `{ data: ... }` envelope.\n- `currentPlan` (object) — `{ store_plan_id, status, cancel_subscription_at, plan_start, plan_end, plan_id, plan (name), price, daysRemaining, totalDays, features: [feature_name, ...] }` (shape from `Store::getStorePlanDetails()`).\n- `who_will_pay` (string) — billing payer flag from `store_options` (e.g. `partner`)."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"currentPlan\": {\n      \"store_plan_id\": 5,\n      \"status\": \"active\",\n      \"plan_start\": \"2026-04-01\",\n      \"plan_end\": \"2026-12-31\",\n      \"plan\": \"Pro Monthly\",\n      \"price\": 49.99,\n      \"daysRemaining\": 211,\n      \"totalDays\": 274,\n      \"totalResponse\": 90,\n      \"responseRemaining\": 45,\n      \"features\": [\n        \"response_to_past_reviews\",\n        \"ai_response\",\n        \"response_posting\"\n      ]\n    },\n    \"who_will_pay\": \"brand\"\n  }\n}"
                }
              ]
            },
            {
              "name": "3.B.3 Add prorated plan to stores",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"plan_id\": \"{{planId}}\",\n  \"store_ids\": [\n    \"{{storeId}}\"\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/account/add-prorated-plan",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "add-prorated-plan"
                  ]
                },
                "description": "Assigns a plan to one or more stores for the current month. For each store it closes any active/expired `store_plans` without an issued next-invoice, opens a new active `StorePlan` (today -> end of month), applies the plan's features, sets auto-response, and seeds blank `store_review_sites`. It also upserts the `CompanyPlan` pointer for each affected brand and fires a `planChanged` billing webhook. **Billing side effect:** the plan amount is applied to next month's invoice.\n\n## Auth\n- Bearer **partner / account** token (`auth:api`). `plan_id` must be assigned to the caller's plan-assignment company (`company_plans` for `planAssignmentCompanyId()`); every `store_ids[]` must belong to the caller's hierarchy (`BelongsToAuthCompany`).\n\n## Rate limit\n- No rate limit (no `throttle` middleware on this route).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `plan_id` | integer | Required | Must exist in `company_plans` for the caller's plan-assignment company. Error message: 'The selected plan is not assigned to your partner account.' |\n| `store_ids` | integer[] | Required | Non-empty array. |\n| `store_ids.*` | integer | Required | `exists:stores,id` and in caller's hierarchy (`BelongsToAuthCompany`). |\n\n## Behaviour\n- `200` — `{ data: { status: 'success', message } }`.\n- `422` — plan not assigned to caller, or a `store_id` invalid / out of scope.\n\n✅ live ([routes/api_v1.php:1056](routes/api_v1.php#L1056) → `PlanController@addProratedPlan`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"The plan has been updated! The plan amount will be applied to the following month's invoice.\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected plan is not assigned to your partner account.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"plan_id\": [\n      \"The selected plan is not assigned to your partner account.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "3.B.4 Cancel store subscriptions",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"store_ids\": [\n    \"{{storeId}}\"\n  ],\n  \"cancelling_reason\": \"Price too high\",\n  \"cancelling_reason_other\": null\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/account/cancel-store-subscriptions",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "cancel-store-subscriptions"
                  ]
                },
                "description": "Cancels the subscription for the specified stores. For each matched store it stamps every non-cancelled `store_plans` row with `cancel_subscription_at` + the cancelling reason (closing any non-closed plan and setting `plan_end` to now), then fires a `storePlanCancelled` billing webhook. No refund is issued — the store is billed to the cancellation date.\n\n## Auth\n- Bearer **partner** (`bundle_id = 1`, `who_will_pay = partner`) **or brand** (`bundle_id = 3`, `who_will_pay = brand`) token (`auth:api`); any other caller gets `403`. Stores are further constrained to the caller's own partner/brand sub-tree AND to `store_ids` in the caller's hierarchy (`BelongsToAuthCompany`).\n\n## Rate limit\n- No rate limit (no `throttle` middleware on this route).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_ids` | integer[] | Required | Non-empty array. |\n| `store_ids.*` | integer | Required | `exists:stores,id` and in caller's hierarchy (`BelongsToAuthCompany`). |\n| `cancelling_reason` | string | Required | One of: `Price too high`, `Response quality not good enough`, `Another solution in place`, `Prefer to respond myself`, `Do not get enough reviews to justify it`, `Responses are not important to our business`, `Other`. |\n| `cancelling_reason_other` | string | Optional | `max:255`. Free-text detail (typically used with `Other`). |\n\n## Behaviour\n- `200` — `{ data: { status: 'success', response: 'Subscription has been cancelled successfully' } }`.\n- `403` — caller is not a paying partner/brand ('Permission denied').\n- `422` — validation failure (missing store_ids, bad reason, out-of-scope store).\n- `500` — no matching active/pending store found for the given ids in the caller's scope.\n\n✅ live ([routes/api_v1.php:1058](routes/api_v1.php#L1058) → `PlanController@cancelStoreSubscriptions`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": \"Subscription has been cancelled successfully\"\n  }\n}"
                },
                {
                  "name": "403 Forbidden",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Permission denied\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                }
              ]
            }
          ],
          "description": "Brand-side plan listing + currently-active plan details for a store. Read-only; the partner manages the plan catalogue."
        },
        {
          "name": "§ B.3 Review sites — catalogue & bulk credentials",
          "item": [
            {
              "name": "4.A.1 Master review-site catalog",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 5,\n    \"except_review_site_id\": [\n        44,\n        3\n    ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/account/reviewsite/list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "reviewsite",
                    "list"
                  ]
                },
                "description": "Returns the selectable review-site catalog (value/label pairs) available to an account — the union of review sites attached (via plan_review_sites) to the plans assigned to the account's brand(s) — with hipages pinned first, then Google (44) and Yelp (1). The legacy \"Google\" site id 52 is always excluded.\n\n**Auth:** Both — `auth:api` under the role-agnostic `/account/*` prefix (top-level `auth:api` group). Target account is taken from body `company_id`, falling back to the auth user's own `person.company_id`; the `company_id` is scoped by the `BelongsToAuthCompany` rule, which resolves brand (`bundle_id=3`) directly and walks the partner (`bundle_id=1`) tree, so a Partner may pass any company in its hierarchy while an Account is limited to its own company subtree.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | optional | `nullable`; must `exists` in `company.id` AND pass `BelongsToAuthCompany` (must be inside the caller's company hierarchy). If omitted, defaults to the auth user's `person.company_id`. |\n| `except_review_site_id` | array | optional | `nullable|array`; review-site ids to exclude from the returned list. |\n| `except_review_site_id.*` | integer | required_with | each element required when `except_review_site_id` is present. |\n\n**Response** — `{ data: ... }` envelope.\n- `data.status` (string) — always `\"success\"`.\n- `data.response` (array) — list of review sites; empty `[]` when the account's brands have no plans or those plans have no review sites. Each item:\n  - `value` (integer) — review site id (`review_sites.id`).\n  - `label` (string) — review site name; id 44 is force-labelled `\"Google\"`, otherwise `review_sites.name`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": [\n      { \"value\": 44, \"label\": \"Google\" },\n      { \"value\": 1, \"label\": \"Yelp\" }\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "4.A.2 Bulk-update credentials across the brand",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"review_site_id\": 5,\n    \"username\": \"myuser@example.com\",\n    \"password\": \"U2FsdGVkX1+abc123==\",\n    \"account_id\": 7,\n    \"apply_to_future_stores\": \"Y\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/bulk-update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "bulk-update"
                  ]
                },
                "description": "Bulk-sets username + password for one review site across every store under a brand-account, enabling its scraper on each, and optionally persists the credentials so future stores inherit them.\n\n**Auth:** Both — `auth:api`. `account_id` must be a `bundle_id=3` brand-company that passes `BelongsToAuthCompany` (the auth user's `person.company_id` must be a parent of it in the company tree). Target stores come from `Store::getStoreListByCompany($brand, true)`, so a Partner can target any brand under their tree while an Account (brand) user can only target its own brand.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `review_site_id` | integer | yes | Must exist in `review_sites`. |\n| `username` | string | yes | 2–1000 chars; `NameNoAngleBrackets` (no `<`/`>`). Stored CryptoJS-AES encrypted. |\n| `password` | string | yes | 5–1000 chars; `NameNoAngleBrackets`. Stored CryptoJS-AES encrypted. |\n| `account_id` | integer | yes | Brand-company id (`bundle_id=3`, not soft-deleted); must belong to caller's hierarchy. |\n| `apply_to_future_stores` | string (`Y`/`N`) | no | When `Y`, saves the credentials to `company_meta_data` (category `review_site`) so future stores reuse them. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `The username and password has been updated successfully!`. Returns a 400 with `Store not found!` if the brand resolves to zero stores or the review site is invalid."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"message\": \"The username and password has been updated successfully!\" } }"
                }
              ]
            },
            {
              "name": "4.A.3 Get saved company credentials",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/company/credentials?company_id={{brandId}}&review_site_id=1",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "company",
                    "credentials"
                  ],
                  "query": [
                    {
                      "key": "company_id",
                      "value": "{{brandId}}"
                    },
                    {
                      "key": "review_site_id",
                      "value": "1"
                    }
                  ]
                },
                "description": "Returns the saved review-site login (username only, never the password) stored at the company/brand level for a given review site.\n\n**Auth:** Both — `auth:api`. `company_id` must pass `BelongsToAuthCompany`, so the company has to sit within the auth user's company hierarchy; Partners can read any company under their tree, an Account user only its own.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `company_id` (integer, required) — must exist in `company` and belong to the caller's hierarchy.\n- Query: `review_site_id` (integer, required) — must exist in `review_sites`.\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `data` (object | empty array) — when credentials exist: `id`, `username`, `updated_at`, `review_site_id`, `review_site_name`. When none are saved, `data` is an empty array `[]`. The password is never returned."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"data\": { \"id\": 42, \"username\": \"myuser@example.com\", \"updated_at\": \"2026-06-15T13:41:47.000000Z\", \"review_site_id\": 1, \"review_site_name\": \"Yelp\" } } }"
                }
              ]
            },
            {
              "name": "4.A.4 Delete saved company credentials",
              "request": {
                "method": "DELETE",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"id\": 42\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/company/credentials",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "company",
                    "credentials"
                  ]
                },
                "description": "Deletes a saved company-level review-site login (a `company_meta_data` row of category `review_site`).\n\n**Auth:** Both — `auth:api`. The Form Request scopes `id` to a `company_meta_data` row whose `category=review_site` AND `company_id` equals the auth user's own `person.company_id`; the controller then re-checks `Company::isBelongsToParentCompany` against the record's company and returns a 422 if it is not in the caller's hierarchy. So a record is reachable both for its own Account and for a parent Partner.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `id` (integer, required) — `company_meta_data` record id (category `review_site`) owned by the caller's company.\n\n**Response** — NOT wrapped in `{ data: ... }`; this success path returns the object directly via `successResponse`, so the envelope is `{ data: { status, message } }`.\n- `status` (string) — `success`.\n- `message` (string) — `Company review site login deleted successfully`. Returns 422 `You are not authorized to delete this company review site login` when the record is outside the caller's tree."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"message\": \"Company review site login deleted successfully\" } }"
                }
              ]
            },
            {
              "name": "4.A.5 Export review-site setup to Excel",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"account_id\": 10\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/excel/list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "excel",
                    "list"
                  ]
                },
                "description": "Generates an Excel export of all review-site credentials for the stores under the given Account (bundle_id=3 company), stores the file on S3, and returns its public download URL.\n\n**Auth:** Both — `auth:api` (route group at routes/api_v1.php:299; no HiPages/persona middleware). Target is scoped by `BelongsToAuthCompany` on `account_id`: it must be a non-deleted `company` row with `bundle_id=3`, and `Company::isBelongsToParentCompany` must place it at or under the auth user's company. A Partner user can therefore export any brand-account in their tree; an Account user can only export their own account. The exported store set is resolved via `Store::getStoreListByCompany($brand, true)` (review sites 9 and 52 excluded).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `account_id` | integer | yes | Account (brand) company id; must exist in `company` with `bundle_id=3` and not be soft-deleted, and must belong to the auth user's company hierarchy. |\n\n**Response** — `{ data: ... }` envelope.\n- `path` (string) — public S3/CloudFront URL of the generated `.xlsx` file (key pattern `download/excel/YYYY-MM/{timestamp}-ReviewSiteData.xlsx`). The workbook columns are Location Name, Address, City, State, Zip, SAU Code, Review Site, Url, Username, Password (decrypted), Landing Page URL."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"path\": \"https://d2ny6zb7otrnhl.cloudfront.net/download/excel/2026-06/1782122214-ReviewSiteData.xlsx\" } }"
                }
              ]
            },
            {
              "name": "4.A.6 Import review-site updates from Excel",
              "request": {
                "method": "POST",
                "header": [],
                "body": {
                  "mode": "raw",
                  "formdata": [
                    {
                      "key": "account_id",
                      "value": "{{brandId}}",
                      "type": "text"
                    },
                    {
                      "key": "review_site_file",
                      "type": "file",
                      "src": []
                    }
                  ],
                  "raw": "{\n    \"account_id\": 10\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/excel/review-site-update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "excel",
                    "review-site-update"
                  ]
                },
                "description": "Uploads a filled-in review-site credentials Excel file for an Account, validates its header row, stores it on S3, and emails a confirmation to the support address for back-office processing.\n\n**Auth:** Both — `auth:api` (route group at routes/api_v1.php:299; no HiPages/persona middleware). Target is scoped by `BelongsToAuthCompany` on `account_id`: it must be a non-deleted `company` row with `bundle_id=3` at or under the auth user's company (per `Company::isBelongsToParentCompany`). A Partner can upload for any brand-account in their tree; an Account user only for their own account. Sent as `multipart/form-data`. The uploaded file's first heading row must match exactly: `location_name, address, city, state, zip, sau_code, review_site, url, username, password, landing_page_url` (an invalid/mismatched header returns a 400 bad-request, not a 422).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (`multipart/form-data`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `account_id` | integer | yes | Account (brand) company id; must exist in `company` with `bundle_id=3`, not soft-deleted, and belong to the auth user's hierarchy. |\n| `review_site_file` | file | yes | Excel file, mimetype `application/vnd.ms-excel` or `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (.xls/.xlsx), max 10240 KB. Header row must match the 11-column template above. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — always `\"success\"` on a valid upload.\n- `message` (string) — fixed confirmation copy: \"Thank you. Upload complete. We will confirm with you once review sites are live.\""
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"message\": \"Thank you. Upload complete. We will confirm with you once review sites are live.\" } }"
                }
              ]
            },
            {
              "name": "4.A.7 List locations for review-site management",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"sort_field\": \"store_name\",\n    \"sort_by\": \"ASC\",\n    \"search_field\": \"store_name\",\n    \"search_value\": \"Main St\",\n    \"active_plan\": 1,\n    \"limit\": 10\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store/list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store",
                    "list"
                  ]
                },
                "description": "Returns a paginated list of stores accessible to the authenticated user, each joined to full location, brand, and geo detail plus the active `store_plan` (with plan features) and a generated `connect_page_url`. Used here to pick a location for review-site management. Backs `getUserStoreWithLocation` — this is the SAME `POST /store/list` endpoint as idx=56, documented under a second collection folder.\n\n**Auth:** Both (Partner or Account/brand user) — `auth:api`, role-agnostic `store/` group. Default scope `Store::getStoreListByUser($user)` (caller's accessible stores); `company_id` switches to `Store::getStoreListByCompany`, `store_id` narrows to one store. Both id filters are validated by `BelongsToAuthCompany`, confining brand users to their own stores while a partner reaches its whole tree.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `page` (integer, optional) — pagination page.\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| sort_field | string | optional | store_name, address, zip, city_name, state_code, company_phone, country_name, state_name, storeid, account_name (default store_name) |\n| sort_by | string | optional | asc / ASC / desc / DESC |\n| search_field | string | optional | same column set as sort_field |\n| search_value | string | optional | NameNoAngleBrackets |\n| active_plan | int (0\\|1) | optional | 1 = only stores with an active plan |\n| company_id | int | optional | exists (bundle 1/2/3, not deleted) + BelongsToAuthCompany |\n| store_id | int | optional | exists:store + BelongsToAuthCompany |\n| limit | int | optional | page size (default 10) |\n\n**Response** — `{ data: ... }` envelope; `data` is the raw Laravel paginator (`current_page`, `data[]`, `per_page`, `total`, page urls, `links`). Each `data.data[]` row: `id`, `account_name`, `store_name`, `store_full_name`, `storeid`, `client_location_id`, `address`/`address1`/`address2`/`zip`, `country_id/_name/_code`, `state_id/_name/_code`, `city_id/_name`, `company_phone`, `brand_company_id`, `connect_page_url`, and `active_store_plan` (object|null with nested `plan.features[]` of `{ id, plan_id, feature_name, options }`)."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"current_page\": 1, \"data\": [ { \"id\": 14, \"account_name\": \"John Doe\", \"store_name\": \"GSLoc\", \"store_full_name\": \"GSLoc - SID123\", \"storeid\": \"SID123\", \"client_location_id\": \"\", \"address\": \"Test-address\", \"address1\": \"Test-address\", \"address2\": null, \"zip\": \"46546\", \"country_id\": 233, \"country_name\": \"United States\", \"country_code\": \"US\", \"state_id\": 1416, \"state_name\": \"California\", \"state_code\": \"CA\", \"city_id\": 133682, \"city_name\": \"Del Mar\", \"company_phone\": \"8888888888\", \"brand_company_id\": 12, \"connect_page_url\": \"https://hipages.manage-myreviews.com/connect-review-sites/rxsx\", \"active_store_plan\": { \"id\": 16, \"store_id\": 14, \"plan_id\": 4, \"status\": \"active\", \"plan_start\": \"2026-06-22T06:55:59.000000Z\", \"plan_end\": \"2026-06-30T23:59:59.000000Z\", \"cancel_subscription_at\": null, \"plan\": { \"id\": 4, \"plan_name\": \"Gold\", \"features\": [ { \"id\": 68, \"plan_id\": 4, \"feature_name\": \"ai_response\", \"options\": [] } ] } } } ], \"per_page\": 10, \"total\": 1 } }"
                }
              ]
            }
          ],
          "description": "Master review-site catalogue lookup, bulk credential updates across a brand, Excel export / import, credential cleanup. Brand-owner / partner-admin setup surface."
        },
        {
          "name": "§ B.4 Review sites — per-store",
          "item": [
            {
              "name": "4.B.1 List review sites for a store",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"store_id\": {{storeId}},\n    \"has_landing_page\": 1,\n    \"check_connection\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "list"
                  ]
                },
                "description": "> 🧭 **E2E journey 5/9 — List Store Review Sites.** Uses `{{storeId}}`. Captures the first row's `review_site.id` → `{{reviewSiteId}}` (global review-site id, used by step 6) and its top-level `id` → `{{storeReviewSiteId}}` (pivot id, for the `reviews:pull-due --store_review_site_id` accelerator). Note: the top-level `id` is the `store_review_sites` pivot, **not** the review-site id.\n\nLists every review site configured for a store, each with its URL, scraper flag, connection card-state and review-site metadata — drives the store's connection/listing page.\n\n**Auth:** Both — `auth:api`. The store is targeted by `store_id` OR `client_location_id` (resolved to a store id via `Store::getStoreIdFromClientLocationId` scoped to the caller's company), and `BelongsToAuthCompany` confirms the store sits in the auth user's company hierarchy. Partners reach any store under their tree; Account users only their own brand's stores.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | required without `client_location_id` | Must exist in `store` and belong to the caller. |\n| `client_location_id` | string | optional | Max 255; partner-owned stable id, resolved to `store_id` server-side; `BelongsToAuthCompany`. |\n| `has_landing_page` | integer (`0`/`1`) | optional | `1` filters to rows that have a non-empty `landing_page_url`. |\n| `check_connection` | integer (`0`/`1`) | optional | `0` short-circuits connection resolution (empty card-state) for a cheaper call. |\n| `sort_field` | string (`review_site`) | optional | Accepted but listing is ordered by a fixed FIELD() priority. |\n| `sort_by` | string (`asc`/`desc`) | optional | |\n\n**Response** — `{ data: [...] }` resource collection plus a sibling `store_name` key.\n- `data[]` (array) — each row: `id`, `url`, `landing_page_url`, `created_at`, `url_status`, `connectionStatus`, `is_connected`, `lock_color`, `sendto_scrapper`, `display_as_connected`, `reviews_status`, `posting_status`, `has_url`, `error_code`, `error_title`, `error_reason`, `fix_sheet`, `last_scraped_at`, `connect_page_url`, and nested `review_site` (`id`, `name`, `publisher`, `link`, `review_site_icon`, `review_site_color`, `self_form_enabled`, `auth_type`, `scraping_enabled`, `posting_enabled`, `keywords`).\n- `store_name` (string) — top-level, store name with optional ` - {storeid}` suffix."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": [ { \"id\": 5, \"url\": \"https://maps.google.com/maps?cid=6987565896915883812\", \"landing_page_url\": \"https://search.google.com/local/writereview?placeid=ChIJ...\", \"created_at\": \"2026-06-12T11:54:11.000000Z\", \"url_status\": \"enable\", \"connectionStatus\": \"Disconnected\", \"is_connected\": 0, \"lock_color\": \"gray\", \"sendto_scrapper\": \"N\", \"display_as_connected\": false, \"reviews_status\": \"offline\", \"posting_status\": \"offline\", \"has_url\": true, \"error_code\": \"E-002\", \"error_title\": \"Posting paused\", \"error_reason\": \"Google connection has expired. Please reconnect this location.\", \"fix_sheet\": \"oauth_reconnect\", \"last_scraped_at\": null, \"connect_page_url\": \"https://hipages.manage-myreviews.com/connect-review-sites/6zf8/edit-site/2/Google/5/\", \"review_site\": { \"id\": 44, \"name\": \"Google\", \"publisher\": \"maps.google.com\", \"link\": \"http://maps.google.com\", \"review_site_icon\": \"https://.../google_ic.png\", \"review_site_color\": \"#4285F4\", \"self_form_enabled\": \"N\", \"auth_type\": \"oauth\", \"scraping_enabled\": true, \"posting_enabled\": true, \"keywords\": [\"[reviewSite_44]\"] } } ], \"store_name\": \"Demo Brew - 89\" }"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  const rows = (j.data && (j.data.data || j.data)) || [];",
                      "  if (Array.isArray(rows) && rows.length) {",
                      "    const row = rows[0];",
                      "    if (row.review_site && row.review_site.id) { pm.environment.set('reviewSiteId', String(row.review_site.id)); }",
                      "    if (row.id) { pm.environment.set('storeReviewSiteId', String(row.id)); }",
                      "    console.log('journey: reviewSiteId =', row.review_site && row.review_site.id, '| storeReviewSiteId =', row.id);",
                      "  }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "4.B.2 Show single store-review-site",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/show/{{storeReviewSiteId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "show",
                    "{{storeReviewSiteId}}"
                  ]
                },
                "description": "Returns the full detail bundle for one store-review-site row: the pivot record, its review-site, its store (+company), the non-LDE site map, hipages flag, and the latest cadence pull summary — drives the edit-site page.\n\n**Auth:** Both — `auth:api` OR invite `hash`. Registered in the public group: an authenticated user reaches the row only if `store_review_site_id` (taken from the `{id}` path) passes `BelongsToAuthCompany` (Partner or Account hierarchy). With a `hash` query param (no token), the hash must decode to `[store_id, 15]` and the row must belong to that store. Differs per persona only by reachable scope: Partner sees any row under its tree, Account only its brand's.\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\n- Path: `{id}` (integer, required) — the `store_review_sites` row id (validated as `store_review_site_id`).\n- Query: `hash` (string, optional) — invite hashid; grants tokenless access when it encodes this row's store.\n\n**Response** — `{ data: ... }` envelope.\n- `store_review_site` (object) — the pivot row plus connection-state fields folded in (`isConnected()` + `ConnectionStateResolver::cardState`): includes `id`, `review_site_id`, `store_id`, `review_site_url`, `url`, `landing_page_url`, `username`, `password` (re-encrypted for transport), `sendto_scrapper`, `display_as_connected`, `connection_status`, `disconnect_reason`, `error_code`, `is_connected`, `connectionStatus`, `lock_color`, `reviews_status`, `posting_status`, `has_url`, `error_title`, `error_reason`, `fix_sheet`, `last_scraped_at`, `connect_page_url`, etc. (Caring/BBB rows also carry `external_id`/`external_name`.)\n- `review_site` (object) — `id`, `name`, `link`, `review_site_button`, `auth_type`, `scraping_enabled`, `posting_enabled`, etc.\n- `store` (object) — `id`, `name`, `storeId`, `company_id`, nested `company` (`id`, `address`, `zip`, `client_account_id`).\n- `is_hipages_partner` (bool); `non_lde` (object map of site→id); `latest_review_pull` (object|null) — `status`, `time`, `reviews_pulled`, `error_message`."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"store_review_site\": { \"id\": 5, \"review_site_id\": 44, \"store_id\": 2, \"review_site_url\": \"https://maps.google.com/...\", \"url\": \"https://maps.google.com/...\", \"landing_page_url\": \"https://search.google.com/local/writereview?placeid=ChIJ...\", \"username\": null, \"password\": null, \"sendto_scrapper\": \"N\", \"connection_status\": \"Disconnected\", \"is_connected\": 0, \"reviews_status\": \"offline\", \"posting_status\": \"offline\", \"has_url\": true, \"fix_sheet\": \"oauth_reconnect\", \"connect_page_url\": \"https://hipages.manage-myreviews.com/connect-review-sites/6zf8/edit-site/2/Google/5/\" }, \"review_site\": { \"id\": 44, \"name\": \"Google\", \"auth_type\": \"oauth\", \"scraping_enabled\": true, \"posting_enabled\": true }, \"store\": { \"id\": 2, \"name\": \"Demo Brew\", \"storeId\": \"Alameda County\", \"company_id\": 7, \"company\": { \"id\": 7, \"address\": \"123 Main St\", \"zip\": \"92014\", \"client_account_id\": null } }, \"is_hipages_partner\": true, \"non_lde\": { \"Vrbo\": 7 }, \"latest_review_pull\": { \"status\": \"no_new\", \"time\": \"2026-06-15T13:41:47.000000Z\", \"reviews_pulled\": 0, \"error_message\": null } } }"
                }
              ]
            },
            {
              "name": "4.B.3 Update store-review-site",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"store_review_site_id\": 5,\n    \"url\": \"https://www.google.com/maps/place/Demo\",\n    \"landing_page_url\": \"https://search.google.com/local/writereview?placeid=ChIJ123\",\n    \"username\": \"owner@example.com\",\n    \"password\": \"Secret@123\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "update"
                  ]
                },
                "description": "Updates a single store-review-site row's URL, landing page, credentials and site-specific extras (BBB / Caring / SeniorAdvisor fields), enables its scraper when a URL is present on an active store, and dispatches a pull job.\n\n**Auth:** Both — `auth:api` OR invite `hash`. Authenticated callers target the row via `store_review_site_id`, or via `store_id`+`review_site_id`, or `client_location_id`+`review_site_id` (the alt identifiers are resolved to a pivot id in `prepareForValidation`, scoped to the caller's accessible stores), then `BelongsToAuthCompany` enforces hierarchy. The `hash` flow accepts ONLY `store_review_site_id` and binds it to the hash's store. Per persona: Partner can reach/resolve rows across its whole tree, Account only its brand's; `client_location_id` resolution is scoped per caller so the same id can differ between partners.\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_review_site_id` | integer | one identifier path required | Direct pivot id (highest priority); `exists` + `BelongsToAuthCompany`. |\n| `store_id` | integer | with `review_site_id` (alt path) | `exists:store,id`; resolves the pivot within the store. |\n| `client_location_id` | string | with `review_site_id` (alt path) | Max 255; resolves within caller's accessible stores. |\n| `review_site_id` | integer | required when using `store_id` or `client_location_id` | `exists:review_sites,id`. |\n| `hash` | string | hash flow only | Invite hashid `[store_id,15]`; tokenless; only with `store_review_site_id`. |\n| `url` | string (url) | optional | 5–2000 chars. |\n| `landing_page_url` | string (url) | optional | 5–2000; defaults to `url` when omitted. |\n| `username` | string | optional | 2–1000; `NameNoAngleBrackets`. |\n| `password` | string | optional | 5–1000; becomes `required_with:username` when the row has no saved creds and the site is not non-LDE. |\n| `bbb_owner_name` / `bbb_owner_email` / `bbb_location_zip` | string | sometimes-required (BBB, id 45) | |\n| `sa_replier_first_name` / `_last_name` / `_email` / `_title` | string | sometimes-required (SeniorAdvisor, id 68) | |\n| `external_id` / `external_name` | string | `required_if:review_site_id,20` (Caring) | Max 255; setting `external_id` connects Caring. |\n| `conFirmDisableTwoFactorAuthentication` | integer (`0`/`1`) | optional | `1` resets the row out of the 2FA-pending state. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `The URL and/or password was successfully updated!`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"message\": \"The URL and/or password was successfully updated!\" } }"
                }
              ]
            },
            {
              "name": "4.B.4 Remove credentials",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"store_review_site_id\": 5\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/remove",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "remove"
                  ]
                },
                "description": "Clears the credentials and site-specific detail for a store-review-site row, turns its scraper off, marks it manually disconnected, and emails the account owner.\n\n**Auth:** Both — `auth:api` OR invite `hash`. Authenticated callers pass `store_review_site_id` which must pass `BelongsToAuthCompany` (Partner or Account hierarchy); the `hash` flow (tokenless) requires the hash to decode to `[store_id, 15]` matching the row's store. Differs per persona only by reachable scope.\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_review_site_id` | integer | yes | `exists:store_review_sites,id`; `BelongsToAuthCompany` in the token flow. |\n| `hash` | string | hash flow only | Invite hashid `[store_id,15]`; grants tokenless access for the encoded store. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `The review site credentials has been removed!`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"message\": \"The review site credentials has been removed!\" } }"
                }
              ]
            },
            {
              "name": "4.B.5 Toggle scraper",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"review_site_id\": {{reviewSiteId}},\n    \"store_id\": {{storeId}},\n    \"sendto_scrapper\": \"Y\",\n    \"review_site_url\": \"https://maps.google.com/?cid=6987565896915883812\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/change-scrapper-status",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "change-scrapper-status"
                  ]
                },
                "description": "> 🧭 **E2E journey 6/9 — Data Pulling (enrol into pull cadence).** Uses `{{reviewSiteId}}` + `{{storeId}}`. Setting `sendto_scrapper=Y` with a valid `review_site_url` enrols the row into the cadence-driven pull pipeline (`reviews:pull-due`); reviews arrive asynchronously via the RD webhook. There is no synchronous \"pull now\" endpoint.\n\nToggles data-pulling (`sendto_scrapper` Y/N) for a store's review site; turning it off marks the row disconnected (data-pulling paused) and emails the owner, turning it on clears the pause.\n\n**Auth:** Both — `auth:api` OR invite `hash`. Authenticated callers send `store_id` (+ `review_site_id`) and `store_id` must pass `BelongsToAuthCompany`. With a `hash` (tokenless), `store_id` is derived from the hash in `prepareForValidation` and any client-supplied `store_id` is ignored. Differs per persona only by reachable scope (Partner vs Account tree).\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `review_site_id` | integer | yes | `exists:review_sites,id`. |\n| `store_id` | integer | yes (token flow) | `exists:store,id` + `BelongsToAuthCompany`; ignored/overridden in the hash flow. |\n| `sendto_scrapper` | string (`Y`/`N`) | yes | Enable/disable pulling. |\n| `hash` | string | hash flow only | Invite hashid `[store_id,15]`; supplies `store_id` and grants tokenless access. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Review site enabled successfully` or `Review site disabled successfully`. Returns 400 `No Record Found!` if no matching (store, review-site) row exists."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"message\": \"Review site enabled successfully\" } }"
                }
              ]
            },
            {
              "name": "4.B.6 Bulk landing-page URL update",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"store_id\": 42,\n    \"landing_page_urls\": [\n        {\n            \"review_site_id\": 44,\n            \"url\": \"https://search.google.com/local/writereview?placeid=ChIJ123\"\n        },\n        {\n            \"review_site_id\": 1,\n            \"url\": \"https://www.yelp.com/biz/demo\"\n        }\n    ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/landing-page-url/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "landing-page-url",
                    "update"
                  ]
                },
                "description": "Backfills landing-page URLs for several of a store's review sites in one call — only fills rows whose `landing_page_url` is currently empty (skips already-set rows silently), and also seeds `review_site_url`+enables the scraper when that column is empty on an active store.\n\n**Auth:** Both — `auth:api`. `store_id` must pass `BelongsToAuthCompany`, so the store has to be in the auth user's hierarchy; Partner reaches any store under its tree, Account only its own brand's.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | `exists:store,id` + `BelongsToAuthCompany`. |\n| `landing_page_urls` | array (min 1) | yes | List of `{review_site_id, url}` entries. |\n| `landing_page_urls.*.review_site_id` | integer | yes | `exists:review_sites,id`. |\n| `landing_page_urls.*.url` | string (url) | yes | Max 2048. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Landing page URL updated successfully`.\n- `total_updated` (integer) — count of rows actually updated (rows already holding a landing page URL are skipped and not counted)."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"message\": \"Landing page URL updated successfully\", \"total_updated\": 2 } }"
                }
              ]
            },
            {
              "name": "4.B.6b Missing URLs backfill grid",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"page\": 1,\n    \"per_page\": 20,\n    \"search\": \"Acme\",\n    \"review_site_id\": 44,\n    \"account_id\": 23\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/missing-urls",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "missing-urls"
                  ]
                },
                "description": "Returns a paginated grid of store-review-site rows in the caller's accessible tree that are missing their review-site URL and/or landing-page URL — drives the URL backfill grid.\n\n**Auth:** Both — `auth:api`. Scope comes from `Store::getStoreListByUser($user, true)`: a Partner sees every store under its tree, a brand/Account user only its own; an empty accessible set returns an empty page. Optional `account_id` further narrows to one brand by re-scoping to `bundle_id=2` location-companies whose parent is that brand — a cross-tenant id just yields an empty page.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (all optional):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `page` | integer | no | Min 1; default 1. |\n| `per_page` | integer | no | 1–100; default 20. |\n| `search` | string | no | Max 255; case-insensitive substring over store name / review-site name / `client_location_id`. |\n| `review_site_id` | integer | no | `exists:review_sites,id`; narrow to one site. |\n| `account_id` | integer | no | `exists:company,id`; brand narrowing within caller's tree. |\n\n**Response** — `{ data: ... }` envelope (note: nested `data.data`).\n- `data` (array) — rows: `id`, `store_id`, `store_name`, `client_location_id`, `brand_id`, `brand_name`, `review_site_id`, `review_site_name`, `review_site_url`, `landing_page_url`, `review_site_url_missing` (bool), `landing_page_url_missing` (bool), `sendto_scrapper`.\n- `meta` (object) — `current_page`, `per_page`, `total`, `last_page`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"data\": [ { \"id\": 13, \"store_id\": 5, \"store_name\": \"John Doe\", \"client_location_id\": null, \"brand_id\": 12, \"brand_name\": \"Acme\", \"review_site_id\": 1, \"review_site_name\": \"Yelp\", \"review_site_url\": null, \"landing_page_url\": null, \"review_site_url_missing\": true, \"landing_page_url_missing\": true, \"sendto_scrapper\": \"N\" } ], \"meta\": { \"current_page\": 1, \"per_page\": 20, \"total\": 1, \"last_page\": 1 } } }"
                }
              ]
            },
            {
              "name": "4.B.7 Send review-site connection-request invite",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"store_id\": 10,\n    \"email\": \"owner@example.com\",\n    \"name\": \"Jane Doe\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/send/request",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "send",
                    "request"
                  ]
                },
                "description": "Emails a tokenized review-site connection invite for a store and echoes the connect URL + token so the UI can also show copy/resend; the response reflects the real dispatch outcome (sent / queued / skipped / failed).\n\n**Auth:** Both — `auth:api`. The store is targeted by `store_id` OR `client_location_id` (resolved to a store id scoped to the caller's company), and both pass `BelongsToAuthCompany`, so the store must be in the auth user's hierarchy. Partner can invite for any store under its tree, Account only its own brand's stores.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | required without `client_location_id` | `exists:store,id` + `BelongsToAuthCompany`. |\n| `client_location_id` | string | optional | Max 255; resolved to `store_id`; `BelongsToAuthCompany`. |\n| `email` | string (email) | yes | Validated `email:rfc,dns` — recipient of the invite. |\n| `name` | string | no | Max 120; `NameNoAngleBrackets`; recipient display name in the email. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — always `success` on the happy path.\n- `delivered` (bool) — true when delivery status is sent or pending(queued).\n- `delivery_status` (string) — `sent` | `pending` | `skipped` | `failed`.\n- `skip_reason` (string|null) — e.g. template disabled / recipient suppressed.\n- `message` (string) — human summary matching the delivery status.\n- `connect_url` (string) — the store's connect-review-sites URL.\n- `token` (string) — raw hashid of `[store_id, 15]` for copy/resend.\n- `recipient` (string) — echoed email."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"success\": true,\n    \"data\": {\n      \"status\": \"success\",\n      \"message\": \"Invitation email sent successfully.\",\n      \"connect_url\": \"https://app.example.com/connect-review-sites/L9p2KqXm\",\n      \"token\": \"L9p2KqXm\",\n      \"recipient\": \"owner@tradies-shop.com\"\n    }\n  }\n}"
                },
                {
                  "name": "422 Out-of-scope store_id",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The store ID is invalid for the authenticated user.\",\n  \"errors\": {\n    \"store_id\": [\n      \"The store ID is invalid for the authenticated user.\"\n    ]\n  }\n}"
                },
                {
                  "name": "422 Email fails DNS-MX lookup",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The email field must be a valid email address.\",\n  \"errors\": {\n    \"email\": [\n      \"The email field must be a valid email address.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Change URL Status (enable/disable landing page)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/change-url-status",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "change-url-status"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"store_id\": 10,\n    \"review_site_id\": 5,\n    \"url_status\": \"enable\",\n    \"landing_page_url\": \"https://example.com/landing\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Toggles `url_status` (enable/disable) of a store's landing-page URL — drives the \"Enable URL\" column on the Landing Page grid. A `landing_page_url` may be supplied in the same request and is saved to the record; enabling requires a landing-page URL (sent here or already stored). Disabling never requires a URL.\n\n**Auth:** Both — `auth:api`. Authenticated-only (no public hash flow); the guard resolves the api guard explicitly. `store_id` must pass `BelongsToAuthCompany`, so it must be in the auth user's hierarchy — Partner any store under its tree, Account only its own brand's.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | `exists:store,id` + `BelongsToAuthCompany`. |\n| `review_site_id` | integer | yes | `exists:review_sites,id`. |\n| `url_status` | string (`enable`/`disable`) | yes | `enable` needs a landing-page URL (supplied here or already stored); `disable` never requires one. |\n| `landing_page_url` | string (URL) | conditional | Validated `nullable` + `url`. Required when enabling and no URL is saved on the row yet; saved to the record whenever provided. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `URL enabled successfully` / `URL disabled successfully`.\n\n**Errors**\n- `422` — a required field is missing, `url_status` not in `enable`/`disable`, enabling without any URL (supplied or stored), or `landing_page_url` is not a valid URL.\n- `400` — no matching (store, review-site) row (`No Record Found!`).\n- `401` — missing/invalid token.\n- `500` — unexpected server error."
              },
              "response": [
                {
                  "name": "200 OK - URL enabled",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"URL enabled successfully\"\n  }\n}"
                },
                {
                  "name": "200 OK - URL disabled",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"URL disabled successfully\"\n  }\n}"
                },
                {
                  "name": "422 Unprocessable Entity - URL required when enabling",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The landing page URL is required when enabling the URL.\",\n  \"errors\": {\n    \"landing_page_url\": [\n      \"The landing page URL is required when enabling the URL.\"\n    ]\n  }\n}"
                },
                {
                  "name": "400 Bad Request - record not found",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"message\": \"No Record Found!\"\n  }\n}"
                },
                {
                  "name": "401 Unauthorized - missing/invalid token",
                  "status": "Unauthorized",
                  "code": 401,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Unauthenticated.\"\n}"
                }
              ]
            },
            {
              "name": "List Store Review Site URLs (Review Sites / Landing Page grids)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/url-list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "url-list"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"url_type\": \"landing_page\",\n    \"filter\": \"missing\",\n    \"page\": 1,\n    \"per_page\": 20,\n    \"search\": \"Acme\",\n    \"review_site_id\": 44,\n    \"account_id\": 23,\n    \"store_id\": 5\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Unified paginated URL grid that powers both the \"Review Sites\" and \"Landing Page\" pages, each with an All / Missing tab; `url_type` picks the managed column and `filter` picks the tab. Generalises the missing-urls endpoint.\n\n**Auth:** Both — `auth:api`. Scope is `Store::getStoreListByUser($user, true)` — Partner sees all stores under its tree, brand/Account only its own; empty set → empty page. Optional `account_id` narrows to one brand's `bundle_id=2` location children; `store_id` narrows to a single store (always bounded by the accessible tree, so out-of-tree ids yield an empty page).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `url_type` | string (`review_site`/`landing_page`) | yes | Selects the managed URL column. |\n| `filter` | string (`all`/`missing`) | no | Default `all`; `missing` narrows to rows where the selected column is empty. |\n| `page` | integer | no | Min 1. |\n| `per_page` | integer | no | 1–100; default 20. |\n| `search` | string | no | Max 255; substring over store / review-site name / `client_location_id`. |\n| `review_site_id` | integer | no | `exists:review_sites,id`. |\n| `account_id` | integer | no | `exists:company,id`; brand narrowing within caller's tree. |\n| `store_id` | integer | no | `exists:store,id`; single-location narrowing. |\n\n**Response** — `{ data: ... }` envelope (nested `data.data`).\n- `data` (array) — rows: `id`, `store_id`, `store_name`, `client_location_id`, `brand_id`, `brand_name`, `review_site_id`, `review_site_name`, `review_site_icon`, `review_site_color`, `scraping_enabled` (bool), `sendto_scrapper`, `url_status`, `review_site_url`, `landing_page_url`, `review_site_url_missing` (bool), `landing_page_url_missing` (bool).\n- `meta` (object) — `current_page`, `per_page`, `total`, `last_page`."
              },
              "response": [
                {
                  "name": "200 OK - paginated rows",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": [\n      {\n        \"id\": 5,\n        \"store_id\": 2,\n        \"store_name\": \"Demo Brew\",\n        \"client_location_id\": \"hp-1234\",\n        \"brand_id\": 3,\n        \"brand_name\": \"Demo Brew Group\",\n        \"review_site_id\": 44,\n        \"review_site_name\": \"Google\",\n        \"review_site_icon\": \"https://d2ny6zb7otrnhl.cloudfront.net/assets/backend/images/logos/google_ic.png\",\n        \"review_site_color\": \"#4285F4\",\n        \"scraping_enabled\": true,\n        \"sendto_scrapper\": \"N\",\n        \"url_status\": \"disable\",\n        \"review_site_url\": \"https://maps.google.com/maps?cid=6987565896915883812\",\n        \"landing_page_url\": null,\n        \"review_site_url_missing\": false,\n        \"landing_page_url_missing\": true\n      }\n    ],\n    \"meta\": {\n      \"current_page\": 1,\n      \"per_page\": 20,\n      \"total\": 1,\n      \"last_page\": 1\n    }\n  }\n}"
                },
                {
                  "name": "200 OK - empty page (no accessible stores)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": [],\n    \"meta\": {\n      \"current_page\": 1,\n      \"per_page\": 20,\n      \"total\": 0,\n      \"last_page\": 1\n    }\n  }\n}"
                },
                {
                  "name": "422 Unprocessable Entity - validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected url type is invalid.\",\n  \"errors\": {\n    \"url_type\": [\"The selected url type is invalid.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Connect Review Site (MANAGER_INVITE publisher flow)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/123/connect",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "123",
                    "connect"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"hash\": \"{{hash}}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Drives the MANAGER_INVITE publisher connect flow for one store-review-site row (Yelp / TripAdvisor / Expedia / Booking.com / DoorDash / …): requires a completed review-pull first, then calls ReviewData `/get-publisher-info` (cache hit) or `/create-publisher-account` (cache miss) and returns the business-manager alias plus the live connection status mirrored from RD.\n\n**Auth:** Both — `auth:api` OR invite `hash`. Registered in the public group: authenticated callers must have the `{id}` row pass `BelongsToAuthCompany` (Partner or Account hierarchy); a `hash` (tokenless) must decode to `[store_id, 15]` matching the row's store. Differs per persona only by reachable scope.\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\n- Path: `{id}` (integer, required) — the `store_review_sites` row id (mapped to `store_review_site_id`).\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `hash` | string | hash flow only | Invite hashid `[store_id,15]`; grants tokenless access; the path row must belong to the encoded store. |\n\n**Response** — `{ data: ... }` envelope (via `okResponse`). The payload is assembled by `IdentityAddressResolver::connectAndStatus`.\n- `identity_address`, `publisher_key`, `publisher_account_id`, `connection_status`, `is_connected`, `lock_color`, `sendto_scrapper`, `publisher_url`, `auth_type`, `scraping_enabled`, `posting_enabled`, `last_scraped_at`, `disconnect_reason`, `is_valid_credential`, `is_valid_cookie`, `url_status`, `credential_status`, `cookie_status`, `last_response_posted_at`, `status_synced_at`, `source` (e.g. `rd_get_info` | `rd_provisioned` | `rd_already_existed` | `rm_oauth_not_connected`).\n- Returns 422 (`please pull data first`) when no completed review-pull job exists yet, 400 on RD failure, 404 when the row is not found."
              },
              "response": [
                {
                  "name": "200 OK - connection status",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"identity_address\": \"bm-alias-4f2a@reviewdata.ai\",\n    \"publisher_key\": \"yelp.com\",\n    \"publisher_account_id\": \"acct_8812\",\n    \"connection_status\": \"Pending invite\",\n    \"is_connected\": 0,\n    \"lock_color\": \"amber\",\n    \"sendto_scrapper\": \"Y\",\n    \"publisher_url\": \"https://www.yelp.com/biz/en-fuego-cantina-and-grill-del-mar\",\n    \"auth_type\": \"manager_invite\",\n    \"scraping_enabled\": true,\n    \"posting_enabled\": true,\n    \"last_scraped_at\": null,\n    \"disconnect_reason\": \"Invite the business manager alias to finish connecting.\",\n    \"is_valid_credential\": null,\n    \"is_valid_cookie\": null,\n    \"url_status\": \"enable\",\n    \"credential_status\": null,\n    \"cookie_status\": null,\n    \"last_response_posted_at\": null,\n    \"status_synced_at\": \"2026-06-22T09:53:28+00:00\",\n    \"source\": \"rd_provisioned\"\n  }\n}"
                },
                {
                  "name": "422 Unprocessable Entity - pull data first",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Please pull review data for this location before connecting.\",\n  \"errors\": {\n    \"connect\": [\"Please pull review data for this location before connecting.\"]\n  }\n}"
                },
                {
                  "name": "422 Unprocessable Entity - invalid hash",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Invalid or expired token.\",\n  \"errors\": {\n    \"hash\": [\"Invalid or expired token.\"]\n  }\n}"
                },
                {
                  "name": "404 Not Found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Store review site not found.\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "List Review Sites by Invite Hash (PUBLIC)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/list/{{hash}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "list",
                    "{{hash}}"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"has_landing_page\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "PUBLIC endpoint hit by recipients of the review-site invite email: lists a store's review sites identified solely by the invite `hash`, returning the same row shape as the authenticated list endpoint plus the store name/id.\n\n**Auth:** Neither — no `auth:api` (public group, `throttle:30,1`). Access is granted entirely by the `{hash}` path param, a hashid that must decode to `[store_id, 15]` with a positive store id; the store_id comes from the hash so a recipient can only ever see their own store's review sites. No persona — there is no authenticated user.\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\n- Path: `{hash}` (string, required) — invite hashid (`[a-zA-Z0-9]+`) encoding `[store_id, 15]`.\n- Body (optional):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `has_landing_page` | integer (`0`/`1`) | no | `1` filters to rows with a non-empty `landing_page_url`. |\n\n**Response** — `{ data: [...] }` resource collection plus sibling `store_name` and `store_id` keys. Each row matches the idx=74 listing shape (`id`, `url`, `landing_page_url`, `url_status`, `connectionStatus`, `is_connected`, `lock_color`, `sendto_scrapper`, `display_as_connected`, `reviews_status`, `posting_status`, `has_url`, `error_code`, `error_title`, `error_reason`, `fix_sheet`, `last_scraped_at`, `connect_page_url`, nested `review_site`). Returns 400 `Invalid or expired token.` for a bad hash, 404 `Store not found` when the decoded store is missing."
              },
              "response": [
                {
                  "name": "200 OK - review site list",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"id\": 5,\n      \"url\": \"https://maps.google.com/maps?cid=6987565896915883812\",\n      \"landing_page_url\": \"https://search.google.com/local/writereview?placeid=ChIJ3xX5m7MI3IARJBdOWinS-GA\",\n      \"created_at\": \"2026-06-12T11:54:11.000000Z\",\n      \"url_status\": \"enable\",\n      \"connectionStatus\": \"Disconnected\",\n      \"is_connected\": 0,\n      \"lock_color\": \"gray\",\n      \"sendto_scrapper\": \"N\",\n      \"reviews_status\": \"offline\",\n      \"posting_status\": \"offline\",\n      \"has_url\": true,\n      \"error_code\": \"E-002\",\n      \"error_title\": \"Posting paused\",\n      \"error_reason\": \"Google connection has expired. Please reconnect this location.\",\n      \"fix_sheet\": \"oauth_reconnect\",\n      \"last_scraped_at\": null,\n      \"connect_page_url\": \"https://hipages.manage-myreviews.com/connect-review-sites/6zf8/edit-site/2/Google/5/\",\n      \"review_site\": {\n        \"id\": 44,\n        \"name\": \"Google\",\n        \"link\": \"http://maps.google.com\",\n        \"review_site_icon\": \"https://d2ny6zb7otrnhl.cloudfront.net/assets/backend/images/logos/google_ic.png\",\n        \"review_site_color\": \"#4285F4\",\n        \"self_form_enabled\": \"N\",\n        \"auth_type\": \"oauth\",\n        \"scraping_enabled\": true,\n        \"posting_enabled\": true,\n        \"keywords\": [\"[reviewSite_44]\"]\n      }\n    }\n  ],\n  \"store_name\": \"Demo Brew - Alameda County\",\n  \"store_id\": 2\n}"
                },
                {
                  "name": "400 Bad Request - invalid hash",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Invalid or expired token.\",\n  \"errors\": []\n}"
                },
                {
                  "name": "404 Not Found - store not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Store not found\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "4.B.8 Change display-connected flag (non-prod)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"review_site_id\": \"{{reviewSiteId}}\",\n  \"store_id\": \"{{storeId}}\",\n  \"display_as_connected\": \"Y\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/store-review-site/change-display-connected",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "change-display-connected"
                  ]
                },
                "description": "**Non-production only** (returns `403` on production). Sets the `display_as_connected` flag on the `(store_id, review_site_id)` store-review-site row. When `Y`, the connection page shows the site as Connected without a live platform connection; enabling requires the row to already have a `review_site_url`. When `N`, the connected display is turned off.\n\n## Auth\n- **Auth-optional** (lives in the public `store-review-site` group). Either an authenticated admin/account Bearer token (`store_id` then scoped by `BelongsToAuthCompany`), OR an emailed invite `hash` (no token) — the `store_id` is then derived from the hash so a recipient can only toggle their own store. Blocked entirely (`403`) on production.\n\n## Rate limit\n- 30 requests/min per IP (`throttle:30,1`).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `review_site_id` | integer | Required | `exists:review_sites,id`. |\n| `store_id` | integer | Required | `exists:stores,id`. Token flow: must be in caller's hierarchy (`BelongsToAuthCompany`). Hash flow: overridden by the store encoded in `hash` (any supplied value is ignored). |\n| `display_as_connected` | string (Y\\|N) | Required | `Y` = mark connected (requires an existing `review_site_url`); `N` = turn off. |\n| `hash` | string | Optional | Invite hash (alphanumeric). When present, grants tokenless access and supplies `store_id` (must decode to entity type 15). |\n\n## Behaviour\n- `200` — `{ data: { status: 'success', message } }` ('Review site marked as connected' / 'Connected display turned off').\n- `400` — no matching store-review-site row, or enabling without a stored `review_site_url`.\n- `403` — called on production (route disabled) or unauthorised.\n- `422` — validation failure.\n\n✅ live ([routes/api_v1.php:237](routes/api_v1.php#L237) → `StoreReviewSiteController@changeDisplayConnected`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Review site marked as connected\"\n  }\n}"
                },
                {
                  "name": "400 Missing URL",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"message\": \"Review site URL is required before marking this site as connected.\"\n  }\n}"
                }
              ]
            }
          ],
          "description": "Per-store review-site CRUD. Toggles scraper on / off for a single site, swaps credentials, updates the landing-page URL, and sends the public tokenized connect-link invitation."
        },
        {
          "name": "§ B.5.A Reading & response posting",
          "item": [
            {
              "name": "5.A.1 Get a single review with response",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/response/get/review?review_id={{reviewId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "response",
                    "get",
                    "review"
                  ],
                  "query": [
                    {
                      "key": "review_id",
                      "value": "{{reviewId}}"
                    }
                  ]
                },
                "description": "Fetches a single review together with its current response, review-site/store context, flags, connection state, and `response_capabilities` for the response composer (forces `type=editResponse`).\n\n**Auth:** Both — `auth:api` (flat `/response/*` route, brand-scoped at the controller level). The `review_id` is resolved to its store's `company_id` and validated by the `BelongsToAuthCompany` rule via `Company::isBelongsToParentCompany`, then re-checked in-controller against `Store::getStoreListByUser($user, true)`. Any user whose company hierarchy contains the review's store passes — a partner-tier user reaches every review under its tree; a brand/account user reaches only its own stores' reviews.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `review_id` (integer, required) — must exist in `reviews.id` and belong to the caller's company hierarchy.\n\n**Response** — `{ data: ... }` envelope (the `ResponseFeed` resource).\n- `id` (int) — review id.\n- `source_platform` (string) — originating platform; falls back to the normalised review-site slug when null.\n- `review` (object) — `id`, `reviewer`, `rating`, `message`, `review_date` (null if zero-date).\n- `reviewsite` (object) — `id`, `name` (`Maps.Google` → `Google`), `link`, `review_site_logo`, `review_site_color`, `auth_type`, `scraping_enabled` (bool), `posting_enabled` (bool).\n- `store` (object) — `id`, `name` (appends ` - storeid` when present), `slug`, `address` (composed from company address/city/state).\n- `store_reviewsite` (object) — `id`, `url`, `landing_page_url`, `connection_status`, `is_connected` (bool; derived per-site: Google rs=44 via `external_token`, Facebook rs=3 via `external_token`+`external_id`, Caring rs=20 via `external_id`, else `connection_status==connected`).\n- `flag` (array|string) — status-badge objects (`flag`, `title`, `statusColor`, `icon`, optional `links`) derived from the response status + any review flag; empty string in standalone mode.\n- `plan` (string) — active plan name or `''`.\n- `review_flag` (object|null) — raw flag relation.\n- `is_flaggable` (bool), `flag_status` (string, `'none'` when unflagged), `flag_details` (object|null: `reason`, `reason_identifier`, `flag_reason_detail`, `flag_date`).\n- hipages-only block (present only in hipages context): `flagging` (`window_days`, `expires_at`, `reason`), `is_legacy`, `is_qualified`, `is_off_platform`, `classification_label`, `job_id`, `sub_dimensions`, `is_profanity`, `badges`, `has_history`, `moderation` (timestamps).\n- `response_capabilities` (object) — `ResponseButtonPolicy::evaluate` output (e.g. `manual` bool, `reason`); universal.\n- `response` (object|null) — `id`, `status`, `public_contents`, `private_contents`, `response_date`, `deleted`; null when no response (or for unverified automated responses).\n- Top-level `with()` extras: `checkUnamePass` (int 0/1, edit mode), `standalone_type`, `standalone_popup_message`."
              },
              "response": [
                {
                  "name": "200 Success — HiPages partner store",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"review\": {\n      \"id\": 13383822,\n      \"reviewer\": \"A Paredes\",\n      \"rating\": 5,\n      \"message\": \"The team was on time, did a great job.\",\n      \"review_date\": \"2026-01-05 16:55:13\",\n      \"source_platform\": \"HiPages\"\n    },\n    \"reviewsite\": {\n      \"id\": 100,\n      \"name\": \"HiPages\",\n      \"link\": \"https://www.hipages.com.au\",\n      \"review_site_color\": \"#FF6B00\"\n    },\n    \"response\": {\n      \"id\": 1039928,\n      \"status\": \"pending\",\n      \"public_contents\": \"Thank you!\"\n    },\n    \"store\": {\n      \"id\": 8748,\n      \"name\": \"Acme NYC\",\n      \"address\": \"123 Main St, NYC, NY\"\n    },\n    \"flag\": [],\n    \"is_flaggable\": true,\n    \"flag_status\": \"none\",\n    \"flag_details\": null,\n    \"flagging\": {\n      \"window_days\": 90,\n      \"expires_at\": \"2026-04-05T00:00:00Z\",\n      \"reason\": null\n    },\n    \"is_legacy\": false,\n    \"is_qualified\": true,\n    \"is_off_platform\": false,\n    \"classification_label\": null,\n    \"is_profanity\": false,\n    \"badges\": [],\n    \"has_history\": false\n  }\n}"
                }
              ]
            },
            {
              "name": "5.A.2 Save response",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"review_id\": {{reviewId}},\n    \"response\": \"Thank you for the wonderful review! We are thrilled you enjoyed your visit.\",\n    \"ai_response_id\": {{aiResponseId}},\n    \"ai_response\": \"Thank you for the wonderful review!\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/response/save",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "response",
                    "save"
                  ]
                },
                "description": "> 🧭 **E2E journey 9/9 — Submit Response.** Uses `{{reviewId}}` + `{{aiResponseId}}`. Gated: 403 (not in tenant / response-button policy), 422 (posting disabled OR store↔site not connected), 402 (feature hard-stop), 400 (char limit / duplicate), else 200. A scraper-only review is **not** posting-connected — expect 422 \"not connected\" unless `reviewsite.posting_enabled` && `store_reviewsite.is_connected`.\n\nSaves (creates or updates) a response to a review and queues it for posting to the live review site. Runs posting/connection/feature gates, site-specific character-limit checks, records billable usage, dispatches the platform post job (Google/Facebook/Caring/RD), and audit-logs the result.\n\n**Auth:** Both — `auth:api` (flat `/response/*` route, brand-scoped at the controller level). `review_id` is resolved to its store's `company_id` and validated by `BelongsToAuthCompany` (`Company::isBelongsToParentCompany`), then re-checked against `Store::getStoreListByUser($user, true)` — failures return `Access denied` (403). Reachable scope is hierarchy-based: a partner-tier user can save responses for any store under its tree; a brand/account user only for its own stores' reviews.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `review_id` | integer | required | Must exist in `reviews.id` and belong to the caller's company hierarchy. |\n| `response` | string | required | Response text. Length limits enforced per site: OpenTable (rs=16) ≤400 non-space chars; Optspot (rs=81) 80–160 chars; all sites ≤4000 chars. Must differ (case-insensitively) from the existing response. |\n| `ai_response_id` | integer | optional (nullable) | Identifier of the AI/smart response used, if any. |\n| `ai_response` | string | optional (nullable) | The AI/smart response text, if any. |\n\n**Response** — `{ data: ... }` envelope (also carries usage/feature response headers via `withFeatureHeaders`).\n- `status` (string) — `\"success\"`.\n- `response` (string) — confirmation message `\"Your response has been submitted and will be posted live\"`.\n\nNon-success paths return error envelopes: 403 `Access denied` / pre-subscription or unavailable response; 422 when posting is disabled for the site (`posting`) or the store↔site is not connected (`connection`); `FeatureLimitException` when the per-feature service limit/quota is exceeded; 400 for character-limit or duplicate-response violations."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": \"Your response has been submitted and will be posted live\"\n  }\n}"
                }
              ]
            },
            {
              "name": "5.A.3 Delete response",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/response/delete?response_id={{responseId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "response",
                    "delete"
                  ],
                  "query": [
                    {
                      "key": "response_id",
                      "value": "{{responseId}}"
                    }
                  ]
                },
                "description": "Soft-deletes a review response on the platform (sets it back to `notresponding`, clears flags, records a `DeletedResponse` row) and best-effort removes the live reply from Google (review_site_id 44) or Facebook (review_site_id 3).\n\n**Auth:** Account — `auth:api`; mounted under the brand-side `response/*` group. Scoping is implicit: the response is loaded by `response_id` and the action only proceeds if the underlying `ResponseReview.company_id` is set, otherwise a 403 is returned (`AuditAction::RESPONSE_DELETED` / `DENIED`). The Form Request only validates existence, not ownership.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `response_id` (integer, required) — must exist in `response_reviews.id`.\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success` on accepted delete; `failed` when permission is denied.\n- `response` (string) — human-readable message. Google path returns the standard 48-hour message; the Facebook path (when the live comment cannot be auto-deleted) returns a message asking the user to delete it in Facebook directly."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"response\": \"We have received your request to delete this review response, and the request will be completed within 48 hours. Thank you\" } }"
                }
              ]
            }
          ],
          "description": "Single-review reads + response writes (submit / edit / delete). Forms the everyday respond-to-review workflow."
        },
        {
          "name": "§ B.5.C AI assistance",
          "item": [
            {
              "name": "5.C.1 Generate AI response",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"store_id\": {{storeId}},\n    \"review_id\": {{reviewId}},\n    \"tone\": \"professional\",\n    \"style\": \"Short and friendly\",\n    \"owner_instructions\": \"Mention our support team\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/account/generate-ai-response",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "generate-ai-response"
                  ]
                },
                "description": "> 🧭 **E2E journey 8/9 — Generate AI Response.** Uses `{{storeId}}` + `{{reviewId}}`; plan-gated (403 if `ai_response` not entitled). Captures `data.data.ai_review_response_id` → `{{aiResponseId}}` (two `data` levels — not `data.id`).\n\nSynchronously calls the ReviewData AI provider to draft a reply for a review, persists an `AiReviewResponse` row, and counts 1 unit against the store's `ai_response` plan quota (hard-stop plans return 403 once exhausted; soft-stop plans bill the call as overage).\n\n**Auth:** Both — `auth:api`. The target `store_id`/`review_id` are scoped by the `BelongsToAuthCompany` rule: the store (and the review's store) must resolve to a company that is the authenticated user's own `person.company_id` or a descendant of it (`Company::isBelongsToParentCompany` walks the `company.company_id` self-FK chain up). Partner (bundle_id=1) and Account/brand (bundle_id=3) personas use the same endpoint; only the reachable subtree differs.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | Must exist in `stores` and belong to the auth company hierarchy (`BelongsToAuthCompany`). |\n| `review_id` | integer | yes | Must exist in `reviews`, belong to the auth company hierarchy, and its `store_id` must equal the given `store_id` (cross-field check in `withValidator`). |\n| `tone` | string | no | One of `professional`, `casual`, `empathetic`, `apologetic`, `grateful`, `neutral`. Defaults to `professional`. |\n| `style` | string | no | Free-text style hint, max 500 chars. |\n| `owner_instructions` | string | no | Free-text instructions to the model, max 1000 chars. |\n\nThe controller also pulls `review_text` (`review->message`) and `rating` (`review->rate`) from the resolved review itself — they are not client-supplied.\n\n**Response** — `{ data: ... }` envelope. On success the controller returns `{ success: true, data: <service result> }` wrapped by `ApiResponses::successResponse`, plus `ai_response` feature headers via `withFeatureHeaders`.\n- `data.success` (boolean) — always `true` on the happy path.\n- `data.data.response_text` (string) — the generated reply text.\n- `data.data.model` (string) — model id used, e.g. `gpt-4.1-mini`.\n- `data.data.tokens` (object) — `{ input, output, total }` token counts (integers).\n- `data.data.task_id` (string) — ReviewData task id.\n- `data.data.foreign_key` (string) — RD foreign key.\n- `data.data.ai_review_response_id` (integer) — id of the persisted `AiReviewResponse` row.\n- `data.data.remaining` (string|null) — remaining quota, null when uncapped.\n- `data.data.overage` (boolean) — whether this call was billed as overage.\n- `data.data.usage_log_id` (integer) — usage record id.\n\nFailures: 403 quota exceeded / feature disabled, 422 validation, 502 provider unreachable; review-not-found returns an error envelope."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "X-Feature",
                      "value": "ai_response"
                    },
                    {
                      "key": "X-Feature-Limit",
                      "value": "50"
                    },
                    {
                      "key": "X-Feature-Used",
                      "value": "3"
                    },
                    {
                      "key": "X-Feature-Remaining",
                      "value": "47"
                    },
                    {
                      "key": "X-Feature-Hard-Stop",
                      "value": "1"
                    }
                  ],
                  "body": "{\n  \"data\": {\n    \"success\": true,\n    \"data\": {\n      \"ai_review_response_id\": 901,\n      \"response_text\": \"Thanks for your honest feedback. I am sorry the visit did not meet expectations - please reach out to our support team and we would love to make it right.\",\n      \"model\": \"gpt-4o-mini\",\n      \"tokens\": {\n        \"input\": 412,\n        \"output\": 87,\n        \"total\": 499\n      },\n      \"task_id\": \"task_a1b2c3\",\n      \"foreign_key\": \"rd-fk-xyz789\",\n      \"remaining\": 47,\n      \"overage\": false\n    }\n  }\n}"
                },
                {
                  "name": "403 Quota exceeded (hard-stop plan)",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "X-Feature",
                      "value": "ai_response"
                    },
                    {
                      "key": "X-Feature-Limit",
                      "value": "50"
                    },
                    {
                      "key": "X-Feature-Used",
                      "value": "50"
                    },
                    {
                      "key": "X-Feature-Remaining",
                      "value": "0"
                    },
                    {
                      "key": "X-Feature-Hard-Stop",
                      "value": "1"
                    }
                  ],
                  "body": "{\n  \"errors\": {\n    \"ai_response\": [\"AI response quota exceeded.\"]\n  },\n  \"message\": \"AI response quota exceeded.\"\n}"
                },
                {
                  "name": "502 Upstream AI provider unreachable",
                  "status": "Bad Gateway",
                  "code": 502,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"errors\": {\n    \"upstream\": [\"AI provider unreachable.\"]\n  },\n  \"message\": \"AI provider unreachable.\"\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  const d = j.data && j.data.data;",
                      "  if (d && d.ai_review_response_id) {",
                      "    pm.environment.set('aiResponseId', String(d.ai_review_response_id));",
                      "    console.log('journey: aiResponseId =', d.ai_review_response_id);",
                      "  }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "5.C.2 List successful AI responses for a review",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/ai-responses?review_id={{reviewId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "ai-responses"
                  ],
                  "query": [
                    {
                      "key": "review_id",
                      "value": "{{reviewId}}"
                    }
                  ]
                },
                "description": "Lists every successfully-generated AI response row for a given review, newest first (`status=success`, ordered by `id` descending), serialized through `AiReviewResponseResource`.\n\n**Auth:** Both — `auth:api`. The `review_id` query param is scoped by the `BelongsToAuthCompany` rule: the review's store must resolve to a company that is the authenticated user's own `person.company_id` or a descendant of it (`Company::isBelongsToParentCompany` walks the `company.company_id` self-FK chain). Partner (bundle_id=1) and Account/brand (bundle_id=3) personas share the endpoint; only the reachable subtree differs.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `review_id` (integer, required) — must exist in `reviews` and belong to the auth company hierarchy.\n\n**Response** — `{ data: [...] }` envelope (resource collection). Each element:\n- `id` (integer) — `AiReviewResponse` row id.\n- `review_id` (integer) — the review this response belongs to.\n- `response` (string) — the generated reply text.\n- `status` (string) — always `success` (the query filters to it).\n- `created_at` (datetime) — ISO-8601 timestamp."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"id\": 901,\n      \"review_id\": 123,\n      \"response\": \"Thanks for your honest feedback. I am sorry the visit did not meet expectations...\",\n      \"status\": \"success\",\n      \"created_at\": \"2026-05-26T11:30:00+10:00\"\n    },\n    {\n      \"id\": 887,\n      \"review_id\": 123,\n      \"response\": \"We really appreciate your review! Our team works hard to deliver...\",\n      \"status\": \"success\",\n      \"created_at\": \"2026-05-25T14:12:00+10:00\"\n    }\n  ]\n}"
                }
              ]
            }
          ],
          "description": "Synchronous AI response generation + history. Calls the upstream provider, counts against the store-s `ai_response` plan-feature quota, persists every attempt to `ai_review_responses`. Live on auth:api with per-store + per-review scope gating."
        },
        {
          "name": "§ B.5.E Flagging",
          "item": [
            {
              "name": "5.E.1 Flag a review",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"submit_type\": \"standalone\",\n    \"review_site\": \"yelp\",\n    \"reason\": \"It contains threats, lewdness or hate-speech.\",\n    \"reason_identifier\": \"Inappropriate content\",\n    \"flag_reason_detail\": \"This reviewer was never a customer and the claims are fabricated.\",\n    \"user_id\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/reviews/{{reviewId}}/flag",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "reviews",
                    "{{reviewId}}",
                    "flag"
                  ]
                },
                "description": "Submits (or re-submits, while still pending) a flag against the review identified by the URL `{id}`, persisting a `ReviewFlag` row with status `pending`, writing an audit log and a per-review history event, and consuming the `review_flag` plan-feature quota.\n\n**Auth:** Both — `auth:api`. Target scoped by `Store::getStoreListByUser($user, true)`: the review's `store_id` must be in the caller's resolvable store subtree (partner walks its whole tree; account/brand resolves its own location/store children). A review outside that set returns 403 `This review is outside your scope.`. Additional gates: the existing flag must be tradie-editable (only `pending`), and `ReviewFlaggingPolicy::evaluate()` must return `is_flaggable=true` (Cat-A lock, tier rules, 90/14-day window, connect-gate).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — review id to flag; merged into payload as `review_id` and validated `exists:reviews,id`.\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `review_site` | string | yes | One of `yelp,glassdoor,google,indeed,tripadvisor,apartmentratings,facebook,hipages`. |\n| `submit_type` | string | no | Nullable; only allowed value is `standalone`. |\n| `user_id` | integer | no | Nullable; `exists:users,id`. Stored on the flag as the flagging user. |\n| `reason` | string | conditional | Required when `review_site` is any of the listed sites (effectively always). Long platform description matching `reason_identifier`. |\n| `reason_identifier` | string | conditional | Required (same condition); `max:255`; must match a `review_site_flag_reasons` row for the review's `review_site_id`, else 422. |\n| `flag_reason_detail` | string | conditional | Required (same condition); `max:500`. Legacy `description` field is mirrored into this if present. |\n| `review_response` | string | no | Nullable. Stored only inside `json_data` for Indeed. |\n| `first_name` | string | conditional | Required only when `review_site=indeed`. |\n| `last_name` | string | conditional | Required only when `review_site=indeed`. |\n| `company` | string | conditional | Required only when `review_site=indeed`. |\n| `company_url` | string | conditional | Required only when `review_site=indeed`. |\n| `phone` | string | conditional | Required only when `review_site=indeed`. |\n| `country` | string | conditional | Required only when `review_site=indeed`. |\n| `country_name` | string | conditional | Required only when `review_site=indeed`. |\n| `number_of_employees` | string | conditional | Required only when `review_site=indeed` (stored as `numEmployees` in json_data). |\n| `flag_email_address` | string (email) | conditional | Required only when `review_site=indeed`. |\n\n**Response** — `{ data: ... }` envelope. Also adds plan feature-usage headers.\n- `status` (string) — `success`.\n- `message` (string) — `Review has been Flagged Successfully`."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "X-Feature",
                      "value": "review_flag"
                    },
                    {
                      "key": "X-Feature-Limit",
                      "value": "50"
                    },
                    {
                      "key": "X-Feature-Used",
                      "value": "23"
                    },
                    {
                      "key": "X-Feature-Remaining",
                      "value": "27"
                    },
                    {
                      "key": "X-Feature-Hard-Stop",
                      "value": "1"
                    }
                  ],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Review has been Flagged Successfully\"\n  }\n}"
                },
                {
                  "name": "400 Already flagged (non-pending)",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"This review has already been flagged. Please check the review site for more details.\",\n  \"errors\": []\n}"
                },
                {
                  "name": "402 Feature limit",
                  "status": "Payment Required",
                  "code": 402,
                  "_postman_previewlanguage": "json",
                  "header": [
                    {
                      "key": "X-Feature",
                      "value": "review_flag"
                    },
                    {
                      "key": "X-Feature-Hard-Stop",
                      "value": "1"
                    }
                  ],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Review Flags limit reached for this period.\",\n  \"feature\": \"review_flag\",\n  \"remaining\": 0,\n  \"hard_stop\": true\n}"
                }
              ]
            },
            {
              "name": "5.E.2 Get flag status",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/reviews/{{reviewId}}/flag/status",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "reviews",
                    "{{reviewId}}",
                    "flag",
                    "status"
                  ]
                },
                "description": "Returns the current flag record for the given review id, or a `none`/null state if the review has never been flagged.\n\n**Auth:** Both — `auth:api`. Note: this method does NOT re-check store scope; it loads the review by id and returns its flag regardless of persona, so any authenticated partner or account user can read any review's flag status by id.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — review id (route constrained to `[0-9]+`).\n\n**Response** — `{ data: ... }` envelope.\n- `review_id` (integer) — the review id.\n- `is_flagged` (boolean) — true when a flag row exists.\n- `flag_status` (string) — the flag's status value (`pending`, `denied`, `approved_removed`, `submitted_to_platform`), or `none` when unflagged.\n- `flag` (object|null) — the `ReviewFlag` row with columns `id, review_id, reason, reason_identifier, flag_reason_detail, flag_status, flag_date, user_id, created_at, updated_at`, or null when unflagged."
              },
              "response": [
                {
                  "name": "200 Flag exists",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"review_id\": 13383822,\n      \"is_flagged\": true,\n      \"flag_status\": \"pending\",\n      \"flag\": {\n        \"id\": 5012,\n        \"reason_identifier\": \"Profanity\",\n        \"reason\": \"Review contains swear words…\",\n        \"flag_reason_detail\": \"Customer used profane language about staff…\",\n        \"flag_status\": \"pending\",\n        \"flag_date\": \"2026-05-07T11:35:00.000000Z\",\n        \"user_id\": 42\n      }\n    }\n  }\n}"
                },
                {
                  "name": "200 No flag exists",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"review_id\": 13383822,\n      \"is_flagged\": false,\n      \"flag_status\": \"none\",\n      \"flag\": null\n    }\n  }\n}"
                }
              ]
            },
            {
              "name": "5.E.3 Flag history",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/reviews/flag/history?store_id={{storeId}}&flag_status=pending&page=1&limit=20",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "reviews",
                    "flag",
                    "history"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}"
                    },
                    {
                      "key": "flag_status",
                      "value": "pending"
                    },
                    {
                      "key": "review_site",
                      "value": "hipages",
                      "disabled": true
                    },
                    {
                      "key": "page",
                      "value": "1"
                    },
                    {
                      "key": "limit",
                      "value": "20"
                    }
                  ]
                },
                "description": "Paginated list of review flags whose review belongs to a store the authenticated user can access, newest flag first.\n\n**Auth:** Both — `auth:api`. Reachable stores resolved via `Store::getStoreListByUser($user, true)` (partner sees its whole subtree; account/brand sees its own store children). If `store_id` is supplied it must be in that accessible set, else 403 `You do not have access to this store.`. When the user has no accessible stores the response is `{ data: { data: [], total: 0 } }`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — restrict to one store the user can access; rejected with 403 if not in their scope.\n- Query: `flag_status` (string, optional) — one of `pending`, `denied`, `approved_removed`, `submitted_to_platform`; an unknown value returns 400 `Invalid flag_status value.`.\n- Query: `review_site` (string, optional) — filters by review-site `name` (matched against `review.reviewsite.name`).\n- Query: `page` (integer, optional) — standard Laravel paginator page; default 1.\n- Query: `limit` (integer, optional) — per-page size; default 20.\n\n**Response** — `{ data: ... }` envelope wrapping a Laravel length-aware paginator.\n- `current_page` / `last_page` / `per_page` / `total` / `from` / `to` (integers/null) — paginator meta.\n- `first_page_url` / `last_page_url` / `next_page_url` / `prev_page_url` / `path` (string|null) — paginator URLs.\n- `links` (array) — paginator link objects (`url`, `label`, `page`, `active`).\n- `data` (array) — `ReviewFlag` rows, each eager-loading `review` (`id, store_id, review_site_id, reviewer, rate, message, review_date`), `review.reviewsite` (`id, name`) and `review.store` (`id, name, storeid, company_id`)."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"current_page\": 1, \"data\": [ { \"id\": 4, \"review_id\": 11, \"reason\": \"It contains threats, lewdness or hate-speech.\", \"reason_identifier\": \"Inappropriate content\", \"flag_reason_detail\": \"Fabricated claims.\", \"flag_status\": \"pending\", \"flag_date\": \"2026-06-25T10:00:00.000000Z\", \"user_id\": 1, \"review\": { \"id\": 11, \"store_id\": 7, \"review_site_id\": 100, \"reviewer\": \"Jane D.\", \"rate\": 1, \"message\": \"Terrible.\", \"review_date\": \"2026-06-20\", \"reviewsite\": { \"id\": 100, \"name\": \"Yelp\" }, \"store\": { \"id\": 7, \"name\": \"Downtown\", \"storeid\": \"DT-01\", \"company_id\": 55 } } } ], \"first_page_url\": \"https://reputationmanagement-backend.com/api/v1/reviews/flag/history?page=1\", \"from\": 1, \"last_page\": 1, \"last_page_url\": \"https://reputationmanagement-backend.com/api/v1/reviews/flag/history?page=1\", \"links\": [], \"next_page_url\": null, \"path\": \"https://reputationmanagement-backend.com/api/v1/reviews/flag/history\", \"per_page\": 20, \"prev_page_url\": null, \"to\": 1, \"total\": 1 } }"
                }
              ]
            },
            {
              "name": "5.E.4 Flag reason catalog",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/reviews/flag/reasons?review_site_id=100",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "reviews",
                    "flag",
                    "reasons"
                  ],
                  "query": [
                    {
                      "key": "review_site_id",
                      "value": "100",
                      "description": "Optional. Omit to get all sites grouped."
                    }
                  ]
                },
                "description": "Returns the flag-reason catalog used by the flagging dropdown. With `review_site_id` it returns a flat array of that site's reasons; without it, the full catalog grouped by review site.\n\n**Auth:** Both — `auth:api`. No store/persona scoping is applied; the catalog is global and identical for any authenticated partner or account user.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `review_site_id` (integer, optional) — when present, limit reasons to that single review site (flat array); when omitted, return all reasons grouped by site.\n\n**Response** — `{ data: ... }` envelope.\n\nWhen `review_site_id` is given — `data` is an array of `ReviewSiteFlagReason` rows:\n- `id` (integer) — reason row id.\n- `reason_identifier` (string) — short reason code.\n- `reason` (string) — long platform-defined description.\n\nWhen omitted — `data` is an array of per-site groups:\n- `review_site_id` (integer) — site id.\n- `review_site_name` (string|null) — site name (from `reviewSite.name`).\n- `reasons` (array) — objects of `{ id, reason_identifier, reason }`."
              },
              "response": [
                {
                  "name": "200 Single site",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    { \"id\": 47, \"reason_identifier\": \"Profanity\", \"reason\": \"Review contains swear words, or has pornographic or sexually explicit language\" },\n    { \"id\": 48, \"reason_identifier\": \"Harmful\", \"reason\": \"Review contains content that encourages…\" }\n  ]\n}"
                }
              ]
            }
          ],
          "description": "Brand-initiated review flagging — create a flag, check status, read history, fetch the reason catalogue. Drives the partner-admin moderation queue."
        },
        {
          "name": "§ B.8.A Campaigns — CRUD",
          "item": [
            {
              "name": "8.A.1 List campaigns",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "list"
                  ]
                },
                "description": "Lists campaigns for the authenticated user's own company (paginated), with status/search filtering and sorting.\n\n**Auth:** Account — `auth:api`, under the BRAND-scope route group. Scoped to `RsCampaign.company_id = $user->person->company_id` (the caller's own company); no partner-tree walk. If the user has `assign_location` set with assigned stores, results are further restricted to `store_id IN (assignedStores)`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `status` (string, optional) — one of `draft`, `active` (→ scheduled/in_progress), `inactive` (→ completed/paused/cancelled).\n- Query: `page` (integer, optional) — min 1.\n- Query: `per_page` (integer, optional) — 1–100, default 15.\n- Query: `search_field` (string, optional) — one of `name`, `description`, `store_name`, `store_id`, `storeId`, `type` (paired with `search_value`).\n- Query: `search_value` (string, optional) — max 255.\n- Query: `sort_field` (string, optional) — one of `name`, `status`, `created_at`, `updated_at`, `scheduled_at`, `started_at`, `completed_at`, `request_file_status`, `request_per_day`, `request_frequency`, `request_delay`, `next_execution_time`, `total_audience`, `total_valid_phones`, `total_valid_emails`, `store_name`, `storeId`, `type` (default `created_at`).\n- Query: `sort_by` (string, optional) — `asc`/`ASC`/`desc`/`DESC` (default DESC).\n- Query: `location_id` (integer, optional) — `exists:store,id`; filters to a single store.\n\n**Response** — `{ data: ... }` envelope; `data` is a Laravel paginator whose `data` array items are `CampaignListResource`.\n- `data.data[]` (array) — campaign rows; each item:\n  - `id`, `store_id`, `user_id` (int)\n  - `name`, `description`, `status`, `current_step` — campaign basics\n  - `scheduled_at`, `started_at`, `completed_at`, `created_at`, `updated_at`, `next_execution_time` (datetime|null)\n  - `total_audience`, `total_valid_phones`, `total_valid_emails` (int) — non-test counts\n  - `request_file_name`, `request_file_status`, `request_per_day`, `request_frequency`, `request_delay`, `request_after_time`, `send_immediately` (bool), `type`\n  - `channels[]` — `{ id, rs_campaign_id, channel_type, is_enabled(bool), template_id, errors[], landing_page{} }`\n  - `store` (object|null) — `{ id, name, storeId }`\n  - `request_file_url` (string|null), `unique_code` (string|null)\n  - `has_sms_channel` (bool), `has_email_channel` (bool), `is_active` (bool)\n- `data.current_page`, `data.per_page`, `data.total`, `data.last_page`, `data.from`, `data.to`, `data.first_page_url`, `data.last_page_url`, `data.prev_page_url`, `data.next_page_url`, `data.path`, `data.links[]` — standard pagination meta."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"current_page\": 1,\n    \"data\": [\n      {\n        \"id\": 1,\n        \"store_id\": 123,\n        \"user_id\": 456,\n        \"name\": \"Summer Review Campaign\",\n        \"description\": \"Collect summer reviews\",\n        \"status\": \"draft\",\n        \"current_step\": 3,\n        \"scheduled_at\": null,\n        \"started_at\": null,\n        \"completed_at\": null,\n        \"total_audience\": 1000,\n        \"total_valid_phones\": 850,\n        \"total_valid_emails\": 920,\n        \"request_file_name\": \"audience.xlsx\",\n        \"request_file_status\": \"completed\",\n        \"request_per_day\": 50,\n        \"request_frequency\": 1,\n        \"request_delay\": 0,\n        \"request_after_time\": \"09:00:00\",\n        \"next_execution_time\": null,\n        \"send_immediately\": false,\n        \"type\": \"bulk_upload\",\n        \"created_at\": \"2024-01-10T14:30:00.000000Z\",\n        \"updated_at\": \"2024-01-15T10:00:00.000000Z\",\n        \"channels\": [\n          { \"id\": 1, \"rs_campaign_id\": 1, \"channel_type\": \"email\", \"is_enabled\": true, \"template_id\": 5, \"errors\": [], \"landing_page\": [] }\n        ],\n        \"store\": { \"id\": 123, \"name\": \"Downtown Store\", \"storeId\": \"STORE001\" },\n        \"request_file_url\": null,\n        \"unique_code\": \"a1b2c3\",\n        \"has_sms_channel\": false,\n        \"has_email_channel\": true,\n        \"is_active\": false\n      }\n    ],\n    \"per_page\": 15,\n    \"total\": 45,\n    \"last_page\": 3,\n    \"from\": 1,\n    \"to\": 15,\n    \"first_page_url\": \"http://example.com/api/v1/campaign/list?page=1\",\n    \"next_page_url\": \"http://example.com/api/v1/campaign/list?page=2\",\n    \"prev_page_url\": null,\n    \"path\": \"http://example.com/api/v1/campaign/list\",\n    \"links\": []\n  }\n}"
                }
              ]
            },
            {
              "name": "8.A.2 Get campaign by id",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/by-unique-code/{{campaignId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "by-unique-code",
                    "{{campaignId}}"
                  ]
                },
                "description": "Fetches a single campaign by its numeric id, with channels, follow-up template, email/SMS requests and file header eager-loaded.\n\n**Auth:** Account — `auth:api`, BRAND-scope group. The route is `by-unique-code/{id}` constrained to `[0-9]+`, backed by `getCampaign($id)`. The campaign is looked up by primary key, then authorized by validating its `store_id` with the `BelongsToAuthCompany` rule (the store must belong to the caller's company); fails closed with a 400 otherwise.\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\n- Path: `id` (integer, required) — campaign id (route param named `campaignId` in Postman; the route regex requires it to be numeric).\n\n**Response** — `{ data: ... }` envelope. `data` is `{ status: \"success\", data: <campaign> }` (the inner `data` is `RsCampaign->toArray()` from `getCampaignData`).\n- `data.status` (string) — `\"success\"`.\n- `data.data` (object|[]) — selected campaign columns: `id`, `name`, `description`, `status`, `current_step`, `store_id`, `scheduled_at`, `started_at`, `completed_at`, `total_audience`, `request_per_day`, `request_frequency`, `request_delay`, `request_after_time`, `next_execution_time`, `request_file_status`, `request_file_name`, `send_immediately`, `request_file_path`, `type`. Returns `[]` if the campaign id resolves but the re-fetch finds nothing.\n  - `channels[]` — `{ id, rs_campaign_id, channel_type, is_enabled, template_id, follow_up_template_id, follow_up_interval, follow_up_interval_count, follow_up_email_template? }`\n  - `email_requests[]`, `sms_requests[]` — related request rows\n  - `file_header` — campaign file-header mapping (object|null)\n- Returns 400 (`badRequestResponse`) if the store does not belong to the caller's company; 404 if the campaign id is not found."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 1,\n      \"name\": \"Summer Review Campaign\",\n      \"description\": \"Collect summer reviews\",\n      \"status\": \"draft\",\n      \"current_step\": 3,\n      \"store_id\": 123,\n      \"scheduled_at\": null,\n      \"started_at\": null,\n      \"completed_at\": null,\n      \"total_audience\": 0,\n      \"request_per_day\": 50,\n      \"request_frequency\": 1,\n      \"request_delay\": 0,\n      \"request_after_time\": \"14:00:00\",\n      \"next_execution_time\": null,\n      \"request_file_status\": \"completed\",\n      \"request_file_name\": \"audience.xlsx\",\n      \"send_immediately\": false,\n      \"request_file_path\": \"campaigns/123/audience.xlsx\",\n      \"type\": \"bulk_upload\",\n      \"channels\": [\n        { \"id\": 1, \"rs_campaign_id\": 1, \"channel_type\": \"email\", \"is_enabled\": true, \"template_id\": 5, \"follow_up_template_id\": null, \"follow_up_interval\": 0, \"follow_up_interval_count\": 0 }\n      ],\n      \"email_requests\": [],\n      \"sms_requests\": [],\n      \"file_header\": null\n    }\n  }\n}"
                }
              ]
            },
            {
              "name": "8.A.3 Cancel campaign",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/{{campaignId}}/cancel",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "{{campaignId}}",
                    "cancel"
                  ]
                },
                "description": "Cancels a campaign owned by the caller's company by setting its status to `cancelled`.\n\n**Auth:** Account — `auth:api`, BRAND-scope group. Resolved by `RsCampaign::where('company_id', $user->person->company_id)->find($id)`; if the id is not owned by the caller's company it returns 400 (\"You are not authorized to cancel this campaign\"). Campaigns of `type == 'default'` cannot be cancelled. Emits `AuditAction::CAMPAIGN_CANCELLED`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — campaign id; route-constrained to `[0-9]+`, validated by `CancelCampaignRequest` as `exists:rs_campaigns,id`. No request body (the form request maps the route param into validation data).\n\n**Response** — `{ data: ... }` envelope. `data` is `{ status: \"success\", message: \"Campaign cancelled successfully\" }`.\n- `data.status` (string) — `\"success\"`.\n- `data.message` (string) — confirmation text.\n- 400 if not owned by the caller, or if the campaign is the default campaign."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Campaign cancelled successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "8.A.4 Duplicate campaign",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/{{campaignId}}/duplicate",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "{{campaignId}}",
                    "duplicate"
                  ]
                },
                "description": "Replicates a campaign (and its active channels) under the same company as a fresh `draft` named \"… (Copy)\" with audience/scheduling fields reset.\n\n**Auth:** Account — `auth:api`, BRAND-scope group. Resolved by `RsCampaign::where('company_id', $user->person->company_id)->find($id)`; if not owned by the caller it returns 403 (\"You are not authorized to duplicate this campaign\"). Emits `AuditAction::CAMPAIGN_DUPLICATED`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — source campaign id; route-constrained to `[0-9]+`, validated by `DuplicateCampaignRequest` as `exists:rs_campaigns,id`. No request body.\n\n**Response** — `{ data: ... }` envelope. `data` is `{ status: \"success\", message: \"Campaign duplicated successfully\", data: <new campaign> }`.\n- `data.status` (string) — `\"success\"`.\n- `data.message` (string).\n- `data.data` (object) — the new campaign via `getCampaignData(newCampaignId)`; same shape as idx=98's inner `data`. The copy is forced to `status=\"draft\"`, `current_step=1`, `total_audience=0`, `request_file_status=\"pending\"`, with `scheduled_at`/`started_at`/`completed_at`/`next_execution_time` nulled and `request_per_day`/`request_frequency`/`request_delay` zeroed; channels are replicated against the new campaign id."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Campaign duplicated successfully\",\n    \"data\": {\n      \"id\": 42,\n      \"name\": \"Summer Review Campaign (Copy)\",\n      \"description\": \"Collect summer reviews\",\n      \"status\": \"draft\",\n      \"current_step\": 1,\n      \"store_id\": 123,\n      \"scheduled_at\": null,\n      \"started_at\": null,\n      \"completed_at\": null,\n      \"total_audience\": 0,\n      \"request_per_day\": 0,\n      \"request_frequency\": 0,\n      \"request_delay\": 0,\n      \"request_after_time\": \"14:00:00\",\n      \"next_execution_time\": null,\n      \"request_file_status\": \"pending\",\n      \"request_file_name\": null,\n      \"send_immediately\": false,\n      \"type\": \"bulk_upload\",\n      \"channels\": [],\n      \"email_requests\": [],\n      \"sms_requests\": [],\n      \"file_header\": null\n    }\n  }\n}"
                }
              ]
            },
            {
              "name": "8.A.5 Remove follow-up template",
              "request": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/channel/{{campaignId}}/remove/followup",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "channel",
                    "{{campaignId}}",
                    "remove",
                    "followup"
                  ]
                },
                "description": "Clears the follow-up email template association on a campaign channel by setting its `follow_up_template_id` to null. The channel is looked up by its `unique_code`; if not found a 400 is returned, and if the channel's store company does not belong to the authenticated user a 403 is returned.\n\n**Auth:** Account — `auth:api`. The channel is resolved by `unique_code`, then scoped via `$channel->campaign->store->company->isCompanyBelongsToUser($request->user())`, which matches the user's `person.company_id` against the channel's store-company id or up to two parent companies (location → brand → partner) in the company tree. Lives in the brand-operated `campaign` route group.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `unique_code` (string, required) — unique code identifying the campaign channel.\n\n**Response** — `{ data: ... }` envelope.\n- `message` (string) — confirmation text `\"Follow up template removed successfully\"`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"message\": \"Follow up template removed successfully\" } }"
                }
              ]
            },
            {
              "name": "8.A.6 Create / update campaign (wizard step)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"store_id\": {{storeId}},\n  \"step\": 1,\n  \"campaign_id\": null,\n  \"name\": \"Summer Review Campaign\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/create",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "create"
                  ]
                },
                "description": "Creates or advances a review-solicitation campaign through the 4-step setup wizard, branching on the `step` field. Step 1 `updateOrCreate`s a `draft` campaign (owner = authenticated user, `company_id` = user's company); step 2 toggles the SMS/Email channels (only when the store's active plan enables the matching solicitation feature); step 3 stores the scheduling config; step 4 finalizes the campaign to `scheduled` and computes `next_execution_time`. Side effects: writes to `rs_campaigns` and `campaign_channels` (soft-delete/restore on channel toggles).\n\n## Auth\n- Account — `auth:api`, BRAND-scope group. `store_id` is validated by the `BelongsToAuthCompany` rule (must belong to the caller's company); the campaign is stamped with `user_id` and the caller's `person.company_id`.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | Required | `BelongsToAuthCompany` — must belong to the caller's company. |\n| `step` | integer | Required | Wizard step; one of `1`,`2`,`3`,`4`. Controls which other fields are required. |\n| `campaign_id` | integer | Conditional | Nullable on step 1 (create-new); **required** on steps 2–4. `exists:rs_campaigns,id`. |\n| `name` | string | Required (step 1) | max 255. |\n| `channel_sms` | boolean | Required (step 2) | `required_without_all:channel_email`. Creates/restores or soft-deletes the SMS channel (only applied when the plan enables `review_solicitation_sms`). |\n| `channel_email` | boolean | Required (step 2) | `required_without_all:channel_sms`. Creates/restores or soft-deletes the Email channel (only applied when the plan enables `review_solicitation_email`). |\n| `per_day_request` | integer | Required (step 3) | 1–50. |\n| `request_frequency` | integer | Required (step 3) | one of `0,1,2,3,4`. |\n| `delay_request` | integer | Required (step 3) | 0–60 (days). |\n| `request_after_time_pst` | string | Optional (step 3) | `H:i:s`; defaults `14:00:00`. |\n| `send_immediately` | boolean | Optional (step 3) | nullable. When true, step 4 sets `next_execution_time` to now. |\n| `type` | string | Optional (step 3) | one of `bulk_upload`, `automation`, `kiosk`. |\n\n## Behaviour\n- 200 on every successful step. The response envelope carries `status`, `message`, `campaign_id`, and the full re-loaded campaign under `data`.\n- 400 (`badRequestResponse`) for invalid state transitions: editing a non-`draft` campaign on step 2; missing solicitation feature on the plan; audience/automation not set up on steps 3–4; file still processing on step 4.\n- 404 if `campaign_id` is supplied on steps ≥2 but does not resolve.\n- 422 on validation failure (Laravel FormRequest).\n\n✅ live ([routes/api_v1.php:903](routes/api_v1.php#L903) → `CampaignController@createCampaign`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Campaign has been saved successfully\",\n    \"campaign_id\": 1,\n    \"data\": {\n      \"id\": 1,\n      \"name\": \"Summer Review Campaign\",\n      \"description\": null,\n      \"status\": \"draft\",\n      \"current_step\": 1,\n      \"store_id\": 123,\n      \"scheduled_at\": null,\n      \"started_at\": null,\n      \"completed_at\": null,\n      \"total_audience\": 0,\n      \"request_per_day\": 0,\n      \"request_frequency\": 0,\n      \"request_delay\": 0,\n      \"request_after_time\": \"14:00:00\",\n      \"next_execution_time\": null,\n      \"request_file_status\": \"pending\",\n      \"request_file_name\": null,\n      \"send_immediately\": false,\n      \"request_file_path\": null,\n      \"type\": null,\n      \"channels\": [],\n      \"email_requests\": [],\n      \"sms_requests\": [],\n      \"file_header\": null\n    }\n  }\n}"
                },
                {
                  "name": "422 Validation",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The name field is required.\",\n  \"errors\": {\n    \"name\": [\n      \"The name field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "8.A.7 Send review request (test-review-request — deprecated alias)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"campaign_id\": {{campaignId}},\n  \"customer_name\": \"John Doe\",\n  \"email\": \"john.doe@example.com\",\n  \"phone\": \"+15551234567\",\n  \"is_test\": true,\n  \"keywords\": {\n    \"paragraph_1\": \"Thanks for visiting our store today!\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/test-review-request",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "test-review-request"
                  ]
                },
                "description": "Creates a one-off audience row and queues an Email and/or SMS review request for a campaign (dispatched after DB commit onto the messaging queue). Branches on whether `email` and/or `phone` is supplied. Serves both production and test sends via the `is_test` flag; a non-test send increments `rs_campaigns.total_audience` and schedules a follow-up email when the email channel has a follow-up template.\n\n**Deprecated path.** `POST /campaign/test-review-request` is a backward-compatible alias that points at the same `sendReviewRequest` handler as the canonical `POST /campaign/review-request` (documented as `8.C.3`). Migrate callers to `review-request`.\n\n## Auth\n- Account — `auth:api`, BRAND-scope group. Campaign resolved by `RsCampaign::where('company_id', $user->person->company_id)->where('id', campaign_id)`; if not owned by the caller it returns 403 (\"You are not authorized to send a review request for this campaign\"). Store must have an active plan (400 otherwise).\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `campaign_id` | integer | Required | `exists:rs_campaigns,id`; must belong to the caller's company. |\n| `customer_name` | string | Required | max 255; trimmed. |\n| `email` | string | Conditional | `required_without:phone`; nullable, `email:rfc,dns`, max 255. Drives the Email send. |\n| `phone` | string | Conditional | `required_without:email`; nullable, min 7, max 20. Drives the SMS send. |\n| `is_test` | boolean | Optional | nullable. When true, tags every downstream row as test and skips follow-ups; does not increment `total_audience`. Defaults false. |\n| `keywords` | object | Optional | nullable map; each key max 100 chars. Empty/null values skipped. |\n| `keywords.*` | string | Optional | nullable value per keyword. |\n\nAt least one of `email` or `phone` is required.\n\n## Behaviour\n- 200 `{ status: \"success\", message }` where message reflects which channel(s) were queued (\"Email and SMS...\", \"Email...\", or \"SMS review request sent successfully\").\n- 403 if the campaign is not owned by the caller.\n- 400 for: no active plan; a requested channel is missing/disabled or lacks a valid template; missing review-site URLs in the landing page / email template (returns the per-site error list); or \"Failed to send review request\" if nothing was created.\n- 422 on validation failure.\n\n✅ live ([routes/api_v1.php:914](routes/api_v1.php#L914) → `CampaignController@sendReviewRequest`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Email and SMS review requests sent successfully\"\n  }\n}"
                },
                {
                  "name": "403 Not owned",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"You are not authorized to send a review request for this campaign\",\n  \"errors\": []\n}"
                },
                {
                  "name": "400 No active plan",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Store does not have an active plan\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "8.A.8 Direct-to-company email",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"unique_code\": \"{{emailRequestUniqueCode}}\",\n  \"direct_to_option\": \"owner\",\n  \"email\": \"jane.doe@example.com\",\n  \"first_name\": \"Jane\",\n  \"last_name\": \"Doe\",\n  \"phone\": \"+15551234567\",\n  \"comments\": \"I had an issue with my recent visit and would like to discuss it.\",\n  \"g_recaptcha_response\": \"03AGdBq24...\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/direct-to-company-email",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "direct-to-company-email"
                  ]
                },
                "description": "Records an internal `customer_comment` tied to an Email or SMS review request (looked up by `unique_code`) and emails the resolved recipients (store owner and/or configured manager/other-manager addresses, based on `direct_to_option`) using the `DIRECT_TO_COMPANY` template. Customer-facing feedback path.\n\n## Auth\n- Public — no bearer token (route sits in the unauthenticated `campaign` group; the item is marked `noauth`). Reached by end customers via the review-request link. The `unique_code` is the only identifier that scopes the write to a store.\n\n## Rate limit\n- 30 requests/min per IP (`throttle:30,1`, public).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `unique_code` | string | Required | max 50. Email or SMS request unique code; resolves the target store. |\n| `direct_to_option` | string | Required | one of `owner`, `manager`, `customer`. Picks the recipient set. |\n| `email` | string | Required | `email:rfc,dns`, max 100. Customer's email. |\n| `first_name` | string | Required | max 100. |\n| `last_name` | string | Required | max 100. |\n| `phone` | string | Required | min 7, max 20. |\n| `comments` | string | Required | max 255. |\n| `g_recaptcha_response` | string | Required | Google reCAPTCHA token; verified server-side via siteverify. |\n\n## Behaviour\n- 200 `{ status: \"success\", message }` (\"...sent directly to the {owner|manager|customer}.\").\n- 400 (`badRequestResponse`) \"Email or SMS request not found\" when `unique_code` resolves to neither an email nor an SMS request.\n- 500 (`errorResponse`) \"Mail not sent! {Option} Email is not available\" when no recipient address can be resolved, or \"Failed to add customer comment\" on save failure.\n- 422 on validation failure (including an invalid reCAPTCHA token).\n\n✅ live ([routes/api_v1.php:197](routes/api_v1.php#L197) → `CampaignController@directToCompanyEmail`)",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Thank you for your feedback! Your message has been sent directly to the owner.\"\n  }\n}"
                },
                {
                  "name": "400 Request not found",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Bad Request\",\n  \"errors\": \"Email or SMS request not found\"\n}"
                },
                {
                  "name": "422 Validation",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The g recaptcha response field is required.\",\n  \"errors\": {\n    \"g_recaptcha_response\": [\n      \"The g recaptcha response field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "8.A.9 Direct comment feedback",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"unique_code\": \"{{emailRequestUniqueCode}}\",\n  \"email\": \"jane.doe@example.com\",\n  \"name\": \"Jane\",\n  \"phone\": \"+15551234567\",\n  \"comments\": \"Great service, thank you!\",\n  \"star_rating\": 5,\n  \"g_recaptcha_response\": \"03AGdBq24...\",\n  \"review_site_id\": {{reviewSiteId}},\n  \"is_qualified\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/direct-comment-feedback",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "direct-comment-feedback"
                  ]
                },
                "description": "Creates an off-platform `reviews` row plus an internal customer comment tied to an Email or SMS review request (looked up by `unique_code`), then notifies the store's configured recipients with the `CUSTOMER_COMMENT` template. De-dupes on `(store_id, review_site_id, reviewer_email|reviewer_phone)`. On save it emits `review_campaign_collected` via `ReviewHistoryService`, which drives the partner-webhook batch fan-out. For review site id 100 the review is stamped `is_off_platform=1`, `is_legacy=0`, and `is_qualified` from the request.\n\n## Auth\n- Public — no bearer token (unauthenticated `campaign` group; item marked `noauth`). Reached by end customers via the review-request link; `unique_code` scopes the write to a store.\n\n## Rate limit\n- 30 requests/min per IP (`throttle:30,1`, public).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `unique_code` | string | Required | max 50. Email or SMS request unique code. |\n| `email` | string | Required | `email:rfc,dns`, max 100. Stored as `reviewer_email`. |\n| `name` | string | Required | max 100. Stored as `reviewer`. |\n| `phone` | string | Optional | nullable, min 7, max 20. Stored as `reviewer_phone`. |\n| `comments` | string | Required | max 255. Stored as the review message. |\n| `star_rating` | integer | Required | 1–5. Stored as `rate`. |\n| `g_recaptcha_response` | string | Required | Google reCAPTCHA token; verified server-side. |\n| `review_site_id` | integer | Required | `exists:review_sites,id`. |\n| `is_qualified` | integer | Optional | nullable; `0` or `1` (default 1). Only applied when `review_site_id` = 100. |\n\n## Behaviour\n- 200 `{ status: \"success\", message: \"Thank you for your comment. Your feedback helps us improve our service.\" }` (same message whether or not a notification recipient was resolved).\n- 400 (`badRequestResponse`) for: \"Email or SMS request not found\"; \"You have already submitted your feedback. Thank you!\" (dedupe hit); or \"Review site not found\".\n- 500 (`errorResponse`) \"Failed to add customer comment. Please try again.\" on save failure.\n- 422 on validation failure (including an invalid reCAPTCHA token).\n\n✅ live ([routes/api_v1.php:198](routes/api_v1.php#L198) → `CampaignController@directToCommentEmail`)",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Thank you for your comment. Your feedback helps us improve our service.\"\n  }\n}"
                },
                {
                  "name": "400 Already submitted",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Bad Request\",\n  \"errors\": \"You have already submitted your feedback. Thank you!\"\n}"
                },
                {
                  "name": "422 Validation",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The star rating field is required.\",\n  \"errors\": {\n    \"star_rating\": [\n      \"The star rating field is required.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Campaign lifecycle: list, get, cancel, duplicate, template cleanup."
        },
        {
          "name": "§ B.8.B Campaigns — audience",
          "item": [
            {
              "name": "8.B.1 Upload audience",
              "request": {
                "method": "POST",
                "header": [],
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    {
                      "key": "audience_file",
                      "type": "file",
                      "src": []
                    },
                    {
                      "key": "campaign_id",
                      "value": "{{campaignId}}",
                      "type": "text"
                    }
                  ]
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/{{campaignId}}/upload-audience",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "{{campaignId}}",
                    "upload-audience"
                  ]
                },
                "description": "Uploads an Excel/CSV audience file to a draft bulk-upload campaign, stores it on S3, and dispatches an async job (`ProcessCampaignAudienceUpload`) to parse and validate the recipients.\n\n**Auth:** Both — `auth:api` bearer token; no persona middleware. The campaign is scoped in-controller via `$campaign->store->company->isCompanyBelongsToUser($user)`, which matches the caller's `person.company_id` against the campaign-store's company or its parent/grandparent — so any Partner- or Account/Brand-tier user within the campaign's company tree may upload. Also rejected (400) unless `status === 'draft'` and `type` is neither `automation` nor `kiosk`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (integer, required) — campaign ID (route-constrained to `[0-9]+`); 404 if no `RsCampaign` found.\n- Body (`multipart/form-data`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `file` | file (xlsx, xls, csv) | yes | Max 10 MB (`max:10240` KB). Headers validated server-side: file must contain an `Email` OR `Cell Phone` column (case-insensitive); `Requestor Name` / `Customer Name` are optional. Bad headers → 400. |\n\n**Response** — `{ data: ... }` envelope (via `ApiResponses::successResponse`).\n- `message` (string) — `\"File uploaded successfully. Processing will begin shortly.\"`\n- `campaign_id` (integer) — the campaign ID.\n- `file_name` (string) — original uploaded filename.\n\nNote: on accept, the campaign row is updated (`request_file_path`, `request_file_name`, `request_file_status='processing'`, `type='bulk_upload'`, counters reset) before the job is queued; actual parsing happens asynchronously."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"message\": \"File uploaded successfully. Processing will begin shortly.\",\n    \"campaign_id\": 1,\n    \"file_name\": \"audience.xlsx\"\n  }\n}"
                }
              ]
            },
            {
              "name": "8.B.2 List audiences (recipients)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/{{campaignId}}/audiences",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "{{campaignId}}",
                    "audiences"
                  ]
                },
                "description": "Returns the paginated, searchable, sortable list of audience recipients for a campaign, plus aggregate validation KPIs, a quality summary, and basic campaign info.\n\n**Auth:** Both — `auth:api` bearer token; no persona middleware. When the campaign has a `company_id`, it is scoped in-controller via `Company::find($campaign->company_id)->isCompanyBelongsToUser($user)` (matches caller's `person.company_id` against that company or its parent/grandparent), so Partner- and Account/Brand-tier users inside the campaign's company tree may read it. 404 if campaign not found; 403 if it belongs to another company.\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\n- Path: `{id}` (integer, required) — campaign ID (route-constrained to `[0-9]+`).\n- Query: `page` (integer, optional) — page number, `min:1`.\n- Query: `limit` (integer, optional) — page size, `min:1` / `max:100`, default 10 (capped at 100).\n- Query: `search_value` (string, optional) — `max:255`; substring match.\n- Query: `search_field` (string, optional) — one of `email`, `phone`, `customer_name`, `requestor_name`, `company`, `all` (default `all` searches across all five).\n- Query: `sort_by` (string, optional) — `asc` or `desc` (case-insensitive), default `desc`.\n- Query: `sort_field` (string, optional) — one of `created_at`, `email`, `phone`, `customer_name`, `requestor_name`; default `created_at`.\n\n**Response** — `{ data: ... }` envelope (via `CampaignAudienceCollection`). Note the collection sets a top-level `status: \"success\"` alongside `data`.\n- `data.audiences` (array of recipient objects, `CampaignAudienceResource`):\n  - `id` (integer)\n  - `email` (string|null)\n  - `phone` (string|null)\n  - `requestor_name` (string|null)\n  - `customer_name` (string|null)\n  - `company` (string|null)\n  - `validation_status` (string)\n  - `is_email_valid` (string) — e.g. `valid` / `invalid` / `duplicate`\n  - `is_phone_valid` (string) — e.g. `valid` / `invalid` / `duplicate`\n  - `is_test` (bool)\n  - `contact_quality` (string) — derived: `excellent` / `good` / `poor`\n  - `created_at` (string `Y-m-d H:i:s` | null)\n  - `updated_at` (string `Y-m-d H:i:s` | null)\n- `data.kpis` (object) — `total_audience`, `total_valid_emails`, `total_valid_phones`, `total_with_both_contacts`, `total_invalid_emails`, `total_invalid_phones`, `total_with_no_contacts`, `total_kiosk_audience`, `total_test_audience` (ints; all but `total_test_audience` exclude `is_test`), plus `email_validation_rate`, `phone_validation_rate`, `both_contacts_rate` (floats, %).\n- `data.summary` (object) — `insights` (array of strings) and `data_quality_score` `{ score (float), grade (string A+..F), status (excellent|good|needs_improvement) }`.\n- `data.campaign_info` (object) — `id`, `name`, `status`, `file_status` (= `request_file_status`), `created_at`, `updated_at`.\n\nNote: this endpoint does NOT use the `page` rule for slicing beyond Laravel's default `paginate()` page resolution; pagination metadata comes from the underlying LengthAwarePaginator on `data.audiences` (Laravel resource-collection shape: `data`, `links`, `meta`)."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"success\",\n  \"data\": {\n    \"audiences\": [\n      {\n        \"id\": 101,\n        \"email\": \"john@example.com\",\n        \"phone\": \"+15551234567\",\n        \"requestor_name\": \"Store Manager\",\n        \"customer_name\": \"John Doe\",\n        \"company\": \"Acme Co\",\n        \"validation_status\": \"validated\",\n        \"is_email_valid\": \"valid\",\n        \"is_phone_valid\": \"valid\",\n        \"is_test\": false,\n        \"contact_quality\": \"excellent\",\n        \"created_at\": \"2026-06-25 10:14:02\",\n        \"updated_at\": \"2026-06-25 10:14:02\"\n      }\n    ],\n    \"kpis\": {\n      \"total_audience\": 67,\n      \"total_valid_emails\": 52,\n      \"total_valid_phones\": 43,\n      \"total_with_both_contacts\": 38,\n      \"total_invalid_emails\": 15,\n      \"total_invalid_phones\": 24,\n      \"total_with_no_contacts\": 6,\n      \"total_kiosk_audience\": 10,\n      \"total_test_audience\": 5,\n      \"email_validation_rate\": 77.61,\n      \"phone_validation_rate\": 64.18,\n      \"both_contacts_rate\": 56.72\n    },\n    \"summary\": {\n      \"insights\": [\"Good email reach potential\", \"Moderate SMS campaign potential\"],\n      \"data_quality_score\": { \"score\": 67.46, \"grade\": \"C\", \"status\": \"good\" }\n    },\n    \"campaign_info\": {\n      \"id\": 1,\n      \"name\": \"Summer Campaign 2024\",\n      \"status\": \"draft\",\n      \"file_status\": \"completed\",\n      \"created_at\": \"2026-06-20 08:00:00\",\n      \"updated_at\": \"2026-06-25 10:14:02\"\n    }\n  }\n}"
                }
              ]
            }
          ],
          "description": "Audience upload + recipients listing."
        },
        {
          "name": "§ B.8.C Campaigns — test sends",
          "item": [
            {
              "name": "8.C.1 Test SMS review request",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"campaign_id\": 1,\n    \"customer_name\": \"John Doe\",\n    \"phone\": \"+15551234567\",\n    \"is_test\": true,\n    \"keywords\": {\n        \"paragraph_1\": \"Thanks for visiting our store today!\"\n    }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/test-sms-review-request",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "test-sms-review-request"
                  ]
                },
                "description": "Creates a one-off audience row and queues a single SMS review request for the given campaign (dispatched after commit on the messaging queue).\n\n**Auth:** Account — `auth:api`, BRAND-scope group. Campaign resolved by `RsCampaign::where('company_id', $user->person->company_id)->where('id', campaign_id)`; if not owned by the caller it returns 403 (\"You are not authorized to test this campaign\"). The campaign must have an enabled SMS channel with a valid template, and the store must have an active plan (`StorePlan::getActivePlan`), else 400.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `campaign_id` | integer | yes | `exists:rs_campaigns,id`; must belong to caller's company. |\n| `customer_name` | string | yes | max 255; trimmed before save. |\n| `phone` | string | yes | min 7, max 20; include country code. |\n| `is_test` | boolean | no | nullable; tagged onto audience + sms request rows. Defaults false. |\n| `keywords` | object | no | nullable map; each key max 100 chars, values are strings. Empty/null values skipped; stored as recipient template placeholders. |\n\n**Response** — `{ data: ... }` envelope. `data` is `{ status: \"success\", message: \"SMS review request sent successfully\" }`. (Response also carries plan feature headers for `review_solicitation_sms`.) Returns 400 (\"Failed to send SMS review request\") if no request was created.\n- `data.status` (string) — `\"success\"`.\n- `data.message` (string)."
              },
              "response": [
                {
                  "name": "201 Created",
                  "status": "Created",
                  "code": 201,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"SMS review request sent successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "8.C.2 Test email review request",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"campaign_id\": 1,\n    \"customer_name\": \"John Doe\",\n    \"email\": \"john.doe@example.com\",\n    \"is_test\": true,\n    \"keywords\": {\n        \"paragraph_1\": \"Thanks for visiting our store today!\"\n    }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/test-email-review-request",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "test-email-review-request"
                  ]
                },
                "description": "Creates a one-off audience row and queues a single Email review request for the given campaign (dispatched after commit on the messaging queue).\n\n**Auth:** Account — `auth:api`, BRAND-scope group. Campaign resolved by `RsCampaign::where('company_id', $user->person->company_id)->where('id', campaign_id)`; if not owned by the caller it returns 403 (\"You are not authorized to test this campaign\"). The campaign must have an enabled Email channel with a valid template, and the store must have an active plan, else 400.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `campaign_id` | integer | yes | `exists:rs_campaigns,id`; must belong to caller's company. |\n| `customer_name` | string | yes | max 255; trimmed before save. |\n| `email` | string | yes | `email:rfc,dns`, max 255. |\n| `is_test` | boolean | no | nullable; tagged onto audience + email request rows. Defaults false. |\n| `keywords` | object | no | nullable map; each key max 100 chars, values are strings. Empty/null values skipped; stored as recipient template placeholders. |\n\n**Response** — `{ data: ... }` envelope. `data` is `{ status: \"success\", message: \"Email review request sent successfully\" }`. (Response also carries plan feature headers for `review_solicitation_email`.) Returns 400 (\"Failed to send Email review request\") if no request was created.\n- `data.status` (string) — `\"success\"`.\n- `data.message` (string)."
              },
              "response": [
                {
                  "name": "201 Created",
                  "status": "Created",
                  "code": 201,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Email review request sent successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "8.C.3 Send review request (email and/or SMS)",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"campaign_id\": 1,\n    \"customer_name\": \"John Doe\",\n    \"email\": \"gopal@shoutaboutus.com\",\n    \"phone\": \"+15551234567\",\n    \"is_test\": false,\n    \"keywords\": {\n        \"paragraph_1\": \"Thanks for visiting our store today!\"\n    }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/review-request",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "review-request"
                  ]
                },
                "description": "Unified send-review-request: creates audience row(s) and queues an Email and/or SMS review request for a campaign, branching on whether `email` and/or `phone` is supplied. Serves both production and test sends via the `is_test` flag. (Canonical 2026-06 rename of `test-review-request`, which remains a deprecated alias to the same handler.)\n\n**Auth:** Account — `auth:api`, BRAND-scope group. Campaign resolved by `RsCampaign::where('company_id', $user->person->company_id)->where('id', campaign_id)`; if not owned by the caller it returns 403 (\"You are not authorized to send a review request for this campaign\"). Store must have an active plan (400 otherwise). Each requested channel must be enabled with a valid template; missing review-site URLs in the landing page / email template surface as a 400 listing the missing sites. A non-test send (`is_test=false`) also increments `total_audience` and schedules a follow-up email if the email channel has a follow-up template.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `campaign_id` | integer | yes | `exists:rs_campaigns,id`; must belong to caller's company. |\n| `customer_name` | string | yes | max 255; trimmed. |\n| `email` | string | conditional | `required_without:phone`; nullable, `email:rfc,dns`, max 255. Drives the Email send. |\n| `phone` | string | conditional | `required_without:email`; nullable, min 7, max 20. Drives the SMS send. |\n| `is_test` | boolean | no | nullable. When true, tags every downstream row as test and skips follow-up emails; does not increment `total_audience`. Defaults false. |\n| `keywords` | object | no | nullable map; each key max 100 chars, values strings. Empty/null values skipped. |\n\nAt least one of `email` or `phone` is required.\n\n**Response** — `{ data: ... }` envelope. `data` is `{ status: \"success\", message: <which channels sent> }`. Message is one of \"Email and SMS review requests sent successfully\", \"Email review request sent successfully\", or \"SMS review request sent successfully\". Returns 400 (\"Failed to send review request\") if nothing was created, or 400 with a channel/template/review-site error if a requested channel is unusable.\n- `data.status` (string) — `\"success\"`.\n- `data.message` (string) — reflects which channel(s) were queued."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Email and SMS review requests sent successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "8.C.4 Kiosk review request",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"unique_code\": \"CMP-7F3A9K2\",\n    \"customer_name\": \"Jane Doe\",\n    \"email\": \"jane.doe@example.com\",\n    \"phone\": \"+15551234567\",\n    \"requester_name\": \"Front Desk Kiosk\",\n    \"is_test\": false,\n    \"keywords\": {\n        \"visit_reason\": \"Routine checkup\",\n        \"staff_member\": \"Dr. Smith\"\n    }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/kiosk-review-request",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "kiosk-review-request"
                  ]
                },
                "description": "Submits an in-store kiosk review request: looks up a campaign by `unique_code`, creates a kiosk audience row, and queues an email and/or SMS review-request send (one or both, depending on whether `email`/`phone` are supplied and the matching channel is enabled and within service limits).\n\n**Auth:** Account — `auth:api`; the Form Request's `authorize()` requires the authenticated user's company to be a Brand account (`bundle_id = CompanyBundle::BRAND = 3`). The campaign is resolved by `unique_code` and then scoped by `campaign.company_id === user->person->company_id` (mismatch returns a 404 \"Campaign not found\"), so a brand can only fire kiosk requests for its own campaigns.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `unique_code` | string | yes | Campaign's unique code; must resolve to a campaign owned by the caller's brand company. |\n| `customer_name` | string (max 255) | yes | Customer name; validated by `NameNoAngleBrackets` (no `<`/`>`). Trimmed before save. |\n| `email` | string, email (`rfc,dns`), max 255, nullable | required_without `phone` | Triggers the email channel. At least one of `email`/`phone` must be present. |\n| `phone` | string, min 7, max 20, nullable | required_without `email` | Triggers the SMS channel. At least one of `email`/`phone` must be present. |\n| `requester_name` | string (max 255), nullable | no | Kiosk operator/requestor name; `NameNoAngleBrackets`. Stored as `requestor_name` on the audience row. |\n| `is_test` | boolean, nullable | no | When true, marks audience/requests as test and does NOT increment `campaign.total_audience` or schedule email follow-ups. Defaults false. |\n| `keywords` | object/map, nullable | no | Key/value personalization pairs; each key max 100 chars. Empty/null values are skipped. |\n| `keywords.*` | string, nullable | no | Value for each keyword key. |\n\n**Response** — `{ data: ... }` envelope. On success the controller returns a `successResponse` carrying a status/message object.\n- `status` (string) — `\"success\"`.\n- `message` (string) — one of `\"Email and SMS review requests sent successfully\"`, `\"Email review request sent successfully\"`, or `\"SMS review request sent successfully\"` depending on which channel(s) were queued.\n\nError paths (not wrapped as success): 404 `\"Campaign not found\"` (missing or not owned by caller); 400 `\"Store does not have an active plan\"`; 400 `\"Some review site URLs are missing for this location...\"` (with the per-site error list); 400 `\"No valid channel to send review request\"` (with channel/limit errors); 400 `\"Review Solicitation is not enabled for this store...\"`; 400 `\"Failed to send review request\"`."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Email and SMS review requests sent successfully\"\n  }\n}"
                }
              ]
            }
          ],
          "description": "SMS / email / generic / kiosk test sends — preview a review-request before launching the campaign."
        },
        {
          "name": "§ B.8.E Campaigns — overage",
          "item": [
            {
              "name": "8.E.1 Email solicitation overage summary",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/solicitation/email-overage-summary",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "solicitation",
                    "email-overage-summary"
                  ]
                },
                "description": "Returns a paginated per-store-plan summary of email solicitation volume (count of `campaign_email_requests` per `store_plan_id`), used for the brand-side email overage display.\n\n**Auth:** Both — `auth:api` bearer token. Target is scoped by `BelongsToAuthCompany` on the optional `company_id`/`store_id` (must resolve into the auth user's company hierarchy via `Company::isBelongsToParentCompany`); when `company_id` is omitted it defaults to the auth user's own `person->company_id`. A Partner login can pass any descendant brand/store id (reaching its whole tree); an Account (brand) login is limited to its own brand and stores under it.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `company_id` (integer, optional) — filter by brand/company id; defaults to auth user's `person.company_id`. Must belong to auth company hierarchy.\n- Query: `store_id` (integer, optional) — filter by a single store; also restricts the email-count subquery to that store. Must belong to auth company hierarchy.\n- Query: `sort_field` (string, optional) — one of `account_name`, `store_name`, `store_plan_id`, `store_plan_status`, `store_plan_end`, `email_sent_count`; ignored if not in this whitelist (falls back to store name ASC).\n- Query: `sort_by` (string, optional) — `asc|ASC|desc|DESC`; default `ASC`.\n- Query: `search_field` (string, optional) — one of `store_name`, `account_name`, `store_plan_status`, `store_plan_end`, `store_plan_id` (no angle brackets allowed).\n- Query: `search_value` (string, optional) — value matched against `search_field` (LIKE for name fields, exact/date otherwise; no angle brackets).\n- Query: `limit` (integer, optional) — items per page, 1–100; default 20.\n\n**Response** — `{ data: [...] }` envelope (Laravel paginator: `data`, `links`, `meta`).\n- `id` (integer) — store_plan id (also surfaced as `store_plan_id`).\n- `account_name` (string) — brand company name, falling back to the store's location-company name (`COALESCE(brand.name, c.name)`).\n- `store_name` (string) — `\"{store.name} - {store.storeid}\"` when `storeid` is set, else just `store.name`.\n- `store_plan_id` (integer) — store_plan id.\n- `store_plan_status` (string) — `store_plans.status`.\n- `store_plan_end` (string|null) — `store_plans.plan_end` date.\n- `email_sent_count` (integer) — count of `campaign_email_requests` for that store_plan (0 when none)."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"id\": 1234,\n      \"account_name\": \"Acme Brand\",\n      \"store_name\": \"Downtown - 1001\",\n      \"store_plan_id\": 1234,\n      \"store_plan_status\": \"active\",\n      \"store_plan_end\": \"2025-12-31\",\n      \"email_sent_count\": 1200\n    }\n  ],\n  \"links\": { \"first\": \"...\", \"last\": \"...\", \"prev\": null, \"next\": \"...\" },\n  \"meta\": { \"current_page\": 1, \"per_page\": 20, \"total\": 1 }\n}"
                }
              ]
            },
            {
              "name": "8.E.2 SMS solicitation overage summary",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/solicitation/sms-overage-summary",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "solicitation",
                    "sms-overage-summary"
                  ]
                },
                "description": "Returns a paginated per-store-plan summary of SMS solicitation volume (count of `campaign_sms_requests` per `store_plan_id`), used for the brand-side SMS overage display.\n\n**Auth:** Both — `auth:api` bearer token. Target is scoped by `BelongsToAuthCompany` on the optional `company_id`/`store_id` (must resolve into the auth user's company hierarchy via `Company::isBelongsToParentCompany`); when `company_id` is omitted it defaults to the auth user's own `person->company_id`. A Partner login can pass any descendant brand/store id (reaching its whole tree); an Account (brand) login is limited to its own brand and stores under it.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `company_id` (integer, optional) — filter by brand/company id; defaults to auth user's `person.company_id`. Must belong to auth company hierarchy.\n- Query: `store_id` (integer, optional) — filter by a single store; also restricts the SMS-count subquery to that store. Must belong to auth company hierarchy.\n- Query: `sort_field` (string, optional) — one of `account_name`, `store_name`, `store_plan_id`, `store_plan_status`, `store_plan_end`, `sms_sent_count`; ignored if not in this whitelist (falls back to store name ASC).\n- Query: `sort_by` (string, optional) — `asc|ASC|desc|DESC`; default `ASC`.\n- Query: `search_field` (string, optional) — one of `store_name`, `account_name`, `store_plan_status`, `store_plan_end`, `store_plan_id` (no angle brackets allowed).\n- Query: `search_value` (string, optional) — value matched against `search_field` (LIKE for name fields, exact/date otherwise; no angle brackets).\n- Query: `limit` (integer, optional) — items per page, 1–100; default 20.\n\n**Response** — `{ data: [...] }` envelope (Laravel paginator: `data`, `links`, `meta`).\n- `id` (integer) — store_plan id (also surfaced as `store_plan_id`).\n- `account_name` (string) — brand company name, falling back to the store's location-company name (`COALESCE(brand.name, c.name)`).\n- `store_name` (string) — `\"{store.name} - {store.storeid}\"` when `storeid` is set, else just `store.name`.\n- `store_plan_id` (integer) — store_plan id.\n- `store_plan_status` (string) — `store_plans.status`.\n- `store_plan_end` (string|null) — `store_plans.plan_end` date.\n- `sms_sent_count` (integer) — count of `campaign_sms_requests` for that store_plan (0 when none)."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"id\": 1234,\n      \"account_name\": \"Acme Brand\",\n      \"store_name\": \"Downtown - 1001\",\n      \"store_plan_id\": 1234,\n      \"store_plan_status\": \"active\",\n      \"store_plan_end\": \"2025-12-31\",\n      \"sms_sent_count\": 300\n    }\n  ],\n  \"links\": { \"first\": \"...\", \"last\": \"...\", \"prev\": null, \"next\": \"...\" },\n  \"meta\": { \"current_page\": 1, \"per_page\": 20, \"total\": 1 }\n}"
                }
              ]
            }
          ],
          "description": "Email + SMS solicitation overage summaries (post-launch usage billing surface)."
        },
        {
          "name": "§ B.9.A QR codes — CRUD",
          "item": [
            {
              "name": "9.A.1 List QR codes",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/qr-codes",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes"
                  ]
                },
                "description": "Returns a paginated list of QR codes scoped to the authenticated brand-user's company, with each row's `total_scans` computed from scan history. Supports filtering by store, destination type, date range, free-text/field search, and sorting.\n\n**Auth:** Account — `auth:api`; rows are hard-scoped by `qr_codes.company_id = $user->person->company_id` (the brand company). The optional `store_id` filter is additionally validated by `BelongsToAuthCompany` (walks the company hierarchy via `Company::isBelongsToParentCompany`).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must `exists:store,id` and belong to the auth company hierarchy.\n- Query: `destination_type` (string, optional) — one of `landing_page`, `review_site`, `custom_url`.\n- Query: `exclude_destination_type` (string, optional) — one of `landing_page`, `review_site`, `custom_url`.\n- Query: `start_date` (string `Y-m-d`, optional) — filters `created_at` from this day.\n- Query: `end_date` (string `Y-m-d`, optional) — filters `created_at` to this day.\n- Query: `search_field` (string, optional) — one of `name`, `destination_type`, `landing_page_id`, `review_site_id`, `destination_url`, `created_at`, `updated_at`, `last_scanned_at`, `store_name`, `storeId`, `total_scans`.\n- Query: `search_value` (string, optional, max 255) — paired with `search_field`. For `total_scans` accepts exact `5`, range `5-10`, gte `5+`, or lte `5-`.\n- Query: `search` (string, optional, max 255) — fallback name `like` search (used only when `search_field`/`search_value` absent).\n- Query: `sort_field` (string, optional) — same enum as `search_field`.\n- Query: `sort_by` (string, optional) — `asc`/`ASC`/`desc`/`DESC` (defaults `DESC`).\n- Query: `per_page` (integer, optional, 1–100, default 15).\n\n**Response** — `{ data: ... }` envelope wrapping a Laravel paginator.\n- `data.current_page` (int)\n- `data.data[]` (array of QrCode rows) — selected columns: `id`, `name`, `destination_type`, `store_id`, `landing_page_id`, `review_site_id`, `destination_url`, `customization`, `last_scanned_at`, `created_at`, `updated_at`, `qr_code_image_path`, plus computed `total_scans` (int) and the QrCode's appended `qr_code_image_url`. Eager-loads `store:id,name,storeId`, `landingPage:id`, `reviewSite:id`.\n- `data.per_page` (int), `data.total` (int), `data.last_page` (int), and the rest of the standard paginator keys."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"current_page\": 1,\n    \"data\": [\n      {\n        \"id\": 123,\n        \"name\": \"Clinic front desk\",\n        \"destination_type\": \"landing_page\",\n        \"store_id\": 8748,\n        \"landing_page_id\": 1,\n        \"review_site_id\": null,\n        \"destination_url\": \"https://example.com/lp/1?store_id=8748\",\n        \"customization\": null,\n        \"last_scanned_at\": \"2026-06-15T10:30:00.000000Z\",\n        \"created_at\": \"2026-06-01T09:00:00.000000Z\",\n        \"updated_at\": \"2026-06-15T10:30:00.000000Z\",\n        \"qr_code_image_path\": \"qr-codes/abc.png\",\n        \"qr_code_image_url\": \"https://s3.example.com/qr-codes/abc.png\",\n        \"total_scans\": 42\n      }\n    ],\n    \"per_page\": 15,\n    \"total\": 120,\n    \"last_page\": 8\n  }\n}"
                }
              ]
            },
            {
              "name": "9.A.2 Get QR code",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/{{qrCodeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "{{qrCodeId}}"
                  ]
                },
                "description": "Retrieves a single QR code by ID; the row must belong to the authenticated brand-user's company.\n\n**Auth:** Account — `auth:api`; scoped by `where('company_id', $request->user()->person->company_id)` then `->find($id)`. A row outside the brand company resolves to null and returns 404.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — QR code ID (route constrained to `[0-9]+`).\n\n**Response** — `{ data: ... }` envelope. The controller passes an array to `successResponse`, so the payload nests under `data` with `status`/`message` siblings inside it.\n- `data.status` (string) — `\"success\"`.\n- `data.data` (object) — the QrCode model with `company:id,name`, `store:id,name`, `landingPage:id`, `reviewSite:id` eager-loaded, plus appended `qr_code_image_url`.\n- `data.message` (string) — `\"QR code retrieved successfully\"`."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 200,\n      \"name\": \"Front desk\",\n      \"company_id\": 412,\n      \"store_id\": 8748,\n      \"destination_type\": \"landing_page\",\n      \"landing_page_id\": 1,\n      \"review_site_id\": null,\n      \"destination_url\": \"https://example.com/lp/1?store_id=8748\",\n      \"customization\": null,\n      \"qr_code_image_path\": \"qr-codes/abc.png\",\n      \"qr_code_image_url\": \"https://s3.example.com/qr-codes/abc.png\",\n      \"last_scanned_at\": \"2026-06-15T10:30:00.000000Z\",\n      \"company\": { \"id\": 412, \"name\": \"Acme Brand\" },\n      \"store\": { \"id\": 8748, \"name\": \"Downtown\" },\n      \"landingPage\": { \"id\": 1 },\n      \"reviewSite\": null\n    },\n    \"message\": \"QR code retrieved successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "9.A.3 Update QR code",
              "request": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"name\": \"Front desk\",\n    \"destination_type\": \"custom_url\",\n    \"destination_url\": \"https://example.com/promo\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/{{qrCodeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "{{qrCodeId}}"
                  ]
                },
                "description": "Partially updates a QR code belonging to the authenticated brand-user. All fields are optional (only sent fields are applied via `fill(validated())`); when any destination-related field changes the QR image is regenerated. Requires the store's active plan to still have a review-solicitation feature enabled.\n\n**Auth:** Account — `auth:api`; scoped by `where('company_id', $request->user()->person->company_id)->find($id)` (404 if outside the brand company). Also enforces an active `StorePlan` whose plan has `review_solicitation_email` or `review_solicitation_sms` feature `enabled = 1`, else HTTP 400.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — QR code ID (route `[0-9]+`).\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| name | string | sometimes | max 255; unique on `qr_codes.name` ignoring the current id |\n| destination_type | string | sometimes | one of `landing_page`, `review_site`, `custom_url` |\n| landing_page_id | integer | conditional | `required_if:destination_type,landing_page`; must `exists:campaign_landing_pages,id`; else nullable |\n| review_site_id | integer | conditional | `required_if:destination_type,review_site`; must `exists:review_sites,id`; else nullable |\n| destination_url | string | conditional | `required_if:destination_type,custom_url`; else nullable |\n| customization | array | optional | nullable JSON object |\n\n**Response** — `{ data: ... }` envelope wrapping an array (so `status`/`message` sit beside the model inside `data`).\n- `data.status` (string) — `\"success\"`.\n- `data.data` (object) — updated QrCode with `company:id,name`, `store:id,name`, `landingPage:id`, `reviewSite:id` loaded and `qr_code_image_url` appended.\n- `data.message` (string) — `\"QR code updated successfully\"`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 200,\n      \"name\": \"Front desk\",\n      \"destination_type\": \"custom_url\",\n      \"destination_url\": \"https://example.com/promo\",\n      \"landing_page_id\": null,\n      \"review_site_id\": null,\n      \"qr_code_image_path\": \"qr-codes/abc.png\",\n      \"qr_code_image_url\": \"https://s3.example.com/qr-codes/abc.png\"\n    },\n    \"message\": \"QR code updated successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "9.A.4 Delete QR code",
              "request": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/{{qrCodeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "{{qrCodeId}}"
                  ]
                },
                "description": "Soft-deletes a QR code belonging to the authenticated brand-user and removes its stored image from S3.\n\n**Auth:** Account — `auth:api`; scoped by `where('company_id', $request->user()->person->company_id)->find($id)` (404 if not found in the brand company). The `DestroyQRCodeRequest` injects the route `id` into validation data and asserts it `exists` on the `qr_codes` table. Emits `AuditAction::QR_CODE_DELETED`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — QR code ID (route `[0-9]+`; also validated `exists:qr_codes,id`).\n\n**Response** — `{ data: ... }` envelope wrapping an array.\n- `data.status` (string) — `\"success\"`.\n- `data.message` (string) — `\"QR code deleted successfully\"`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"QR code deleted successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "9.A.5 Duplicate QR code",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/{{qrCodeId}}/duplicate",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "{{qrCodeId}}",
                    "duplicate"
                  ]
                },
                "description": "Creates a copy of an existing QR code with optional field overrides; the source must belong to the authenticated brand-user. The clone defaults its name to `\"<source name> (Copy)\"`, copies the source's destination fields/customization where not overridden, then regenerates its own destination URL and QR image.\n\n**Auth:** Account — `auth:api`; source scoped by `where('company_id', $request->user()->person->company_id)->find($id)` (404 if outside the brand company). The clone inherits the source's `company_id`. An overriding `store_id` is validated by `BelongsToAuthCompany` (hierarchy walk).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — source QR code ID (route `[0-9]+`).\n- Body (all optional — overrides only):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| name | string | optional | max 255; defaults to `\"<source> (Copy)\"` |\n| store_id | integer | optional | `exists:store,id` + `BelongsToAuthCompany` |\n| destination_type | string | optional | one of `landing_page`, `review_site`, `custom_url` |\n| landing_page_id | integer | optional | `exists:campaign_landing_pages,id` |\n| review_site_id | integer | optional | `exists:review_sites,id` |\n| destination_url | string | optional | — |\n| customization | array | optional | nullable JSON object |\n\n**Response** — `{ data: ... }` envelope wrapping an array (HTTP 201 via `createdResponse`).\n- `data.status` (string) — `\"success\"`.\n- `data.data` (object) — the new QrCode with `company:id,name`, `store:id,name`, `landingPage:id`, `reviewSite:id` loaded and `qr_code_image_url` appended.\n- `data.message` (string) — `\"QR code duplicated successfully\"`.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"name\": \"Front desk (Copy)\",\n    \"destination_type\": \"custom_url\",\n    \"destination_url\": \"https://example.com/promo\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "201 Created",
                  "status": "Created",
                  "code": 201,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 201,\n      \"name\": \"Front desk (Copy)\",\n      \"company_id\": 412,\n      \"store_id\": 8748,\n      \"destination_type\": \"custom_url\",\n      \"destination_url\": \"https://example.com/promo\",\n      \"qr_code_image_path\": \"qr-codes/def.png\",\n      \"qr_code_image_url\": \"https://s3.example.com/qr-codes/def.png\"\n    },\n    \"message\": \"QR code duplicated successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "9.A.6 Create QR code",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Lobby\",\n  \"store_id\": {{storeId}},\n  \"destination_type\": \"landing_page\",\n  \"landing_page_id\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/qr-codes",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes"
                  ]
                },
                "description": "Creates a QR code for the authenticated brand. Requires the store's active plan to have the review-solicitation feature enabled (`review_solicitation_email` or `review_solicitation_sms`, `enabled = 1`), else 400. Generates and stores the QR image on save. Optionally fan-out the same QR to every sibling store that also has the feature (`generate_for_sibling_stores`). Writes a `QR_CODE_CREATED` audit event.\n\n## Auth\n- Bearer — **account** (brand) persona (`auth:api`). `store_id` is validated by `BelongsToAuthCompany` (hierarchy walk); the QR is stamped with the caller's `company_id`.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `name` | string | Required | Max 255. Unique per `store_id`. |\n| `store_id` | integer | Required | `exists:store,id` + must belong to the auth company. |\n| `destination_type` | string | Required | `landing_page`, `review_site` or `custom_url`. |\n| `landing_page_id` | integer | Optional | Required when `destination_type = landing_page`; `exists:campaign_landing_pages,id`. |\n| `review_site_id` | integer | Optional | Required when `destination_type = review_site`; `exists:review_sites,id`. |\n| `destination_url` | string | Optional | Required when `destination_type = custom_url`. |\n| `customization` | object | Optional | QR styling payload. |\n| `generate_for_sibling_stores` | boolean | Optional | Also create for sibling stores with the feature enabled. |\n\n## Behaviour\n- 201 (`createdResponse`). Body is double-nested: `{ \"data\": { \"data\": { ...QrCode }, \"message\": \"QR code created successfully\" } }`. The model appends `unique_code` (hashid) and `qr_code_image_url`.\n- 400 solicitation feature not enabled · 422 validation.\n\n✅ live ([routes/api_v1.php:983](routes/api_v1.php#L983) → `QRCodeController@store`)"
              },
              "response": [
                {
                  "name": "201 Created",
                  "status": "Created",
                  "code": 201,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": {\n      \"id\": 124,\n      \"name\": \"Lobby\",\n      \"company_id\": 456,\n      \"store_id\": 26,\n      \"qr_code_image_path\": \"uploads/qr/124.png\",\n      \"destination_type\": \"landing_page\",\n      \"landing_page_id\": 1,\n      \"review_site_id\": null,\n      \"destination_url\": \"https://development.shoutaboutus.com/lp/1?store_id=26\",\n      \"last_scanned_at\": null,\n      \"customization\": null,\n      \"created_at\": \"2026-07-09T10:00:00.000000Z\",\n      \"updated_at\": \"2026-07-09T10:00:00.000000Z\",\n      \"unique_code\": \"a1B2c3d4\",\n      \"qr_code_image_url\": \"https://s3.amazonaws.com/bucket/uploads/qr/124.png\",\n      \"company\": {\n        \"id\": 456,\n        \"name\": \"Brand 1\"\n      },\n      \"store\": {\n        \"id\": 26,\n        \"name\": \"Demo Store\"\n      },\n      \"landing_page\": {\n        \"id\": 1\n      },\n      \"review_site\": null\n    },\n    \"message\": \"QR code created successfully\"\n  }\n}"
                },
                {
                  "name": "400 Bad Request",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Review Solicitation is not enabled for this store. Please enable it in your plan or contact support for assistance.\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"QR code name is required.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"name\": [\n      \"QR code name is required.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "List / get / update / delete / duplicate the QR codes a brand uses for in-person review solicitation."
        },
        {
          "name": "§ B.9.B QR templates — CRUD",
          "item": [
            {
              "name": "9.B.1 List QR templates",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/template/list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "template",
                    "list"
                  ]
                },
                "description": "Returns a paginated list of QR code templates scoped to the authenticated user's company, with filtering (store, type, date range), full-text search, and sorting; each row is decorated with a `total_scans` count.\n\n**Auth:** Account — `auth:api`; templates are hard-scoped to `qr_code_templates.company_id = $user->person->company_id` (the auth user's own company, no partner-tree walk). The optional `store_id` filter is additionally validated by `BelongsToAuthCompany`, so it must resolve into the auth company's hierarchy.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must exist in `store` and belong to the auth company (BelongsToAuthCompany).\n- Query: `template_type` (string, optional) — one of `standard`, `hangtag`, `custom`.\n- Query: `start_date` (string `Y-m-d`, optional) — filters `created_at` from; swapped with `end_date` if reversed.\n- Query: `end_date` (string `Y-m-d`, optional) — filters `created_at` to.\n- Query: `search_field` (string, optional) — one of `name`, `description`, `template_type`, `created_at`, `updated_at`, `store_name`, `storeId`, `total_scans`.\n- Query: `search_value` (string, optional) — LIKE value; for `total_scans` supports exact `5`, range `5-10`, gte `5+`, lte `5-`. Rejects angle brackets (NameNoAngleBrackets).\n- Query: `search` (string, optional, max 255) — fallback `name` LIKE search, used only when `search_field`/`search_value` absent.\n- Query: `sort_field` (string, optional) — same allowed list as `search_field` (plus `id`).\n- Query: `sort_by` (string, optional) — `asc`/`ASC`/`desc`/`DESC`; defaults to `DESC`. Default sort when omitted is `created_at DESC`.\n- Query: `per_page` (integer, optional, 1–100, default 15).\n\n**Response** — `{ data: ... }` envelope wrapping a Laravel paginator (the paginator itself is the value of `data`).\n- `current_page` (integer)\n- `data` (array) — template rows, each selecting `id`, `name`, `description`, `template_type`, `store_id`, `created_at`, `updated_at`, plus eager-loaded `store` (`id`, `name`, `storeId`) and an injected `total_scans` (integer, non-deleted scan count).\n- `per_page` (integer)\n- `total` (integer)\n- `last_page`, `from`, `to`, `links`, etc. (standard paginator fields)"
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"current_page\": 1,\n    \"data\": [\n      {\n        \"id\": 10,\n        \"name\": \"Standard A6\",\n        \"description\": \"Front counter card\",\n        \"template_type\": \"standard\",\n        \"store_id\": 8748,\n        \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n        \"updated_at\": \"2026-06-15T10:30:00.000000Z\",\n        \"store\": { \"id\": 8748, \"name\": \"Downtown Branch\", \"storeId\": \"ST-8748\" },\n        \"total_scans\": 7\n      }\n    ],\n    \"per_page\": 15,\n    \"total\": 50,\n    \"last_page\": 4\n  }\n}"
                }
              ]
            },
            {
              "name": "9.B.2 Get QR template",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/template/{{templateId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "template",
                    "{{templateId}}"
                  ]
                },
                "description": "Returns a single QR code template by its path ID, with the related store eager-loaded; 404s if the template does not belong to the authenticated user's company.\n\n**Auth:** Account — `auth:api`; the lookup is `QrCodeTemplate::where('company_id', $user->person->company_id)->find($id)`, so a template outside the auth user's own company yields 404 (no partner-tree walk). The `get()` method takes a plain `Request` — no Form Request, the `{id}` segment is constrained to `[0-9]+` at the route.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — template ID; route-constrained to digits.\n\n**Response** — `{ data: ... }` envelope. The controller passes an array, so the envelope wraps these keys directly:\n- `status` (string) — `\"success\"`.\n- `data` (object) — the `QrCodeTemplate` model, including `id`, `name`, `description`, `store_id`, `template_type`, `design_html`, `design_settings`, `design_html_back`, `design_settings_back`, `qr_code_id`, `company_id`, `created_at`, `updated_at`, and eager-loaded `store` (`id`, `name`).\n- `message` (string) — `\"QR code template retrieved successfully\"`."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 10,\n      \"name\": \"Standard A6\",\n      \"store_id\": 8748,\n      \"template_type\": \"standard\",\n      \"design_html\": \"<p>Example content</p>\",\n      \"design_settings\": {},\n      \"design_html_back\": null,\n      \"design_settings_back\": null,\n      \"qr_code_id\": 1,\n      \"company_id\": 456,\n      \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n      \"updated_at\": \"2026-06-15T10:30:00.000000Z\",\n      \"store\": { \"id\": 8748, \"name\": \"Downtown Branch\" }\n    },\n    \"message\": \"QR code template retrieved successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "9.B.3 Update QR template",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"id\": 10,\n    \"name\": \"Updated Standard A6\",\n    \"template_type\": \"standard\",\n    \"design_html\": \"<p>Scan to leave us a review</p>\",\n    \"design_settings\": {\n        \"primaryColor\": \"#222\"\n    },\n    \"design_html_back\": \"<p>Thank you</p>\",\n    \"design_settings_back\": {\n        \"logo\": true\n    },\n    \"qr_code_id\": 123\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/template/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "template",
                    "update"
                  ]
                },
                "description": "Updates an existing QR code template (identified by `id` in the body), filling the validated fields and saving; requires that the template's store has a review-solicitation feature enabled.\n\n**Auth:** Account — `auth:api`; the template is loaded via `QrCodeTemplate::where('company_id', $user->person->company_id)->find($id)` (own company only, 404 otherwise). Before saving, the store's active plan must have `review_solicitation_email` or `review_solicitation_sms` enabled — otherwise a 400 is returned. The route is `POST .../template/update`; `id` comes from the body (the optional `{id}` method arg is unused by this route).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `id` | integer | optional (effectively required) | Template ID; must exist in `qr_code_templates`. Resolved from body; without it the lookup 404s. |\n| `name` | string | required | Max 255. |\n| `template_type` | string | required | One of `standard`, `hangtag`, `custom`. |\n| `design_html` | string | optional | Nullable. |\n| `design_settings` | mixed | optional | Nullable; JSON string is decoded to an object/array (invalid JSON → `[]`). |\n| `design_settings_back` | mixed | optional | Nullable; same JSON decoding as `design_settings`. |\n| `design_html_back` | string | optional | Nullable. |\n| `qr_code_id` | integer | optional | Nullable; must exist in `qr_codes`. |\n\n**Response** — `{ data: ... }` envelope wrapping the controller array:\n- `status` (string) — `\"success\"`.\n- `data` (object) — the updated `QrCodeTemplate` model (with eager-loaded `store` id/name).\n- `message` (string) — `\"QR code template updated successfully\"`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 10,\n      \"name\": \"Updated Standard A6\",\n      \"store_id\": 8748,\n      \"template_type\": \"standard\",\n      \"design_html\": \"<p>Scan to leave us a review</p>\",\n      \"design_settings\": { \"primaryColor\": \"#222\" },\n      \"design_html_back\": \"<p>Thank you</p>\",\n      \"design_settings_back\": { \"logo\": true },\n      \"qr_code_id\": 123,\n      \"company_id\": 456,\n      \"store\": { \"id\": 8748, \"name\": \"Downtown Branch\" }\n    },\n    \"message\": \"QR code template updated successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "9.B.4 Delete QR template",
              "request": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/template/delete",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "template",
                    "delete"
                  ]
                },
                "description": "Soft-deletes a QR code template identified by the `id` query/body parameter; 404s if it does not belong to the authenticated user's company. Writes a `QR_CODE_TEMPLATE_DELETED` audit log on success.\n\n**Auth:** Account — `auth:api`; the template is loaded via `QrCodeTemplate::where('company_id', $user->person->company_id)->find($id)` (own company only). The Form Request requires a valid `id`; the controller reads `$request->input('id')`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `id` (integer, required) — template ID; must exist in `qr_code_templates`.\n\n**Response** — `{ data: ... }` envelope wrapping the controller array:\n- `status` (string) — `\"success\"`.\n- `message` (string) — `\"QR code template deleted successfully\"`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"QR code template deleted successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "9.B.5 Duplicate QR template",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/template/{{templateId}}/duplicate",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "template",
                    "{{templateId}}",
                    "duplicate"
                  ]
                },
                "description": "Duplicates an existing QR code template (by path ID) into a new row under the same store, auto-generating a unique \"Copy of …\" name; the duplicate counts as a fresh create and is metered against the store's `review_qr_code` plan allocation.\n\n**Auth:** Account — `auth:api`; the original is loaded via `QrCodeTemplate::where('company_id', $user->person->company_id)->find($id)` (own company only, 404 otherwise). Before creating the copy, `enforceQrCodeTemplateLimit()` checks the store's `review_qr_code` allocation — a disabled feature or exhausted hard-stop cap throws `FeatureLimitException` (HTTP 402). The `{id}` route segment is digit-constrained and supplied to the Form Request via `validationData()`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — template ID to duplicate; must exist in `qr_code_templates`.\n\n**Response** — `{ data: ... }` envelope (HTTP 201) wrapping the controller array:\n- `status` (string) — `\"success\"`.\n- `data` (object) — the new `QrCodeTemplate` copy, carrying `name` (`Copy of <original>`, de-duplicated with ` (n)` suffix), `store_id`, `company_id`, `template_type`, `design_html`, `design_settings`, `design_html_back`, `design_settings_back`, `qr_code_id`, plus `id`/`created_at`/`updated_at`.\n- `message` (string) — `\"QR code template duplicated successfully\"`."
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 12,\n      \"name\": \"Copy of Standard A6 Template\",\n      \"store_id\": 8748,\n      \"company_id\": 456,\n      \"template_type\": \"standard\",\n      \"design_html\": \"<p>Example content</p>\",\n      \"design_settings\": {},\n      \"design_html_back\": null,\n      \"design_settings_back\": null,\n      \"qr_code_id\": 1,\n      \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n      \"updated_at\": \"2026-06-15T10:30:00.000000Z\"\n    },\n    \"message\": \"QR code template duplicated successfully\"\n  }\n}"
                }
              ]
            },
            {
              "name": "9.B.6 Create QR template",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Standard A6 Template\",\n  \"store_id\": {{storeId}},\n  \"template_type\": \"standard\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/template/create",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "template",
                    "create"
                  ]
                },
                "description": "Creates a QR code template for the authenticated brand. Template names must be unique within the store. Enforces the store's `review_qr_code` plan allocation before saving — a hard-stop / disabled feature raises `FeatureLimitException` (HTTP 402) via the global handler; on success the usage is recorded. Writes a `QR_CODE_TEMPLATE_CREATED` audit event.\n\n## Auth\n- Bearer — **account** (brand) persona (`auth:api`). `store_id` validated by `BelongsToAuthCompany`; the template is stamped with the caller's `company_id`.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `name` | string | Required | Max 255. Unique per `store_id` (soft-deleted rows excluded). |\n| `store_id` | integer | Required | `exists:store,id` + must belong to the auth company. |\n| `template_type` | string | Required | `standard`, `hangtag` or `custom`. |\n| `design_html` | string | Optional | Front design markup. |\n| `design_settings` | object | Optional | Front design settings (JSON; string is decoded). |\n| `design_html_back` | string | Optional | Back design markup. |\n| `design_settings_back` | object | Optional | Back design settings (JSON; string is decoded). |\n| `qr_code_id` | integer | Optional | `exists:qr_codes,id` — link the template to an existing QR code. |\n\n## Behaviour\n- 201 (`createdResponse`). Body is double-nested: `{ \"data\": { \"data\": { ...QrCodeTemplate }, \"message\": \"QR code template created successfully\" } }`. The model appends `unique_code`.\n- 402 QR-template feature hard-stop / disabled (`FeatureLimitException`) · 422 validation.\n\n✅ live ([routes/api_v1.php:972](routes/api_v1.php#L972) → `QRCodeTemplateController@create`)"
              },
              "response": [
                {
                  "name": "201 Created",
                  "status": "Created",
                  "code": 201,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": {\n      \"id\": 11,\n      \"store_id\": 26,\n      \"company_id\": 456,\n      \"name\": \"Standard A6 Template\",\n      \"description\": null,\n      \"qr_code_id\": 123,\n      \"template_type\": \"standard\",\n      \"design_settings\": null,\n      \"design_html\": \"<div class=\\\"qr-card\\\">Scan to review</div>\",\n      \"design_settings_back\": null,\n      \"design_html_back\": null,\n      \"metadata\": null,\n      \"created_at\": \"2026-07-09T10:00:00.000000Z\",\n      \"updated_at\": \"2026-07-09T10:00:00.000000Z\",\n      \"unique_code\": \"e5F6g7h8\"\n    },\n    \"message\": \"QR code template created successfully\"\n  }\n}"
                },
                {
                  "name": "402 Payment Required",
                  "status": "Payment Required",
                  "code": 402,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"You have reached your QR template limit for this store.\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Template name already exists.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"name\": [\n      \"Template name already exists.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Reusable QR templates — design / colours / logo. Brands roll their own."
        },
        {
          "name": "§ B.9.D QR public scan tracking",
          "item": [
            {
              "name": "9.D.1 Public scan tracking",
              "request": {
                "auth": {
                  "type": "noauth"
                },
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/qr-codes/abc-unique-code/scan",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "abc-unique-code",
                    "scan"
                  ]
                },
                "description": "Public, unauthenticated endpoint hit when an end customer scans a QR code. Resolves the `unique_code` to a QrCode (falling back to a QrCodeTemplate, then its parent QrCode), records a scan-history row (IP, user-agent, referer, optional geo/device fields), bumps `last_scanned_at`, and returns the destination URL.\n\n**Auth:** Neither persona — no auth middleware. Registered outside the `auth:api` group under `throttle:30,1`. Anyone with the code may call it.\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\n- Path: `unique_code` (string, required) — unique code of a QrCode or QrCodeTemplate. In the collection example this is the literal `abc-unique-code`.\n- Body (untyped `Request`, all optional; only included fields are persisted):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| latitude | number | optional | stored only if both latitude and longitude are present |\n| longitude | number | optional | stored only if both latitude and longitude are present |\n| device_type | string | optional | — |\n| browser | string | optional | — |\n| os | string | optional | — |\n| metadata | object | optional | arbitrary JSON |\n\n**Response** — `{ data: ... }` envelope wrapping an array.\n- `data.data.destination_url` (string) — resolved via `getDestinationUrl()`.\n- `data.data.qr_code_name` (string)\n- `data.data.scan_count` (int) — total scan-history rows for the QR code.\n- `data.data.last_scanned_at` (datetime, nullable)\n- `data.message` (string) — `\"QR code scanned successfully\"`.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"latitude\": 40.7128,\n    \"longitude\": -74.006,\n    \"device_type\": \"mobile\",\n    \"browser\": \"Chrome\",\n    \"os\": \"iOS\",\n    \"metadata\": {\n        \"campaign\": \"summer\"\n    }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "202 Accepted",
                  "status": "Accepted",
                  "code": 202,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": {\n      \"destination_url\": \"https://example.com/lp/1?store_id=8748\",\n      \"qr_code_name\": \"Lobby\",\n      \"scan_count\": 10,\n      \"last_scanned_at\": \"2026-06-26T10:30:00.000000Z\"\n    },\n    \"message\": \"QR code scanned successfully\"\n  }\n}"
                }
              ]
            }
          ],
          "description": "Public scan endpoint — receives the in-person QR scan event and pings the review-request flow."
        },
        {
          "name": "§ B.12 Account — FAQ, images & widgets",
          "item": [
            {
              "name": "List child accounts",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/child-accounts",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "child-accounts"
                  ]
                },
                "description": "Returns the immediate child companies (brands/locations) directly under the authenticated user's own company, formatted as value/label option pairs for dropdowns.\n\n**Auth:** Both — `auth:api`. Role-agnostic `account` prefix: the target company is always the *current user's* company (`$user->person->company`); children are resolved as `Company::where('company_id', $company->id)` (direct one-level children only, not a recursive tree walk). If the user has brand assignments (`user.assign_brand` set), results are further restricted to the ids returned by `$user->assignBrands()`. The persona only differs in reachable scope: a partner user sees its brand/location children, a brand user sees its own children — the same single query applies to both.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- No path, query, or body params. The backing `ChildAccountsRequest` declares no validation rules.\n\n**Response** — `{ data: [...] }` envelope; an array of option objects from the select.\n- `value` (integer) — the child company id (aliased from `company.id`).\n- `label` (string) — the child company `name`; when the child has `bundle_id = 1` (a partner row) the label is suffixed with `\" (Partner)\"` via SQL `CASE`.\n\nNote: the OpenAPI attribute on the method advertises a `client_account_id` field, but the actual query `select` only returns `value` and `label` — `client_account_id` is NOT in the response.\n\nReturns `404` (`notFoundResponse 'Company not found.'`) if the authenticated user has no associated company."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    { \"value\": 12, \"label\": \"Acme Plumbing (Partner)\" },\n    { \"value\": 34, \"label\": \"Acme Plumbing - North Brand\" },\n    { \"value\": 35, \"label\": \"Acme Plumbing - South Brand\" }\n  ]\n}"
                },
                {
                  "name": "404 Company not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [],\n  \"message\": \"Company not found.\"\n}"
                }
              ]
            },
            {
              "name": "Overall ranking by location (last 12 months)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/dashboard/get-overall-ranking-bylocation-last12months?store_id={{storeId}}&review_site_id=5&range_start=2025-06-01 00:00:00&range_end=2026-05-31 23:59:59",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "dashboard",
                    "get-overall-ranking-bylocation-last12months"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}",
                      "description": "Optional. Restrict ranking to a single store the auth company owns. Omit to rank all of the user's stores.",
                      "disabled": true
                    },
                    {
                      "key": "review_site_id",
                      "value": "5",
                      "description": "Optional. Filter reviews to one review site (review_sites.id).",
                      "disabled": true
                    },
                    {
                      "key": "range_start",
                      "value": "2025-06-01 00:00:00",
                      "description": "Optional. Window start, format Y-m-d H:i:s. Defaults to start of month 13 months ago.",
                      "disabled": true
                    },
                    {
                      "key": "range_end",
                      "value": "2026-05-31 23:59:59",
                      "description": "Optional. Window end, format Y-m-d H:i:s. Must be >= range_start. Defaults to end of last month.",
                      "disabled": true
                    }
                  ]
                },
                "description": "Overall location ranking for the selected window (default last 12 months): up to 10 store locations ranked and graded by review count, response rate, and average rating (bridge-aware for hipages stores). Identical route to idx 249 — the only difference is this variant passes `range_start`/`range_end` as full `Y-m-d H:i:s` datetimes, which is exactly what the form request requires.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic; no `EnsureHipagesPartner`). Scope is the authenticated user's own company tree: if `store_id` is supplied it is validated by the `BelongsToAuthCompany` rule (must be in the caller's hierarchy); otherwise all stores under the user are resolved via `Store::getStoreListByUser` (which itself honours the user's `assign_location`/`assign_brand` restrictions).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must exist and belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `review_site_id` (integer, optional) — filter to one platform (e.g. `5`); must exist in `review_sites`.\n- Query: `range_start` (string `Y-m-d H:i:s`, optional) — defaults to start of 13 months ago. NOTE: this endpoint requires the full datetime format `Y-m-d H:i:s` (unlike most other dashboard endpoints which use `Y-m-d`).\n- Query: `range_end` (string `Y-m-d H:i:s`, optional) — must be ≥ `range_start`; defaults to end of last month.\n\n**Response** — `{ data: [...] }` envelope; an array of up to 10 ranked locations (empty array when no qualifying reviews).\n- `id` (string) — `loc_<store id>`.\n- `final_grade` (string) — overall letter grade (e.g. `A+`, `N/A`).\n- `name` (string) — `\"<store name> - <storeid>\"`.\n- `avgRating` (string) — average rating formatted to 2 decimals (e.g. `\"0.00\"`).\n- `review_count` (integer) — qualifying review count (bridge-unified for hipages stores).\n- `response_count` (string) — `\"<count> (<percent>%)\"`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"id\": \"loc_101\",\n      \"final_grade\": \"A\",\n      \"name\": \"North Branch - NB01\",\n      \"avgRating\": \"4.62\",\n      \"review_count\": 248,\n      \"response_count\": \"231 (93%)\"\n    },\n    {\n      \"id\": \"loc_102\",\n      \"final_grade\": \"B\",\n      \"name\": \"South Branch - SB02\",\n      \"avgRating\": \"4.18\",\n      \"review_count\": 173,\n      \"response_count\": \"140 (81%)\"\n    }\n  ]\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The range end must be greater than or equal to range start.\",\n  \"errors\": {\n    \"range_end\": [\"The range end must be greater than or equal to range start.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Get FAQ list",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/faq/get?company_id={{brandId}}&type=landing_page&status=active",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "faq",
                    "get"
                  ],
                  "query": [
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "description": "Required. Company whose FAQs to fetch (exists:company,id)."
                    },
                    {
                      "key": "type",
                      "value": "landing_page",
                      "description": "Optional filter. One of landing_page | widget.",
                      "disabled": true
                    },
                    {
                      "key": "status",
                      "value": "active",
                      "description": "Optional filter. One of active | inactive.",
                      "disabled": true
                    }
                  ]
                },
                "description": "Returns the FAQ entries for a company, optionally filtered by `type` and `status`.\n\n**Auth:** Both — `auth:api` bearer token. Target company is taken straight from the `company_id` query param (validated `exists:company,id`); there is no `BelongsToAuthCompany` / partner-tree ownership check, so any authenticated Partner or Account user can read any company's FAQs by id. Persona only differs in which `company_id` the caller would naturally pass.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `company_id` (integer, required) — must exist in `company` table.\n- Query: `type` (string, optional) — one of `landing_page`, `widget`; filters when present.\n- Query: `status` (string, optional) — one of `active`, `inactive`; filters when present.\n\n**Response** — `{ data: ... }` envelope. Note the controller passes its own `status`/`data` array into `successResponse`, producing a **nested** envelope.\n- `data.status` (string) — literal `\"success\"`.\n- `data.data` (array) — list of raw `Faq` Eloquent models (SoftDeletes + timestamps). Each item: `id`, `company_id`, `question`, `answer`, `status`, `type`, `created_at`, `updated_at`, `deleted_at`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"id\": 1,\n        \"company_id\": 34,\n        \"question\": \"How long does delivery take?\",\n        \"answer\": \"Most orders arrive within 3-5 business days.\",\n        \"status\": \"active\",\n        \"type\": \"landing_page\",\n        \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n        \"updated_at\": \"2026-06-15T10:30:00.000000Z\"\n      }\n    ]\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The company id field is required.\",\n  \"errors\": {\n    \"company_id\": [\"The company id field is required.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Get how-it-works blocks",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/faq/get-block?company_id={{brandId}}&type=landing_page",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "faq",
                    "get-block"
                  ],
                  "query": [
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "description": "Required. Company whose blocks to fetch (exists:company,id)."
                    },
                    {
                      "key": "type",
                      "value": "landing_page",
                      "description": "Optional filter. One of landing_page | widget.",
                      "disabled": true
                    }
                  ]
                },
                "description": "Returns the 'how it works' blocks (stored in `rs_blocks`) for a company, filtered by `type`.\n\n**Auth:** Both — `auth:api` bearer token. Target company comes from the `company_id` query param (`exists:company,id`); no ownership/partner-tree scoping, so any authenticated Partner or Account user can read any company's blocks. Persona only differs in which `company_id` is passed.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `company_id` (integer, required) — must exist in `company` table.\n- Query: `type` (string, optional) — one of `landing_page`, `widget`. Note: the controller filters `where('type', $type)`; if `type` is omitted it filters on `type = null`, so pass `type` to get rows back.\n\n**Response** — `{ data: ... }` envelope (nested: controller wraps its own `status`/`data` array).\n- `data.status` (string) — literal `\"success\"`.\n- `data.data` (array) — list of raw `RsBlock` Eloquent models (SoftDeletes + timestamps). Each item: `id`, `company_id`, `type`, `content`, `image_url`, `image_direction`, `status`, `created_at`, `updated_at`, `deleted_at`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"id\": 7,\n        \"company_id\": 34,\n        \"type\": \"landing_page\",\n        \"content\": \"<p>Tell us about your project</p>\",\n        \"image_url\": \"https://s3.amazonaws.com/bucket/how-it-works/65f2a1.png\",\n        \"image_direction\": \"left\",\n        \"status\": \"active\"\n      }\n    ]\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The company id field is required.\",\n  \"errors\": {\n    \"company_id\": [\"The company id field is required.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Get testimonials",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/faq/get-testimonials?company_id={{brandId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "faq",
                    "get-testimonials"
                  ],
                  "query": [
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "description": "Required. Company whose testimonials to fetch (exists:company,id)."
                    }
                  ]
                },
                "description": "Returns the testimonials configured for a company (rows in `rs_blocks` with `type = testimonials`).\n\n**Auth:** Both — `auth:api` bearer token. Target company comes from the `company_id` query param (`exists:company,id`); no ownership/partner-tree scoping, so any authenticated Partner or Account user can read any company's testimonials. Persona only differs in which `company_id` is passed.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `company_id` (integer, required) — must exist in `company` table.\n\n**Response** — `{ data: ... }` envelope (nested: controller wraps its own `status`/`data` array).\n- `data.status` (string) — literal `\"success\"`.\n- `data.data` (array) — list of raw `RsBlock` models filtered to `type = \"testimonials\"`. Each item: `id`, `company_id`, `type` (`\"testimonials\"`), `content`, `image_url`, `image_direction`, `status`, `created_at`, `updated_at`, `deleted_at`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"id\": 21,\n        \"company_id\": 34,\n        \"type\": \"testimonials\",\n        \"content\": \"<p>Fantastic service, highly recommended!</p>\",\n        \"status\": \"active\"\n      }\n    ]\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The company id field is required.\",\n  \"errors\": {\n    \"company_id\": [\"The company id field is required.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Save how-it-works block",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/faq/block",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "faq",
                    "block"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 42,\n    \"type\": \"landing_page\",\n    \"howItWorks\": [\n        {\n            \"content\": \"<p>Step one: leave a review.</p>\",\n            \"image\": \"https://example.com/existing.png\",\n            \"imageDirection\": \"left\"\n        },\n        {\n            \"content\": \"<p>Step two: get a response.</p>\",\n            \"image\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==\",\n            \"imageDirection\": \"right\"\n        }\n    ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Replaces the 'how it works' blocks for a company. Deletes all existing `rs_blocks` rows for the given `company_id` + `type`, then inserts the supplied blocks. Each block's `image` may be an existing URL (stored as-is) or a `data:image/...;base64,...` data URI, which is decoded and uploaded to S3 (`how-it-works/<uniqid>.<ext>`), with the resulting public URL stored as `image_url`.\n\n**Auth:** Both — `auth:api` bearer token. Target company comes from the `company_id` body field (`exists:company,id`); no ownership/partner-tree scoping, so any authenticated Partner or Account user can write blocks for any company id. Persona only differs in which `company_id` is passed.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | required | Must exist in `company` table. |\n| `type` | string | optional (nullable) | One of `landing_page`, `widget`. Used both for the pre-delete scope and stored on each row; if omitted, rows are deleted/saved with `type = null`. |\n| `howItWorks` | array | required | Full replacement set of blocks. |\n| `howItWorks.*.content` | string | required | HTML content of the block. |\n| `howItWorks.*.image` | string | required | Existing URL (stored verbatim) or a base64 `data:image/...` URI (decoded + uploaded to S3). |\n| `howItWorks.*.imageDirection` | string | required | One of `left`, `right`. |\n\n**Response** — `{ data: ... }` envelope (nested). No persisted rows are returned.\n- `data.status` (string) — literal `\"success\"`.\n- `data.message` (string) — `\"Block content saved successfully\"`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Block content saved successfully\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The how it works field is required.\",\n  \"errors\": {\n    \"howItWorks\": [\"The how it works field is required.\"],\n    \"howItWorks.0.imageDirection\": [\"The selected how it works.0.image direction is invalid.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Save FAQ entries",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/faq/save",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "faq",
                    "save"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 42,\n    \"type\": \"landing_page\",\n    \"faqs\": [\n        {\n            \"question\": \"How do I leave a review?\",\n            \"answer\": \"Click the link in the email we sent you.\",\n            \"status\": \"active\"\n        },\n        {\n            \"question\": \"Can I edit my review?\",\n            \"answer\": \"Contact support to make changes.\",\n            \"status\": \"inactive\"\n        }\n    ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Replaces the FAQ entries for a company. Within a DB transaction it deletes all existing `faqs` rows for the given `company_id` + `type`, then inserts the supplied FAQ entries.\n\n**Auth:** Both — `auth:api` bearer token. Target company comes from the `company_id` body field (`exists:company,id`); no ownership/partner-tree scoping, so any authenticated Partner or Account user can write FAQs for any company id. Persona only differs in which `company_id` is passed.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | required | Must exist in `company` table. |\n| `type` | string | optional (nullable) | One of `landing_page`, `widget`. Scopes the pre-delete and is stored on each row; if omitted, rows are deleted/saved with `type = null`. |\n| `faqs` | array | required | Full replacement set of FAQ entries. |\n| `faqs.*.question` | string | required | FAQ question text. |\n| `faqs.*.answer` | string | required | FAQ answer text. |\n| `faqs.*.status` | string | required | One of `active`, `inactive`. |\n\n**Response** — `{ data: ... }` envelope (nested). The controller echoes back the submitted `faqs` array (not the persisted models, so no `id`/timestamps).\n- `data.status` (string) — literal `\"success\"`.\n- `data.message` (string) — `\"FAQ saved successfully\"`.\n- `data.data` (array) — the `faqs` array exactly as submitted."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"question\": \"How long does delivery take?\",\n        \"answer\": \"Most orders arrive within 3-5 business days.\",\n        \"status\": \"active\"\n      },\n      {\n        \"question\": \"Do you offer refunds?\",\n        \"answer\": \"Yes, within 30 days of purchase.\",\n        \"status\": \"inactive\"\n      }\n    ],\n    \"message\": \"FAQ saved successfully\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The faqs field is required.\",\n  \"errors\": {\n    \"faqs\": [\"The faqs field is required.\"],\n    \"faqs.0.status\": [\"The selected faqs.0.status is invalid.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Save testimonials",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/faq/testimonials",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "faq",
                    "testimonials"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 42,\n    \"testimonials\": [\n        {\n            \"content\": \"<p>Great service, highly recommend!</p>\"\n        },\n        {\n            \"content\": \"<p>The team was fantastic.</p>\"\n        }\n    ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Replaces the testimonials for a company. Within a DB transaction it deletes all existing `rs_blocks` rows with `type = testimonials` for the given `company_id`, then inserts the supplied testimonials (each saved with `type = \"testimonials\"`, `status = \"active\"`).\n\n**Auth:** Both — `auth:api` bearer token. Target company comes from the `company_id` body field (`exists:company,id`); no ownership/partner-tree scoping, so any authenticated Partner or Account user can write testimonials for any company id. Persona only differs in which `company_id` is passed.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | required | Must exist in `company` table. |\n| `testimonials` | array | required | Full replacement set of testimonials. |\n| `testimonials.*.content` | string | required | HTML content of the testimonial. (No `type`/`status` accepted — both are set server-side.) |\n\n**Response** — `{ data: ... }` envelope (nested). No persisted rows returned.\n- `data.status` (string) — literal `\"success\"`.\n- `data.message` (string) — `\"Testimonials saved successfully\"`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Testimonials saved successfully\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The testimonials field is required.\",\n  \"errors\": {\n    \"testimonials\": [\"The testimonials field is required.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Get images",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/images/get?company_id={{brandId}}&type=partners&per_page=10",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "images",
                    "get"
                  ],
                  "query": [
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "description": "Required. Company that owns the images (exists:company,id) and must belong to the auth company."
                    },
                    {
                      "key": "type",
                      "value": "partners",
                      "description": "Required. One of partners | campaign. campaign returns a paginated ImagesResource collection."
                    },
                    {
                      "key": "per_page",
                      "value": "10",
                      "description": "Optional. Page size, only used when type=campaign (default 10).",
                      "disabled": true
                    }
                  ]
                },
                "description": "Returns the active images uploaded for a company, filtered by `type`. For `type=campaign` the result is a paginated `ImagesResource` collection (with pagination meta); for `type=partners` it returns a flat (non-paginated) array, so `per_page` only applies to `campaign`. Each image's `path` is resolved to a full S3 URL.\n\n**Auth:** Both — `auth:api` (no persona middleware). Target company is scoped by the `BelongsToAuthCompany` rule on `company_id`: the value must resolve into the authenticated user's company hierarchy via `Company::isBelongsToParentCompany(authCompanyId, company_id)`. A Partner can reach any company in its tree; an Account is limited to companies in its own hierarchy.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `company_id` (integer, required) — must exist in `company` and belong to the auth company hierarchy.\n- Query: `type` (string, required) — one of `partners`, `campaign`.\n- Query: `per_page` (integer, optional) — defaults to 10; only honored when `type=campaign` (the paginated branch).\n\n**Response** — `{ data: ... }` envelope.\n- For `type=partners`, `data` is an array of image objects:\n  - `id` (integer) — image id.\n  - `name` (string) — sanitized file name.\n  - `type` (string) — image type (`partners`).\n  - `status` (string) — `active`.\n  - `path` (string|null) — full S3 URL, or `null` when no stored path.\n- For `type=campaign`, `data` is a paginated `ImagesResource` collection where each item has the same `id`/`name`/`type`/`status`/`path` shape, plus standard `links`/`meta` pagination keys."
              },
              "response": [
                {
                  "name": "200 OK (partners)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"id\": 5,\n        \"name\": \"partner_logo.png\",\n        \"type\": \"partners\",\n        \"status\": \"active\",\n        \"path\": \"https://s3.amazonaws.com/bucket/account/images/partners/abc.png\"\n      }\n    ]\n  }\n}"
                },
                {
                  "name": "200 OK (campaign — paginated)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"id\": 8,\n      \"name\": \"campaign_banner.png\",\n      \"type\": \"campaign\",\n      \"status\": \"active\",\n      \"path\": \"https://s3.amazonaws.com/bucket/account/images/campaign/xyz.png\"\n    }\n  ],\n  \"links\": {\n    \"first\": \"https://api.example.com/api/v1/account/images/get?page=1\",\n    \"last\": \"https://api.example.com/api/v1/account/images/get?page=1\",\n    \"prev\": null,\n    \"next\": null\n  },\n  \"meta\": {\n    \"current_page\": 1,\n    \"from\": 1,\n    \"last_page\": 1,\n    \"per_page\": 10,\n    \"to\": 1,\n    \"total\": 1\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The type field is required.\",\n  \"errors\": {\n    \"type\": [\"The selected type is invalid.\"],\n    \"company_id\": [\"The company id field is required.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Save images (upload)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/images/save",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "images",
                    "save"
                  ]
                },
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "type": "text",
                      "description": "Required. exists:company,id + must belong to auth company."
                    },
                    {
                      "key": "type",
                      "value": "partners",
                      "type": "text",
                      "description": "Required. One of partners | campaign."
                    },
                    {
                      "key": "status",
                      "value": "active",
                      "type": "text",
                      "description": "Optional. active | inactive (defaults to active in the controller).",
                      "disabled": true
                    },
                    {
                      "key": "images[]",
                      "type": "file",
                      "src": [],
                      "description": "Required. One or more image files. Each: image, mimes:jpeg,png, max 2048 KB."
                    }
                  ]
                },
                "description": "Uploads one or more image files to S3 (under `account/images/{type}`) and creates an `Image` row per file for the company, returning the saved records with public S3 URLs. Sent as `multipart/form-data`.\n\n**Auth:** Both — `auth:api` (no persona middleware). Target company is scoped by the `BelongsToAuthCompany` rule on `company_id`: it must resolve into the authenticated user's company hierarchy via `Company::isBelongsToParentCompany`. A Partner can target any company in its tree; an Account is limited to its own hierarchy.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (multipart/form-data):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | Yes | Must exist in `company` and belong to the auth company hierarchy. |\n| `type` | string | Yes | One of `partners`, `campaign`. |\n| `images` | array (file[]) | Yes | One or more uploaded files. |\n| `images.*` | file | Yes | Each must be an image, mimes `jpeg`/`png`, max 2048 KB. |\n| `status` | string | No | Persisted as-is; controller defaults to `active` when omitted (not validated). |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Logos saved successfully!`.\n- `data` (array) — one object per saved image:\n  - `id` (integer) — new image id.\n  - `name` (string) — sanitized original filename (`[^\\w.\\-]` replaced with `_`, truncated to 100 chars) plus guessed extension.\n  - `type` (string) — echoes request `type`.\n  - `status` (string) — request `status` or `active`.\n  - `path` (string) — full S3 URL of the stored file."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Logos saved successfully!\",\n    \"data\": [\n      {\n        \"id\": 12,\n        \"name\": \"partner_logo.png\",\n        \"type\": \"partners\",\n        \"status\": \"active\",\n        \"path\": \"https://s3.amazonaws.com/bucket/account/images/partners/AbC123.png\"\n      }\n    ]\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The images field is required.\",\n  \"errors\": {\n    \"images\": [\"The images field is required.\"],\n    \"images.0\": [\"The images.0 must be a file of type: jpeg, png.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Delete image",
              "request": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/images/delete",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "images",
                    "delete"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"image_id\": 42\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Deletes a stored company image. The matched row is marked `status=inactive` and then soft-deleted (the model uses `SoftDeletes`). Returns a bad-request error if no image matches the `company_id` + `image_id` pair.\n\n**Auth:** Both — `auth:api` (no persona middleware). Target company is scoped by the `BelongsToAuthCompany` rule on `company_id`: it must resolve into the authenticated user's company hierarchy via `Company::isBelongsToParentCompany`. The image is additionally constrained to the given `company_id`, so a caller can only delete images belonging to a company in their own tree.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (JSON):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | Yes | Must exist in `company` and belong to the auth company hierarchy. |\n| `image_id` | integer | Yes | Must exist in `images`; the image must also belong to `company_id` or the request returns `Image not found!`. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Image has been deleted successfully!`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Image has been deleted successfully!\"\n  }\n}"
                },
                {
                  "name": "400 Image not found",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [],\n  \"message\": \"Image not found!\"\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The image id field is required.\",\n  \"errors\": {\n    \"image_id\": [\"The selected image id is invalid.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "List widgets",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/widget/list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "widget",
                    "list"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"limit\": 10,\n    \"page\": 1,\n    \"sort_field\": \"created_at\",\n    \"sort_by\": \"DESC\",\n    \"search_field\": \"widget_title\",\n    \"search_value\": \"Feed\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Returns a paginated list of widgets scoped to a single store (`store_id`) or a brand company and its child companies (`company_id`), joined to widget type, store, and company-account names, with optional search/sort.\n\n**Auth:** Both — `auth:api` in the role-agnostic `account/*` group (route name `api.brand.widget.list`); no HiPages gate. Target is scoped by `BelongsToAuthCompany` on `store_id`/`company_id`, which walks the auth user's company hierarchy (`Auth::user()->person->company_id` → `Company::isBelongsToParentCompany`). When `store_id` is absent, the controller resolves `company_id`: a brand-bundle company (`bundle_id` = BRAND) lists its own widgets, otherwise it lists widgets of all child companies (`childids`). A partner reaches a wider tree than a brand user, but the param shape is identical.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | required_without `company_id` | Must `exists:store,id` and belong to the auth company hierarchy. |\n| `company_id` | integer | required_without `store_id` | Must `exists:company,id` and belong to the auth company hierarchy. |\n| `limit` | integer | optional | 1–100; default 10 (per-page size). |\n| `page` | integer | optional | min 1. |\n| `sort_field` | string | optional | One of `widget_title,created_at,updated_at,store_name,unique_id,widget_type,account_name`. |\n| `sort_by` | string | optional | One of `asc,ASC,desc,DESC`; default DESC. |\n| `search_field` | string | optional | Same enum as `sort_field`. `store_name=all` filters widgets with NULL `store_id`. |\n| `search_value` | string | optional | Like-matched (or date-matched for created_at/updated_at). |\n\n**Response** — raw Laravel paginator (the controller returns `->paginate()` directly, NOT the `{ data: ... }` success wrapper); `data` is the array of widget rows.\n- `current_page` (integer)\n- `data[].id` (integer)\n- `data[].store_id` (integer, nullable)\n- `data[].unique_id` (string) — hashid widget code\n- `data[].created_at` (datetime)\n- `data[].widget_title` (string, nullable)\n- `data[].widget_type` (string) — `widget_types.name`\n- `data[].widget_type_id` (integer) — `widget_types.id`\n- `data[].account_name` (string, nullable) — `company.name`\n- `data[].store_name` (string, nullable) — store name, or `name - storeid` when a storeid exists\n- `per_page` / `total` / `last_page` / `links` etc. (standard paginator meta)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"current_page\": 1,\n  \"data\": [\n    {\n      \"id\": 14,\n      \"store_id\": 102,\n      \"unique_id\": \"abc123\",\n      \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n      \"widget_title\": \"Homepage Feed Widget\",\n      \"widget_type\": \"Feed Widget\",\n      \"widget_type_id\": 1,\n      \"account_name\": \"North Brand\",\n      \"store_name\": \"North Branch - NB01\"\n    }\n  ],\n  \"first_page_url\": \"https://api.example.com/api/v1/account/widget/list?page=1\",\n  \"from\": 1,\n  \"last_page\": 1,\n  \"per_page\": 10,\n  \"to\": 1,\n  \"total\": 1\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The store id field is required when company id is not present.\",\n  \"errors\": {\n    \"store_id\": [\"The store id field is required when company id is not present.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Create or update widget",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/widget/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "widget",
                    "update"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"store_id\": 12,\n    \"widget_type_id\": 1,\n    \"widget_title\": \"My Feed Widget\",\n    \"widget_options\": {\n        \"numberOfReviews\": 3,\n        \"reviewSiteValue\": {\n            \"value\": 5\n        }\n    }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Creates a new widget, or updates an existing one when a valid `unique_id` is supplied. Requires the location to have an active widget add-on; a new widget gets a hashid `unique_id` generated from its id after the first save.\n\n**Auth:** Both — `auth:api` in the role-agnostic `account/*` group (route name `api.brand.widget.update`); no HiPages gate. `store_id` is validated by `BelongsToAuthCompany` (auth user's company-hierarchy walk). Note: despite the controller's OpenAPI annotation listing `company_id` as an alternative, the actual `WidgetRequest` makes **`store_id` required** for every persona — `company_id` is NOT a request field here and is only set from `$validated['company_id']` (always null in practice). Both personas use the same params; a partner can target stores anywhere in its tree, a brand user only its own.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | required | Must `exists:store,id` and pass `BelongsToAuthCompany`. Also used for the active-add-on check (`Widget::hasActiveWidgetAddOn`). |\n| `widget_type_id` | integer | required | Must `exists:widget_types,id`. |\n| `unique_id` | string | optional | When present, validated by `WidgetUniqueIdRule` (hashid decode must resolve to a Widget whose `unique_id` matches); switches the call to update that widget. |\n| `widget_title` | string | optional | max 255. |\n| `widget_options` | array/object | optional | Arbitrary widget config JSON (e.g. `numberOfReviews`, `reviewSiteValue.value`). |\n\n**Response** — `{ data: ... }` envelope (success wrapper), plus feature-limit headers via `withFeatureHeaders(..., REVIEW_WIDGETS)`. Returns 400 (`badRequestResponse`) when the store has no active widget add-on.\n- `message` (string)\n- `status` (string) — `success`\n- `data.unique_id` (string) — the saved widget's hashid code"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"message\": \"Widget has been saved successfully\",\n    \"status\": \"success\",\n    \"data\": { \"unique_id\": \"abc123\" }\n  }\n}"
                },
                {
                  "name": "400 No active widget add-on",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [],\n  \"message\": \"You do not have any active widget add-ons. Please add an add-on to your location in order to access the widget features.\"\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The widget type id field is required.\",\n  \"errors\": {\n    \"widget_type_id\": [\"The widget type id field is required.\"],\n    \"store_id\": [\"The store id field is required.\"]\n  }\n}"
                }
              ]
            },
            {
              "name": "Delete widget",
              "request": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/widget/delete",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "widget",
                    "delete"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"unique_id\": \"abc123\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Permanently deletes a widget identified by its `unique_id`.\n\n**Auth:** Both — `auth:api` in the role-agnostic `account/*` group (route name `api.brand.widget.delete`); no HiPages gate. `unique_id` is validated only with `exists:widgets,unique_id` — there is NO `BelongsToAuthCompany` ownership check on delete, so any authenticated user (partner or account) who knows a valid `unique_id` can delete that widget. Identical params for both personas.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `unique_id` | string | required | Must `exists:widgets,unique_id`. Sent in the DELETE request body. |\n\n**Response** — `{ data: ... }` envelope (success wrapper).\n- `message` (string)\n- `status` (string) — `success`"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"message\": \"Widget has been deleted successfully\",\n    \"status\": \"success\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected unique id is invalid.\",\n  \"errors\": {\n    \"unique_id\": [\"The selected unique id is invalid.\"]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Account self-service: company FAQ blocks, testimonials, gallery images, and review widgets."
        },
        {
          "name": "§ B.13 Account — White-label & branding",
          "item": [
            {
              "name": "White Label - Resolve branding (public/login)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 5\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Resolves white-label branding (logo, login logo, favicon, title, white-label URL, status) and saved theme options for a company so the SPA can brand the login screen before authentication.\n\n**Auth:** Both — public route under `throttle:30,1` (no `auth:api`, `authorize()` returns `true`). Target company is scoped either by an explicit `company_id` in the body, or — when omitted — by matching the request `Referer` host (`extractDomainParts` → subdomain + domain) against an `rs_white_labels` row (`domain_prefix` + `domain_name`) via `Company::whereHas('whiteLabels', ...)`. No partner-tree walk or `BelongsToAuthCompany`. If no company resolves, still HTTP 200 with `status: error`.\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | No | `nullable|integer|exists:company,id`. When omitted, company is resolved from the `Referer` header host. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success` when a company resolved, else `error`.\n- `data.white_label` (object) — from `Company::getWhiteLabel($id, true, true)`; falls back to defaults if no `rs_white_labels` row (walks to parent company if the company has a parent with `bundle_id > 0`):\n  - `logo` (string) — S3 URL or `''`.\n  - `white_url` (string) — `https://`-prefixed white-label URL when status active, else `config('common.APP_FRONTEND_URL')`.\n  - `favicon` (string) — S3 URL or `''`.\n  - `title` (string) — white-label title or `config('app.name')`.\n  - `status` (string) — `active` / `inactive`.\n  - `login_logo` (string) — S3 URL; falls back to `logo` when empty.\n  - `company_id` (integer|null) — resolved white-label company id, else `null`.\n  - `email_templates_enabled` (boolean) — `Company::emailTemplatesSectionEnabled()` flag.\n- `data.theme_options` (string|null) — `RsUserThemeSetting.theme_options` JSON string, or `null`.\n- On failure: `status: error`, `data: []`, `message: \"Company not found\"`."
              },
              "response": [
                {
                  "name": "200 OK (resolved)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"white_label\": {\n        \"logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/logo/mylHBMxvvl8Hdf6zJKr3Xb0QkPnBojcYMOAA2L3d.png\",\n        \"white_url\": \"https://hipages.manage-myreviews.com\",\n        \"favicon\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/favicon/s4XHlK1rwZgNHfRsOnCxPRsm0kYencPuTrWaQrMr.png\",\n        \"title\": \"hipages\",\n        \"status\": \"active\",\n        \"login_logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/login_logo/nuIpzz018FFtHvcBUb4Wpgj4WyUrUZsnmFaL2lnW.png\",\n        \"company_id\": 5,\n        \"email_templates_enabled\": true\n      },\n      \"theme_options\": \"{\\\"mode\\\":\\\"system\\\",\\\"skin\\\":\\\"default\\\",\\\"semiDark\\\":false,\\\"layout\\\":\\\"vertical\\\",\\\"navbarContentWidth\\\":\\\"wide\\\",\\\"contentWidth\\\":\\\"wide\\\",\\\"footerContentWidth\\\":\\\"wide\\\",\\\"primaryColor\\\":\\\"#f1874c\\\"}\"\n    }\n  }\n}"
                },
                {
                  "name": "200 OK (company not found)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"error\",\n    \"data\": [],\n    \"message\": \"Company not found\"\n  }\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected company id is invalid.\",\n  \"errors\": {\n    \"company_id\": [\n      \"The selected company id is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "White Label - Get data (data resolver)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/data",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "data"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Resolves the white-label branding/domain config for a company — looked up by explicit `company_id`, by `referral_code` (resolved to its company), or, if neither is given, inferred from the request `Referer` host (subdomain + domain matched against `RsWhiteLabel.domain_prefix`/`domain_name`). When a record is found it also live-checks the custom subdomain status via AWS Amplify (persisting `active`/`inactive`), pulls the latest theme settings and the partner referral code, and rewrites logo/favicon paths to versioned S3 URLs.\n\n**Auth:** Both — `auth:api` (top-level authenticated group), role-agnostic `account/white-label` prefix. When `company_id` is supplied it is scoped by the `BelongsToAuthCompany` rule (must be in the caller's company hierarchy via `Company::isBelongsToParentCompany`); a partner can therefore resolve any partner/brand/location company beneath it, while an account/brand user is limited to its own subtree. With no `company_id`/`referral_code` the resolver falls back to the `Referer` host and returns whatever public white-label matches, regardless of persona.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (all optional; if none given, falls back to `Referer` host):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | optional | `exists:company,id` + `BelongsToAuthCompany` (must be in caller's hierarchy). |\n| referral_code | string | optional | `exists:referral_codes,code`; resolved to its `company_id` if `company_id` absent. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — always `\"success\"`.\n- `theme_settings` (object|null) — latest `RsUserThemeSetting` row for the resolved company (`id`, `user_id`, `company_id`, `theme_options` JSON string, timestamps), or null if none.\n- `company` (object) — `{ name }` of the resolved company (empty string if unresolved).\n- `data` (object|array) — the `RsWhiteLabel` row spread out (`id`, `company_id`, `domain_name`, `domain_prefix`, `white_url`, `email`, `title`, `status`, timestamps, nested `company` relation) with `logo`/`login_logo`/`mobile_logo`/`favicon` rewritten to versioned S3 URLs (or null), plus `subDomainSetting` (e.g. `\"verified\"` / `\"Domain not found!\"`). Returns an empty array `[]` when no white-label record is found.\n- `instruction_file` (string) — asset URL to the domain-setup PDF.\n- `partner_referral` (string|null) — referral code of the owning partner (only present when a white-label record is found)."
              },
              "response": [
                {
                  "name": "200 OK (found)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"theme_settings\": {\n      \"id\": 1,\n      \"user_id\": 3,\n      \"company_id\": 5,\n      \"theme_options\": \"{\\\"mode\\\":\\\"system\\\",\\\"skin\\\":\\\"default\\\",\\\"primaryColor\\\":\\\"#f1874c\\\"}\",\n      \"deleted_at\": null,\n      \"created_at\": \"2026-06-11T11:53:05.000000Z\",\n      \"updated_at\": \"2026-06-11T11:53:05.000000Z\"\n    },\n    \"company\": {\n      \"name\": \"Yelp partner\"\n    },\n    \"data\": {\n      \"id\": 1,\n      \"company_id\": 5,\n      \"domain_name\": \"manage-myreviews.com\",\n      \"domain_prefix\": \"hipages\",\n      \"white_url\": \"hipages.manage-myreviews.com\",\n      \"email\": null,\n      \"title\": \"hipages\",\n      \"status\": \"active\",\n      \"created_at\": \"2026-06-11T11:52:44.000000Z\",\n      \"updated_at\": \"2026-06-11T11:52:44.000000Z\",\n      \"deleted_at\": null,\n      \"logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/logo/mylHBMxvvl8Hdf6zJKr3Xb0QkPnBojcYMOAA2L3d.png?v=1781608178\",\n      \"login_logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/login_logo/nuIpzz018FFtHvcBUb4Wpgj4WyUrUZsnmFaL2lnW.png?v=1781608178\",\n      \"mobile_logo\": null,\n      \"favicon\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/favicon/s4XHlK1rwZgNHfRsOnCxPRsm0kYencPuTrWaQrMr.png?v=1781608178\",\n      \"subDomainSetting\": \"verified\"\n    },\n    \"instruction_file\": \"https://reputationmanagement-backend.com/assets/sample/RM White Label domain Setup Instructions.pdf\",\n    \"partner_referral\": \"2TGU5VFcN5\"\n  }\n}"
                },
                {
                  "name": "200 OK (no white-label)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"company\": {\n      \"name\": \"Yelp partner\"\n    },\n    \"data\": [],\n    \"instruction_file\": \"https://reputationmanagement-backend.com/assets/sample/RM White Label domain Setup Instructions.pdf\"\n  }\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected company id does not belong to your company hierarchy.\",\n  \"errors\": {\n    \"company_id\": [\n      \"The selected company id does not belong to your company hierarchy.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "White Label - Get (partner get)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/get",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "get"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 5\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Returns the authenticated user's white-label configuration row (logos, favicon, domain prefix/name, sub-domain verification status), theme settings, and the email-templates-enabled flag for editing in the partner UI.\n\n**Auth:** Both — `auth:api` (route lives under the authenticated `account/white-label` group). Target company is scoped by the body `company_id` when `> 0`, otherwise it falls back to the authenticated user's own company via `$request->user()->person?->company_id`. No `BelongsToAuthCompany` rule and no partner-tree walk — any `company_id` that `exists:company,id` is accepted; differs per persona only in the fallback target (own company when `company_id` omitted/0).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | No | `nullable|exists:company,id`. When `<= 0`/omitted, resolves to the authenticated user's `person.company_id`. |\n\n**Response** — `{ data: ... }` envelope. (404 `Company not found` if neither resolves to an existing company.)\n- `status` (string) — `success`.\n- `theme_settings` (object|null) — latest `RsUserThemeSetting` row for the company (present only when a white-label row exists).\n- `company.name` (string) — resolved company name, or `''`.\n- `data` (object) — the spread `RsWhiteLabel` model with S3-resolved asset URLs overlaid; only `email_templates_enabled` is present when no white-label row exists:\n  - all `rs_white_labels` columns (e.g. `id`, `company_id`, `domain_prefix`, `domain_name`, `white_url`, `title`, `status`, ...).\n  - `logo` (string|null) — S3 URL with `?v=<timestamp>` cache-buster, else `null`.\n  - `login_logo` (string|null) — S3 URL, else `null`.\n  - `mobile_logo` (string|null) — S3 URL, else `null`.\n  - `favicon` (string|null) — S3 URL, else `null`.\n  - `subDomainSetting` (string) — `'verified'` when Amplify domain status `AVAILABLE`/active, `'Domain not found!'`, or `''` (the `status` column is re-saved from this lookup).\n  - `email_templates_enabled` (boolean) — `Company::emailTemplatesSectionEnabled()`.\n- `instruction_file` (string) — always `''`.\n- `partner_referral` (string|null) — referral `code` when present, else `null` (only when a white-label row exists)."
              },
              "response": [
                {
                  "name": "200 OK (found)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"theme_settings\": {\n      \"id\": 1,\n      \"user_id\": 3,\n      \"company_id\": 5,\n      \"theme_options\": \"{\\\"mode\\\":\\\"system\\\",\\\"primaryColor\\\":\\\"#f1874c\\\"}\",\n      \"created_at\": \"2026-06-11T11:53:05.000000Z\",\n      \"updated_at\": \"2026-06-11T11:53:05.000000Z\"\n    },\n    \"company\": {\n      \"name\": \"Yelp partner\"\n    },\n    \"data\": {\n      \"id\": 1,\n      \"company_id\": 5,\n      \"domain_name\": \"manage-myreviews.com\",\n      \"domain_prefix\": \"hipages\",\n      \"white_url\": \"hipages.manage-myreviews.com\",\n      \"title\": \"hipages\",\n      \"status\": \"active\",\n      \"logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/logo/abc.png?v=1781608178\",\n      \"login_logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/login_logo/def.png\",\n      \"mobile_logo\": null,\n      \"favicon\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/favicon/ghi.png\",\n      \"subDomainSetting\": \"verified\",\n      \"email_templates_enabled\": true\n    },\n    \"instruction_file\": \"\",\n    \"partner_referral\": null\n  }\n}"
                },
                {
                  "name": "404 Not Found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Company not found\",\n  \"data\": []\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected company id is invalid.\",\n  \"errors\": {\n    \"company_id\": [\n      \"The selected company id is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "White Label - Update (domain + branding upload)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "update"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "formdata": [
                    {
                      "key": "title",
                      "value": "My Reviews",
                      "type": "text",
                      "description": "Optional. Page title. Max 100 chars, no angle brackets."
                    },
                    {
                      "key": "domain_name",
                      "value": "manage-myreviews.com",
                      "type": "text",
                      "description": "Required. Root domain. Max 100 chars, no angle brackets."
                    },
                    {
                      "key": "domain_prefix",
                      "value": "hipages",
                      "type": "text",
                      "description": "Required. Sub-domain prefix. Combined white_url (prefix.domain) must be unique across other companies."
                    },
                    {
                      "key": "logo",
                      "type": "file",
                      "src": [],
                      "description": "Optional. JPEG/PNG image, max 400 KB. Main app logo."
                    },
                    {
                      "key": "login_logo",
                      "type": "file",
                      "src": [],
                      "description": "Optional. JPEG/PNG image, max 400 KB. Login screen logo."
                    },
                    {
                      "key": "mobile_logo",
                      "type": "file",
                      "src": [],
                      "description": "Optional. JPEG/PNG image, max 400 KB. Mobile logo."
                    },
                    {
                      "key": "favicon",
                      "type": "file",
                      "src": [],
                      "description": "Optional. JPEG/PNG/ICO image, max 10 KB. Browser favicon."
                    }
                  ],
                  "raw": "{\n    \"title\": \"Acme Reviews\",\n    \"domain_name\": \"manage-myreviews.com\",\n    \"domain_prefix\": \"acme\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Creates or updates the white-label domain + branding for the authenticated user's own company. Registers the `domain_prefix.domain_name` subdomain with AWS Amplify (setting `status` to `active`/`inactive` from the returned domain status), stores `title`/`domain_name`/`domain_prefix`/`white_url`, and uploads any of the four image files (`favicon`, `logo`, `login_logo`, `mobile_logo`) to S3. Sent as `multipart/form-data`.\n\n**Auth:** Both — `auth:api`, role-agnostic `account/white-label` prefix, but the controller hard-restricts to the caller's own company resolved from `$user->person->company` and only allows `bundle_id` 1 (partner) or 3 (brand). A `bundle_id` 2 (location) user receives a 403 (`\"Sub-partner/Brand user can access this endpoint.\"`). The target is never a parameter — it is always the caller's own company row.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (`multipart/form-data`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| title | string | optional | max:100, no angle brackets (`NameNoAngleBrackets`). |\n| domain_name | string | required | max:100, no angle brackets; lowercased on save. |\n| domain_prefix | string | required | lowercased on save; unique across other companies' `white_url` (`prefix.domain_name`). |\n| favicon | file (image) | optional | jpeg/png/ico, max 10 KB. |\n| logo | file (image) | optional | jpeg/png, max 400 KB. |\n| login_logo | file (image) | optional | jpeg/png, max 400 KB. |\n| mobile_logo | file (image) | optional | jpeg/png, max 400 KB. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `\"success\"`.\n- `message` (string) — `\"White-label data saved successfully!\"`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"White-label data saved successfully!\"\n  }\n}"
                },
                {
                  "name": "403 Forbidden (wrong company type)",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Sub-partner/Brand user can access this endpoint.\"\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"hipages.manage-myreviews.com: White URL already exists\",\n  \"errors\": {\n    \"domain_name\": [\n      \"The domain name field is required.\"\n    ],\n    \"domain_prefix\": [\n      \"hipages.manage-myreviews.com: White URL already exists\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "White Label - Save theme options",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/theme/save",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "theme",
                    "save"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "formdata": [
                    {
                      "key": "user_id",
                      "value": "3",
                      "type": "text",
                      "description": "Required. Owning user id; must exist in users."
                    },
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "type": "text",
                      "description": "Required. Must exist in companies and belong to your company hierarchy."
                    },
                    {
                      "key": "theme_options",
                      "value": "{\"mode\":\"system\",\"skin\":\"default\",\"primaryColor\":\"#f1874c\"}",
                      "type": "text",
                      "description": "Required. JSON-encoded theme options string."
                    },
                    {
                      "key": "logo_image",
                      "type": "file",
                      "src": [],
                      "description": "Optional. JPEG/PNG image, max 400 KB. Uploaded to the white-label logo slot on S3."
                    }
                  ],
                  "raw": "{\n    \"user_id\": 3,\n    \"company_id\": 5,\n    \"theme_options\": \"{\\\"mode\\\":\\\"system\\\",\\\"primaryColor\\\":\\\"#f1874c\\\"}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Saves (creates or updates) the per-company theme options and optionally uploads a theme logo. Upserts the latest `RsUserThemeSetting` for `company_id` with the given `user_id` and `theme_options` JSON; if a `logo_image` file is sent it is stored to S3 and written onto the company's `RsWhiteLabel` row (created if missing). Sent as `multipart/form-data`.\n\n**Auth:** Both — `auth:api`, role-agnostic `account/white-label` prefix. `company_id` is scoped by `BelongsToAuthCompany` (must be in the caller's hierarchy), so a partner can save theme options for any company beneath it while an account/brand user is limited to its own subtree. `user_id` must merely be an existing user (`exists:users,id`), not hierarchy-scoped.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (`multipart/form-data`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| user_id | integer | required | `exists:users,id`. |\n| company_id | integer | required | `exists:company,id` + `BelongsToAuthCompany`. |\n| theme_options | string | required | JSON-encoded theme options string, stored verbatim. |\n| logo_image | file (image) | optional | jpeg/png, max 400 KB; written to the company white-label `logo`. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `\"success\"`.\n- `message` (string) — `\"Theme options saved successfully.\"`.\n- `data` (object) — the saved `RsUserThemeSetting` row (`id`, `user_id`, `company_id`, `theme_options`, timestamps).\n- `white_label` (object) — the company `RsWhiteLabel` row spread out with `logo`/`login_logo`/`mobile_logo`/`favicon` rewritten to S3 URLs (or null); mostly empty fields when no white-label existed and only a logo was uploaded."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Theme options saved successfully.\",\n    \"data\": {\n      \"company_id\": 5,\n      \"user_id\": 3,\n      \"theme_options\": \"{\\\"mode\\\":\\\"system\\\",\\\"skin\\\":\\\"default\\\",\\\"primaryColor\\\":\\\"#f1874c\\\"}\",\n      \"updated_at\": \"2026-06-23T10:00:00.000000Z\",\n      \"created_at\": \"2026-06-23T10:00:00.000000Z\",\n      \"id\": 1\n    },\n    \"white_label\": {\n      \"id\": 1,\n      \"company_id\": 5,\n      \"logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/white-label/logo/abc.png\",\n      \"login_logo\": null,\n      \"mobile_logo\": null,\n      \"favicon\": null\n    }\n  }\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The theme options field is required.\",\n  \"errors\": {\n    \"user_id\": [\n      \"The selected user id is invalid.\"\n    ],\n    \"theme_options\": [\n      \"The theme options field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "White Label - Delete logo / favicon",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/logo/delete",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "logo",
                    "delete"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 5,\n    \"file_type\": \"logo\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Removes one branding image from a company's white-label config by nulling the matching column (`favicon`, `logo`, `login_logo`, or `mobile_logo`) on the latest `RsWhiteLabel` row and saving. Returns a 400 if the company has no white-label record.\n\n**Auth:** Both — `auth:api`, role-agnostic `account/white-label` prefix. `company_id` is scoped by `BelongsToAuthCompany` (must be in the caller's hierarchy), so a partner can clear images for any company beneath it while an account/brand user is limited to its own subtree.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | required | `exists:company,id` + `BelongsToAuthCompany`. |\n| file_type | string | required | one of `favicon`, `logo`, `login_logo`, `mobile_logo`. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `\"success\"`.\n- `message` (string) — `\"File has been deleted!\"`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"File has been deleted!\"\n  }\n}"
                },
                {
                  "name": "400 Bad Request (not found)",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"White label data not found!\"\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected file type is invalid.\",\n  \"errors\": {\n    \"file_type\": [\n      \"The selected file type is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Email White Label - Get (Mandrill status)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 5\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Returns the company's email white-label record plus live Mandrill verification status. Loads the latest `VerifiedEmail` for `company_id`, then queries Mandrill for sender-domain verification (`/senders/verify-domain`, `/senders/check-domain`) and tracking-domain status (`/urls/check-tracking-domain`, auto-adding it via `/urls/add-tracking-domain` if unknown), and includes the platform default sender settings.\n\n**Auth:** Both — `auth:api`, role-agnostic `account/white-label` prefix. `company_id` is required and scoped by `BelongsToAuthCompany` (must be in the caller's hierarchy); a partner can read any company beneath it, an account/brand user only its own subtree. Invalid/out-of-hierarchy ids return 422.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | required | `exists:company,id` + `BelongsToAuthCompany`. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `\"success\"`.\n- `data` (object|null) — the `VerifiedEmail` row (`id`, `company_id`, `email`, `name`, `domain`, `tracking_url`, `domain_verified_at`, `url_verified_at`, timestamps), or null if none.\n- `verification` (object) — `verify_domain`, `check_domain`, `check_tracking_domain` (raw Mandrill responses; empty objects when no model/domain), and `default_settings` (`email`, `name`, `tracking_url` from `config('mail.*')`).\n- `instruction_file` (string) — versioned asset URL to the email-setup PDF."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 1,\n      \"company_id\": 5,\n      \"email\": \"noreply@manage-myreviews.com\",\n      \"name\": \"hipages\",\n      \"domain\": \"manage-myreviews.com\",\n      \"tracking_url\": \"track.manage-myreviews.com\",\n      \"domain_verified_at\": \"2026-06-11T11:53:59.000000Z\",\n      \"url_verified_at\": \"2026-06-11T11:53:59.000000Z\",\n      \"deleted_at\": null,\n      \"created_at\": \"2026-06-11T11:53:59.000000Z\",\n      \"updated_at\": \"2026-06-11T11:53:59.000000Z\"\n    },\n    \"verification\": {\n      \"verify_domain\": {\n        \"status\": \"already_verified\",\n        \"domain\": \"manage-myreviews.com\",\n        \"email\": \"noreply@manage-myreviews.com\"\n      },\n      \"check_domain\": {\n        \"domain\": \"manage-myreviews.com\",\n        \"created_at\": \"2026-05-27 10:56:00\",\n        \"last_tested_at\": \"2026-06-16 11:11:27\",\n        \"spf\": { \"valid\": true, \"valid_after\": null, \"error\": null },\n        \"dkim\": { \"valid\": true, \"valid_after\": null, \"error\": null },\n        \"dkim2\": { \"valid\": true, \"valid_after\": null, \"error\": null },\n        \"dmarc\": { \"valid\": true, \"error\": null },\n        \"verified_at\": \"2026-06-16 11:11:27\",\n        \"valid_signing\": true,\n        \"verify_txt_key\": \"39ndgULxlJP7Wzns_f-nJA\"\n      },\n      \"check_tracking_domain\": {\n        \"domain\": \"track.manage-myreviews.com\",\n        \"created_at\": \"2026-05-27 11:04:37.62299\",\n        \"last_tested_at\": \"2026-06-16 11:11:28.28119\",\n        \"cname\": { \"valid\": true, \"valid_after\": null, \"error\": null },\n        \"valid_tracking\": true\n      },\n      \"default_settings\": {\n        \"email\": \"noreply@shoutaboutus.com\",\n        \"name\": \"Review Management\",\n        \"tracking_url\": \"track.shoutaboutus.com\"\n      }\n    },\n    \"instruction_file\": \"https://reputationmanagement-backend.com/assets/sample/RM White Label Email Setup Instructions.pdf?v=1781608288\"\n  }\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected company id is invalid. (and 1 more error)\",\n  \"errors\": {\n    \"company_id\": [\n      \"The selected company id is invalid.\",\n      \"The selected company id does not belong to your company hierarchy.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Email White Label - Add / verify",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email/add",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email",
                    "add"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 5,\n    \"sender_name\": \"Acme Reviews\",\n    \"email\": \"noreply@acme.com\",\n    \"tracking_domain\": \"track.acme.com\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Creates or updates the email white-label sender for a company and verifies its domain via Mandrill. If a fully-verified `VerifiedEmail` already exists for the company/email/tracking_domain it just updates the sender name and returns `already_verified`. Otherwise it validates the email's domain through Mandrill (`MandrillHelper::checkDomain`); on success it upserts the `VerifiedEmail`, runs the Mandrill verification suite, and stamps `domain_verified_at`/`url_verified_at` when DKIM/DKIM2/DMARC/signing and tracking CNAME all pass. An invalid domain returns a 400 (`\"Invalid Domain\"`).\n\n**Auth:** Both — `auth:api`, role-agnostic `account/white-label` prefix. `company_id` is required and scoped by `BelongsToAuthCompany` (must be in the caller's hierarchy); a partner can configure any company beneath it, an account/brand user only its own subtree.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | required | `exists:company,id` + `BelongsToAuthCompany`. |\n| sender_name | string | required | max:100, no angle brackets (`NameNoAngleBrackets`); stored as `name`. |\n| email | string | required | `email:rfc,dns`; its domain is verified via Mandrill. |\n| tracking_domain | string | required | max:100, no angle brackets; stored as `tracking_url`. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `\"success\"` (new/updated unverified record) or `\"already_verified\"` (was already fully verified, name updated).\n- `data` (object|array) — the `VerifiedEmail` row (`id`, `company_id`, `email`, `name`, `domain`, `tracking_url`, `domain_verified_at`, `url_verified_at`, timestamps); empty array `[]` on the `already_verified` short-circuit branch.\n- `verification` (object) — Mandrill verification payload (`verify_domain`, `check_domain`, `check_tracking_domain`, `default_settings`); present only on the `success` branch."
              },
              "response": [
                {
                  "name": "200 OK (saved)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"company_id\": 5,\n      \"email\": \"noreply@manage-myreviews.com\",\n      \"tracking_url\": \"track.manage-myreviews.com\",\n      \"domain\": \"manage-myreviews.com\",\n      \"name\": \"hipages\",\n      \"domain_verified_at\": \"2026-06-23 10:00:00\",\n      \"url_verified_at\": \"2026-06-23 10:00:00\",\n      \"updated_at\": \"2026-06-23T10:00:00.000000Z\",\n      \"created_at\": \"2026-06-23T10:00:00.000000Z\",\n      \"id\": 1\n    },\n    \"verification\": {\n      \"verify_domain\": {\n        \"status\": \"already_verified\",\n        \"domain\": \"manage-myreviews.com\",\n        \"email\": \"noreply@manage-myreviews.com\"\n      },\n      \"check_domain\": {\n        \"domain\": \"manage-myreviews.com\",\n        \"dkim\": { \"valid\": true },\n        \"dkim2\": { \"valid\": true },\n        \"dmarc\": { \"valid\": true },\n        \"valid_signing\": true\n      },\n      \"check_tracking_domain\": {\n        \"domain\": \"track.manage-myreviews.com\",\n        \"cname\": { \"valid\": true }\n      },\n      \"default_settings\": {\n        \"email\": \"noreply@shoutaboutus.com\",\n        \"name\": \"Review Management\",\n        \"tracking_url\": \"track.shoutaboutus.com\"\n      }\n    }\n  }\n}"
                },
                {
                  "name": "200 OK (already verified)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"already_verified\",\n    \"data\": []\n  }\n}"
                },
                {
                  "name": "400 Bad Request (invalid domain)",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Invalid Domain\"\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The email field must be a valid email address.\",\n  \"errors\": {\n    \"email\": [\n      \"The email field must be a valid email address.\"\n    ],\n    \"sender_name\": [\n      \"The sender name field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Theme Options - Save (options + logo)",
              "request": {
                "method": "POST",
                "header": [],
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "type": "text",
                      "description": "Required. Must exist in company and belong to your company hierarchy (BelongsToAuthCompany)."
                    },
                    {
                      "key": "theme_options",
                      "value": "{\"mode\":\"system\",\"skin\":\"default\",\"primaryColor\":\"#f1874c\"}",
                      "type": "text",
                      "description": "Required. JSON-encoded theme options string; stored verbatim."
                    },
                    {
                      "key": "logo_image",
                      "type": "file",
                      "src": [],
                      "description": "Optional. JPEG/PNG image, max 400 KB. Written to the company white-label logo slot on S3."
                    }
                  ]
                },
                "url": {
                  "raw": "{{baseUrl}}/theme-options/save",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "theme-options",
                    "save"
                  ]
                },
                "description": "Upserts the latest `RsUserThemeSetting` for a company (recording the authenticated user as author) with the supplied `theme_options` JSON, and — when a `logo_image` file is sent — stores it to S3 and writes it onto the company's `RsWhiteLabel` logo. Distinct from `/account/white-label/theme/save` (`WhiteLabelController@saveThemeOptions`): this route is `ThemeSettingController@saveOptions` and does not take a `user_id` (it uses the authenticated user). Sent as `multipart/form-data`.\n\n## Auth\n- Account/brand bearer (`auth:api`). `company_id` is scoped by `BelongsToAuthCompany` — partner can target any company beneath it; account/brand user limited to its own subtree.\n\n## Rate limit\n- No rate limit.\n\n## Body (multipart/form-data)\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | Required | `exists:company,id` + `BelongsToAuthCompany`. |\n| `theme_options` | string | Required | JSON-encoded theme settings, stored verbatim. |\n| `logo_image` | file (image) | Optional | JPEG/PNG, max 400 KB. |\n\n## Behaviour\n- `200` with `{ status, message, data, white_label }`: `data` is the saved `RsUserThemeSetting` row; `white_label` carries `company_id` and S3-resolved `logo`/`login_logo`/`mobile_logo`/`favicon` (null when unset).\n- `422` if `company_id` is missing/invalid/out of hierarchy, `theme_options` is missing, or `logo_image` is too large / wrong type.\n\n✅ live ([routes/api_v1.php:1091](routes/api_v1.php#L1091) → `ThemeSettingController@saveOptions`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Theme options saved successfully.\",\n    \"data\": {\n      \"company_id\": \"6\",\n      \"user_id\": 3,\n      \"theme_options\": \"{\\\"mode\\\":\\\"system\\\",\\\"skin\\\":\\\"default\\\",\\\"primaryColor\\\":\\\"#f1874c\\\"}\",\n      \"updated_at\": \"2026-06-16T10:50:03.000000Z\",\n      \"created_at\": \"2026-06-16T10:50:03.000000Z\",\n      \"id\": 2\n    },\n    \"white_label\": {\n      \"company_id\": \"6\",\n      \"logo\": null,\n      \"login_logo\": null,\n      \"mobile_logo\": null,\n      \"favicon\": null\n    }\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected company id is invalid. (and 1 more error)\",\n  \"errors\": {\n    \"company_id\": [\n      \"The selected company id is invalid.\",\n      \"The selected company id does not belong to your company hierarchy.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Per-account white-label: domain, logos, theme, and sender-email branding."
        },
        {
          "name": "§ B.14 Account — Email templates",
          "item": [
            {
              "name": "List email template types",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/list",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "list"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Lists every email-template type from the catalog merged with the company's own override flags (enabled state, delivery method, logo visibility, customised flag); platform-owned types are hidden for non-platform companies.\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled` middleware. Target company is scoped by an in-controller `ownsCompany()` check (`Company::isBelongsToParentCompany(authCompanyId, company_id)`), so a partner or an account/brand user may pass any `company_id` inside their own company hierarchy. (Partner persona can reach more companies down the tree; an account/brand user is limited to itself and its descendants.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | A company in the authenticated user's hierarchy. Validated in-controller (no FormRequest); a missing/unowned id returns 403, not 422. |\n\n**Response** — `{ data: ... }` envelope. The envelope `data` itself contains a nested `status` + `data` array (one row per catalog type).\n- `status` (string) — always `success`.\n- `data` (array) — rows, each:\n  - `type` (string) — EmailTemplateType value, e.g. `verify_email`.\n  - `label` (string) — human label.\n  - `group` (string) — UI group, e.g. `User`, `Location`, `Account`.\n  - `audience` (string) — audience enum value, e.g. `user`, `location`, `account`.\n  - `is_critical` (bool) — critical transactional type.\n  - `is_platform_owned` (bool) — centrally managed (only listed for the platform company).\n  - `is_report` (bool) — report-style email.\n  - `supports_webhook` (bool) — eligible for webhook delivery.\n  - `is_enabled` (bool) — override value, else catalog default for the company.\n  - `show_logo` (bool) — override value, else `true`.\n  - `delivery_method` (string) — override value, else `direct`.\n  - `customized` (bool) — true if any content region/subject override is set.\n  - `default_subject` (string) — catalog default subject.\n  - `updated_at` (string|null) — override row's updated_at, or null when no override exists."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"type\": \"verify_email\",\n        \"label\": \"Email Verification\",\n        \"group\": \"User\",\n        \"audience\": \"user\",\n        \"is_critical\": true,\n        \"is_platform_owned\": false,\n        \"is_report\": false,\n        \"supports_webhook\": true,\n        \"is_enabled\": true,\n        \"show_logo\": true,\n        \"delivery_method\": \"direct\",\n        \"customized\": false,\n        \"default_subject\": \"Please verify your email address.\",\n        \"updated_at\": null\n      },\n      {\n        \"type\": \"user_welcome\",\n        \"label\": \"User Welcome\",\n        \"group\": \"User\",\n        \"audience\": \"user\",\n        \"is_critical\": true,\n        \"is_platform_owned\": false,\n        \"is_report\": false,\n        \"supports_webhook\": true,\n        \"is_enabled\": true,\n        \"show_logo\": true,\n        \"delivery_method\": \"direct\",\n        \"customized\": true,\n        \"default_subject\": \"Welcome to [partnerTitle]\",\n        \"updated_at\": \"2026-06-20T14:31:05.000000Z\"\n      },\n      {\n        \"type\": \"review_response_reply\",\n        \"label\": \"Review Response Reply\",\n        \"group\": \"Location\",\n        \"audience\": \"location\",\n        \"is_critical\": false,\n        \"is_platform_owned\": false,\n        \"is_report\": false,\n        \"supports_webhook\": false,\n        \"is_enabled\": true,\n        \"show_logo\": true,\n        \"delivery_method\": \"direct\",\n        \"customized\": false,\n        \"default_subject\": \"A reply to your review for [storeName]\",\n        \"updated_at\": null\n      },\n      {\n        \"type\": \"report_activity\",\n        \"label\": \"Report: Activity\",\n        \"group\": \"Account\",\n        \"audience\": \"account\",\n        \"is_critical\": false,\n        \"is_platform_owned\": false,\n        \"is_report\": true,\n        \"supports_webhook\": false,\n        \"is_enabled\": true,\n        \"show_logo\": true,\n        \"delivery_method\": \"direct\",\n        \"customized\": false,\n        \"default_subject\": \"Activity Report\",\n        \"updated_at\": null\n      }\n    ]\n  }\n}"
                },
                {
                  "name": "403 Forbidden (company not owned)",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"You do not have access to this company.\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "Get template variables for a type",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/variables",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "variables"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"type\": \"verify_email\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Returns the list of supported variable tokens (with sample values) usable in the header/body/footer/signature content for one template type.\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled`. `company_id` scoped by the `BelongsToAuthCompany` validation rule (must resolve into the authenticated user's company hierarchy via `Company::isBelongsToParentCompany`). (Same scope rule for both personas; a partner can address any descendant company, an account/brand only itself and its descendants.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | `exists:companies,id` + `BelongsToAuthCompany`. |\n| type | string | Yes | Must be a valid `EmailTemplateType` value. |\n\n**Response** — `{ data: ... }` envelope, with nested `status` + `data` array of `EmailVariableRegistry` definitions.\n- `status` (string) — `success`.\n- `data` (array) — each item:\n  - `name` (string) — display name, e.g. `Recipient Name`.\n  - `token` (string) — bracketed token to insert, e.g. `[recipientName]`.\n  - `description` (string) — what the token resolves to.\n  - `sample` (string) — sample value used in preview/test.\n  - `scope` (string) — token scope, e.g. `recipient`, `review`, `location`, `account`, `global`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"name\": \"Recipient Name\",\n        \"token\": \"[recipientName]\",\n        \"description\": \"First name of the alert recipient.\",\n        \"sample\": \"Jane\",\n        \"scope\": \"recipient\"\n      },\n      {\n        \"name\": \"Partner Title\",\n        \"token\": \"[partnerTitle]\",\n        \"description\": \"White-label product title shown in emails.\",\n        \"sample\": \"Acme Reviews\",\n        \"scope\": \"account\"\n      },\n      {\n        \"name\": \"Primary Color\",\n        \"token\": \"[primaryColor]\",\n        \"description\": \"Partner brand color from theme settings.\",\n        \"sample\": \"#1459c4\",\n        \"scope\": \"account\"\n      },\n      {\n        \"name\": \"Current Year\",\n        \"token\": \"[currentYear]\",\n        \"description\": \"The current four-digit year.\",\n        \"sample\": \"2026\",\n        \"scope\": \"global\"\n      },\n      {\n        \"name\": \"Unsubscribe URL\",\n        \"token\": \"[unsubscribeUrl]\",\n        \"description\": \"Link the recipient can use to unsubscribe.\",\n        \"sample\": \"https://app.example.com/unsubscribe/abc123\",\n        \"scope\": \"recipient\"\n      }\n    ]\n  }\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected type is invalid.\",\n  \"errors\": {\n    \"type\": [\n      \"The selected type is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Get a single template (editor payload)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/get",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "get"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"type\": \"user_welcome\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Loads the full editor payload for one template type: raw override regions (null = inherit), resolved effective content, catalog defaults, theme color, and current settings (enabled, delivery method, logo visibility).\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled`. `company_id` scoped by `BelongsToAuthCompany`. Additionally, platform-owned types are forbidden (403) for any non-platform company via `deniesPlatformType()`. (Scope differs only in reachable companies per persona, as above.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | `exists:companies,id` + `BelongsToAuthCompany`. |\n| type | string | Yes | Valid `EmailTemplateType` value. |\n\n**Response** — `{ data: ... }` envelope. Envelope `data` carries nested `status`, `data` (editor state) and `defaults`. Region HTML fields (`override.*`, `resolved.*`, `defaults.*` except `subject`) are base64-encoded; `override.*` regions are `null` when no override is stored.\n- `status` (string) — `success`.\n- `data.type` (string) — the template type value.\n- `data.override` (object) — raw stored override, all nullable: `header`, `body`, `footer`, `signature` (base64 or null), `subject_override` (plain string or null), `custom_html` (base64 or null).\n- `data.template_mode` (string) — `regions` or `html`.\n- `data.resolved` (object) — effective rendered regions: `header`, `body`, `footer`, `signature` (base64), `subject` (plain).\n- `data.is_enabled` (bool), `data.show_logo` (bool), `data.delivery_method` (string).\n- `data.customized` (bool) — any content override present.\n- `data.theme.primary_color` (string) — partner brand color.\n- `defaults` (object) — catalog defaults: `header`, `body`, `footer`, `signature`, `custom_html` (all base64) and `subject` (plain)."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"type\": \"user_welcome\",\n      \"override\": {\n        \"header\": null,\n        \"body\": \"PHA+SGkgdGhlcmUsPC9wPg==\",\n        \"footer\": null,\n        \"signature\": null,\n        \"subject_override\": \"Welcome aboard, [recipientName]!\",\n        \"custom_html\": null\n      },\n      \"template_mode\": \"regions\",\n      \"resolved\": {\n        \"header\": \"\",\n        \"body\": \"PHA+SGkgdGhlcmUsPC9wPg==\",\n        \"footer\": \"\",\n        \"signature\": \"PHA+VGhhbmsgeW91LDxicj48c3Ryb25nPltwYXJ0bmVyVGl0bGVdPC9zdHJvbmc+PC9wPg==\",\n        \"subject\": \"Welcome aboard, [recipientName]!\"\n      },\n      \"is_enabled\": true,\n      \"show_logo\": true,\n      \"delivery_method\": \"direct\",\n      \"customized\": true,\n      \"theme\": {\n        \"primary_color\": \"#1459c4\"\n      }\n    },\n    \"defaults\": {\n      \"header\": \"\",\n      \"body\": \"PHA+SGkgdGhlcmUsPC9wPg==\",\n      \"footer\": \"\",\n      \"signature\": \"PHA+VGhhbmsgeW91LDxicj48c3Ryb25nPltwYXJ0bmVyVGl0bGVdPC9zdHJvbmc+PC9wPg==\",\n      \"subject\": \"Welcome to [partnerTitle]\",\n      \"custom_html\": \"PHA+SGkgdGhlcmUsPC9wPg==\"\n    }\n  }\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The type field is required.\",\n  \"errors\": {\n    \"type\": [\n      \"The type field is required.\"\n    ]\n  }\n}"
                },
                {
                  "name": "403 Forbidden (platform-owned type)",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"This template is managed centrally and cannot be edited here.\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "Save (upsert) a template override",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/save",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "save"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"type\": \"user_welcome\",\n    \"body\": \"PHA+SGVsbG8gW3JlY2lwaWVudE5hbWVdPC9wPg==\",\n    \"subject_override\": \"Welcome to [partnerTitle]!\",\n    \"template_mode\": \"regions\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Creates or updates (upserts) the company's content override for a template type. Region HTML arrives base64-encoded; null/empty regions inherit the system/white-label default. Variables in content are validated against the type's allowed token set.\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled`. `company_id` scoped by `BelongsToAuthCompany`; platform-owned types are forbidden (403) for non-platform companies via `deniesPlatformType()`. (Scope differs only in reachable companies per persona.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (region fields are base64-encoded HTML; `ValidTemplateVariables` checks tokens against the type):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | `exists:companies,id` + `BelongsToAuthCompany`. |\n| type | string | Yes | Valid `EmailTemplateType` value. |\n| header | string | No | nullable, base64 HTML, `max:80000`, token-validated. Empty → inherit default. |\n| body | string | No | nullable, base64 HTML, `max:80000`, token-validated. |\n| footer | string | No | nullable, base64 HTML, `max:80000`, token-validated. |\n| signature | string | No | nullable, base64 HTML, `max:80000`, token-validated. |\n| subject_override | string | No | nullable, `max:255`, token-validated. Plain text (not base64). |\n| template_mode | string | No | nullable, one of `regions`, `html`. |\n| custom_html | string | No | nullable, base64 HTML, `max:400000`. Used in `html` mode; omitting it on a regions-mode save does NOT clear a stored full-HTML draft. |\n\n**Response** — `{ data: ... }` envelope. Unlike sibling endpoints there is no nested `status`/`data` object — the controller returns `status` + `message` directly inside the envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Email template saved successfully.`"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"success\",\n  \"message\": \"Email template saved successfully.\"\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The body contains an unsupported variable. (and 1 more error)\",\n  \"errors\": {\n    \"body\": [\n      \"The body contains an unsupported variable: [storeName].\"\n    ],\n    \"type\": [\n      \"The selected type is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Preview a template (render-only)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/preview",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "preview"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"type\": \"user_welcome\",\n    \"body\": \"PHA+SGVsbG8gW3JlY2lwaWVudE5hbWVdPC9wPg==\",\n    \"show_logo\": true\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Renders the template with sample variable values and returns the resulting HTML + resolved subject. Draft region fields (base64) can be posted to preview unsaved edits. Deterministic tokens (partner title, theme colours) resolve from live white-label data; recipient/review/location tokens use sample placeholders. Never sends an email.\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled`. `company_id` scoped by `BelongsToAuthCompany`; platform-owned types forbidden (403) for non-platform companies. (Scope differs only in reachable companies per persona.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (uses `PreviewEmailTemplateRequest` — same draft fields as save, plus `show_logo`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | `exists:companies,id` + `BelongsToAuthCompany`. |\n| type | string | Yes | Valid `EmailTemplateType` value. |\n| header | string | No | nullable, base64 HTML, `max:80000`, token-validated. Falls back to saved/resolved if omitted. |\n| body | string | No | nullable, base64 HTML, `max:80000`, token-validated. |\n| footer | string | No | nullable, base64 HTML, `max:80000`, token-validated. |\n| signature | string | No | nullable, base64 HTML, `max:80000`, token-validated. |\n| subject_override | string | No | nullable, `max:255`, token-validated. Plain text. |\n| template_mode | string | No | nullable, `regions` or `html`. Defaults to saved value. |\n| custom_html | string | No | nullable, base64 HTML, `max:400000`. Used in `html` mode. |\n| show_logo | boolean | No | Not in FormRequest rules; read by the controller (`$request->has('show_logo')`) to override logo visibility for this preview only. |\n\n**Response** — `{ data: ... }` envelope with nested `status` + `data` object.\n- `status` (string) — `success`.\n- `data.html` (string) — fully rendered email HTML document.\n- `data.subject` (string) — resolved, variable-applied, tag-stripped subject."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"html\": \"<!DOCTYPE html>\\n<html><head><title>Review Management</title></head><body><table class=\\\"wrapper\\\"><tr><td class=\\\"header\\\"><img src=\\\"https://cdn.example.com/logo.png\\\" alt=\\\"Acme\\\"></td></tr><tr><td class=\\\"content-cell\\\"><p>Thank you for signing up, Jane.</p><p>Thank you,<br><strong>Acme Reviews</strong></p></td></tr></table></body></html>\",\n      \"subject\": \"Welcome aboard, Jane!\"\n    }\n  }\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected type is invalid.\",\n  \"errors\": {\n    \"type\": [\n      \"The selected type is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Enable/disable a template type",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/toggle",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "toggle"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"type\": \"review_response_reply\",\n    \"is_enabled\": true\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Enables or disables sending for a template type. A disabled template sends nothing — no email and no webhook.\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled`. `company_id` scoped by `BelongsToAuthCompany`; platform-owned types forbidden (403) for non-platform companies via `deniesPlatformType()` inside the shared `updateOverride()`. (Scope differs only in reachable companies per persona.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | `exists:companies,id` + `BelongsToAuthCompany`. |\n| type | string | Yes | Valid `EmailTemplateType` value. |\n| is_enabled | boolean | Yes | `true` to enable, `false` to disable. |\n\n**Response** — `{ data: ... }` envelope with nested `status` + `message`.\n- `status` (string) — `success`.\n- `message` (string) — `Template updated.`"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Template updated.\"\n  }\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The is enabled field is required.\",\n  \"errors\": {\n    \"is_enabled\": [\n      \"The is enabled field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Set delivery method (direct/webhook)",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/delivery-method",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "delivery-method"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"type\": \"user_welcome\",\n    \"delivery_method\": \"webhook\",\n    \"webhook_subscription_id\": 5\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Switches a template type between Direct Send (email) and Webhook delivery. Webhook delivery posts rendered content to the partner's webhook URL at send time and requires an active webhook subscription; email-only types cannot be set to webhook.\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled`. `company_id` scoped by `BelongsToAuthCompany`; platform-owned types forbidden (403) for non-platform companies. (Scope differs only in reachable companies per persona.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | `exists:companies,id` + `BelongsToAuthCompany`. |\n| type | string | Yes | Valid `EmailTemplateType` value. Must support webhook if `delivery_method=webhook`. |\n| delivery_method | string | Yes | One of `direct`, `webhook`. `webhook` fails validation when the type does not support webhook, or when there is no active webhook subscription (`RequiresActiveWebhookSubscription`). |\n| webhook_subscription_id | integer | No | nullable. The active webhook subscription to use when `delivery_method=webhook`. |\n\n**Response** — `{ data: ... }` envelope with nested `status` + `message` (shared `updateOverride`).\n- `status` (string) — `success`.\n- `message` (string) — `Template updated.`"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Template updated.\"\n  }\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"This email type does not support webhook delivery.\",\n  \"errors\": {\n    \"delivery_method\": [\n      \"This email type does not support webhook delivery.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Set logo visibility",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/logo-visibility",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "logo-visibility"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"type\": \"user_welcome\",\n    \"show_logo\": true\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Controls whether the white-label partner logo is shown in the email header for a template type (defaults to visible).\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled`. `company_id` scoped by `BelongsToAuthCompany`; platform-owned types forbidden (403) for non-platform companies via `deniesPlatformType()` in the shared `updateOverride()`. (Scope differs only in reachable companies per persona.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | `exists:companies,id` + `BelongsToAuthCompany`. |\n| type | string | Yes | Valid `EmailTemplateType` value. |\n| show_logo | boolean | Yes | `true` to show the white-label logo, `false` to hide it. |\n\n**Response** — `{ data: ... }` envelope with nested `status` + `message`.\n- `status` (string) — `success`.\n- `message` (string) — `Template updated.`"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Template updated.\"\n  }\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The show logo field is required.\",\n  \"errors\": {\n    \"show_logo\": [\n      \"The show logo field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "List delivery logs",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/logs",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "logs"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"type\": \"user_welcome\",\n    \"status\": \"sent\",\n    \"recipient\": \"jane@\",\n    \"date_from\": \"2024-06-01\",\n    \"date_to\": \"2024-06-30\",\n    \"per_page\": 25\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Returns a paginated log of all email/webhook delivery attempts for the company, newest first, filterable by type, status, recipient, and date range.\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled`. `company_id` scoped by `BelongsToAuthCompany`. (Scope differs only in reachable companies per persona.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | `exists:companies,id` + `BelongsToAuthCompany`. |\n| type | string | No | nullable. Filter by `EmailTemplateType` value. |\n| status | string | No | nullable. One of `pending`, `sent`, `failed`, `skipped`. |\n| recipient | string | No | nullable, `max:255`. Partial email LIKE match. |\n| date_from | string (date) | No | nullable. Include records with `created_at` >= this date. |\n| date_to | string (date) | No | nullable. Include records with `created_at` <= this date. |\n| per_page | integer | No | nullable, `min:1`, `max:100`. Defaults to 25. |\n\n**Response** — `{ data: ... }` envelope with nested `status` + `data`, where `data` is a standard Laravel paginator of `EmailDeliveryLog` rows.\n- `status` (string) — `success`.\n- `data` (object) — paginator: `current_page`, `data` (array of log rows), `first_page_url`, `from`, `last_page`, `last_page_url`, `links`, `next_page_url`, `path`, `per_page`, `prev_page_url`, `to`, `total`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"current_page\": 1,\n      \"data\": [\n        {\n          \"id\": 412,\n          \"partner_company_id\": 1,\n          \"template_type\": \"user_welcome\",\n          \"recipient_email\": \"jane@example.com\",\n          \"delivery_method\": \"email\",\n          \"status\": \"sent\",\n          \"subject\": \"Welcome aboard, Jane!\",\n          \"error_message\": null,\n          \"sent_at\": \"2026-06-23T09:14:02.000000Z\",\n          \"created_at\": \"2026-06-23T09:14:01.000000Z\",\n          \"updated_at\": \"2026-06-23T09:14:02.000000Z\"\n        },\n        {\n          \"id\": 410,\n          \"partner_company_id\": 1,\n          \"template_type\": \"account_disconnected\",\n          \"recipient_email\": \"owner@brightsmile.com\",\n          \"delivery_method\": \"webhook\",\n          \"status\": \"failed\",\n          \"subject\": \"Action needed \\u2014 your Google connection at Downtown Clinic\",\n          \"error_message\": \"Webhook responded 500\",\n          \"sent_at\": null,\n          \"created_at\": \"2026-06-22T18:02:55.000000Z\",\n          \"updated_at\": \"2026-06-22T18:02:56.000000Z\"\n        }\n      ],\n      \"first_page_url\": \"https://reputationmanagement-backend.com/api/v1/account/white-label/email-templates/logs?page=1\",\n      \"from\": 1,\n      \"last_page\": 1,\n      \"last_page_url\": \"https://reputationmanagement-backend.com/api/v1/account/white-label/email-templates/logs?page=1\",\n      \"links\": [\n        {\n          \"url\": null,\n          \"label\": \"&laquo; Previous\",\n          \"active\": false\n        },\n        {\n          \"url\": \"https://reputationmanagement-backend.com/api/v1/account/white-label/email-templates/logs?page=1\",\n          \"label\": \"1\",\n          \"active\": true\n        },\n        {\n          \"url\": null,\n          \"label\": \"Next &raquo;\",\n          \"active\": false\n        }\n      ],\n      \"next_page_url\": null,\n      \"path\": \"https://reputationmanagement-backend.com/api/v1/account/white-label/email-templates/logs\",\n      \"per_page\": 25,\n      \"prev_page_url\": null,\n      \"to\": 2,\n      \"total\": 2\n    }\n  }\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected status is invalid.\",\n  \"errors\": {\n    \"status\": [\n      \"The selected status is invalid.\"\n    ],\n    \"date_from\": [\n      \"The date from field must be a valid date.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Restore template to system defaults",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/restore-default",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "restore-default"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"type\": \"user_welcome\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Clears the company's content overrides (header, body, footer, signature, subject_override) for a type, reverting to system/white-label defaults. Enable/disable and delivery settings are preserved; if the row is left fully default it is deleted.\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled`. `company_id` scoped by `BelongsToAuthCompany`; platform-owned types forbidden (403) for non-platform companies via `deniesPlatformType()`. (Scope differs only in reachable companies per persona.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (uses `EmailTemplateTypeRequest`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | `exists:companies,id` + `BelongsToAuthCompany`. |\n| type | string | Yes | Valid `EmailTemplateType` value. |\n\n**Response** — `{ data: ... }` envelope with nested `status` + `message`.\n- `status` (string) — `success`.\n- `message` (string) — `Template restored to default.`"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"success\",\n  \"message\": \"Template restored to default.\"\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The type field is required.\",\n  \"errors\": {\n    \"type\": [\n      \"The type field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "Send a test email to yourself",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/account/white-label/email-templates/send-test",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "account",
                    "white-label",
                    "email-templates",
                    "send-test"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"company_id\": 1,\n    \"type\": \"user_welcome\",\n    \"body\": \"PHA+SGVsbG8gW3JlY2lwaWVudE5hbWVdPC9wPg==\",\n    \"show_logo\": true\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "description": "Sends a real test email (subject prefixed with `[Test] `) to the authenticated user's own email address, using sample variable values and any posted draft regions. Bypasses the enable/suppression gates and writes an `EmailDeliveryLog` entry for every send.\n\n**Auth:** Both — `auth:api` + `EnsureEmailTemplateSectionEnabled`. `company_id` scoped by `BelongsToAuthCompany`; platform-owned types forbidden (403) for non-platform companies. Recipient is always `request.user().email`; returns 400 if the authenticated account has no email. (Scope differs only in reachable companies per persona; recipient is always the caller's own address.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (uses `PreviewEmailTemplateRequest` — same draft fields as preview, plus `show_logo`):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| company_id | integer | Yes | `exists:companies,id` + `BelongsToAuthCompany`. |\n| type | string | Yes | Valid `EmailTemplateType` value. |\n| header | string | No | nullable, base64 HTML, `max:80000`, token-validated. |\n| body | string | No | nullable, base64 HTML, `max:80000`, token-validated. |\n| footer | string | No | nullable, base64 HTML, `max:80000`, token-validated. |\n| signature | string | No | nullable, base64 HTML, `max:80000`, token-validated. |\n| subject_override | string | No | nullable, `max:255`, token-validated. Plain text. |\n| template_mode | string | No | nullable, `regions` or `html`. |\n| custom_html | string | No | nullable, base64 HTML, `max:400000`. |\n| show_logo | boolean | No | Not in FormRequest rules; read by the controller to override logo visibility for this test send. |\n\n**Response** — `{ data: ... }` envelope with nested `status` + `message` (message echoes the recipient address).\n- `status` (string) — `success`.\n- `message` (string) — `Test email sent to <recipient>`."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Test email sent to gopal@shoutaboutus.com\"\n  }\n}"
                },
                {
                  "name": "400 Bad Request (no email on account)",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Your account has no email address to send a test to.\",\n  \"errors\": []\n}"
                },
                {
                  "name": "422 Validation Error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected type is invalid.\",\n  \"errors\": {\n    \"type\": [\n      \"The selected type is invalid.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Per-account email-template customization (copy, regions, delivery method, logs, test sends)."
        },
        {
          "name": "§ B.8.F Campaigns — public links & tracking",
          "item": [
            {
              "name": "8.F.1 Get landing page HTML by channel code",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/landing-page/{{channelUniqueCode}}?preview=false",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "landing-page",
                    "{{channelUniqueCode}}"
                  ],
                  "query": [
                    {
                      "key": "preview",
                      "value": "false",
                      "description": "Optional. When true, skips click/delivery tracking.",
                      "disabled": true
                    }
                  ]
                },
                "description": "Resolves a campaign SMS request (or, failing that, a campaign channel) by its `unique_code` and returns the personalized landing-page HTML plus the review-site landing-page URL. Unless `preview=true`, it stamps `link_clicked_at` and `delivered_at` on the matched SMS request (click/delivery tracking side effect).\n\n## Auth\n- Public — no bearer token (unauthenticated `campaign` group; item marked `noauth`). Reached by end customers from the SMS/landing-page link.\n\n## Rate limit\n- 30 requests/min per IP (`throttle:30,1`, public).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `preview` | boolean | Optional | When true, skips the click/delivery tracking write. Defaults false. |\n\n## Behaviour\n- 200 `{ status: \"success\", data: { landing_page_html, landing_page_url } }`.\n- 400 (`badRequestResponse`) \"Campaign not found\", \"SMS template not found\", or \"Landing page not found\".\n\n✅ live ([routes/api_v1.php:194](routes/api_v1.php#L194) → `CampaignController@getLandingPageByUniqueCode`)",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"landing_page_html\": \"<html><head><title>Leave a Review</title></head><body><h1>How was your experience?</h1><a href=\\\"https://example.com/review/abc123\\\">Write a review</a></body></html>\",\n      \"landing_page_url\": \"https://search.google.com/local/writereview?placeid=XXXX\"\n    }\n  }\n}"
                },
                {
                  "name": "400 Not found",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Bad Request\",\n  \"errors\": \"Landing page not found\"\n}"
                }
              ]
            },
            {
              "name": "8.F.2 Get landing page by code (store-scoped, auth)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/landing-page-by-code/{{landingPageUniqueCode}}?store_id={{storeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "landing-page-by-code",
                    "{{landingPageUniqueCode}}"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}",
                      "description": "Required — exists:store,id + BelongsToAuthCompany."
                    }
                  ]
                },
                "description": "Returns the landing-page HTML for a campaign landing page, resolved by its `unique_code`, with the store's placeholders substituted in. Unlike the other links in this folder, this is the **authenticated** landing-page fetch used by the SPA (store-scoped preview), not a customer-facing link.\n\n## Auth\n- Account — `auth:api`, BRAND-scope group (requires a brand/account bearer token). The `store_id` query param is validated by `BelongsToAuthCompany` (must belong to the caller's company); the store's placeholders are applied to the HTML.\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | Required | `exists:store,id` + `BelongsToAuthCompany` — must belong to the caller's company. |\n\n## Behaviour\n- 200 `{ status: \"success\", landing_page_html }` (note: HTML sits directly under `data`, not under a nested `data.data`).\n- 400 (`badRequestResponse`) \"Landing page not found\".\n- 401 when unauthenticated; 422 when `store_id` is missing/invalid or out of scope.\n\n✅ live ([routes/api_v1.php:901](routes/api_v1.php#L901) → `CampaignController@getLandingPageByCode`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"landing_page_html\": \"<p>Hi Jane, thanks for visiting Downtown Store!</p>\"\n  }\n}"
                },
                {
                  "name": "400 Not found",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Bad Request\",\n  \"errors\": \"Landing page not found\"\n}"
                },
                {
                  "name": "422 Store scope",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The store id field is required.\",\n  \"errors\": {\n    \"store_id\": [\n      \"The store id field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "8.F.3 Resolve click URL & record click",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/click-url/{{clickHashId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "click-url",
                    "{{clickHashId}}"
                  ]
                },
                "description": "Decodes the Hashids `hash_id` into `[click_url_id, request_id]`, records a click event (IP / User-Agent / referer, deduped per IP for 5 minutes), marks the underlying Email/SMS request as clicked (and delivered, for SMS), and returns the personalized landing-page HTML, the raw landing-page URL, and the request's `unique_code`.\n\n## Auth\n- Public — no bearer token (unauthenticated `campaign` group; item marked `noauth`). Hit directly when a customer clicks a tracked link.\n\n## Rate limit\n- 30 requests/min per IP (`throttle:30,1`, public).\n\n## Behaviour\n- 200 `{ status: \"success\", data: { landing_page_html, landing_page_url, request_unique_code } }`.\n- 400 (`badRequestResponse`) \"Invalid request\" (undecodable hash), \"Click URL not found\", \"Campaign not found\" (request id missing), or \"Landing page not found\".\n\n✅ live ([routes/api_v1.php:195](routes/api_v1.php#L195) → `CampaignController@getClickUrlByHashId`)",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"landing_page_html\": \"<html><body><h1>How was your experience?</h1></body></html>\",\n      \"landing_page_url\": \"https://search.google.com/local/writereview?placeid=XXXX\",\n      \"request_unique_code\": \"98az1b2c3d\"\n    }\n  }\n}"
                },
                {
                  "name": "400 Invalid request",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Bad Request\",\n  \"errors\": \"Invalid request\"\n}"
                }
              ]
            },
            {
              "name": "8.F.4 Track review-request click",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/review-request/{{clickHashId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "review-request",
                    "{{clickHashId}}"
                  ]
                },
                "description": "Records a click event for a tracked campaign link identified by the Hashids `unique_code` (decoded into click-url id + request id). Duplicate clicks from the same IP within 5 minutes are suppressed via cache. Returns a status/message envelope (no landing page HTML — use `click-url/{hash_id}` for that).\n\n## Auth\n- Public — no bearer token (unauthenticated `campaign` group; item marked `noauth`).\n\n## Rate limit\n- 30 requests/min per IP (`throttle:30,1`, public).\n\n## Behaviour\n- 200 `{ status: \"success\", message: \"Click event tracked successfully\" }`.\n- 400 (`badRequestResponse`) \"Invalid request\" or \"Click URL not found\".\n- 422 when `unique_code` is empty (route param mapped into validation; `min:1`).\n\n✅ live ([routes/api_v1.php:201](routes/api_v1.php#L201) → `CampaignTrackingController@reviewRequestTracking`)",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Click event tracked successfully\"\n  }\n}"
                },
                {
                  "name": "400 Invalid request",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Bad Request\",\n  \"errors\": \"Invalid request\"\n}"
                }
              ]
            },
            {
              "name": "8.F.5 Track email open (pixel)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/view-email/{{emailRequestUniqueCode}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "view-email",
                    "{{emailRequestUniqueCode}}"
                  ]
                },
                "description": "Email-open tracking pixel. Looks up a `sent` campaign email request by `unique_code` and, on first view, stamps `opened_at` (and `delivered_at` if unset). Returns an `image/png` response so it can be embedded as an `<img>` in the email body — not a JSON envelope on the happy path.\n\n## Auth\n- Public — no bearer token (unauthenticated `campaign` group; item marked `noauth`). Requested by the recipient's mail client when the email is opened.\n\n## Rate limit\n- 30 requests/min per IP (`throttle:30,1`, public).\n\n## Behaviour\n- 200 with `Content-Type: image/png` (tracking pixel) on success; caching disabled via `Cache-Control`/`Pragma`/`Expires` headers.\n- 200 JSON `{ status: \"failed\", message: \"Sent email not found\" }` when no matching `sent` email request exists.\n- 200 JSON `{ status: \"failed\", message: \"Something went wrong! please try again.\" }` on unexpected error (still 200, so the mail client renders nothing broken).\n\n✅ live ([routes/api_v1.php:196](routes/api_v1.php#L196) → `CampaignController@viewEmail`)",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 OK (tracking pixel)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "text",
                  "header": [
                    {
                      "key": "Content-Type",
                      "value": "image/png"
                    }
                  ],
                  "body": "<binary image/png body — tracking pixel; not JSON>"
                },
                {
                  "name": "200 Sent email not found",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"failed\",\n    \"message\": \"Sent email not found\"\n  }\n}"
                }
              ]
            },
            {
              "name": "8.F.6 Web-view email HTML",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/email/web-view/{{emailRequestUniqueCode}}?store_id={{storeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "email",
                    "web-view",
                    "{{emailRequestUniqueCode}}"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}",
                      "description": "Optional — nullable, exists:store,id (template-id branch only).",
                      "disabled": true
                    }
                  ]
                },
                "description": "Returns the rendered HTML of a campaign email for the browser \"view in web\" link. Resolves a campaign email request by `unique_code` (or, if `unique_code` is numeric, a template by id), picks the base or follow-up template, substitutes placeholders, and — for the request path — stamps `delivered_at`/`clicked_at`/`opened_at` (tracking side effect).\n\n## Auth\n- Public — no bearer token (unauthenticated `campaign` group; item marked `noauth`). Reached from the \"view in browser\" link in the email.\n\n## Rate limit\n- 30 requests/min per IP (`throttle:30,1`, public).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | Optional | nullable, `exists:store,id`. Only used on the numeric-`unique_code` (template-id) branch to apply that store's placeholders. |\n\n## Behaviour\n- 200 `{ status: \"success\", data: { html_content } }`.\n- 404 (`notFoundResponse`) \"Email template not found\" or \"Store not found\".\n- 422 when `store_id` is present but does not exist.\n\n✅ live ([routes/api_v1.php:200](routes/api_v1.php#L200) → `EmailTemplateController@webViewEmail`)",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"html_content\": \"<html><body><h1>Thanks for visiting!</h1><p>We would love your feedback. <a href=\\\"https://example.com/review\\\">Leave a review</a></p></body></html>\"\n    }\n  }\n}"
                },
                {
                  "name": "404 Not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Not Found\",\n  \"errors\": \"Email template not found\"\n}"
                }
              ]
            },
            {
              "name": "8.F.7 Show unsubscribe status",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/email/unsubscribe/{{unsubscribeToken}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "email",
                    "unsubscribe",
                    "{{unsubscribeToken}}"
                  ]
                },
                "description": "Decodes the tamper-proof, Laravel-encrypted `UnsubscribeToken` from a campaign-email unsubscribe link and returns the masked recipient address plus whether they are already on the suppression list. Render-only — no state change. A generic 404 is returned for any invalid/expired token so the response never reveals whether a specific address or token is valid.\n\n## Auth\n- Public — no bearer token (unauthenticated `campaign` group; item marked `noauth`). Reached from the unsubscribe link in customizable emails.\n\n## Rate limit\n- 30 requests/min per IP (`throttle:30,1`, public).\n\n## Behaviour\n- 200 `{ status: \"success\", data: { email_masked, already_unsubscribed } }`. `email_masked` shows only the first local-part character (e.g. `j***@example.com`); `already_unsubscribed` is true when the address is on the `blacklists` suppression list.\n- 404 (`notFoundResponse`) \"Invalid or expired unsubscribe link.\" for any bad/expired token.\n\n✅ live ([routes/api_v1.php:205](routes/api_v1.php#L205) → `EmailUnsubscribeController@show`)",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"email_masked\": \"j***@example.com\",\n      \"already_unsubscribed\": false\n    }\n  }\n}"
                },
                {
                  "name": "404 Invalid token",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Invalid or expired unsubscribe link.\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "8.F.8 Confirm unsubscribe",
              "request": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/email/unsubscribe/{{unsubscribeToken}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "email",
                    "unsubscribe",
                    "{{unsubscribeToken}}"
                  ]
                },
                "description": "Confirms an email unsubscribe. Idempotent — safe to call repeatedly for the same address. On success the decoded email is added to the `blacklists` suppression table (`type=email`, `event=unsub`, `source=unsubscribe`, plus the token's `company_id`) so the email dispatcher never sends to it again, and an `AuditAction::EMAIL_TEMPLATE_UNSUBSCRIBED` audit event is logged. A generic 404 is returned for any invalid/expired token.\n\n## Auth\n- Public — no bearer token (unauthenticated `campaign` group; item marked `noauth`). Posted from the unsubscribe confirmation page.\n\n## Rate limit\n- 30 requests/min per IP (`throttle:30,1`, public).\n\n## Body\n- None. The recipient email + company id are carried inside the encrypted `{token}` path segment.\n\n## Behaviour\n- 200 `{ status: \"success\", message: \"You have been unsubscribed.\" }` (same response whether the address was newly added or already present).\n- 404 (`notFoundResponse`) \"Invalid or expired unsubscribe link.\" for any bad/expired token.\n- 500 (`defaultErrorResponse`) on unexpected error.\n\n✅ live ([routes/api_v1.php:206](routes/api_v1.php#L206) → `EmailUnsubscribeController@confirm`)",
                "auth": {
                  "type": "noauth"
                }
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"You have been unsubscribed.\"\n  }\n}"
                },
                {
                  "name": "404 Invalid token",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Invalid or expired unsubscribe link.\",\n  \"errors\": []\n}"
                }
              ]
            }
          ],
          "description": "Public, unauthenticated links embedded in review-solicitation emails/SMS and landing pages — hit directly by end customers (and their mail clients) via Hashids-encoded codes/tokens, not by the SPA. Contains: get landing page by channel code (8.F.1), get landing page by code (8.F.2 — the one **authenticated** exception, needs a brand/account token + `store_id`), resolve click URL & record the click (8.F.3), track review-request click (8.F.4), email-open tracking pixel (8.F.5, returns image/png), web-view email HTML (8.F.6), and the two-step email unsubscribe: show status (8.F.7) + confirm (8.F.8). All are rate-limited to 30 req/min per IP (`throttle:30,1`) except 8.F.2, which inherits the authenticated group's no-throttle."
        },
        {
          "name": "§ B.8.G Campaign templates — Email",
          "item": [
            {
              "name": "B.8.G.1 Create email template",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Welcome Email Template\",\n  \"subject\": \"Welcome to our service!\",\n  \"store_id\": \"{{storeId}}\",\n  \"template_type\": \"regular\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/email/create",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "email",
                    "create"
                  ]
                },
                "description": "Creates a new campaign **email template** owned by the authenticated caller's company. `subject` defaults to an empty string, `template_type` to `regular`, and `template_editor_mode` to `custom` when omitted. The saved row is returned in full.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api` (no throttle group). The template is stamped with `company_id = user.person.company_id`. `store_id`, if supplied, must belong to the caller's company hierarchy (`BelongsToAuthCompany`).\n\n## Rate limit\n- No rate limit (`auth:api` only, no `throttle` middleware).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `name` | string | Required | Max 255. Must be unique among the caller's company templates **and** global (company_id NULL) templates. |\n| `subject` | string | Optional | Max 255. Defaults to `\"\"`. |\n| `store_id` | integer | Optional | Scope the template to one store. Must be in the caller's company hierarchy (`BelongsToAuthCompany`). |\n| `template_type` | string | Optional | `regular` or `follow_up`. Defaults to `regular`. |\n| `template_editor_mode` | string | Optional | `custom` or `html_editor`. Defaults to `custom`. |\n\n## Behaviour\n- Synchronous. `200 OK` on success with the created model under `data.data` (note the double-`data` envelope). `422` when validation fails (e.g. duplicate `name`, `store_id` outside the caller's hierarchy).\n\n> Test script saves `data.data.id` into `{{emailTemplateId}}`.\n\n✅ live ([routes/api_v1.php:933](routes/api_v1.php#L933) → `EmailTemplateController@createEmailTemplate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Email Template has been created successfully!\",\n    \"data\": {\n      \"company_id\": 30,\n      \"name\": \"Welcome Email Template\",\n      \"subject\": \"Welcome to our service!\",\n      \"store_id\": 123,\n      \"template_type\": \"regular\",\n      \"template_editor_mode\": \"custom\",\n      \"updated_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"created_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"id\": 1\n    }\n  }\n}"
                },
                {
                  "name": "422 Duplicate name",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The template name has already been taken.\",\n  \"errors\": {\n    \"name\": [\n      \"The template name has already been taken.\"\n    ]\n  }\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  const id = j && j.data && j.data.data && j.data.data.id;",
                      "  if (id) { pm.environment.set('emailTemplateId', String(id)); }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "B.8.G.2 List email templates",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/email/list?store_id={{storeId}}&template_type=regular",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "email",
                    "list"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}"
                    },
                    {
                      "key": "template_type",
                      "value": "regular",
                      "disabled": true
                    }
                  ]
                },
                "description": "Returns email templates selectable for the caller as a `{value,label}` dropdown source: the given store's templates (when `store_id` is provided) **plus** the caller company's templates **plus** global (company_id NULL) templates, sorted by name.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. Company templates are scoped to `user.person.company_id`; `store_id` (optional) must belong to the caller's company hierarchy.\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | Optional | Also include templates scoped to this store. Must be in the caller's hierarchy (`BelongsToAuthCompany`). |\n| `template_type` | string | Optional | Filter to `regular` or `follow_up`. |\n\n## Behaviour\n- Synchronous, not paginated. `200 OK` with `data.response` = array of `{value,label}`. `422` on invalid `store_id`.\n\n✅ live ([routes/api_v1.php:934](routes/api_v1.php#L934) → `EmailTemplateController@getEmailTemplateList`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": [\n      {\n        \"value\": 1,\n        \"label\": \"Welcome Email Template\"\n      },\n      {\n        \"value\": 2,\n        \"label\": \"Follow-up Email Template\"\n      }\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.8.G.3 Update email template",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"email_template_id\": \"{{emailTemplateId}}\",\n  \"subject\": \"Welcome to our service!\",\n  \"html_content\": \"<html><body><h1>Welcome!</h1></body></html>\",\n  \"json_content\": \"{}\",\n  \"title\": \"Updated Template Name\",\n  \"template_editor_mode\": \"custom\",\n  \"channel_id\": \"{{channelId}}\",\n  \"manager_email\": \"manager@example.com\",\n  \"other_manager_email\": [\n    \"owner@example.com\"\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/email/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "email",
                    "update"
                  ]
                },
                "description": "Updates an existing email template (subject, HTML/JSON content, name, editor mode, manager emails). **Copy-on-write for globals:** if the target template has no `company_id` (a global template), it is replicated into the caller's company first (a name clash there returns `400`). When `channel_id` is supplied the template is wired onto that campaign channel — a `follow_up` template sets the channel's `follow_up_template_id` + interval fields, otherwise it sets `template_id`.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. `email_template_id` must resolve to a template owned by the caller's company (or a global one); otherwise validation fails.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `email_template_id` | integer | Required | `exists:campaign_email_templates,id`; must belong to the caller's company (or be global). |\n| `subject` | string | Required | Max 255. |\n| `html_content` | string | Required* | Rendered HTML. *Optional when `html_content_b64` is sent. |\n| `html_content_b64` | string (base64) | Optional | Base64 of `html_content` — use for payloads with `<script>`/`<iframe>`/`on*=` that AWS WAF blocks in clear. Decoded into `html_content` before validation. |\n| `json_content` | string (JSON) | Required | Editor JSON, validated as `json`. |\n| `title` | string | Optional | New name. Unique within the caller's company (ignores this row). |\n| `template_editor_mode` | string | Optional | `custom` or `html_editor`. |\n| `channel_id` | integer | Optional | `exists:campaign_channels,id`. Associates the template with the channel. |\n| `manager_email` | string(email) | Optional | Validated `email:rfc,dns`. |\n| `other_manager_email` | string[] | Optional | Each a distinct `email:rfc,dns`. |\n| `follow_up_interval` | integer | Optional | One of 1,2,3,4,5,6,7,14,30. Applied to the channel for `follow_up` templates. |\n| `follow_up_interval_count` | integer | Optional | One of 1,2,3. |\n\n## Behaviour\n- Synchronous. `200 OK` returns the saved model under `data.data`. `400` (envelope `{message,status,errors}`) when replicating a global template and the chosen name already exists for the company. `422` on validation failure or when the template is not in the caller's company.\n\n✅ live ([routes/api_v1.php:935](routes/api_v1.php#L935) → `EmailTemplateController@updateEmailTemplate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Email Template has been updated successfully!\",\n    \"data\": {\n      \"id\": 1,\n      \"company_id\": 30,\n      \"store_id\": 123,\n      \"name\": \"Updated Template Name\",\n      \"subject\": \"Welcome to our service!\",\n      \"html_content\": \"<html><body><h1>Welcome!</h1></body></html>\",\n      \"json_content\": {},\n      \"template_editor_mode\": \"custom\",\n      \"template_type\": \"regular\",\n      \"manager_email\": \"manager@example.com\",\n      \"other_manager_email\": [\n        \"owner@example.com\"\n      ],\n      \"created_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"updated_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"deleted_at\": null\n    }\n  }\n}"
                },
                {
                  "name": "422 Not your template",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected email template does not belong to your company.\",\n  \"errors\": {\n    \"email_template_id\": [\n      \"The selected email template does not belong to your company.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.8.G.4 Get email template by ID",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/email/{{emailTemplateId}}?store_id={{storeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "email",
                    "{{emailTemplateId}}"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}",
                      "disabled": true
                    }
                  ]
                },
                "description": "Fetches a single email template by id. When `store_id` is supplied the template's `html_content` has its placeholders resolved against that store (`Store::replacePlaceholders`).\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. The template must belong to the caller's company or be a global template; otherwise validation fails.\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `id` (path) | integer | Required | `exists:campaign_email_templates,id`; ownership-checked. |\n| `store_id` | integer | Optional | Resolve HTML placeholders against this store. Must be in the caller's hierarchy. |\n\n## Behaviour\n- Synchronous. `200 OK` with the full model under `data.data`. `404` (envelope) if the id is not found by the controller. `422` if the template is outside the caller's company or `store_id` is invalid.\n\n✅ live ([routes/api_v1.php:936](routes/api_v1.php#L936) → `EmailTemplateController@getEmailTemplate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 1,\n      \"company_id\": 30,\n      \"store_id\": 123,\n      \"name\": \"Welcome Email Template\",\n      \"subject\": \"Welcome to our service!\",\n      \"html_content\": \"<html><body><h1>Welcome!</h1></body></html>\",\n      \"json_content\": {},\n      \"template_editor_mode\": \"custom\",\n      \"template_type\": \"regular\",\n      \"manager_email\": \"manager@example.com\",\n      \"other_manager_email\": [\n        \"owner@example.com\"\n      ],\n      \"created_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"updated_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"deleted_at\": null\n    }\n  }\n}"
                },
                {
                  "name": "404 Not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Not Found\",\n  \"status\": \"error\",\n  \"errors\": \"Email template not found\"\n}"
                }
              ]
            }
          ],
          "description": "Account/brand-token CRUD for campaign **email templates** (`campaign/template/email/*`, `auth:api`, no throttle). Endpoints: create, list (`{value,label}` dropdown of store + company + global templates), update (subject/HTML/JSON, optional channel wiring, copy-on-write for global templates), and get-by-id (optional store placeholder resolution). Scoped to the caller's `company_id`; templates must belong to the caller's company or be global. Called by the campaign builder / templates UI."
        },
        {
          "name": "§ B.8.H Campaign templates — Landing pages",
          "item": [
            {
              "name": "B.8.H.1 Create landing page template",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"title\": \"Summer Campaign Landing Page\",\n  \"html_content\": \"<html><body><h1>Welcome</h1></body></html>\",\n  \"json_content\": \"{}\",\n  \"template_editor_mode\": \"custom\",\n  \"store_id\": \"{{storeId}}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/landing-page/create",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "landing-page",
                    "create"
                  ]
                },
                "description": "Creates a new campaign **landing-page template** owned by the caller's company. `template_editor_mode` defaults to `custom`. Only `title`, `company_id`, `store_id` and `template_editor_mode` are persisted at create time; content is added via the update endpoint.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. Stamped with `company_id = user.person.company_id`. `store_id`, if supplied, must belong to the caller's hierarchy (`BelongsToAuthCompany`).\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `title` | string | Required | Unique among the caller's company templates **and** global templates. |\n| `html_content` | string | Optional | HTML body. Optional when `html_content_b64` is sent. Not persisted at create (set via update). |\n| `html_content_b64` | string (base64) | Optional | Base64 of `html_content` for WAF-blocked bytes; decoded before validation. |\n| `json_content` | string (JSON) | Optional | Editor JSON, validated as `json`. |\n| `template_editor_mode` | string | Optional | `custom` or `html_editor`. Defaults to `custom`. |\n| `store_id` | integer | Optional | Scope to one store; must be in the caller's hierarchy. |\n\n## Behaviour\n- Synchronous. `200 OK` returns `data.data` = `only(id,title,description,store_id,enable_direct_to_customer,enable_direct_to_manager,enable_direct_to_owner,manager_email,other_manager_email)` — unset fields serialise as `null` on a fresh create. `422` on validation failure (e.g. duplicate `title`).\n\n> Test script saves `data.data.id` into `{{landingPageId}}`.\n\n✅ live ([routes/api_v1.php:926](routes/api_v1.php#L926) → `LandingPageController@createLandingPageTemplate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Landing Page has been created successfully!\",\n    \"data\": {\n      \"id\": 1,\n      \"title\": \"Summer Campaign Landing Page\",\n      \"description\": null,\n      \"store_id\": 123,\n      \"enable_direct_to_customer\": null,\n      \"enable_direct_to_manager\": null,\n      \"enable_direct_to_owner\": null,\n      \"manager_email\": null,\n      \"other_manager_email\": null\n    }\n  }\n}"
                },
                {
                  "name": "422 Duplicate title",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The template name has already been taken.\",\n  \"errors\": {\n    \"title\": [\n      \"The template name has already been taken.\"\n    ]\n  }\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  const id = j && j.data && j.data.data && j.data.data.id;",
                      "  if (id) { pm.environment.set('landingPageId', String(id)); }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "B.8.H.2 List landing page templates",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/landing-page/list?store_id={{storeId}}&account_id={{accountId}}&page=1&per_page=10",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "landing-page",
                    "list"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}",
                      "disabled": true
                    },
                    {
                      "key": "account_id",
                      "value": "{{accountId}}",
                      "disabled": true
                    },
                    {
                      "key": "page",
                      "value": "1",
                      "disabled": true
                    },
                    {
                      "key": "per_page",
                      "value": "10",
                      "disabled": true
                    }
                  ]
                },
                "description": "Dual-mode listing of landing-page templates.\n\n- **Campaign flow** (`store_id` present): returns *every* selectable template (store + caller company + global), unpaginated and without usage counts — a `<select>` source.\n- **Account-level templates tab** (`store_id` omitted): scopes by brand(s), **excludes** global templates, paginates, and adds per-row campaign-usage counts. A partner (bundle_id=1) sees all their brands, or one brand via `account_id`; an account user sees their own brand.\n\nRows carry `company_name` (owning account name; null for globals) and the appended `unique_code`.\n\n## Auth\n- Bearer token, **account (brand) or partner persona** — `auth:api`. Brand scoping derives from `user.person.company` and its bundle_id.\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | Optional | Campaign-flow mode: scope to a store + return all templates unpaginated. Must be in the caller's hierarchy. |\n| `account_id` | integer | Optional | Partner-only: narrow the account-level listing to one of the caller's brands. Ignored if not the caller's. |\n| `page` | integer | Optional | Account-level pagination; min 1. |\n| `per_page` | integer | Optional | Account-level pagination; 1–100 (clamped), default 10. |\n\n## Behaviour\n- Synchronous. Campaign flow: `200 OK` with `data.response` only. Account-level: `200 OK` with `data.response` **and** `data.meta` (current_page/per_page/total/last_page). `422` on invalid `store_id`.\n\n✅ live ([routes/api_v1.php:925](routes/api_v1.php#L925) → `LandingPageController@getLandingPageList`)"
              },
              "response": [
                {
                  "name": "200 OK (account-level, paginated)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": [\n      {\n        \"id\": 1,\n        \"value\": 1,\n        \"label\": \"Summer Campaign Landing Page\",\n        \"company_id\": 30,\n        \"company_name\": \"Board & Brew\",\n        \"unique_code\": \"aBcDeFg\",\n        \"sms_campaign_count\": 2,\n        \"email_campaign_count\": 1\n      }\n    ],\n    \"meta\": {\n      \"current_page\": 1,\n      \"per_page\": 10,\n      \"total\": 1,\n      \"last_page\": 1\n    }\n  }\n}"
                },
                {
                  "name": "200 OK (campaign flow, store_id)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": [\n      {\n        \"id\": 1,\n        \"value\": 1,\n        \"label\": \"Summer Campaign Landing Page\",\n        \"company_id\": 30,\n        \"company_name\": \"Board & Brew\",\n        \"unique_code\": \"aBcDeFg\"\n      }\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.8.H.3 Preview landing page template",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"store_id\": \"{{storeId}}\",\n  \"html_content\": \"<h1>Hi [customerName]</h1><div>[allReviewSite]</div>\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/landing-page/preview",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "landing-page",
                    "preview"
                  ]
                },
                "description": "Resolves draft landing-page HTML for an editor preview. Runs the supplied HTML through the same send-time path: expands the `[allReviewSite]` grid into the store's enabled sites (using the store's DIRECT landing-page URLs — never click-tracking), fills location/account tokens via `Store::replacePlaceholders`, and swaps recipient-only tokens (`[customerName]`, `[senderName]`, `[paragraph_1]`) for friendly sample values. No template is saved and no recipient/campaign is involved.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. `store_id` must exist and belong to the caller's hierarchy (`BelongsToAuthCompany`).\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | Required | `exists:store,id` + `BelongsToAuthCompany`. |\n| `html_content` | string | Optional | Draft HTML to resolve. Empty resolves to an empty preview. |\n\n## Behaviour\n- Synchronous. `200 OK` with `data.data.html` = the resolved HTML string. `422` on validation failure. If the store cannot be loaded post-validation the controller returns a `500`-coded error envelope (`Store not found`).\n\n✅ live ([routes/api_v1.php:928](routes/api_v1.php#L928) → `LandingPageController@previewLandingPageTemplate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"html\": \"<h1>Hi Customer</h1><div><a href=\\\"https://example.com/lp/site-1\\\">Google</a></div>\"\n    }\n  }\n}"
                },
                {
                  "name": "422 Invalid store",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected store id does not belong to your company hierarchy.\",\n  \"errors\": {\n    \"store_id\": [\n      \"The selected store id does not belong to your company hierarchy.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.8.H.4 Update landing page template",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"landing_page_id\": \"{{landingPageId}}\",\n  \"title\": \"Summer Campaign Landing Page\",\n  \"manager_email\": \"manager@company.com\",\n  \"other_manager_email\": [\n    \"owner@company.com\"\n  ],\n  \"enable_direct_to_customer\": true,\n  \"enable_direct_to_manager\": true,\n  \"enable_direct_to_owner\": false,\n  \"html_content\": \"<p>Example content</p>\",\n  \"json_content\": \"{}\",\n  \"template_editor_mode\": \"custom\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/landing-page/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "landing-page",
                    "update"
                  ]
                },
                "description": "Updates an existing landing-page template (title, manager emails, HTML/JSON content, editor mode). **Copy-on-write for globals:** a template with no `company_id` is replicated into the caller's company first (a name clash returns `400`).\n\n> Known issue: `enable_direct_to_customer` / `enable_direct_to_manager` / `enable_direct_to_owner` are accepted and validated but **not persisted** by this endpoint yet.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. `landing_page_id` must resolve to a template owned by the caller's company (or a global one).\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `landing_page_id` | integer | Required | `exists:campaign_landing_pages,id`; ownership-checked. |\n| `title` | string | Optional | New title. Unique within the caller's company (ignores this row). |\n| `manager_email` | string(email) | Optional | `email:rfc,dns`. |\n| `other_manager_email` | string[] | Optional | Each a distinct `email:rfc,dns`. |\n| `enable_direct_to_customer` | boolean | Optional | Accepted but not yet persisted. |\n| `enable_direct_to_manager` | boolean | Optional | Accepted but not yet persisted. |\n| `enable_direct_to_owner` | boolean | Optional | Accepted but not yet persisted. |\n| `html_content` | string | Optional | HTML body. Optional when `html_content_b64` is sent. |\n| `html_content_b64` | string (base64) | Optional | Base64 of `html_content` for WAF-blocked bytes; decoded before validation. |\n| `json_content` | string (JSON) | Optional | Editor JSON, validated as `json`. |\n| `template_editor_mode` | string | Optional | `custom` or `html_editor`. |\n\n## Behaviour\n- Synchronous. `200 OK` returns the saved model under `data.data` (includes the appended `unique_code`). `400` (envelope) when replicating a global and the name already exists for the company. `422` on validation failure or when the template is not in the caller's company.\n\n✅ live ([routes/api_v1.php:927](routes/api_v1.php#L927) → `LandingPageController@updateLandingPageTemplate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Landing Page has been updated successfully!\",\n    \"data\": {\n      \"id\": 1,\n      \"company_id\": 30,\n      \"store_id\": 123,\n      \"title\": \"Summer Campaign Landing Page\",\n      \"description\": null,\n      \"url_slug\": null,\n      \"html_content\": \"<p>Example content</p>\",\n      \"json_content\": {},\n      \"template_editor_mode\": \"custom\",\n      \"manager_email\": \"manager@company.com\",\n      \"other_manager_email\": [\n        \"owner@company.com\"\n      ],\n      \"enable_direct_to_manager\": false,\n      \"enable_direct_to_owner\": false,\n      \"enable_direct_to_customer\": false,\n      \"created_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"updated_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"unique_code\": \"aBcDeFg\"\n    }\n  }\n}"
                },
                {
                  "name": "422 Not your template",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected landing page does not belong to your company.\",\n  \"errors\": {\n    \"landing_page_id\": [\n      \"The selected landing page does not belong to your company.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.8.H.5 Get landing page template by ID",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/landing-page/{{landingPageId}}?store_id={{storeId}}&preview=true",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "landing-page",
                    "{{landingPageId}}"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}",
                      "disabled": true
                    },
                    {
                      "key": "preview",
                      "value": "true",
                      "disabled": true
                    }
                  ]
                },
                "description": "Fetches a single landing-page template by id. When `store_id` is supplied the `html_content` placeholders are resolved against that store; adding `preview=true` also expands the `[allReviewSite]` grid and substitutes friendly sample values for recipient-only tokens.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. Template must belong to the caller's company or be global.\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `id` (path) | integer | Required | `exists:campaign_landing_pages,id`. |\n| `store_id` | integer | Optional | `exists:store,id`. Resolve placeholders against this store. |\n| `preview` | boolean | Optional | Read by the controller (not validated). With `store_id`, expands `[allReviewSite]` and fills sample recipient tokens. |\n\n## Behaviour\n- Synchronous. `200 OK` with the full model under `data.data` (includes appended `unique_code`). `422` if the id/`store_id` is invalid or the template is outside the caller's company.\n\n✅ live ([routes/api_v1.php:929](routes/api_v1.php#L929) → `LandingPageController@getLandingPageTemplate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 1,\n      \"company_id\": 30,\n      \"store_id\": 123,\n      \"title\": \"Summer Campaign Landing Page\",\n      \"description\": null,\n      \"url_slug\": null,\n      \"html_content\": \"<p>Example content</p>\",\n      \"json_content\": {},\n      \"template_editor_mode\": \"custom\",\n      \"manager_email\": \"manager@company.com\",\n      \"other_manager_email\": [\n        \"owner@company.com\"\n      ],\n      \"enable_direct_to_manager\": false,\n      \"enable_direct_to_owner\": false,\n      \"enable_direct_to_customer\": false,\n      \"created_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"updated_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"unique_code\": \"aBcDeFg\"\n    }\n  }\n}"
                }
              ]
            }
          ],
          "description": "Account/brand-token (and partner) CRUD for campaign **landing-page templates** (`campaign/template/landing-page/*`, `auth:api`, no throttle). Endpoints: create, list (dual-mode: campaign-flow dropdown vs paginated account-level tab with usage counts; partners can scope by `account_id`), preview (resolve draft HTML against a store), update (copy-on-write for globals; note `enable_direct_to_*` accepted-but-not-persisted), and get-by-id (optional store/preview placeholder resolution). Templates must belong to the caller's company or be global. Called by the landing-page editor and the account Landing Page Templates tab."
        },
        {
          "name": "§ B.8.I Campaign templates — SMS",
          "item": [
            {
              "name": "B.8.I.1 Create SMS template",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Welcome SMS Template\",\n  \"store_id\": \"{{storeId}}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/sms/create",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "sms",
                    "create"
                  ]
                },
                "description": "Creates a new campaign **SMS template** owned by the caller's company. The body content is seeded with a fixed default message (`Hi [customerName], … [landingPageUrl] … [locationName]. Stop 2 End.`); edit it via the update endpoint. Returns a trimmed field set.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. Stamped with `company_id = user.person.company_id`. `store_id`, if supplied, must belong to the caller's hierarchy (`BelongsToAuthCompany`).\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `name` | string | Required | Unique among the caller's company templates **and** global templates. |\n| `store_id` | integer | Optional | Scope to one store; must be in the caller's hierarchy (`BelongsToAuthCompany`). |\n\n## Behaviour\n- Synchronous. `200 OK` returns `data.data` = `only(id,name,content,landing_page_id,review_site_id,store_id)`. `422` on validation failure (e.g. duplicate `name`).\n\n> Test script saves `data.data.id` into `{{smsTemplateId}}`.\n\n✅ live ([routes/api_v1.php:918](routes/api_v1.php#L918) → `SMSController@createSMSTemplate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"SMS Template has been created successfully!\",\n    \"data\": {\n      \"id\": 1,\n      \"name\": \"Welcome SMS Template\",\n      \"content\": \"Hi [customerName], Your feedback means a lot to us. Please click here to share: [landingPageUrl]. Thank you from [locationName]. Stop 2 End.\",\n      \"landing_page_id\": null,\n      \"review_site_id\": null,\n      \"store_id\": 123\n    }\n  }\n}"
                },
                {
                  "name": "422 Duplicate name",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The template name has already been taken.\",\n  \"errors\": {\n    \"name\": [\n      \"The template name has already been taken.\"\n    ]\n  }\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 200) {",
                      "  const j = pm.response.json();",
                      "  const id = j && j.data && j.data.data && j.data.data.id;",
                      "  if (id) { pm.environment.set('smsTemplateId', String(id)); }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "B.8.I.2 List SMS templates",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/sms/list?store_id={{storeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "sms",
                    "list"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}"
                    }
                  ]
                },
                "description": "Returns SMS templates selectable for a store as a `{value,label}` dropdown source: the given store's templates **plus** the caller company's templates **plus** global (both company_id and store_id NULL) templates, sorted by name.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. Company templates scoped to `user.person.company_id`.\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | Required | Store to list templates for. Must be in the caller's hierarchy (`BelongsToAuthCompany`). |\n\n## Behaviour\n- Synchronous, not paginated. `200 OK` with `data.response` = array of `{value,label}`. `422` if `store_id` is missing or outside the caller's hierarchy.\n\n✅ live ([routes/api_v1.php:919](routes/api_v1.php#L919) → `SMSController@getSMSTemplateList`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": [\n      {\n        \"value\": 1,\n        \"label\": \"Welcome SMS Template\"\n      }\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.8.I.3 Update SMS template",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"sms_template_id\": \"{{smsTemplateId}}\",\n  \"content\": \"Hi [customerName], We value your feedback! Share your experience: [landingPageUrl]. Thanks from [locationName]. Stop 2 End.\",\n  \"name\": \"Welcome SMS Template\",\n  \"channel_id\": \"{{channelId}}\",\n  \"landing_page_id\": \"{{landingPageId}}\",\n  \"review_site_id\": \"{{reviewSiteId}}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/sms/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "sms",
                    "update"
                  ]
                },
                "description": "Updates an existing SMS template (content, name, landing-page and review-site links). **Copy-on-write for globals:** a template with no `company_id` is replicated into the caller's company first (a name clash returns `400`). When `channel_id` is supplied the template is set as that channel's `template_id`.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. `sms_template_id` must resolve to a template owned by the caller's company (or a global one).\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `sms_template_id` | integer | Required | `exists:campaign_sms_templates,id`; ownership-checked. |\n| `content` | string | Required | SMS body with placeholders. |\n| `name` | string | Optional | New name. Unique within the caller's company (ignores this row). |\n| `channel_id` | integer | Optional | `exists:campaign_channels,id`. Sets the channel's `template_id`. |\n| `landing_page_id` | integer | Optional | `exists:campaign_landing_pages,id`. |\n| `review_site_id` | integer | Optional | `exists:review_sites,id`. |\n\n## Behaviour\n- Synchronous. `200 OK` returns `data.data` = `only(id,name,content,landing_page_id,review_site_id,store_id)`. `400` (envelope) when replicating a global and the name already exists for the company. `422` on validation failure or when the template is not in the caller's company.\n\n✅ live ([routes/api_v1.php:920](routes/api_v1.php#L920) → `SMSController@updateSMSTemplate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"SMS Template has been updated successfully!\",\n    \"data\": {\n      \"id\": 1,\n      \"name\": \"Welcome SMS Template\",\n      \"content\": \"Hi [customerName], We value your feedback! Share your experience: [landingPageUrl]. Thanks from [locationName]. Stop 2 End.\",\n      \"landing_page_id\": 10,\n      \"review_site_id\": 3,\n      \"store_id\": 123\n    }\n  }\n}"
                },
                {
                  "name": "422 Not your template",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected SMS template does not belong to your company.\",\n  \"errors\": {\n    \"sms_template_id\": [\n      \"The selected SMS template does not belong to your company.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.8.I.4 Get SMS template by ID",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/template/sms/{{smsTemplateId}}?campaign_id={{campaignId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "template",
                    "sms",
                    "{{smsTemplateId}}"
                  ],
                  "query": [
                    {
                      "key": "campaign_id",
                      "value": "{{campaignId}}",
                      "disabled": true
                    }
                  ]
                },
                "description": "Fetches a single SMS template by id. When `campaign_id` is supplied, resolves the campaign's active SMS channel template instead, substitutes `[landingPageUrl]` with the channel's landing-page URL (white-label aware) and replaces the remaining placeholders against the campaign's store.\n\n## Auth\n- Bearer token, **account (brand) persona** — `auth:api`. Template must belong to the caller's company or be global.\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `id` (path) | integer | Required | `exists:campaign_sms_templates,id`; ownership-checked. |\n| `campaign_id` | integer | Optional | `exists:rs_campaigns,id`. Resolve the campaign's SMS channel template with placeholders filled from its store. |\n\n## Behaviour\n- Synchronous. `200 OK` with the full model under `data.data`. `422` if the id/`campaign_id` is invalid or the template is outside the caller's company.\n\n✅ live ([routes/api_v1.php:921](routes/api_v1.php#L921) → `SMSController@getSMSTemplate`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"id\": 1,\n      \"company_id\": 30,\n      \"store_id\": 123,\n      \"name\": \"Welcome SMS Template\",\n      \"content\": \"Hi [customerName], Your feedback means a lot to us. Please click here to share: [landingPageUrl]. Thank you from [locationName]. Stop 2 End.\",\n      \"json_content\": null,\n      \"landing_page_id\": 10,\n      \"review_site_id\": 3,\n      \"created_at\": \"2026-07-09T10:30:00.000000Z\",\n      \"updated_at\": \"2026-07-09T10:30:00.000000Z\"\n    }\n  }\n}"
                }
              ]
            }
          ],
          "description": "Account/brand-token CRUD for campaign **SMS templates** (`campaign/template/sms/*`, `auth:api`, no throttle). Endpoints: create (seeds a default message body), list (`{value,label}` dropdown scoped to a required `store_id` plus company + global templates), update (content/links, optional channel wiring, copy-on-write for globals), and get-by-id (optional `campaign_id` to resolve the campaign's channel template with placeholders filled). Scoped to the caller's `company_id`; templates must belong to the caller's company or be global. Called by the campaign builder / templates UI."
        },
        {
          "name": "§ B.8.J Automate campaigns",
          "item": [
            {
              "name": "8.J.1 List automation campaigns",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/automate/list?page=1&per_page=15",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "automate",
                    "list"
                  ],
                  "query": [
                    {
                      "key": "page",
                      "value": "1"
                    },
                    {
                      "key": "per_page",
                      "value": "15"
                    }
                  ]
                },
                "description": "Paginated list of the caller's **automation** campaigns (`rs_campaigns.type = 'automation'`), each eager-loaded with its `file_header` configuration (headers map, sample-file path, location identifier). Scoped to the authenticated user's `person.company_id`.\n\n## Auth\n- Account/brand bearer (`auth:api`). No explicit scope rule — rows are filtered to the caller's own `person.company_id`, so you only ever see campaigns owned by your company.\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `page` | integer | Optional | Page number, min 1. Default 1. |\n| `per_page` | integer | Optional | Items per page, 1–100. Default 15. |\n\n## Behaviour\n- `200` with a standard Laravel `LengthAwarePaginator` under `data.data` (the outer `data` is the project envelope). Each campaign row carries a nullable `file_header` object.\n- `422` if `page`/`per_page` fall outside their bounds.\n\n✅ live ([routes/api_v1.php:944](routes/api_v1.php#L944) → `AutomateCampaignController@getAutomateCampaignList`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"current_page\": 1,\n      \"data\": [\n        {\n          \"id\": 12,\n          \"name\": \"Weekly review push\",\n          \"description\": \"Automated weekly review requests\",\n          \"status\": \"in_progress\",\n          \"type\": \"automation\",\n          \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n          \"updated_at\": \"2026-06-15T10:30:00.000000Z\",\n          \"file_header\": {\n            \"id\": 4,\n            \"rs_campaign_id\": 12,\n            \"headers\": {\n              \"email\": \"Email Address\",\n              \"phone\": \"Phone Number\",\n              \"customer_name\": \"Customer Name\",\n              \"location_identifier\": \"Store ID\"\n            },\n            \"sample_file_path\": \"campaign_automation/sample_files/1718447400_sample.xlsx\",\n            \"location_identifier\": \"Store ID\"\n          }\n        }\n      ],\n      \"first_page_url\": \"https://reputationmanagement-backend.com/api/v1/campaign/automate/list?page=1\",\n      \"from\": 1,\n      \"last_page\": 1,\n      \"last_page_url\": \"https://reputationmanagement-backend.com/api/v1/campaign/automate/list?page=1\",\n      \"links\": [\n        {\n          \"url\": null,\n          \"label\": \"&laquo; Previous\",\n          \"active\": false\n        },\n        {\n          \"url\": \"https://reputationmanagement-backend.com/api/v1/campaign/automate/list?page=1\",\n          \"label\": \"1\",\n          \"active\": true\n        },\n        {\n          \"url\": null,\n          \"label\": \"Next &raquo;\",\n          \"active\": false\n        }\n      ],\n      \"next_page_url\": null,\n      \"path\": \"https://reputationmanagement-backend.com/api/v1/campaign/automate/list\",\n      \"per_page\": 15,\n      \"prev_page_url\": null,\n      \"to\": 1,\n      \"total\": 1\n    }\n  }\n}"
                }
              ]
            },
            {
              "name": "8.J.2 Create file headers",
              "request": {
                "method": "POST",
                "header": [],
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    {
                      "key": "rs_campaign_id",
                      "value": "{{campaignId}}",
                      "type": "text",
                      "description": "Required. Campaign id; must exist in rs_campaigns and belong to your company. Must not be a bulk_upload or kiosk campaign."
                    },
                    {
                      "key": "file_headers",
                      "value": "{\"email\":\"Email Address\",\"phone\":\"Phone Number\",\"customer_name\":\"Customer Name\",\"location_identifier\":\"Store ID\"}",
                      "type": "text",
                      "description": "Required. JSON object. Must contain non-empty string values for keys: email, phone, customer_name, location_identifier."
                    },
                    {
                      "key": "sample_file",
                      "type": "file",
                      "src": [],
                      "description": "Required. Sample data file (xlsx, xls, csv), max 10 MB. Stored to S3."
                    },
                    {
                      "key": "location_identifier",
                      "value": "Store ID",
                      "type": "text",
                      "description": "Required. Column name identifying the location/store. Max 255. Unique per campaign group (store)."
                    }
                  ]
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/automate/file-headers",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "automate",
                    "file-headers"
                  ]
                },
                "description": "Creates the file-header (column-mapping) configuration for an automation campaign and uploads the sample file to S3. Any existing file-header for the campaign is deleted first, and the campaign's `type` is forced to `automation`. Sent as `multipart/form-data`. Emits an audit event (`CAMPAIGN_FILE_HEADERS_CREATED`).\n\n## Auth\n- Account/brand bearer (`auth:api`). The campaign must belong to the caller's `person.company_id` (`RsCampaign::where('company_id', ...)`), else `404`.\n\n## Rate limit\n- No rate limit.\n\n## Body (multipart/form-data)\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `rs_campaign_id` | integer | Required | `exists:rs_campaigns,id`; must be owned by caller and not bulk_upload/kiosk. |\n| `file_headers` | string (JSON) | Required | JSON object; must contain non-empty string values for `email`, `phone`, `customer_name`, `location_identifier`. |\n| `sample_file` | file | Required | `mimes:xlsx,xls,csv`, max 10 MB (10240 KB). |\n| `location_identifier` | string | Required | Max 255. Unique per campaign store-group. |\n\n## Behaviour\n- `201` with the created `CampaignAutomationFileHeader` row under `data.data`.\n- `400` if the campaign is a bulk_upload/kiosk type, or `file_headers` is missing required keys / has non-string values.\n- `404` if the campaign is not found or not owned by the caller.\n- `422` if base validation fails (missing file, wrong mime, duplicate `location_identifier`, etc.).\n\n> Saves `data.data.id` into `{{fileHeaderId}}` for the update/delete/sample-file requests.\n\n✅ live ([routes/api_v1.php:945](routes/api_v1.php#L945) → `AutomateCampaignController@createFileHeaders`)"
              },
              "response": [
                {
                  "name": "201 Created",
                  "status": "Created",
                  "code": 201,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"File headers created successfully\",\n    \"data\": {\n      \"rs_campaign_id\": 12,\n      \"headers\": {\n        \"email\": \"Email Address\",\n        \"phone\": \"Phone Number\",\n        \"customer_name\": \"Customer Name\",\n        \"location_identifier\": \"Store ID\"\n      },\n      \"sample_file_path\": \"campaign_automation/sample_files/1718447400_sample.xlsx\",\n      \"location_identifier\": \"Store ID\",\n      \"updated_at\": \"2026-06-15T10:30:00.000000Z\",\n      \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n      \"id\": 4\n    }\n  }\n}"
                },
                {
                  "name": "400 Invalid campaign type",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"rs_campaign_id\": [\n      \"This campaign is a bulk upload campaign. You can't create file header configuration for a bulk upload campaign or kiosk campaign.\"\n    ]\n  }\n}"
                },
                {
                  "name": "404 Campaign not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Campaign not found or not authorized\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The sample file field is required.\",\n  \"errors\": {\n    \"sample_file\": [\n      \"The sample file field is required.\"\n    ]\n  }\n}"
                }
              ],
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) {",
                      "  const j = pm.response.json();",
                      "  if (j.data && j.data.data && j.data.data.id) {",
                      "    pm.environment.set('fileHeaderId', String(j.data.data.id));",
                      "  }",
                      "}"
                    ]
                  }
                }
              ]
            },
            {
              "name": "8.J.3 Get file headers (by campaign id)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/automate/file-headers/{{campaignId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "automate",
                    "file-headers",
                    "{{campaignId}}"
                  ]
                },
                "description": "Returns the file-header configuration(s) for a given automation **campaign**. Note: the `{id}` path segment here is the **campaign id** (not the file-header id) — the campaign must exist, be of type `automation`, and belong to the caller's company.\n\n## Auth\n- Account/brand bearer (`auth:api`). Campaign is matched on the caller's `person.company_id`, else `404`.\n\n## Rate limit\n- No rate limit.\n\n## Path params\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `id` | integer | Required | The **campaign** id (route constrained to `[0-9]+`). |\n\n## Behaviour\n- `200` with an array of file-header rows under `data.data` (selected columns only: `id`, `rs_campaign_id`, `headers`, `sample_file_path`, `location_identifier`, timestamps).\n- `404` if the campaign is not found / not an automation campaign / not owned by the caller.\n\n✅ live ([routes/api_v1.php:946](routes/api_v1.php#L946) → `AutomateCampaignController@getFileHeaders`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"id\": 4,\n        \"rs_campaign_id\": 12,\n        \"headers\": {\n          \"email\": \"Email Address\",\n          \"phone\": \"Phone Number\",\n          \"customer_name\": \"Customer Name\",\n          \"location_identifier\": \"Store ID\"\n        },\n        \"sample_file_path\": \"campaign_automation/sample_files/1718447400_sample.xlsx\",\n        \"location_identifier\": \"Store ID\",\n        \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n        \"updated_at\": \"2026-06-15T10:30:00.000000Z\"\n      }\n    ]\n  }\n}"
                },
                {
                  "name": "404 Campaign not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Campaign not found or not authorized\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "8.J.4 Update file headers",
              "request": {
                "method": "PUT",
                "header": [],
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    {
                      "key": "headers",
                      "value": "{\"email\":\"Email Address\",\"phone\":\"Phone Number\",\"customer_name\":\"Customer Name\",\"location_identifier\":\"Store ID\"}",
                      "type": "text",
                      "description": "Optional. JSON object; when provided must contain non-empty string values for email, phone, customer_name, location_identifier."
                    },
                    {
                      "key": "sample_file",
                      "type": "file",
                      "src": [],
                      "description": "Optional. Replacement sample file (xlsx, xls, csv), max 10 MB. Old file is deleted from S3."
                    },
                    {
                      "key": "location_identifier",
                      "value": "Store ID",
                      "type": "text",
                      "description": "Optional. Column name identifying the location/store. Max 255."
                    }
                  ]
                },
                "url": {
                  "raw": "{{baseUrl}}/campaign/automate/file-headers/{{fileHeaderId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "automate",
                    "file-headers",
                    "{{fileHeaderId}}"
                  ]
                },
                "description": "Updates an existing file-header configuration. Here the `{id}` path segment is the **file-header id** (`campaign_automation_file_headers.id`). Only the fields supplied are updated; a new `sample_file` replaces and deletes the previous S3 object. Sent as `multipart/form-data`. Emits an audit event (`CAMPAIGN_FILE_HEADERS_UPDATED`).\n\n## Auth\n- Account/brand bearer (`auth:api`). The owning campaign must belong to the caller's `person.company_id` **and** be of type `automation`, else `403`.\n\n## Rate limit\n- No rate limit.\n\n## Path params\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `id` | integer | Required | The **file-header** id (route constrained to `[0-9]+`). |\n\n## Body (multipart/form-data — all optional)\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `headers` | string (JSON) | Optional | `nullable|json`; when filled must contain non-empty strings for email, phone, customer_name, location_identifier. |\n| `sample_file` | file | Optional | `mimes:xlsx,xls,csv`, max 10 MB. |\n| `location_identifier` | string | Optional | Max 255. |\n\n## Behaviour\n- `200` with the refreshed row under `data.data`.\n- `400` if `headers` is provided but malformed / missing keys / non-string values.\n- `403` if the config's campaign is not owned by the caller or is not an automation campaign.\n- `404` if no file-header row matches `{id}`.\n\n✅ live ([routes/api_v1.php:947](routes/api_v1.php#L947) → `AutomateCampaignController@updateFileHeaders`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"File headers updated successfully\",\n    \"data\": {\n      \"id\": 4,\n      \"rs_campaign_id\": 12,\n      \"headers\": {\n        \"email\": \"Email Address\",\n        \"phone\": \"Phone Number\",\n        \"customer_name\": \"Customer Name\",\n        \"location_identifier\": \"Store ID\"\n      },\n      \"sample_file_path\": \"campaign_automation/sample_files/1718447400_sample.xlsx\",\n      \"location_identifier\": \"Store ID\",\n      \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n      \"updated_at\": \"2026-06-15T10:30:00.000000Z\"\n    }\n  }\n}"
                },
                {
                  "name": "400 Invalid headers",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"headers\": [\n      \"Headers must contain all required keys: Email field mapping\"\n    ]\n  }\n}"
                },
                {
                  "name": "403 Not authorized",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Not authorized to update this file header configuration\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                },
                {
                  "name": "404 Not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"File header configuration not found\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "8.J.5 Delete file headers",
              "request": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/automate/file-headers/{{fileHeaderId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "automate",
                    "file-headers",
                    "{{fileHeaderId}}"
                  ]
                },
                "description": "Deletes a file-header configuration and its associated sample file from S3. The `{id}` path segment is the **file-header id**, which must exist in `campaign_automation_file_headers`. Emits an audit event (`CAMPAIGN_FILE_HEADERS_DELETED`).\n\n## Auth\n- Account/brand bearer (`auth:api`). The owning campaign must belong to the caller's `person.company_id` and be of type `automation`, else `403`.\n\n## Rate limit\n- No rate limit.\n\n## Path params\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `id` | integer | Required | The **file-header** id. `exists:campaign_automation_file_headers,id` (else `422`). |\n\n## Behaviour\n- `200` with a success message (no `data` payload).\n- `403` if the config's campaign is not owned by the caller or is not an automation campaign.\n- `404` if no file-header row matches `{id}` (post-validation guard).\n- `422` if `{id}` does not exist.\n\n✅ live ([routes/api_v1.php:948](routes/api_v1.php#L948) → `AutomateCampaignController@deleteFileHeaders`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"File headers deleted successfully\"\n  }\n}"
                },
                {
                  "name": "403 Not authorized",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Not authorized to delete this file header configuration\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected id is invalid.\",\n  \"errors\": {\n    \"id\": [\n      \"The selected id is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "8.J.6 Get sample file info",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/campaign/automate/file-headers/{{fileHeaderId}}/sample-file",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "automate",
                    "file-headers",
                    "{{fileHeaderId}}",
                    "sample-file"
                  ]
                },
                "description": "Returns the S3 URL and metadata (name, size, mime type) for the sample file attached to a file-header configuration. The `{id}` path segment is the **file-header id**.\n\n## Auth\n- Account/brand bearer (`auth:api`). The owning campaign must belong to the caller's `person.company_id` and be of type `automation`, else `403`.\n\n## Rate limit\n- No rate limit.\n\n## Path params\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `id` | integer | Required | The **file-header** id (route constrained to `[0-9]+`). |\n\n## Behaviour\n- `200` with `data.data` = `{ file_path (S3 URL), file_name, file_size (bytes), file_type (mime) }`.\n- `403` if the config's campaign is not owned by the caller or is not an automation campaign.\n- `404` if the file-header row is missing, or its `sample_file_path` no longer exists on S3.\n\n✅ live ([routes/api_v1.php:949](routes/api_v1.php#L949) → `AutomateCampaignController@downloadSampleFile`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"file_path\": \"https://d2ny6zb7otrnhl.cloudfront.net/campaign_automation/sample_files/1718447400_sample.xlsx\",\n      \"file_name\": \"1718447400_sample.xlsx\",\n      \"file_size\": 10240,\n      \"file_type\": \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n    }\n  }\n}"
                },
                {
                  "name": "404 Sample file not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Sample file not found\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                }
              ]
            }
          ],
          "description": "Account-token management of **automation** campaigns and their upload file-header (column-mapping) configurations, called by brand/account users. `8.J.1` lists the caller's automation campaigns (with their file_header); `8.J.2`–`8.J.6` create, read, update, delete a campaign's file-header config and fetch the associated S3 sample-file metadata. All scoped to the caller's `person.company_id`; file uploads go to S3. Backed by `AutomateCampaignController`."
        },
        {
          "name": "§ B.13.C Custom landing page",
          "item": [
            {
              "name": "B.13.C.1 Get landing page data (public)",
              "request": {
                "auth": {
                  "type": "noauth"
                },
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/custom-landing-page/data?company_id={{brandId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "custom-landing-page",
                    "data"
                  ],
                  "query": [
                    {
                      "key": "company_id",
                      "value": "{{brandId}}"
                    }
                  ]
                },
                "description": "Public read of a company's hosted landing-page widget configuration (header/button/content/slider/plan/faq/footer props, FAQs, how-it-works and testimonials blocks, plus the partner referral code). Used by the SPA to render the marketing landing page before login. When no saved widget exists a full set of defaults is returned. Image paths are rewritten to S3 URLs.\n\n## Auth\n- Public (No Auth). Route lives in the pre-login public reference group (no `auth:api`).\n\n## Rate limit\n- 100 requests/min per IP (`throttle:100,1`, public group).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | string | Required | Either a numeric company id **or** an active referral code (resolved to its company id). |\n\n## Behaviour\n- `200` with the widget config object under `data.data`.\n- `422` if `company_id` is omitted.\n\n✅ live ([routes/api_v1.php:173](routes/api_v1.php#L173) → `WidgetsController@getData`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"headerProps\": {\n        \"logoURL\": \"\",\n        \"logoImage\": \"https://d2ny6zb7otrnhl.cloudfront.net/landing_page_logo/logo.png\"\n      },\n      \"buttonProps\": {\n        \"startButtonText\": \"Register Now\",\n        \"startButtonFontColor\": \"#ffffff\",\n        \"startButtonBackgroundColor\": \"#1459c4\",\n        \"contactButtonText\": \"Contact sales\",\n        \"contactButtonFontColor\": \"#1459c4\",\n        \"contactButtonBackgroundColor\": \"#ffffff\",\n        \"contactButtonURL\": \"\"\n      },\n      \"contentProps\": {\n        \"contentHeading\": \"Stop dealing with the headache of responding to your reviews\",\n        \"contentDescription\": \"Save time, boost your ranking, and build brand loyalty with a personalized review response service.\",\n        \"youtubeThumbnail\": \"\",\n        \"youtubeUrl\": \"https://response-scribe.s3.us-east-1.amazonaws.com/assets/sample/ResponseScribe.mp4\",\n        \"hideVideoToggle\": false\n      },\n      \"sliderProps\": {\n        \"sliderHeading\": \"Trusted by over 10,000 local businesses\",\n        \"sliderImagesToggle\": false,\n        \"sliderImages\": null\n      },\n      \"planProps\": {\n        \"planText\": \"Choose the plan that fits your needs\",\n        \"planToggle\": false,\n        \"customPricingButtonToggle\": false\n      },\n      \"faqProps\": {\n        \"faqToggle\": false\n      },\n      \"footerProps\": {\n        \"footerText\": \"Personalized review response service for local businesses.\",\n        \"copyrightText\": \"\\u00a9 2023 Shout About Us. All Rights Reserved\",\n        \"socialIconToggle\": false,\n        \"footerLogo\": null\n      },\n      \"referralCode\": \"2TGU5VFcN5\",\n      \"company\": {\n        \"id\": 6\n      },\n      \"faq\": [],\n      \"howItWorksProps\": {\n        \"howItWorksText\": \"How it works\",\n        \"howItWorksToggle\": false,\n        \"howItWorks\": []\n      },\n      \"testimonialsProps\": {\n        \"testimonialsText\": \"You're in good company\",\n        \"testimonialsToggle\": false,\n        \"testimonials\": []\n      }\n    }\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The company id field is required.\",\n  \"errors\": {\n    \"company_id\": [\n      \"The company id field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.13.C.2 Save landing page settings",
              "request": {
                "method": "POST",
                "header": [],
                "body": {
                  "mode": "formdata",
                  "formdata": [
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "type": "text",
                      "description": "Required. Must exist in company and belong to your company hierarchy (BelongsToAuthCompany)."
                    },
                    {
                      "key": "logoImage",
                      "type": "file",
                      "src": [],
                      "description": "Optional. Header logo image (png/jpg/jpeg)."
                    },
                    {
                      "key": "footerLogo",
                      "type": "file",
                      "src": [],
                      "description": "Optional. Footer logo image (png/jpg/jpeg)."
                    },
                    {
                      "key": "youtubeThumbnail",
                      "type": "file",
                      "src": [],
                      "description": "Optional. Content video thumbnail image (png/jpg/jpeg)."
                    },
                    {
                      "key": "logoURL",
                      "value": "",
                      "type": "text",
                      "description": "Optional. Header logo link URL."
                    },
                    {
                      "key": "startButtonText",
                      "value": "Register Now",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "startButtonBackgroundColor",
                      "value": "#1459c4",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "startButtonFontColor",
                      "value": "#ffffff",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "contactButtonText",
                      "value": "Contact sales",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "contactButtonBackgroundColor",
                      "value": "#ffffff",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "contactButtonFontColor",
                      "value": "#1459c4",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "contactButtonURL",
                      "value": "",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "contentHeading",
                      "value": "Stop dealing with the headache of responding to your reviews",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "contentDescription",
                      "value": "Save time, boost your ranking, and build brand loyalty.",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "youtubeUrl",
                      "value": "https://example.com/video.mp4",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "hideVideoToggle",
                      "value": "false",
                      "type": "text",
                      "description": "Optional. Boolean."
                    },
                    {
                      "key": "sliderHeading",
                      "value": "Trusted by over 10,000 local businesses",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "sliderImagesToggle",
                      "value": "false",
                      "type": "text",
                      "description": "Optional. Boolean."
                    },
                    {
                      "key": "planText",
                      "value": "Choose the plan that fits your needs",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "planToggle",
                      "value": "false",
                      "type": "text",
                      "description": "Optional. Boolean."
                    },
                    {
                      "key": "customPricingButtonToggle",
                      "value": "false",
                      "type": "text",
                      "description": "Optional. Boolean."
                    },
                    {
                      "key": "faqToggle",
                      "value": "false",
                      "type": "text",
                      "description": "Optional. Boolean."
                    },
                    {
                      "key": "footerText",
                      "value": "Personalized review response service for local businesses.",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "copyrightText",
                      "value": "© 2023 Shout About Us. All Rights Reserved",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "socialIconToggle",
                      "value": "false",
                      "type": "text",
                      "description": "Optional. Boolean."
                    },
                    {
                      "key": "howItWorksToggle",
                      "value": "false",
                      "type": "text",
                      "description": "Optional. Boolean."
                    },
                    {
                      "key": "howItWorksText",
                      "value": "How it works",
                      "type": "text",
                      "description": "Optional."
                    },
                    {
                      "key": "testimonialsToggle",
                      "value": "false",
                      "type": "text",
                      "description": "Optional. Boolean."
                    },
                    {
                      "key": "testimonialsText",
                      "value": "You're in good company",
                      "type": "text",
                      "description": "Optional."
                    }
                  ]
                },
                "url": {
                  "raw": "{{baseUrl}}/custom-landing-page/save",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "custom-landing-page",
                    "save"
                  ]
                },
                "description": "Creates or updates the `landing_page` widget for a company. Uploads any supplied logo/footer-logo/thumbnail images to S3 (falling back to the previously saved images when a slot is left empty), then stores the full landing-page options blob on the company's `landing_page` widget row. Sent as `multipart/form-data`.\n\n## Auth\n- Account/brand bearer (`auth:api`). `company_id` is scoped by `BelongsToAuthCompany` — a partner can target any company beneath it; an account/brand user is limited to its own subtree.\n\n## Rate limit\n- No rate limit.\n\n## Body (multipart/form-data)\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | Required | `exists:company,id` + `BelongsToAuthCompany`. |\n| `logoImage` | file (image) | Optional | png/jpg/jpeg. Header logo. |\n| `footerLogo` | file (image) | Optional | png/jpg/jpeg. Footer logo. |\n| `youtubeThumbnail` | file (image) | Optional | png/jpg/jpeg. Video thumbnail. |\n| `logoURL`, `startButton*`, `contactButton*`, `content*`, `youtubeUrl`, `sliderHeading`, `planText`, `footerText`, `copyrightText`, `howItWorksText`, `testimonialsText` | string | Optional | Text props; each has a documented default when omitted. |\n| `hideVideoToggle`, `sliderImagesToggle`, `planToggle`, `customPricingButtonToggle`, `faqToggle`, `socialIconToggle`, `howItWorksToggle`, `testimonialsToggle` | boolean | Optional | Section toggles, default false. |\n\n## Behaviour\n- `200` with `{ status, message }` on success.\n- `422` if `company_id` is missing/invalid or outside the caller's hierarchy, or an image file is the wrong type.\n- Returns an `error` envelope if the company is not found or an S3 upload fails.\n\n✅ live ([routes/api_v1.php:1011](routes/api_v1.php#L1011) → `WidgetsController@customLandingPageSave`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Settings save successfully.\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected company id is invalid.\",\n  \"errors\": {\n    \"company_id\": [\n      \"The selected company id does not belong to your company hierarchy.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.13.C.3 Delete landing page image",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"company_id\": \"{{brandId}}\",\n  \"imageType\": \"logo\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/custom-landing-page/deleteFile",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "custom-landing-page",
                    "deleteFile"
                  ]
                },
                "description": "Removes one image (logo, thumbnail or footer logo) from a company's `landing_page` widget: deletes the file from S3, clears the corresponding slot in the stored options, and returns the refreshed options blob (with S3 URLs re-resolved).\n\n## Auth\n- Account/brand bearer (`auth:api`). `company_id` is scoped by `BelongsToAuthCompany`.\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `company_id` | integer | Required | `exists:company,id` + `BelongsToAuthCompany`. |\n| `imageType` | string | Optional | One of `logo`, `thumbnail`, `footerLogo`. Any other value is a no-op. |\n\n## Behaviour\n- `200` with `{ status, message, data }`; `data` is the refreshed options blob (or `[]` when the widget has no options).\n- `422` if `company_id` is missing/invalid/out of hierarchy, or `imageType` is not one of the allowed values.\n- Returns an `error` envelope (`Account not found!`) if the company does not exist.\n\n✅ live ([routes/api_v1.php:1012](routes/api_v1.php#L1012) → `WidgetsController@deleteFile`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Image deleted successfully.\",\n    \"data\": {\n      \"headerProps\": {\n        \"logoURL\": \"\",\n        \"logoImage\": \"https://d2ny6zb7otrnhl.cloudfront.net/landing_page_logo/logo.png\"\n      },\n      \"buttonProps\": {\n        \"startButtonText\": \"Register Now\",\n        \"startButtonFontColor\": \"#ffffff\",\n        \"startButtonBackgroundColor\": \"#1459c4\",\n        \"contactButtonText\": \"Contact sales\",\n        \"contactButtonFontColor\": \"#1459c4\",\n        \"contactButtonBackgroundColor\": \"#ffffff\",\n        \"contactButtonURL\": \"\"\n      },\n      \"contentProps\": {\n        \"contentHeading\": \"Stop dealing with the headache of responding to your reviews\",\n        \"contentDescription\": \"Save time, boost your ranking, and build brand loyalty with a personalized review response service.\",\n        \"youtubeThumbnail\": \"\",\n        \"youtubeUrl\": \"https://response-scribe.s3.us-east-1.amazonaws.com/assets/sample/ResponseScribe.mp4\",\n        \"hideVideoToggle\": false\n      },\n      \"sliderProps\": {\n        \"sliderHeading\": \"Trusted by over 10,000 local businesses\",\n        \"sliderImagesToggle\": false,\n        \"sliderImages\": null\n      },\n      \"planProps\": {\n        \"planText\": \"Choose the plan that fits your needs\",\n        \"planToggle\": false,\n        \"customPricingButtonToggle\": false\n      },\n      \"faqProps\": {\n        \"faqToggle\": false\n      },\n      \"footerProps\": {\n        \"footerText\": \"Personalized review response service for local businesses.\",\n        \"copyrightText\": \"\\u00a9 2023 Shout About Us. All Rights Reserved\",\n        \"socialIconToggle\": false,\n        \"footerLogo\": null\n      },\n      \"referralCode\": \"2TGU5VFcN5\",\n      \"company\": {\n        \"id\": 6\n      },\n      \"faq\": [],\n      \"howItWorksProps\": {\n        \"howItWorksText\": \"How it works\",\n        \"howItWorksToggle\": false,\n        \"howItWorks\": []\n      },\n      \"testimonialsProps\": {\n        \"testimonialsText\": \"You're in good company\",\n        \"testimonialsToggle\": false,\n        \"testimonials\": []\n      }\n    }\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected image type is invalid.\",\n  \"errors\": {\n    \"imageType\": [\n      \"The selected image type is invalid.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Brand-managed hosted marketing landing page (white-label asset). `B.13.C.1` is the **public** read used by the SPA to render the page before login; `B.13.C.2` saves the full landing-page widget config + logo/thumbnail images (account bearer); `B.13.C.3` deletes an individual image. Company scope on the write endpoints is enforced by `BelongsToAuthCompany`. Backed by `WidgetsController` (`getData`, `customLandingPageSave`, `deleteFile`)."
        },
        {
          "name": "§ B.0 Users & profile",
          "item": [
            {
              "name": "B.0.1 Add brand user",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"first_name\": \"Jane\",\n  \"last_name\": \"Smith\",\n  \"title\": \"Manager\",\n  \"email\": \"jane@example.com\",\n  \"password\": \"Secret@123\",\n  \"user_type\": \"location_assign\",\n  \"store_ids\": [\n    12\n  ],\n  \"assign_all_accounts\": 1,\n  \"send_default_reports\": 1,\n  \"inactive_at\": null\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/user/add",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "user",
                    "add"
                  ]
                },
                "description": "Creates a new user under the authenticated brand account with a role (`admin` / `location_assign` / `basic`), optional per-store assignments and default reports (activity, review-response, insight). Emails first-login credentials via the white-label welcome email. **Side effects:** creates User + Person + Email + UserStore (+ Report) rows, sends a welcome email, writes a `USER_CREATED` audit event.\n\n## Auth\n- Brand admin — collection bearer token (`auth:api`). FormRequest `authorize()` requires the caller's company `bundle_id` to be BRAND, else `403` (`This action is unauthorized.`).\n\n## Rate limit\n- No rate limit (no `throttle` middleware on the route; inside the `auth:api` group).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `first_name` | string | Required | ≤255 chars; no angle brackets. |\n| `last_name` | string | Optional | ≤255 chars. |\n| `title` | string | Optional | ≤50 chars. |\n| `email` | string | Required | Valid email; unique in `users`. |\n| `password` | string | Required | 8–100 chars, mixed case + number + special char, no spaces/quotes. |\n| `user_type` | string | Required | `admin`, `location_assign`, or `basic`. |\n| `store_ids` | integer[] | Required if `user_type=location_assign`, or `basic` with `assign_all_accounts=0` | Store ids under the caller's brand (`BelongsToAuthCompany`). |\n| `assign_all_accounts` | integer | Required if `user_type=basic` | `0`/`1`. |\n| `send_default_reports` | integer | Optional | `0`/`1`; `1` enables default reports (brand companies only). |\n| `inactive_at` | string | Optional | `Y-m-d H:i:s` deactivation date. |\n\n## Behaviour\n- Synchronous (DB transaction). `200` returns per-report `error`/`info`/`success` arrays alongside the success message.\n- `403` when the caller is not a brand company (authorize fails).\n- `422` on validation failure; `500` on save failure (rolled back).\n\n✅ live ([routes/api_v1.php:1111](routes/api_v1.php#L1111) → `BrandUserController@addUser`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": \"User has been created successfully\",\n    \"error\": [],\n    \"info\": [],\n    \"success\": [\n      \"Activity Report has been enabled.\",\n      \"Review Response Report has been enabled.\",\n      \"Insight Report has been enabled.\"\n    ]\n  }\n}"
                },
                {
                  "name": "403 Unauthorized (not a brand)",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"This action is unauthorized.\"\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The email has already been taken.\",\n  \"errors\": {\n    \"email\": [\n      \"The email has already been taken.\"\n    ],\n    \"store_ids\": [\n      \"The store ids field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.0.2 Edit brand user",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"user\": 12,\n  \"first_name\": \"Jane\",\n  \"last_name\": \"Smith\",\n  \"title\": \"Manager\",\n  \"password\": null,\n  \"user_type\": \"location_assign\",\n  \"store_ids\": [\n    12\n  ],\n  \"assign_all_accounts\": 1,\n  \"send_default_reports\": 1,\n  \"inactive_at\": null\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/user/edit",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "user",
                    "edit"
                  ]
                },
                "description": "Updates an existing brand user's name/title, role, store assignments, optional password and deactivation date. **You cannot edit your own account.** Password changes revoke the target's live tokens and are audited. The target must belong to the caller's brand company.\n\n## Auth\n- Brand admin — collection bearer token (`auth:api`). FormRequest `authorize()` requires caller company `bundle_id=BRAND`, the target in the same company, and the target ≠ self (else `403`).\n\n## Rate limit\n- No rate limit (no `throttle` middleware on the route; inside the `auth:api` group).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user` | integer | Required | Id of the user to edit (`users.id`). |\n| `first_name` | string | Required | ≤255 chars; no angle brackets. |\n| `last_name` | string | Optional | ≤255 chars. |\n| `title` | string | Optional | ≤50 chars. |\n| `password` | string | Optional | 8–100 chars, complexity rules; blank leaves the password unchanged. |\n| `user_type` | string | Required | `admin`, `location_assign`, or `basic`. |\n| `store_ids` | integer[] | Required if `user_type=location_assign`, or `basic` with `assign_all_accounts=0` | Store ids under the caller's brand. |\n| `assign_all_accounts` | integer | Required if `user_type=basic` | `0`/`1`. |\n| `send_default_reports` | integer | Optional | `0`/`1`. |\n| `inactive_at` | string | Optional | `Y-m-d H:i:s`. |\n\n## Behaviour\n- Synchronous (DB transaction). `200` on success.\n- `403` when authorize fails (not a brand / target not in company / editing self).\n- `422` on validation failure; `500` on save failure (rolled back).\n\n✅ live ([routes/api_v1.php:1112](routes/api_v1.php#L1112) → `BrandUserController@userEdit`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": \"User information has been updated successfully\"\n  }\n}"
                },
                {
                  "name": "403 Unauthorized",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"This action is unauthorized.\"\n}"
                }
              ]
            },
            {
              "name": "B.0.3 Get brand user",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"user_id\": 12\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/user/get",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "user",
                    "get"
                  ]
                },
                "description": "Returns a single user's profile, derived `user_type`, role, assigned locations (as `{value,label}` options) and assigned brands/accounts. `user_id` must belong to the caller's company (`BelongsToAuthCompany`).\n\n## Auth\n- Authenticated brand user — collection bearer token (`auth:api`); `user_id` is scoped to the caller's company by the validation rule.\n\n## Rate limit\n- No rate limit (no `throttle` middleware on the route; inside the `auth:api` group).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user_id` | integer | Required | Id of the user to retrieve (`users.id`); must belong to the caller's company. |\n\n## Behaviour\n- `200` returns `data.response`. `location`/`accounts` are `{value,label}` option objects for form pre-fill.\n- `404 User not found.` when the id resolves to no user.\n- `422` when `user_id` is missing or outside scope.\n\n✅ live ([routes/api_v1.php:1113](routes/api_v1.php#L1113) → `UserController@getUser`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": {\n      \"id\": 12,\n      \"first_name\": \"Jane\",\n      \"last_name\": \"Smith\",\n      \"email\": \"jane@example.com\",\n      \"inactive_at\": null,\n      \"email_verified_at\": \"2026-06-16T12:26:09.000000Z\",\n      \"title\": \"Manager\",\n      \"user_type\": \"location_assign\",\n      \"role\": \"Location Assigned\",\n      \"location\": [\n        {\n          \"value\": 12,\n          \"label\": \"Downtown Store (LOC-12)\"\n        }\n      ],\n      \"accounts\": [\n        {\n          \"value\": 13,\n          \"label\": \"Acme Brand (ACC-13)\"\n        }\n      ]\n    }\n  }\n}"
                },
                {
                  "name": "404 User not found",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Not Found\",\n  \"status\": \"error\",\n  \"errors\": \"User not found.\"\n}"
                }
              ]
            },
            {
              "name": "B.0.4 Delete user",
              "request": {
                "method": "DELETE",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"user\": 12\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/users/delete",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "users",
                    "delete"
                  ]
                },
                "description": "Soft-deletes a user (and their person), anonymises the email, revokes any live tokens (SOC 2 CC6.2) and removes the user's email reports. **You cannot delete your own account** and the target must belong to the caller's company. Audited via `USER_DELETED`.\n\n## Auth\n- Authenticated company-admin — collection bearer token (`auth:api`). Gate: the target's `company_id` must equal the caller's and the target ≠ self, else `403 Permission denied.`\n\n## Rate limit\n- No rate limit (no `throttle` middleware on the route; inside the `auth:api` group).\n\n## Body\n_(DELETE request carries a JSON body.)_\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user` | integer | Required | Id of the user to delete (`users.id`). |\n\n## Behaviour\n- Synchronous (DB transaction). `200` on success.\n- `403` on company mismatch or self-delete (`Permission denied.`).\n- `500` on delete failure (rolled back).\n\n✅ live ([routes/api_v1.php:1137](routes/api_v1.php#L1137) → `UserController@userDelete`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": \"User has been deleted successfully.\"\n  }\n}"
                },
                {
                  "name": "403 Permission denied",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Permission denied.\"\n}"
                }
              ]
            },
            {
              "name": "B.0.5 Update own profile",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"first_name\": \"Jane\",\n  \"last_name\": \"Doe\",\n  \"phone_number\": \"+15551234567\",\n  \"title\": \"Owner\",\n  \"email\": \"jane@example.com\",\n  \"password\": null\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/users/profile/update",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "users",
                    "profile",
                    "update"
                  ]
                },
                "description": "Updates the currently authenticated user's own profile — name, phone, title, email and optional password. Changing the password revokes the caller's other tokens and is audited (`AUTH_PASSWORD_CHANGED`).\n\n## Auth\n- Any authenticated user — collection bearer token (`auth:api`). Acts on `$request->user()`; email must be unique across `users` (ignoring the caller).\n\n## Rate limit\n- No rate limit (no `throttle` middleware on the route; inside the `auth:api` group).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `first_name` | string | Required | 2–100 chars; no angle brackets. |\n| `last_name` | string | Required | ≤100 chars; no angle brackets. |\n| `phone_number` | string | Required | 9–15 chars; no angle brackets. |\n| `title` | string | Optional | ≤100 chars. |\n| `email` | string | Required | Valid email; unique in `users` (ignoring the caller). |\n| `password` | string | Optional | 8–100 chars, mixed case + number + special char, no spaces/quotes; blank leaves the password unchanged. |\n\n## Behaviour\n- Synchronous (DB transaction). `200` on success.\n- `422` on validation failure.\n\n✅ live ([routes/api_v1.php:1134](routes/api_v1.php#L1134) → `PersonController@updateProfile`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": \"Saved Successfully\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The email field must be a valid email address.\",\n  \"errors\": {\n    \"email\": [\n      \"The email field must be a valid email address.\"\n    ],\n    \"phone_number\": [\n      \"The phone number field must be at least 9 characters.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Brand/account-side user management and self-service profile (bearer token). Brand company-admins add (`POST user/add`) and edit (`POST user/edit`) brand users with per-store assignments and default reports, fetch a single user (`POST user/get`), and delete a user (`DELETE users/delete`). Any authenticated user updates their own profile via `POST users/profile/update`. No throttle; scope enforced via FormRequest authorize() + BelongsToAuthCompany."
        },
        {
          "name": "§ B.4.G Google & Facebook connection",
          "item": [
            {
              "name": "B.4.G.1 Google — exchange OAuth code for access token",
              "request": {
                "method": "GET",
                "header": [],
                "auth": {
                  "type": "noauth"
                },
                "url": {
                  "raw": "{{baseUrl}}/google/access-token?code=<google_oauth_code>&source=web",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "google",
                    "access-token"
                  ],
                  "query": [
                    {
                      "key": "code",
                      "value": "<google_oauth_code>",
                      "description": "Required. Google OAuth authorization code."
                    },
                    {
                      "key": "source",
                      "value": "web",
                      "description": "Optional. app|web. `app` returns account+location data; also selects the redirect URI."
                    },
                    {
                      "key": "connect_google",
                      "value": "1",
                      "description": "Optional. 1 → link the token to a company and sync its GBP locations.",
                      "disabled": true
                    },
                    {
                      "key": "company_id_hash",
                      "value": "<company_hash>",
                      "description": "Required when connect_google=1. Hashid of the target company id.",
                      "disabled": true
                    },
                    {
                      "key": "hash",
                      "value": "{{inviteHash}}",
                      "description": "Optional. Invite hash encoding [store_id, 15]; grants access without a token.",
                      "disabled": true
                    }
                  ]
                },
                "description": "Exchanges a Google OAuth `code` for an access token, stores it (`google_access_tokens`), and then branches: with `connect_google=1` + `company_id_hash` it links the token to that company and syncs its GBP locations (dispatching `DuplicateGMBDebugger`); with `source=app` it returns the token plus fetched account/location data; otherwise it returns just the stored token id + token. Requires the `business.manage` scope to have been granted at consent. Records `gbp_connection_events`.\n\n## Auth\n- Auth-optional. Bearer token (api guard) **OR** the public connect-page pair (`connect_google=1` + `company_id_hash`) **OR** an invite `hash`. No token needed for the latter two.\n\n## Rate limit\n- 30 requests/min per IP (public).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `code` | string | Required | Google OAuth authorization code. |\n| `connect_google` | integer (0,1) | Optional | 1 → link token to company + sync GBP locations. |\n| `company_id_hash` | string | Optional | **Required when `connect_google`=1.** Hashid of the target company. |\n| `source` | string (app,web) | Optional | `app` returns account+location data; picks the redirect URI. |\n| `hash` | string | Optional | Invite hash encoding [store_id, 15]; grants access without a token. |\n\n## Behaviour\n- 200 with the token payload (web/app) or the synced location list (`connect_google=1`). 400 for an invalid company hash. Note: missing `business.manage` scope, absent access token, and Google token-endpoint errors are surfaced via `errorResponse` (HTTP 500) with an explanatory message.\n\n✅ live ([routes/api_v1.php:260](routes/api_v1.php#L260) → `GoogleController@getAccessToken`)"
              },
              "response": [
                {
                  "name": "200 OK (web flow)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"google_access_token_id\": 42,\n    \"google_access_token\": \"ya29.a0AfB_byExampleAccessTokenValue\",\n    \"account_data\": []\n  }\n}"
                },
                {
                  "name": "200 OK (connect_google=1, locations synced)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"name\": \"locations/987654321\",\n      \"title\": \"Demo Store\"\n    }\n  ]\n}"
                },
                {
                  "name": "400 Invalid company",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": \"Invalid request!\"\n}"
                },
                {
                  "name": "500 Scope not granted",
                  "status": "Internal Server Error",
                  "code": 500,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"You still need to grant us access to your Google Business Profile. Please be sure the check boxes are all selected.\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                }
              ]
            },
            {
              "name": "B.4.G.2 Google — connect location to review site",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"store_review_site_id\": \"{{storeReviewSiteId}}\",\n  \"google_access_token_id\": 42,\n  \"account_name\": \"accounts/123456789\",\n  \"location_name\": \"locations/987654321\",\n  \"google_place_id\": \"ChIJN1t_tDeuEmsRUsoyG83frY4\",\n  \"mapsUri\": \"https://maps.google.com/?cid=123\",\n  \"newReviewUri\": \"https://search.google.com/local/writereview?placeid=ChIJN1t_tDeuEmsRUsoyG83frY4\",\n  \"force_update\": 0\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/google/review-site/connect",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "google",
                    "review-site",
                    "connect"
                  ]
                },
                "description": "Connects a Google Business Profile location to a store review site. Persists the account/location/place ids + token, sets `sendto_scrapper=Y`, `url_status=enable`, `connection_status=connected`; **soft-deletes obsolete RD-scraped Google reviews** for the (store, Google) pair (Google's own API becomes source of truth); dispatches `TriggerReviewPullJob`; and records a `gbp_connection_events` row (CONNECTED / RECONNECTED / ACCOUNT_SWITCHED). Emits audit `STORE_REVIEW_SITE_OAUTH_CONNECTED` (and `GOOGLE_OAUTH_WIPED_RD_REVIEWS` when rows are wiped).\n\n## Auth\n- Auth-optional. Bearer token (api guard, scoped by `BelongsToAuthCompany`) **OR** an invite `hash` (no token) whose encoded store must own `store_review_site_id`.\n\n## Rate limit\n- 30 requests/min per IP (public).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_review_site_id` | integer | Required | Must exist; in caller scope (`BelongsToAuthCompany`) unless using `hash`. |\n| `google_access_token_id` | integer | Required | `google_access_tokens.id` from the token exchange. |\n| `account_name` | string (≤255) | Required | Google account resource, e.g. `accounts/123`. No angle brackets. |\n| `location_name` | string (≤255) | Required | Google location resource, e.g. `locations/456`. No angle brackets. |\n| `google_place_id` | string | Required | Google Place ID. |\n| `mapsUri` | url (≤2000) | Required | Google Maps URL → stored as `review_site_url`. |\n| `newReviewUri` | url (≤2000) | Required | Write-review URL → stored as `landing_page_url`. |\n| `google_response` | any | Optional | Raw Google API payload. |\n| `force_update` | integer (0,1) | Optional | Re-link even if a different location is already connected (wipes prior reviews). |\n| `hash` | string | Optional | Invite hash [store_id, 15]; grants access without a token, must match the row's store. |\n\n## Behaviour\n- 200 on connect. 400 if the location belongs to a different location without `force_update`, or (production) if the Google location is already linked elsewhere.\n\n✅ live ([routes/api_v1.php:263](routes/api_v1.php#L263) → `GoogleController@connectReviewSiteGoogle`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"The review site has been updated successfully.\"\n  }\n}"
                },
                {
                  "name": "400 Location already linked",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": \"The Google location is already linked to other places. Please connect another GMB account.\"\n}"
                },
                {
                  "name": "400 Different location",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": \"The location account does not correspond to the current location. Please choose the same location account\"\n}"
                }
              ]
            },
            {
              "name": "B.4.G.3 Google — disconnect review site",
              "request": {
                "method": "GET",
                "header": [],
                "auth": {
                  "type": "noauth"
                },
                "url": {
                  "raw": "{{baseUrl}}/google/review-site/disconnect?store_review_site_id={{storeReviewSiteId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "google",
                    "review-site",
                    "disconnect"
                  ],
                  "query": [
                    {
                      "key": "store_review_site_id",
                      "value": "{{storeReviewSiteId}}",
                      "description": "Required. The store review site to disconnect."
                    },
                    {
                      "key": "hash",
                      "value": "{{inviteHash}}",
                      "description": "Optional. Invite hash encoding the store_id; grants access without a token.",
                      "disabled": true
                    }
                  ]
                },
                "description": "Disconnects a Google Business Profile location from a store review site: clears the token/location, sets `sendto_scrapper=N`, `connection_status=disconnected`, `disconnect_reason=MANUAL`, clears the company `googleapi_access_token`, logs the change, **emails the account owner** (ACCOUNT_DISCONNECTED), and records a `DISCONNECTED_MANUAL` gbp event. Emits audit `STORE_REVIEW_SITE_OAUTH_DISCONNECTED`.\n\n## Auth\n- Auth-optional. Bearer token (scoped by `BelongsToAuthCompany`) **OR** an invite `hash` (no token) whose encoded store must own `store_review_site_id`.\n\n## Rate limit\n- 30 requests/min per IP (public).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_review_site_id` | integer | Required | The store review site to disconnect. |\n| `hash` | string | Optional | Invite hash encoding the store_id; grants access without a token. |\n\n## Behaviour\n- 200 on disconnect. 422 if `store_review_site_id` does not exist.\n\n✅ live ([routes/api_v1.php:264](routes/api_v1.php#L264) → `GoogleController@disconnectReviewSiteGoogle`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"The review site has been disconnected!\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected store review site id is invalid.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"store_review_site_id\": [\n      \"The selected store review site id is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.4.G.4 Google — record connection tracking event",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"event\": \"oauth.started\",\n  \"source\": \"settings\",\n  \"store_review_site_id\": \"{{storeReviewSiteId}}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/google/connection-event",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "google",
                    "connection-event"
                  ]
                },
                "description": "Records a **browser-side** step of the Google Business Profile connection funnel (sign-in clicked, consent denied, callback error, locations listed) for connection reporting. Fire-and-forget: only client-reportable funnel events are accepted — connection state changes (connected/disconnected) are recorded server-side only and cannot be fabricated here.\n\n## Auth\n- Auth-optional. Bearer token **OR** an invite `hash` **OR** the public connect-page `company_id_hash` **OR** `source=registration` (anonymous signup wizard).\n\n## Rate limit\n- 30 requests/min per IP (public).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `event` | string | Required | One of: `oauth.started`, `oauth.denied`, `oauth.callback_error`, `locations.fetched`, `locations.none_found`. |\n| `source` | string | Required | One of: `invite_link`, `connect_google_page`, `registration`, `settings`, `debugger`. |\n| `store_review_site_id` | integer | Optional | Binds the event to a row (must be accessible via hash/token). |\n| `hash` | string | Optional | Invite hash [store_id, 15]; grants access without a token. |\n| `company_id_hash` | string | Optional | Hashid of the account (public connect page). |\n| `message` | string (≤500) | Optional | Error detail returned by Google, if any. |\n| `accounts_count` | integer (≥0) | Optional | Accounts listed. |\n| `locations_count` | integer (≥0) | Optional | Locations listed. |\n\n## Behaviour\n- Always 200. Returns `{data:{status:\"recorded\"}}`; on an internal tracking error it swallows the failure and returns `{data:{status:\"skipped\"}}` so the SPA is never blocked.\n\n✅ live ([routes/api_v1.php:268](routes/api_v1.php#L268) → `GoogleController@trackConnectionEvent`)"
              },
              "response": [
                {
                  "name": "200 Recorded",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"recorded\"\n  }\n}"
                },
                {
                  "name": "200 Skipped (tracking error swallowed)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"skipped\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected event is invalid.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"event\": [\n      \"The selected event is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.4.G.5 Facebook — save user access token",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"user_access_token\": \"EAABsbCS...userToken\",\n  \"company_id\": \"{{brandId}}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/facebook/access-token/save",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "facebook",
                    "access-token",
                    "save"
                  ]
                },
                "description": "Validates a Facebook user access token via the Graph API debug endpoint and, if valid, persists the FB user details for the company. Used before selecting a page to connect.\n\n## Auth\n- Auth-optional. Bearer token (scoped by `BelongsToAuthCompany` on `company_id`) **OR** an invite `hash` (no token; `company_id` is then omitted).\n\n## Rate limit\n- 30 requests/min per IP (public).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user_access_token` | string | Required | FB user access token to validate + persist. |\n| `company_id` | integer | Required (non-hash) | Must exist + in caller scope (`BelongsToAuthCompany`). Omitted in the hash flow. |\n| `hash` | string | Optional | Invite hash [store_id, 15]; grants access without a token. |\n\n## Behaviour\n- 200 with a positional message. The payload uses an unusual shape: `status` plus a numeric-`0` message key — `{data:{status:\"success\",\"0\":\"User token has been saved successfully!\"}}`.\n\n✅ live ([routes/api_v1.php:275](routes/api_v1.php#L275) → `FacebookController@storeAccessToken`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"0\": \"User token has been saved successfully!\"\n  }\n}"
                },
                {
                  "name": "200 Save failed",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"error\",\n    \"0\": \"Something went wrong!\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The user access token field is required.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"user_access_token\": [\n      \"The user access token field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.4.G.6 Facebook — connect page to review site",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"store_review_site_id\": \"{{storeReviewSiteId}}\",\n  \"fb_token\": \"EAABsbCS...userToken\",\n  \"fbpage_access_token\": \"EAABsbCS...pageToken\",\n  \"fbpage_id\": \"123456789\",\n  \"url\": \"https://www.facebook.com/YourBusinessPage\",\n  \"scopes\": [\n    \"pages_show_list\",\n    \"pages_read_engagement\"\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/facebook/review-site/connect",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "facebook",
                    "review-site",
                    "connect"
                  ]
                },
                "description": "Connects a Facebook page to a store review site. Guards against a page already linked elsewhere; saves the page token + id, sets `sendto_scrapper=Y`, `url_status=enable`, `connection_status=connected`; stores the FB user token on the company if not already set; dispatches `TriggerReviewPullJob`; and deletes prior response rows if the page changed. Emits audit `STORE_REVIEW_SITE_OAUTH_CONNECTED`.\n\n## Auth\n- Auth-optional. Bearer token (scoped by `BelongsToAuthCompany`) **OR** an invite `hash` (no token) whose encoded store must own `store_review_site_id`.\n\n## Rate limit\n- 30 requests/min per IP (public).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_review_site_id` | integer | Required | Must exist; caller scope (`BelongsToAuthCompany`) unless using `hash`. |\n| `fb_token` | string (≤2000) | Required | Long-lived user access token. |\n| `fbpage_access_token` | string (≤2000) | Required | Page access token. |\n| `fbpage_id` | string (≤255) | Required | Facebook page id. |\n| `url` | url (5–2000) | Required | Facebook page URL → `review_site_url` + `landing_page_url`. |\n| `scopes` | array | Optional | Granted OAuth scopes. |\n| `landing_page_url` | url (5–2000) | Optional | Accepted; the page `url` is used for the landing page. |\n| `hash` | string | Optional | Invite hash [store_id, 15]; grants access without a token, must match the row's store. |\n\n## Behaviour\n- 200 on connect. 400 if the page is already linked to another place.\n\n✅ live ([routes/api_v1.php:278](routes/api_v1.php#L278) → `FacebookController@connectReviewSiteFacebook`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"The review site has been connected!\"\n  }\n}"
                },
                {
                  "name": "400 Page already linked",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": \"The page is already linked to other places. Please connect another Facebook page.\"\n}"
                }
              ]
            },
            {
              "name": "B.4.G.7 Facebook — disconnect review site",
              "request": {
                "method": "GET",
                "header": [],
                "auth": {
                  "type": "noauth"
                },
                "url": {
                  "raw": "{{baseUrl}}/facebook/review-site/disconnect?store_review_site_id={{storeReviewSiteId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "facebook",
                    "review-site",
                    "disconnect"
                  ],
                  "query": [
                    {
                      "key": "store_review_site_id",
                      "value": "{{storeReviewSiteId}}",
                      "description": "Required. The store review site to disconnect."
                    },
                    {
                      "key": "hash",
                      "value": "{{inviteHash}}",
                      "description": "Optional. Invite hash encoding the store_id; grants access without a token.",
                      "disabled": true
                    }
                  ]
                },
                "description": "Disconnects a Facebook page from a store review site: clears the token + URLs, sets `sendto_scrapper=N`, `connection_status=disconnected`, `disconnect_reason=MANUAL`, clears the company `fb_access_token`, logs the change, and **emails the account owner** (ACCOUNT_DISCONNECTED). Emits audit `STORE_REVIEW_SITE_OAUTH_DISCONNECTED`.\n\n## Auth\n- Auth-optional. Bearer token (scoped by `BelongsToAuthCompany`) **OR** an invite `hash` (no token) whose encoded store must own `store_review_site_id`.\n\n## Rate limit\n- 30 requests/min per IP (public).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_review_site_id` | integer | Required | The store review site to disconnect. |\n| `hash` | string | Optional | Invite hash encoding the store_id; grants access without a token. |\n\n## Behaviour\n- 200 on disconnect. 422 if `store_review_site_id` does not exist.\n\n✅ live ([routes/api_v1.php:279](routes/api_v1.php#L279) → `FacebookController@disconnectReviewSiteFacebook`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"The review site has been disconnected!\"\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected store review site id is invalid.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"store_review_site_id\": [\n      \"The selected store review site id is invalid.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Auth-optional Google Business Profile and Facebook page connection endpoints for a store's review sites. Each accepts either a Bearer token (authenticated admin/account flow) or an emailed-invite `hash` (no token) — with a hash, the target `store_review_site` must belong to the store the hash encodes. Google: exchange OAuth code for an access token, connect/disconnect a GBP location, and record browser-side connection-funnel tracking events. Facebook: save a validated user access token, connect/disconnect a page. Connect wipes obsolete scraped reviews, flips `connection_status`, and triggers a review pull; disconnect clears credentials and emails the account owner. All live in the public `throttle:30,1` group."
        },
        {
          "name": "§ B.12.W Public review widget (embed)",
          "item": [
            {
              "name": "12.W.1 Get widget by unique id (embed data)",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/widget?unique_id=abc123&feed_data=0",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "widget"
                  ],
                  "query": [
                    {
                      "key": "unique_id",
                      "value": "abc123"
                    },
                    {
                      "key": "feed_data",
                      "value": "0"
                    },
                    {
                      "key": "limit",
                      "value": "5",
                      "disabled": true
                    }
                  ]
                },
                "description": "Fetches an embeddable review widget by its `unique_id`, and — when `feed_data=1` — the paginated review feed the widget renders. Resolves the widget, verifies the owning store/brand has an active widget add-on, then either returns the widget config alone or a `ResponseFeed` collection of reviews with the average rating. The review-site filter and review count come from the widget's saved options.\n\n## Auth\n- Bearer — **account** (brand) persona (`auth:api`). Registered under the account group: a valid brand/account token is required even though the widget renders on public pages. 400 if the add-on is inactive or the widget is missing.\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `unique_id` | string | Required | `exists:widgets,unique_id`. |\n| `feed_data` | integer | Optional | `0` or `1`. `1` returns the paginated review feed + `avg_rating`. |\n| `limit` | integer | Optional | Feed page size (pass-through; defaults to the widget's `numberOfReviews`, else 3). |\n\n## Behaviour\n- 200. **Config only** (`feed_data` omitted/0): `{ \"data\": { \"widget\": { ... }, \"status\": \"success\", \"data\": null } }`.\n- 200. **Feed** (`feed_data=1`): a paginated `ResponseFeed` collection — top-level `data` is the review array, with `widget`, `status`, `avg_rating`, plus `links`/`meta` from pagination.\n- 400 widget not found / no active add-on.\n\n✅ live ([routes/api_v1.php:993](routes/api_v1.php#L993) → `WidgetController@getWidgetDataByUniqueId`)"
              },
              "response": [
                {
                  "name": "200 OK (config only)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"widget\": {\n      \"id\": 5,\n      \"company_id\": 456,\n      \"store_id\": 26,\n      \"widget_text\": null,\n      \"widget_type\": \"feed\",\n      \"widget_type_id\": 1,\n      \"widget_title\": \"My Reviews\",\n      \"sub_type\": null,\n      \"unique_id\": \"abc123\",\n      \"widget_options\": {\n        \"numberOfReviews\": 5,\n        \"reviewSiteValue\": {\n          \"value\": 100,\n          \"label\": \"hipages\"\n        }\n      },\n      \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n      \"updated_at\": \"2026-06-15T10:30:00.000000Z\"\n    },\n    \"status\": \"success\",\n    \"data\": null\n  }\n}"
                },
                {
                  "name": "200 OK (feed)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": [\n    {\n      \"id\": 1546,\n      \"rating\": 5,\n      \"review_text\": \"Fantastic service!\",\n      \"reviewer_name\": \"Jane D.\",\n      \"review_site\": \"hipages\",\n      \"created_at\": \"2026-06-14T12:00:00.000000Z\"\n    }\n  ],\n  \"widget\": {\n    \"id\": 5,\n    \"company_id\": 456,\n    \"store_id\": 26,\n    \"widget_text\": null,\n    \"widget_type\": \"feed\",\n    \"widget_type_id\": 1,\n    \"widget_title\": \"My Reviews\",\n    \"sub_type\": null,\n    \"unique_id\": \"abc123\",\n    \"widget_options\": {\n      \"numberOfReviews\": 5,\n      \"reviewSiteValue\": {\n        \"value\": 100,\n        \"label\": \"hipages\"\n      }\n    },\n    \"created_at\": \"2026-06-15T10:30:00.000000Z\",\n    \"updated_at\": \"2026-06-15T10:30:00.000000Z\"\n  },\n  \"status\": \"success\",\n  \"avg_rating\": 4.5,\n  \"links\": {\n    \"first\": \"?page=1\",\n    \"last\": \"?page=1\",\n    \"prev\": null,\n    \"next\": null\n  },\n  \"meta\": {\n    \"current_page\": 1,\n    \"from\": 1,\n    \"last_page\": 1,\n    \"per_page\": 5,\n    \"to\": 1,\n    \"total\": 1\n  }\n}"
                },
                {
                  "name": "400 Bad Request",
                  "status": "Bad Request",
                  "code": 400,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Widget not found!\",\n  \"status\": \"error\",\n  \"errors\": {}\n}"
                }
              ]
            },
            {
              "name": "12.W.2 Track widget activity",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"widget_unique_id\": \"abc123\",\n  \"activity_type\": \"view\",\n  \"referrer_url\": \"https://example.com/\",\n  \"device_type\": \"desktop\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/widget/activity",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "widget",
                    "activity"
                  ]
                },
                "description": "Records a `view` or `click` event for a widget, deduplicated per widget + activity type + IP + device per calendar day (increments a running `total_count` when the same combination is seen again that day).\n\n## Auth\n- Bearer — **account** (brand) persona (`auth:api`).\n\n## Rate limit\n- No rate limit.\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `widget_unique_id` | string | Required | Existing widget `unique_id` (`WidgetUniqueIdRule`). |\n| `activity_type` | string | Required | `view` or `click`. |\n| `referrer_url` | string | Optional | Page the widget was embedded on. |\n| `ip_address` | string | Optional | Client IP (used in the daily dedup key). |\n| `device_type` | string | Optional | `desktop`, `mobile` or `tablet`. |\n\n## Behaviour\n- 200 with `{ \"data\": { \"status\": \"success\", \"message\": \"Record saved successfully!\" } }`.\n- 422 validation (unknown widget / bad activity_type).\n\n✅ live ([routes/api_v1.php:995](routes/api_v1.php#L995) → `WidgetController@activity`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Record saved successfully!\"\n  }\n}"
                },
                {
                  "name": "422 Unprocessable Entity",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The widget unique id field is required.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"widget_unique_id\": [\n      \"The widget unique id field is required.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "12.W.3 List widget types",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/widget/type?store_id={{storeId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "widget",
                    "type"
                  ],
                  "query": [
                    {
                      "key": "store_id",
                      "value": "{{storeId}}"
                    },
                    {
                      "key": "company_id",
                      "value": "{{brandId}}",
                      "disabled": true
                    }
                  ]
                },
                "description": "Lists all active widget types for the widget builder, plus the widget add-on status when a `store_id` is supplied (whether the store has an active add-on and which store ids it covers).\n\n## Auth\n- Bearer — **account** (brand) persona (`auth:api`).\n\n## Rate limit\n- No rate limit.\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | Optional | `exists:store,id` + must belong to the auth company. When present, `widget_add_ons` is populated. |\n| `company_id` | integer | Optional | `exists:company,id` + must belong to the auth company. |\n\n## Behaviour\n- 200 with `{ \"data\": { \"status\": \"success\", \"data\": [ ...widget types ], \"widget_add_ons\": { status, store_ids } | null } }`. `widget_add_ons` is null when no `store_id` is given.\n\n✅ live ([routes/api_v1.php:994](routes/api_v1.php#L994) → `WidgetController@type`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"id\": 1,\n        \"name\": \"Feed Widget\",\n        \"status\": \"active\"\n      },\n      {\n        \"id\": 2,\n        \"name\": \"Badge Widget\",\n        \"status\": \"active\"\n      }\n    ],\n    \"widget_add_ons\": {\n      \"status\": true,\n      \"store_ids\": [\n        26,\n        27\n      ]\n    }\n  }\n}"
                }
              ]
            }
          ],
          "description": "Endpoints that power the embeddable review widget: fetch a widget's config/feed by unique_id, record view/click activity, and list available widget types. Registered under the account (auth:api) group — a valid brand/account bearer token is required even though the widget renders on public pages. Brand-side widget config CRUD (list/create-or-update/delete) lives in § B.12."
        },
        {
          "name": "§ B.15 Billing address & checkout",
          "item": [
            {
              "name": "B.15.1 Get customer billing address",
              "request": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/billing-address/get?user_id={{userId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "billing-address",
                    "get"
                  ],
                  "query": [
                    {
                      "key": "user_id",
                      "value": "{{userId}}"
                    }
                  ]
                },
                "description": "Returns the latest saved billing address for a user (with its city -> state -> country chain). If that user's company pays via `partner` or `brand` (`company_options.who_will_pay`), the billing address of the paying company's **owner user** is returned instead, so the invoice always resolves to the party that actually pays. Read-only; returns `null` in `data` when no address is stored yet.\n\n## Auth\n- Bearer **account / partner** token (`auth:api`). `user_id` is validated by `BelongsToAuthCompany`, so it must be a user inside the caller's own company hierarchy.\n\n## Rate limit\n- No rate limit (no `throttle` middleware on this route).\n\n## Query\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user_id` | integer | Required | `exists:users,id` and must belong to the caller's company hierarchy (`BelongsToAuthCompany`). |\n\n## Behaviour\n- `200` — `{ data: {...} }` with the address, or `{ data: null }` when none exists. Column aliases: `email -> billing_email`, `phone -> mobile`, `postal_code -> zipcode`, `address -> billing_address`, `company_name -> company`. Eager-loads `city.state.country`.\n- `422` — `user_id` missing/invalid or outside the caller's hierarchy.\n\n✅ live ([routes/api_v1.php:1077](routes/api_v1.php#L1077) → `CustomerBillingAddressController@getCustomerBillingAddresses`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"id\": 1,\n    \"user_id\": 3,\n    \"city_id\": 456,\n    \"name\": \"Test User\",\n    \"billing_email\": \"demo@example.com\",\n    \"mobile\": \"123-456-7890\",\n    \"zipcode\": \"12345\",\n    \"billing_address\": \"123 Example St\",\n    \"company\": \"Example Corp\",\n    \"city\": {\n      \"id\": 456,\n      \"name\": \"Megarine\",\n      \"state_id\": 4902,\n      \"state\": {\n        \"id\": 4902,\n        \"name\": \"Sidi Bel Abb\\u00e8s\",\n        \"country_id\": 4,\n        \"country\": {\n          \"id\": 4,\n          \"name\": \"Algeria\"\n        }\n      }\n    }\n  }\n}"
                },
                {
                  "name": "200 OK (no address saved)",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": null\n}"
                },
                {
                  "name": "422 Out of scope",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The selected user id does not belong to your company hierarchy.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"user_id\": [\n      \"The selected user id does not belong to your company hierarchy.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.15.2 Save customer billing address",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"user_id\": \"{{userId}}\",\n  \"company\": \"Acme Corp\",\n  \"billing_email\": \"john@example.com\",\n  \"mobile\": \"5551234567\",\n  \"billing_address\": \"123 Main St\",\n  \"zipcode\": \"90210\",\n  \"city_id\": \"{{cityId}}\",\n  \"signup_business_id\": null\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/billing-address/save",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "billing-address",
                    "save"
                  ]
                },
                "description": "Creates or updates (upsert on the user's latest row) the billing address for `user_id`. The stored `name` is derived from the user's person firstname + lastname (client-supplied names are ignored). **Side effect:** when `signup_business_id` is supplied and that signup business has `update_address_user_notify = 1`, the linked store's location-account `company` address (phone/address/city/zip) is synced to the same values.\n\n## Auth\n- Bearer **account / partner** token (`auth:api`). `user_id` validated by `BelongsToAuthCompany` (must be in the caller's hierarchy).\n\n## Rate limit\n- No rate limit (no `throttle` middleware on this route).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user_id` | integer | Required | `exists:users,id`; must belong to caller's hierarchy (`BelongsToAuthCompany`). |\n| `company` | string | Required | `max:255`; no angle brackets (`NameNoAngleBrackets`). Stored as `company_name`. |\n| `billing_email` | string | Required | Valid email (`email:rfc,dns`). Stored as `email`. |\n| `mobile` | string | Optional | `max:20`. Stored as `phone`. |\n| `billing_address` | string | Required | `max:255`; no angle brackets. Stored as `address`. |\n| `zipcode` | string | Required | `min:3`, `max:10`. Stored as `postal_code`. |\n| `city_id` | integer | Required | `exists:city,id`. |\n| `signup_business_id` | integer | Optional | `exists:signup_businesses,id`. When set and its `update_address_user_notify = 1`, syncs the store's company address. |\n\n## Behaviour\n- `200` — `{ data: {...} }` with the persisted `BillingAddress` model (raw DB columns: `email`, `phone`, `postal_code`, `address`, `company_name`, `id`, timestamps).\n- `422` — validation failure (invalid email, out-of-scope `user_id`, angle brackets in text fields, etc.).\n\n✅ live ([routes/api_v1.php:1078](routes/api_v1.php#L1078) → `CustomerBillingAddressController@saveCustomerBillingAddress`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"user_id\": 3,\n    \"name\": \"Test User\",\n    \"email\": \"john@example.com\",\n    \"phone\": \"5551234567\",\n    \"city_id\": 456,\n    \"postal_code\": \"90210\",\n    \"address\": \"123 Main St\",\n    \"company_name\": \"Acme Corp\",\n    \"updated_at\": \"2026-06-16T10:42:44.000000Z\",\n    \"created_at\": \"2026-06-16T10:42:44.000000Z\",\n    \"id\": 1\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The billing email field must be a valid email address. (and 1 more error)\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"billing_email\": [\n      \"The billing email field must be a valid email address.\"\n    ],\n    \"city_id\": [\n      \"The selected city id is invalid.\"\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "B.15.3 Partner checkout details",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"plan_id\": \"{{planId}}\",\n  \"partner_id\": \"{{partnerId}}\",\n  \"all_account\": 0,\n  \"account_ids\": [\n    \"{{brandId}}\"\n  ],\n  \"cancel_plan\": 0\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                },
                "url": {
                  "raw": "{{baseUrl}}/billing/checkout-details",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "billing",
                    "checkout-details"
                  ]
                },
                "description": "Returns a partner-level checkout preview for a plan: the plan pricing plus the count and list of the partner's active/pending stores (where `store_options.who_will_pay = partner`) that the plan would apply to. `cancel_plan` filters the store set by whether the store's latest plan is already flagged for cancellation. Read-only preview — nothing is charged or written.\n\n## Auth\n- Bearer **partner** token (`auth:api`). `partner_id` must be a `bundle_id = 1` partner company inside the caller's hierarchy (`exists` + `BelongsToAuthCompany`); each `account_ids[]` must be a `bundle_id = 3` brand company in the caller's hierarchy.\n\n## Rate limit\n- No rate limit (no `throttle` middleware on this route).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `plan_id` | integer | Required | `exists:plans,id`. |\n| `partner_id` | integer | Required | `bundle_id = 1` partner company in caller's hierarchy. Matched as `partner.id` OR `partner.company_id`. |\n| `all_account` | integer (0\\|1) | Required | `1` = all brand accounts under the partner; `0` = only `account_ids`. |\n| `account_ids` | integer[] | Required if `all_account = 0` | Each a `bundle_id = 3` brand company in caller's hierarchy. |\n| `cancel_plan` | integer (0\\|1) | Optional | `0`/empty = only stores NOT flagged for cancellation; `1` = only stores whose latest plan has a `cancel_subscription_at`. |\n\n## Behaviour\n- `200` — double-wrapped envelope `{ data: { data: {...} } }` (controller passes `['data' => ...]` into the `{ data: ... }` responder). Payload: `plan_id`, `plan_name`, `plan_short_info`, `price`, `original_price`, `total_location` (matched store count), `plan_start` (today), `plan_end` (end of month), `store_ids` (matched store ids).\n- `422` — validation failure (missing/invalid plan, partner out of scope, `account_ids` required when `all_account = 0`).\n\n✅ live ([routes/api_v1.php:1082](routes/api_v1.php#L1082) → `BillingController@accountCheckoutDetails`)"
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"data\": {\n      \"plan_id\": 3,\n      \"plan_name\": \"ResponseScribe\",\n      \"plan_short_info\": \"Up to 50 responses/month\",\n      \"price\": 100,\n      \"original_price\": 100,\n      \"total_location\": 3,\n      \"plan_start\": \"2026-07-09\",\n      \"plan_end\": \"2026-07-31\",\n      \"store_ids\": [\n        1,\n        2,\n        3\n      ]\n    }\n  }\n}"
                },
                {
                  "name": "422 Validation error",
                  "status": "Unprocessable Content",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"The account ids field is required when all account is 0.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"account_ids\": [\n      \"The account ids field is required when all account is 0.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Account/partner billing-address management and the partner-level checkout preview. `B.15.1 Get` / `B.15.2 Save` read and upsert a user's billing address (auto-resolving to the paying company's owner when the company pays via partner/brand); `B.15.3` returns a read-only checkout preview (plan price + matched partner-paid store count/ids) for a plan assignment. All `auth:api`, scoped by `BelongsToAuthCompany`."
        }
      ]
    },
    {
      "name": "5. Report Endpoints",
      "description": "All reporting, stats, KPI and dashboard endpoints for both partner and account, split by persona.",
      "item": [
        {
          "name": "Partner",
          "description": "Partner-token reports: performance, stats, billing dashboard, insights/exports/user-reports.",
          "item": [
            {
              "name": "§ P.6 Performance Report (partner)",
              "item": [
                {
                  "name": "6.B.1 KPI cards (partner)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"account_id\": 7\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/partner/report/review-response/kpi",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "report",
                        "review-response",
                        "kpi"
                      ]
                    },
                    "description": "Returns period-over-period KPI cards (review counts, average ratings, response rate, average response time, awaiting-response backlog, sentiment split) for the partner's review-response report.\n\n**Auth:** Partner — `auth:api`; under `partner/report` group. Handler hard-gates on `company.bundle_id == 1` (partner only). Store set resolves by precedence: `store_id` → single store; else `account_id` (a bundle_id=3 brand) → `Store::getStoreListByCompany`; else all of the partner-user's stores via `Store::getStoreListByUser`. `account_id`/`store_id`/`client_*` are all validated by `BelongsToAuthCompany` so the partner can only reach its own tree.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | must belong to auth company; filters to one store |\n| account_id | integer | no | a brand (`company.bundle_id=3`) under this partner |\n| client_location_id | string | no | external location id; resolved to a store_id |\n| client_account_id | string | no | external account id; resolved to an account_id |\n\n**Response** — `{ data: ... }` envelope.\n- `reviews_current` (int) — reviews in range\n- `reviews_previous` (int) — reviews in equivalent prior period\n- `avg_rating` (float) / `avg_rating_previous` (float)\n- `response_rate_pct` (float) / `response_rate_pct_previous` (float) — responded/total × 100\n- `avg_response_time_in_days` (string) — e.g. `\"1.5 Days\"`\n- `awaiting_response` (int) — plan-aware backlog count\n- `reviews_sentiments` (object) — `positive` / `neutral` / `negative` counts"
                  },
                  "response": [
                    {
                      "name": "200 Success — KPI swap (2026-06)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"reviews_current\": 580,\n    \"reviews_previous\": 530,\n    \"avg_rating\": 4.3,\n    \"avg_rating_previous\": 4.1,\n    \"response_rate_pct\": 89.66,\n    \"response_rate_pct_previous\": 84.2,\n    \"avg_response_time_in_days\": \"1.5 Days\",\n    \"awaiting_response\": 14,\n    \"reviews_sentiments\": {\n      \"negativeReviews\": 50,\n      \"negativeReviews_percent\": 8.62,\n      \"positiveReviews\": 450,\n      \"positiveReviews_percent\": 77.59,\n      \"neutralReviews\": 80,\n      \"neutralReviews_percent\": 13.79\n    }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "6.B.2 Locations table (partner)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"account_id\": 7,\n    \"sort_field\": \"total_reviews\",\n    \"sort_by\": \"desc\",\n    \"limit\": 10,\n    \"page\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/partner/report/review-response/by-location",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "report",
                        "review-response",
                        "by-location"
                      ]
                    },
                    "description": "Returns a paginated, per-location (per-store) review-response table for the partner — reviews, responses, average response time, average rating, and response rate per store.\n\n**Auth:** Partner — `auth:api`; under `partner/report` group. Handler hard-gates on `company.bundle_id == 1`. Store set precedence: `store_id` → single store; else `account_id` (brand) → `Store::getSubPartnerStoresList`; else all partner stores via `Store::getStoreListByUser`. All id params guarded by `BelongsToAuthCompany`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n| account_id | integer | no | brand (`bundle_id=3`) under this partner |\n| client_location_id | string | no | resolved to store_id |\n| client_account_id | string | no | resolved to account_id |\n| sort_field | string | no | one of `location_name,total_reviews,total_responses,avg_resp_time,avg_rating,response_rate` (default `location_name`) |\n| sort_by | string | no | `asc`/`ASC`/`desc`/`DESC` (default `ASC`) |\n| search_field | string | no | only `location_name` |\n| search_value | string | no | search term |\n| limit | integer | no | per page (default 10) |\n| page | integer | no | page number |\n\n**Response** — Laravel paginated resource collection (`PartnerReviewResponseReportResource`), `data` array + paginator meta.\n- `data[].id` (int) — store id\n- `data[].location_name` (string) — `name - storeid` (or just name)\n- `data[].total_reviews` (int) / `data[].total_responses` (int)\n- `data[].avg_resp_time` (float) — days (minutes/(24*60), rounded 2dp)\n- `data[].avg_rating` (float) / `data[].response_rate` (float, percentage)\n- `current_page`, `last_page`, `per_page`, `total` (paginator meta)"
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": [ { \"id\": 15, \"location_name\": \"Downtown Location - LOC-001\", \"total_reviews\": 120, \"total_responses\": 108, \"avg_resp_time\": 1.5, \"avg_rating\": 4.44, \"response_rate\": 90.0 } ], \"current_page\": 1, \"last_page\": 4, \"per_page\": 10, \"total\": 38 }"
                    }
                  ]
                },
                {
                  "name": "6.B.3 Reviews-by-Site (partner)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"account_id\": 7\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/partner/report/review-response/by-site",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "report",
                        "review-response",
                        "by-site"
                      ]
                    },
                    "description": "Returns review/response metrics grouped by review site (publisher) for the partner — per-site review count, average rating, % of total, response count, and response rate.\n\n**Auth:** Partner — `auth:api`; under `partner/report` group. No explicit bundle gate in this handler, but all id params are guarded by `BelongsToAuthCompany`. Store set precedence: `store_id` → single store; else `account_id` (brand) → `Store::getSubPartnerStoresList`; else all stores via `Store::getStoreListByUser`. Output array passed through `PartnerReviewResponseBySiteResource` (passthrough of `ReviewSite::reviewResponsesBySite`).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n| account_id | integer | no | brand (`bundle_id=3`) under this partner |\n| client_location_id | string | no | resolved to store_id |\n| client_account_id | string | no | resolved to account_id |\n\n**Response** — `{ data: [...] }` envelope; array of per-site rows.\n- `id` (int) — review_site id\n- `review_site` (string) — lowercased site name (`maps.google` normalized to `google`)\n- `review_site_logo` (string|null) — S3 logo URL\n- `total_reviews` (int) — reviews in range for this site\n- `avg_rating` (float) — average rating (hipages uses §5 bridge)\n- `per_of_total` (float) — % of all reviews from this site\n- `total_response` (int) — responses for this site\n- `per_total_response` (float) — response rate %"
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": [ { \"id\": 44, \"review_site\": \"google\", \"review_site_logo\": \"https://s3/.../google_ic.png\", \"total_reviews\": 350, \"avg_rating\": 4.5, \"per_of_total\": 60.34, \"total_response\": 320, \"per_total_response\": 91.43 } ] }"
                    }
                  ]
                },
                {
                  "name": "6.B.4 Excel export (partner)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"account_id\": 7\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/partner/excel/download/review-response-feed",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "excel",
                        "download",
                        "review-response-feed"
                      ]
                    },
                    "description": "Generates the partner review-response feed as an Excel file, stores it on S3, and returns the public download URL.\n\n**Auth:** Partner — `auth:api`; under `partner/excel` group. Handler hard-gates on `company.bundle_id == 1` (returns `Access denied. Partner plan required.` otherwise). Store set precedence: `account_id` (brand) → `Store::getSubPartnerStoresList`; else `store_id` → single store; else all partner-user stores via `Store::getPartnerUserStoreList`. All id params guarded by `BelongsToAuthCompany`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n| account_id | integer | no | brand (`bundle_id=3`) under this partner |\n| client_location_id | string | no | resolved to store_id |\n| client_account_id | string | no | resolved to account_id |\n| search | string | no | max:255 (accepted but unused in handler) |\n\n**Response** — `{ data: ... }` envelope.\n- `path` (string) — S3 URL of the generated `.xlsx` (`download/excel/YYYY-MM/<time>-ReviewResponseReport.xlsx`)"
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"path\": \"https://s3.amazonaws.com/bucket/download/excel/2024-01/1700000000-ReviewResponseReport.xlsx\" } }"
                    }
                  ]
                },
                {
                  "name": "6.B.5 PDF export (partner)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"account_id\": 7\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/partner/pdf/download/performance-report",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "pdf",
                        "download",
                        "performance-report"
                      ]
                    },
                    "description": "Returns the full data payload used to render the partner performance report PDF — header company info, KPI block, sentiment split, by-site, by-location, and the response feed.\n\n**Auth:** Partner — `auth:api`; under `partner/pdf` group. Handler hard-gates on `company.bundle_id == 1` (returns `Access denied. Partner plan required.` otherwise). Store set precedence: `account_id` (brand) → `Store::getSubPartnerStoresList`; else `store_id` → single store; else all partner-user stores via `Store::getPartnerUserStoreList`. All id params guarded by `BelongsToAuthCompany`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n| account_id | integer | no | brand (`bundle_id=3`) under this partner |\n| client_location_id | string | no | resolved to store_id |\n| client_account_id | string | no | resolved to account_id |\n\n**Response** — `{ data: ... }` envelope.\n- `start` (string) / `end` (string) — echoed date range\n- `company` (object) — `partner_name`, `account_name` (comma-joined brands, truncated to 3 + \"... and N More Brand(s)\"), `store_name` (same truncation for locations), `logo` (string|null, from white-label)\n- `kpis_data` (object) — `Review::metricsData` output\n- `reviewsSentiments` (object)\n- `review_response_by_site` (array) — see by-site shape\n- `review_response_by_location` (array) — per-store rows (`id`, `name`, `total_reviews`, `avg_rating`, `per_of_total`, `total_response`, `per_total_response`, `avg_response_time`)\n- `response_feed` (array) — `ResponseFeed` resource collection (PDF mode)"
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"start\": \"2024-01-01 00:00:00\", \"end\": \"2024-12-31 23:59:59\", \"company\": { \"partner_name\": \"Acme Partner\", \"account_name\": \"Brand A, Brand B\", \"store_name\": \"Store 1, Store 2\", \"logo\": \"https://cdn.example.com/logo.png\" }, \"kpis_data\": {}, \"reviewsSentiments\": {}, \"review_response_by_site\": [], \"review_response_by_location\": [], \"response_feed\": [] } }"
                    }
                  ]
                },
                {
                  "name": "Partner reviews by publisher (lifetime)",
                  "request": {
                    "method": "POST",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/partner/report/review-response/by-publisher",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "report",
                        "review-response",
                        "by-publisher"
                      ]
                    },
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"account_id\": 7\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "description": "Returns per-publisher lifetime totals (all-time review count + overall rating from the synced `store_review_sites` pivot, hipages via §5 bridge) plus the selected-period review count and rating, scoped to the partner.\n\n**Auth:** Partner — `auth:api`; under `partner/report` group. Uses `GetPartnerReviewResponseBySiteRequest`. Store set precedence: `store_id` → single store; else `account_id` (brand) → `Store::getSubPartnerStoresList`; else all partner stores via `Store::getStoreListByUser`. Note: this handler does NOT translate `client_location_id`/`client_account_id` to ids (unlike its by-site sibling) — pass `store_id`/`account_id` directly. All id params guarded by `BelongsToAuthCompany`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n| account_id | integer | no | brand (`bundle_id=3`) under this partner |\n| client_location_id | string | no | validated but NOT resolved by this handler |\n| client_account_id | string | no | validated but NOT resolved by this handler |\n\n**Response** — `{ data: [...] }` envelope; one row per publisher with history, sorted most-reviewed first.\n- `id` (int) — review_site id\n- `review_site` (string) — display name (`maps.google` → `Google`)\n- `review_site_logo` (string) — S3 logo URL\n- `review_site_color` (string|null)\n- `total_reviews` (int) — lifetime/all-time total\n- `overall_rating` (float|null) — count-weighted lifetime rating\n- `period_reviews` (int) — reviews within the date range\n- `period_rating` (float|null) — average rating within range"
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    {\n      \"id\": 44,\n      \"review_site\": \"Google\",\n      \"review_site_logo\": \"https://s3.amazonaws.com/bucket/assets/review-sites/google_ic.png\",\n      \"review_site_color\": \"#4285F4\",\n      \"total_reviews\": 5459,\n      \"overall_rating\": 4.25,\n      \"period_reviews\": 84,\n      \"period_rating\": 4.61\n    },\n    {\n      \"id\": 3,\n      \"review_site\": \"Facebook\",\n      \"review_site_logo\": \"https://s3.amazonaws.com/bucket/assets/review-sites/facebook_ic.png\",\n      \"review_site_color\": \"#1877F2\",\n      \"total_reviews\": 812,\n      \"overall_rating\": 4.1,\n      \"period_reviews\": 12,\n      \"period_rating\": 4.5\n    }\n  ]\n}"
                    },
                    {
                      "name": "422 Validation error",
                      "status": "Unprocessable Content",
                      "code": 422,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"message\": \"The range start field is required. (and 1 more error)\",\n  \"errors\": {\n    \"range_start\": [\"The range start field is required.\"],\n    \"range_end\": [\"The range end does not match the format Y-m-d H:i:s.\"]\n  }\n}"
                    }
                  ]
                }
              ],
              "description": "Partner-scope dashboard tabs — counts across every brand + location in the partner tree. The brand-scoped version of these tabs lives in B.5."
            },
            {
              "name": "§ P.8 Stats",
              "description": "KPI counts, sentiment breakdown, new-vs-closed monthly series, and the recent-activity feed — everything that powers the partner dashboard summary panels.",
              "item": [
                {
                  "name": "P.8.1 Counts (sub-partners / accounts / locations)",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/partner/stats/counts",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "stats",
                        "counts"
                      ]
                    },
                    "description": "Returns headline counts of sub-partners, brand accounts, and locations for the authenticated partner, each with 30-day / 90-day comparison buckets.\n\n**Auth:** Partner — `auth:api` (under the `partner/stats` group, no extra middleware). Scope is the partner-tree walked from `user->person->company_id`: sub-partners are `company` rows (bundle_id=1) directly under it; brand accounts are bundle_id=3 rows under the company OR under its sub-partners; locations are bundle_id=2 rows under those brand IDs. If the user has `assign_brand` set, brand/location scope is further narrowed to `assignBrands()`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- No path, query, or body params (reads the authenticated user only).\n\n**Response** — `{ data: ... }` envelope.\n- `sub-partners.count` (int) — total sub-partner companies under this partner.\n- `sub-partners.last_30_days` (int) — sub-partners created in the last 30 days.\n- `sub-partners.before_30_days` (int) — sub-partners created earlier than 30 days ago.\n- `accounts.count` (int) — total brand accounts in scope.\n- `accounts.last_30_days` (int) — brand accounts created in the last 30 days.\n- `accounts.before_30_days` (int) — brand accounts created earlier than 30 days ago.\n- `locations.count` (int) — total locations (bundle_id=2) in scope.\n- `locations.last_30_days` (int) — locations created in the last 30 days.\n- `locations.before_30_days` (int) — locations created earlier than 30 days ago.\n- `locations.last_90_days` (int) — locations created in the last 90 days."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"sub-partners\": { \"count\": 8, \"last_30_days\": 2, \"before_30_days\": 6 },\n    \"accounts\":     { \"count\": 24, \"last_30_days\": 5, \"before_30_days\": 19 },\n    \"locations\":    { \"count\": 142, \"last_30_days\": 12, \"before_30_days\": 130, \"last_90_days\": 30 }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "P.8.2 Review-response stats",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/partner/stats/review-response",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "stats",
                        "review-response"
                      ]
                    },
                    "description": "Returns review and response totals for the authenticated partner across last-30-days / previous-30-days / before-30-days windows, plus a KPI summary (response rate %, avg response time, awaiting count).\n\n**Auth:** Partner — `auth:api` (under the `partner/stats` group). Scope is the partner tree resolved by `Store::getStoreListByCompany(user->person->company, true)`, restricted to active/pending stores; if the user has `assign_brand` set, counts are further narrowed to the company's `assignBrands()`. Review/response date buckets use `review_date` and `response_created` respectively.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- No path, query, or body params (reads the authenticated user only).\n\n**Response** — `{ data: ... }` envelope.\n- `review_stats.last_30_days` (int) — reviews dated within the last 30 days.\n- `review_stats.previous_30_days_count` (int) — reviews dated in the 30–60 days ago window.\n- `review_stats.before_30_days` (int) — reviews dated earlier than 30 days ago.\n- `response_stats.last_30_days` (int) — responses created within the last 30 days.\n- `response_stats.previous_30_days_count` (int) — responses created in the 30–60 days ago window.\n- `response_stats.before_30_days` (int) — responses created earlier than 30 days ago.\n- `kpi_summary.response_rate_pct` (number) — `responses_last / reviews_last * 100`, rounded to 2dp (0 if no reviews).\n- `kpi_summary.response_rate_pct_previous` (number) — same ratio for the previous 30-day window.\n- `kpi_summary.avg_response_time` (number) — average response time in days (avg minutes / 1440, 2dp; 0 if none).\n- `kpi_summary.awaiting_response` (int) — count of reviews still awaiting a response in the last-30-day window (`Review::awaitingResponseCount`)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"review_stats\":   { \"last_30_days\": 324, \"previous_30_days_count\": 298, \"before_30_days\": 1820 },\n    \"response_stats\": { \"last_30_days\": 310, \"previous_30_days_count\": 285, \"before_30_days\": 1750 }\n  }\n}"
                    },
                    {
                      "name": "200 Success — KPI swap (2026-06)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"review_stats\": {\n      \"last_30_days\": 324,\n      \"previous_30_days_count\": 298,\n      \"before_30_days\": 1820\n    },\n    \"response_stats\": {\n      \"last_30_days\": 310,\n      \"previous_30_days_count\": 285,\n      \"before_30_days\": 1750\n    },\n    \"kpi_summary\": {\n      \"response_rate_pct\": 87.5,\n      \"response_rate_pct_previous\": 82.1,\n      \"avg_response_time\": 1.42,\n      \"awaiting_response\": 22\n    }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "P.8.3 Review sentiments (positive / neutral / negative)",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/partner/stats/review-sentiments",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "stats",
                        "review-sentiments"
                      ]
                    },
                    "description": "Returns the review sentiment breakdown (positive / neutral / negative review counts) for the authenticated partner.\n\n**Auth:** Partner — `auth:api` (under the `partner/stats` group). Scope joins `store → company → brand → partner` and requires `partner.bundle_id=1` matching `user->person->company` (as the partner itself or a sub-partner via `partner.company_id`), restricted to active/pending stores; `assign_brand` narrows further to `assignBrands()`. Sentiment buckets: negative = rate < 3 (with a Google site_id=3 carve-out requiring rate > 0), positive = rate > 3, neutral = everything else.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- No path, query, or body params (reads the authenticated user only).\n\n**Response** — `{ data: ... }` envelope.\n- `positive_count` (int) — reviews with rate > 3.\n- `neutral_count` (int) — reviews that are neither positive nor negative (e.g. rate == 3, or Google site_id=3 with rate 0).\n- `negative_count` (int) — reviews with rate < 3 (Google site_id=3 requires rate > 0 to count)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"positive_count\": 1240,\n    \"neutral_count\": 180,\n    \"negative_count\": 95\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "P.8.4 New vs closed location count (last 4 months)",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/partner/stats/new-vs-closed-location-count",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "stats",
                        "new-vs-closed-location-count"
                      ]
                    },
                    "description": "Returns new-vs-closed location counts for each of the last four calendar months for the authenticated partner.\n\n**Auth:** Partner — `auth:api` (under the `partner/stats` group). Scope joins `store → company(loc, bundle_id=2) → brand → partner → main_partner` and matches when either `partner.id` or `main_partner.id` equals `user->person->company_id`. New counts use `company.created_at` over active/pending stores (narrowed by `assignBrands()` when `assign_brand` is set); closed counts use soft-deleted `company.deleted_at` (`withTrashed`), narrowed by `Company::getStoreIdsByUser` when `assign_location` is set.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- No path, query, or body params (reads the authenticated user only).\n\n**Response** — `{ data: [...] }` envelope: an array of exactly 4 month entries (oldest to newest), each:\n- `month` (string) — short month-year label, e.g. `\"Jun 26\"` (`Carbon` `M y` format).\n- `closed_count` (int) — locations deleted that month (0 if none).\n- `new_count` (int) — locations created that month (0 if none)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"month\": \"Feb 26\", \"closed_count\": 2, \"new_count\": 8 },\n    { \"month\": \"Mar 26\", \"closed_count\": 1, \"new_count\": 5 },\n    { \"month\": \"Apr 26\", \"closed_count\": 3, \"new_count\": 10 },\n    { \"month\": \"May 26\", \"closed_count\": 0, \"new_count\": 7 }\n  ]\n}"
                    }
                  ]
                },
                {
                  "name": "P.8.5 Recent activity log",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/partner/stats/activities?limit=10",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "stats",
                        "activities"
                      ],
                      "query": [
                        {
                          "key": "limit",
                          "value": "10",
                          "description": "Optional — items per page (1–100, default 10)"
                        },
                        {
                          "key": "page",
                          "value": "1",
                          "description": "Optional — pagination cursor",
                          "disabled": true
                        }
                      ]
                    },
                    "description": "Returns a paginated, newest-first audit/activity feed for the authenticated partner and (if a partner) all of its child brand companies.\n\n**Auth:** Partner — `auth:api` under the `partner/` prefix group. Scope is derived from the caller: `$request->user()->person->company` is resolved, then `getAccessibleCompanyIds()` collects that company's id plus, if `bundle_id == 1` (partner), all non-deleted child companies (`company.company_id == partner.id`). Activities are filtered to those `company_id`s; if the user has `assign_brand` set, results are further narrowed to the user's `assignBrands()` company ids. Returns `Company not found` error if the user has no associated company.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `limit` (integer, optional) — items per page; `nullable|integer|min:1|max:100`, defaults to 10. (`page` is honored by the underlying `paginate()` but is not validated in the Form Request.)\n\n**Response** — `{ data: ... }` envelope. The `data` object contains:\n- `data` (array) — list of activity rows, newest first. Each item:\n  - `id` (integer) — activity row id.\n  - `description` (string) — `ucfirst()` of the activity's `description` column.\n  - `date` (string, ISO-8601) — the activity's `created_at`.\n  - `activity_type` (string) — e.g. `location_created`, `brand_created`.\n  - `entity_type` (string) — e.g. `location`, `brand`.\n- `current_page` (integer) — current paginator page.\n- `per_page` (integer) — page size.\n- `total` (integer) — total matching activities."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"data\": [\n      { \"id\": 101, \"description\": \"Partner account created for Acme Partner\", \"date\": \"2026-05-29T14:30:00.000000Z\", \"activity_type\": \"partner_created\", \"entity_type\": \"partner\" }\n    ],\n    \"current_page\": 1,\n    \"per_page\": 10,\n    \"total\": 42\n  }\n}"
                    }
                  ]
                }
              ]
            },
            {
              "name": "§ P.11 Billing dashboard",
              "description": "Partner-side billing UI feed. The \"dashboard\" endpoint is the heavy one (KPIs, accounts, overage breakdown); \"overview\" is the same shape minus the trend + overage data; account/store endpoints drill into per-location and per-feature detail.",
              "item": [
                {
                  "name": "P.11.1 Partner billing dashboard (full UI payload)",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/partner/billing/dashboard?",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "billing",
                        "dashboard"
                      ],
                      "query": [
                        {
                          "key": "year",
                          "value": "2026",
                          "description": "Optional — defaults to current year (2000–2100)",
                          "disabled": true
                        },
                        {
                          "key": "month",
                          "value": "5",
                          "description": "Optional — defaults to current month (1–12)",
                          "disabled": true
                        }
                      ]
                    },
                    "description": "Returns the full partner billing dashboard payload: period overview (total billed, billing/contract model, active stores) plus enriched per-account rows, a summary KPI block (incl. month-over-month change), an overage breakdown grouped by account, Model-B allocation status rows, and a UI context headline.\n\n**Auth:** Partner — `auth:api`; under the `partner/billing` route group. Scope is resolved by `getAuthPartner()`, which takes the authenticated user's `person.company_id` and walks up via `Company::getParentCompany(..., 'partner')` to the owning partner; returns 403 if the user is not associated with a partner. No path target — always the caller's own partner tree.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `year` (integer, optional) — 2000–2100; defaults to current year.\n- Query: `month` (integer, optional) — 1–12; defaults to current month.\n\n**Response** — `{ data: ... }` envelope. Merge of the overview payload plus dashboard-only keys.\n- `total_billed` (number) — period sum of `client_amount` line items for the partner.\n- `period` (string) — `YYYY-MM`.\n- `billing_model` (string|null) — e.g. `plan-based` / `usage-based`.\n- `contract_model` (string|null) — `model-a` / `model-b`.\n- `active_stores` (integer) — total active stores across the partner's brands.\n- `accounts` (array) — enriched per-account (brand) rows: `account_id`, `account_name`, `plan_name`, `store_count`, `cost`, `overage`, `status` (`within_limits`/`overage`/`halted`), plus `plan_subtitle`, `enforcement_mode` (`per-location`/`aggregate-pool`), `total_cost`, `allocation_note`, `allocation_state` (`within_allocation`/`overage`/`halted`), `billing_formula` (string|null; set only for model-b).\n- `summary` (object) — `total_billed`, `total_billed_previous_month`, `total_billed_change_pct` (number|null), `total_billed_caption_model_b` (string|null), `active_accounts`, `active_stores`, `total_overage`, `accounts_with_overage`, `at_limit_locations`, `halted_locations`.\n- `overage_breakdown` (array) — per-account: `account_id`, `account_name`, `features[]` (`feature_name`, `feature_label`, `overage_amount`, `units_over`, `usage_total`, `rate`, `unit_price_label`). Empty unless overage line items exist.\n- `allocation_status` (array) — model-b only (else `[]`); per brand×feature: `account_id`, `account_name`, `feature_name`, `feature_label`, `row_title`, `used`, `allocation`, `percentage` (number|null), `status` (`on_track`/`warning`/`halted`), `enforcement_mode`, `locations_at_limit`.\n- `context` (object) — `headline` (string), `billing_model`, `contract_model`."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"data\": {\n      \"total_billed\": 1249.75,\n      \"period\": \"2026-05\",\n      \"billing_model\": \"usage-based\",\n      \"contract_model\": \"model-a\",\n      \"active_stores\": 12,\n      \"accounts\": [],\n      \"summary\": {},\n      \"overage_breakdown\": [],\n      \"context\": {}\n    }\n  }\n}"
                    },
                    {
                      "name": "403 Not a partner",
                      "status": "Forbidden",
                      "code": 403,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Authenticated user is not associated with an partner.\",\n  \"errors\": []\n}"
                    }
                  ]
                },
                {
                  "name": "P.11.2 Partner billing overview (lightweight summary)",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/partner/billing/overview?",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "billing",
                        "overview"
                      ],
                      "query": [
                        {
                          "key": "year",
                          "value": "2026",
                          "description": "Optional — defaults to current year",
                          "disabled": true
                        },
                        {
                          "key": "month",
                          "value": "5",
                          "description": "Optional — defaults to current month",
                          "disabled": true
                        }
                      ]
                    },
                    "description": "Returns a lightweight billing summary for the authenticated partner for the given period: headline totals plus a flat per-account (brand) cost breakdown and active store count. This is the same base payload the dashboard endpoint enriches.\n\n**Auth:** Partner — `auth:api`; under the `partner/billing` route group. Scope is resolved by `getAuthPartner()` (user's `person.company_id` walked up via `Company::getParentCompany(..., 'partner')`); returns 403 if the user is not associated with a partner. Always the caller's own partner tree — no path target.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `year` (integer, optional) — 2000–2100; defaults to current year.\n- Query: `month` (integer, optional) — 1–12; defaults to current month.\n\n**Response** — `{ data: ... }` envelope.\n- `total_billed` (number) — period sum of `client_amount` line items for the partner.\n- `period` (string) — `YYYY-MM`.\n- `billing_model` (string|null) — e.g. `plan-based` / `usage-based`.\n- `contract_model` (string|null) — `model-a` / `model-b`.\n- `active_stores` (integer) — total active stores across the partner's brands.\n- `accounts` (array) — per-account (brand) summary rows: `account_id` (int), `account_name` (string), `plan_name` (string|null), `store_count` (int), `cost` (number), `overage` (number), `status` (string: `within_limits` / `overage` / `halted`)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"data\": {\n      \"total_billed\": 1249.75,\n      \"period\": \"2026-05\",\n      \"billing_model\": \"usage-based\",\n      \"contract_model\": \"model-a\",\n      \"active_stores\": 12,\n      \"accounts\": []\n    }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "P.11.3 Get billing detail for an account",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/partner/billing/account/{{brandId}}?",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "billing",
                        "account",
                        "{{brandId}}"
                      ],
                      "query": [
                        {
                          "key": "year",
                          "value": "2026",
                          "description": "Optional",
                          "disabled": true
                        },
                        {
                          "key": "month",
                          "value": "5",
                          "description": "Optional",
                          "disabled": true
                        },
                        {
                          "key": "feature",
                          "value": "all",
                          "description": "Optional metered-feature filter, or `all`",
                          "disabled": true
                        }
                      ]
                    },
                    "description": "Returns per-store billing detail for one account (brand) under the authenticated partner. For `model-a` each store lists per-feature usage/limit/cost/overage rows; for `model-b` each store shows a flat charge plus an enabled-feature list. Also returns `feature_options` (with an `All` entry) and a flattened `per_location_drilldown`; an optional `feature` query narrows the rows to one metered feature.\n\n**Auth:** Partner — `auth:api`; under the `partner/billing` route group. Caller's partner is resolved by `getAuthPartner()` (403 if none). The `{accountId}` brand is loaded by `Company::find` (404 if missing) and its partner is verified with `Company::getParentCompany(brandId, 'partner')` — returns 403 unless that partner equals the caller's partner.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `accountId` (integer, required) — account/brand company id (route-constrained to digits).\n- Query: `year` (integer, optional) — 2000–2100; defaults to current year.\n- Query: `month` (integer, optional) — 1–12; defaults to current month.\n- Query: `feature` (string, optional, max 128) — metered feature key to narrow `stores[].features` / `per_location_drilldown`; `all` or omit for every enabled feature. An unknown/disabled feature key returns a 422 validation error on `feature`.\n\n**Response** — `{ data: ... }` envelope.\n- `account_id` (int), `account_name` (string|null), `period` (string `YYYY-MM`), `billing_model` (string|null), `contract_model` (string|null), `plan_subtitle` (string|null).\n- `feature_options` (array) — `{ value, label }` pairs (first entry is `{ \"value\": \"all\", \"label\": \"All\" }`).\n- `per_location_drilldown` (array) — flattened store×feature rows: `store_id`, `store_name` plus the feature fields below (and `flat_charge` for model-b).\n- `stores` (array) — per store: `store_id`, `store_name`, `features[]`. Model-a feature row: `feature_name`, `feature_label`, `units_consumed`, `limit` (int|null), `units_over`, `overage_rate` (number|null), `hard_stop` (bool), `cost`, `overage_cost`, `percentage` (number|null), `row_status` (`on_track`/`approaching`/`overage_billing`/`hard_stop_over_limit`/`halted`). Model-b stores additionally include `flat_charge` (number) and `features_enabled` (string[]); their feature rows carry `overage_rate=null`, `cost=0`, `overage_cost=0`, `row_status` of `on_track`/`warning`/`halted`."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"data\": {\n      \"account_id\": 42,\n      \"account_name\": \"Acme Brand\",\n      \"period\": \"2026-05\",\n      \"billing_model\": \"usage-based\",\n      \"contract_model\": \"model-a\",\n      \"feature_options\": [{ \"key\": \"all\", \"label\": \"All\" }],\n      \"per_location_drilldown\": [],\n      \"stores\": []\n    }\n  }\n}"
                    },
                    {
                      "name": "403 Brand not under partner",
                      "status": "Forbidden",
                      "code": 403,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Brand does not belong to your partner.\",\n  \"errors\": []\n}"
                    },
                    {
                      "name": "404 Brand not found",
                      "status": "Not Found",
                      "code": 404,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Brand not found.\",\n  \"errors\": []\n}"
                    }
                  ]
                },
                {
                  "name": "P.11.4 Get billing detail for a store",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/partner/billing/store/{{storeId}}?",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "billing",
                        "store",
                        "{{storeId}}"
                      ],
                      "query": [
                        {
                          "key": "year",
                          "value": "2026",
                          "description": "Optional",
                          "disabled": true
                        },
                        {
                          "key": "month",
                          "value": "5",
                          "description": "Optional",
                          "disabled": true
                        }
                      ]
                    },
                    "description": "Returns per-feature usage and cost breakdown for a single store under the authenticated partner: each enabled plan feature with its usage count, limit, percentage consumed, status, and period cost.\n\n**Auth:** Partner — `auth:api`; under the `partner/billing` route group. Caller's partner is resolved by `getAuthPartner()` (403 if none). The `{storeId}` store is loaded by `Store::find` (404 if missing) and ownership is verified via `Company::getParentCompany(store.company_id, 'partner')` — returns 403 unless that partner equals the caller's partner.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `storeId` (integer, required) — store id (route-constrained to digits).\n- Query: `year` (integer, optional) — 2000–2100; defaults to current year.\n- Query: `month` (integer, optional) — 1–12; defaults to current month.\n\n**Response** — `{ data: ... }` envelope.\n- `store_id` (int).\n- `store_name` (string|null).\n- `period` (string) — `YYYY-MM`.\n- `features` (array) — one row per enabled plan feature: `feature_name` (string), `usage` (int), `limit` (int|null — `service_limit` for usage-based, `included_units` for plan-based), `percentage` (number|null), `status` (string: `within_limits` / `approaching` / `overage` / `hard_stop_over_limit` / `halted`), `cost` (number — period sum of `client_amount` for that store+feature)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"data\": {\n      \"store_id\": 26,\n      \"store_name\": \"Acme Sydney CBD\",\n      \"period\": \"2026-05\",\n      \"billing_model\": \"usage-based\",\n      \"contract_model\": \"model-a\",\n      \"features\": []\n    }\n  }\n}"
                    },
                    {
                      "name": "403 Store not under partner",
                      "status": "Forbidden",
                      "code": 403,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Store does not belong to your partner.\",\n  \"errors\": []\n}"
                    },
                    {
                      "name": "404 Store not found",
                      "status": "Not Found",
                      "code": 404,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Store not found.\",\n  \"errors\": []\n}"
                    }
                  ]
                }
              ]
            },
            {
              "name": "§ P.13 Reports (insights + exports + user-reports)",
              "description": "AI-driven insights report (spec §10.8), a per-brand Excel locations export, and the partner/brand \"email report subscription\" CRUD — the engine that drives the automated daily/weekly/monthly digest emails delivered to ops staff.",
              "item": [
                {
                  "name": "P.13.1 AI insights report (spec §10.8)",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/reports/insights?store_id={{storeId}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "insights"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}",
                          "description": "Required — must be in caller scope"
                        },
                        {
                          "key": "platform",
                          "value": "",
                          "description": "Optional — review_site name filter (e.g. \"hipages\", \"Google\")",
                          "disabled": true
                        }
                      ]
                    },
                    "description": "Returns the §10.8 AI-insights report envelope for a single store: a live per-platform performance breakdown computed from the latest 30 non-suppressed, on-platform reviews within a 6-month window, plus stubbed narrative fields (`summary`, `key_insights`) reserved for a later AI-generation work-stream.\n\n**Auth:** Both — `auth:api`. No prefix/`EnsureHipagesPartner`; scoping is in-controller via `Store::getStoreListByUser($user, true)`. The `store_id` query value must be in the caller's accessible store set or a 403 is returned. As a partner-tree user, the reachable set is every store under the partner's company hierarchy (`getStoreListByCompany`); the same endpoint serves both personas — only which `store_id`s are reachable differs.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, required) — target store PK; must resolve inside the caller's access scope.\n- Query: `platform` (string, optional) — filters reviews to a single `review_sites.name` (e.g. `\"hipages\"`, `\"Google\"`).\n\n**Response** — `{ data: ... }` envelope.\n- `summary` (string|null) — narrative summary; currently the stub string `\"AI insights pending generation.\"` when reviews exist, otherwise `null`.\n- `key_insights` (array) — AI narrative bullet objects; currently always `[]`.\n- `category_performance` (object) — keyed by platform name (`reviewsite.name`, or `\"Unknown\"`); each value has `count` (int), `avg_rating` (float|null, rounded to 2dp over rates > 0), `positive_count` (int, rate ≥ 4), `negative_count` (int, rate 1–2).\n- `review_count_used` (int) — number of reviews aggregated (0–30).\n- `lookback_days` (int) — days from oldest used review to now; defaults to 180 when no reviews found.\n- `generated_at` (string) — ISO-8601 timestamp of generation."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"summary\": \"AI insights pending generation.\",\n    \"key_insights\": [],\n    \"category_performance\": {},\n    \"review_count_used\": 28,\n    \"lookback_days\": 154,\n    \"generated_at\": \"2026-05-29T14:30:00+00:00\"\n  }\n}"
                    },
                    {
                      "name": "403 Store out of scope",
                      "status": "Forbidden",
                      "code": 403,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Store is outside your access scope.\",\n  \"errors\": []\n}"
                    }
                  ]
                },
                {
                  "name": "P.13.2 Export locations to Excel",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/reports/excel/locations",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "excel",
                        "locations"
                      ]
                    },
                    "description": "Generates an Excel export of all locations under the authenticated brand, stores it to S3, and returns the public download URL.\n\n**Auth:** Account — `auth:api` (route is inside the authenticated group). `ExportLocationsRequest::authorize()` requires the user's company `bundle_id === 3` (BRAND); a non-brand user gets 403. Scope is the caller's own company tree: store ids come from `Store::getAllChildStoreByCompanyId(<auth user's company id>, true)`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- No path/query/body params (`ExportLocationsRequest::rules()` is empty).\n\n**Response** — `{ data: ... }` envelope.\n- `data.path` (string) — public S3 URL of the generated `.xlsx`, keyed `download/excel/<Y-m>/<timestamp>-<SafeBrandName>-Locations.xlsx`."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"path\": \"https://s3.amazonaws.com/your-bucket/download/excel/2026-05/1732540672-Acme-Brand-Locations.xlsx\"\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "P.13.3 Create partner email report",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "url": {
                      "raw": "{{baseUrl}}/partner/reports/add",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "reports",
                        "add"
                      ]
                    },
                    "description": "Creates a scheduled partner-level email report (activity/response/feed/etc.) for a recipient, scoped to all accounts the partner owns or to a specific list of account (brand) companies.\n\n**Auth:** Partner — `auth:api`; lives under the `partner/*` group (partner-admin scope, no Hipages gate). The owning company is the auth user's `person->company`; each `account.*` id is validated by `BelongsToAuthCompany`, so a partner can only target brand companies inside its own company tree. The recipient is resolved either from an existing user (`old_recipient_email`) or created fresh from `recipient_email` + name.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `reportType` | string | yes | One of `activity_report`, `response_report`, `review_feed`, `review_feed_star`, `review_responses`, `review_site_connections`. `review_feed`/`review_feed_star` are forced to `day` frequency. |\n| `reportPeriodType` | string | yes | `day`, `week`, or `month`. Further constrained by `ReportPeriodTypeRule` based on `reportType`. |\n| `all_account` | integer | yes | `1` = all accounts the partner owns, `0` = specific accounts. Rule `required\\|nullable\\|in:0,1`. |\n| `account` | int[] | required_if `all_account=0` | Account (brand) company ids; each must exist in `company` and pass `BelongsToAuthCompany`. |\n| `old_recipient_email` | string(email) | required_without `recipient_email` | Existing user's email; report attached to that user. |\n| `recipient_email` | string(email) | required_without `old_recipient_email` | New recipient email. |\n| `recipient_first_name` | string(max 100) | required_without `old_recipient_email` | New recipient first name. |\n| `recipient_last_name` | string(max 100) | required_without `old_recipient_email` | New recipient last name. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `response` (string) — message `Report has been saved successfully!`. On missing company / unknown `old_recipient_email`, a 400 `{ status: 'error', response: 'Company not found!' | 'User not found!' }` is returned instead.",
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"reportType\": \"activity_report\",\n    \"reportPeriodType\": \"month\",\n    \"all_account\": 0,\n    \"account\": [\n        10,\n        11\n    ],\n    \"recipient_email\": \"new@example.com\",\n    \"recipient_first_name\": \"Jane\",\n    \"recipient_last_name\": \"Doe\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    }
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": { \"status\": \"success\", \"response\": \"Report has been saved successfully!\" }\n}"
                    },
                    {
                      "name": "400 User not found",
                      "status": "Bad Request",
                      "code": 400,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"User not found!\",\n  \"errors\": []\n}"
                    }
                  ]
                },
                {
                  "name": "P.13.4 Edit partner email report",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "url": {
                      "raw": "{{baseUrl}}/partner/reports/edit",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "reports",
                        "edit"
                      ]
                    },
                    "description": "Updates an existing partner email report's frequency and account (brand) targeting; report type and recipient are not changed here.\n\n**Auth:** Partner — `auth:api`; under the `partner/*` group. Requires a partner-tier user (else `403`). The report must belong to the caller's own account hierarchy, and each `account` id must be an account within the partner's own tree. The presence of `report_id` selects the edit branch (report and period validated, recipient fields not required).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `report_id` | integer | yes | Must be an existing report within the caller's account hierarchy. Its presence selects the edit branch. |\n| `reportPeriodType` | string | yes | `day`, `week`, or `month`; validated against the existing report's type. |\n| `all_account` | integer | yes | `1` = all accounts, `0` = specific accounts. Rule `required\\|nullable\\|in:0,1`. Stored as `all_location`. |\n| `account` | int[] | required_if `all_account=0` | Account (brand) company ids; each must exist and pass `BelongsToAuthCompany`. Existing `report_companies` rows are deleted then re-inserted. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `response` (string) — message `Report has been updated successfully!`.",
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"report_id\": 5,\n    \"reportPeriodType\": \"week\",\n    \"all_account\": 0,\n    \"account\": [\n        10,\n        11\n    ]\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    }
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": { \"status\": \"success\", \"response\": \"Report has been updated successfully!\" }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "P.13.5 Add a brand email report (per-user)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "url": {
                      "raw": "{{baseUrl}}/user/reports/add",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "user",
                        "reports",
                        "add"
                      ]
                    },
                    "description": "Adds a new recipient to receive an automated brand-level email report on a daily/weekly/monthly cadence, scoped to all stores under the brand or a specific store list.\n\n**Auth:** Account — `auth:api`; under the `user/*` group. `AddBrandUserReportRequest::authorize()` requires the auth user's `person->company->bundle_id === CompanyBundle::BRAND`, so only a brand (bundle_id=3) user is allowed (else 403). Each `store_ids.*` is checked by `BelongsToAuthCompany` so only stores in the caller's company tree are accepted; `all_store=1` expands to `Store::getStoreListByUser($user)`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `reportType` | string | yes | One of `activity_report`, `response_report`, `review_feed`, `review_feed_star`, `review_responses`, `review_site_connections`. `review_feed`/`review_feed_star` must use `day`. |\n| `reportPeriodType` | string | yes | `day`, `week`, or `month`; constrained by `ReportPeriodTypeRule(reportType)`. |\n| `all_store` | integer | yes | `1` = all stores, `0` = specific stores. Rule `required\\|in:0,1`. |\n| `store_ids` | int[] | required_if `all_store=0` | Store ids; each must exist in `store` and pass `BelongsToAuthCompany`. |\n| `old_recipient_email` | string(email) | required_without `recipient_email` | Existing email; reused (or created from matching user's name) as the recipient. |\n| `recipient_email` | string(email) | required_without `old_recipient_email` | New recipient email. |\n| `recipient_first_name` | string(max 100) | required_without `old_recipient_email` | New recipient first name. |\n| `recipient_last_name` | string(max 100) | required_without `old_recipient_email` | New recipient last name. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `response` (string) — message `Report has been saved successfully!`.",
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"reportType\": \"activity_report\",\n    \"reportPeriodType\": \"month\",\n    \"all_store\": 0,\n    \"store_ids\": [\n        1\n    ],\n    \"recipient_email\": \"new@example.com\",\n    \"recipient_first_name\": \"Jane\",\n    \"recipient_last_name\": \"Doe\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    }
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": { \"status\": \"success\", \"response\": \"Report has been saved successfully!\" }\n}"
                    }
                  ]
                },
                {
                  "name": "P.13.6 Edit a brand email report",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "url": {
                      "raw": "{{baseUrl}}/user/reports/edit",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "user",
                        "reports",
                        "edit"
                      ]
                    },
                    "description": "Updates an existing brand email report's frequency and store assignments (recipient and report type unchanged).\n\n**Auth:** Account — `auth:api`; under the `user/*` group. Requires an account-tier user (else `403`). The report must belong to the caller's own account or one beneath it in the caller's hierarchy, and each `store_ids` entry is scoped to that same hierarchy; existing location assignments are replaced.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `report_id` | integer | yes | Must be an existing report within the caller's account hierarchy. |\n| `reportPeriodType` | string | yes | `day`, `week`, or `month`; validated against the existing report's type. |\n| `all_store` | integer | yes | `1` = all stores, `0` = specific stores. Rule `required\\|in:0,1`. Stored as `all_location`. |\n| `store_ids` | int[] | required_if `all_store=0` | Store ids; each must exist and pass `BelongsToAuthCompany`. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `response` (string) — message `Report has been updated successfully!`.",
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"report_id\": 5,\n    \"reportPeriodType\": \"week\",\n    \"all_store\": 0,\n    \"store_ids\": [\n        1\n    ]\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    }
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": { \"status\": \"success\", \"response\": \"Report has been updated successfully!\" }\n}"
                    }
                  ]
                },
                {
                  "name": "P.13.7 Delete a brand email report",
                  "request": {
                    "method": "DELETE",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "url": {
                      "raw": "{{baseUrl}}/user/reports/delete",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "user",
                        "reports",
                        "delete"
                      ]
                    },
                    "description": "Deletes an account email report together with its schedule and recipient assignments, inside a database transaction.\n\n**Auth:** Account — `auth:api`; under the `user/*` group. Requires an account-tier user (else `403`). The DELETE carries a JSON body (`report_id`), and the report must belong to the caller's own account or one beneath it in the caller's hierarchy; anything outside that is rejected with a `422`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `report_id` | integer | yes | Must be an existing report within the caller's account hierarchy. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `response` (string) — message `Report has been deleted successfully!`. If the report no longer exists it falls into the catch and returns the default error response.",
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"report_id\": 42\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    }
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": { \"status\": \"success\", \"response\": \"Report has been deleted successfully!\" }\n}"
                    }
                  ]
                },
                {
                  "name": "Email reports list",
                  "request": {
                    "method": "POST",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/email-report/list",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "email-report",
                        "list"
                      ]
                    },
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"sort_field\": \"email\",\n    \"sort_by\": \"ASC\",\n    \"search_field\": \"store_name\",\n    \"search_value\": \"Main\",\n    \"limit\": 10\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "description": "Returns a paginated list of the authenticated user's company email reports (recipient + cadence + targeted stores/accounts), with optional sorting and single-field search.\n\n**Auth:** Both — `auth:api`; under the role-agnostic `account/*` group (no bundle gate). The list is hard-scoped to the auth user's `person->company->id` (`reports.company_id = company->id`), so a partner sees partner-level reports and a brand user sees brand-level reports for their own company. If the user has no company, an empty collection is returned.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (all optional):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `sort_field` | string | no | One of `first_name`, `last_name`, `email`, `report_type`, `report_period_type_id`. |\n| `sort_by` | string | no | `asc`/`ASC`/`desc`/`DESC` (defaults to `ASC` when `sort_field` set). |\n| `search_field` | string | no | One of `first_name`, `last_name`, `email`, `report_type`, `report_period_type_id`, `store_name`. |\n| `search_value` | string | no | Filter value (paired with `search_field`); `NameNoAngleBrackets` rule. |\n| `limit` | integer | no | Page size, 1–100; defaults to 10. |\n\n**Response** — `{ data: ... }` envelope (paginated `UserReport` resource collection — `data` array plus `links`/`meta` pagination keys).\n- `id` (integer) — report id.\n- `first_name` (string|null) — recipient first name (from `emails`).\n- `last_name` (string|null) — recipient last name.\n- `email` (string|null) — recipient email.\n- `report_type` (string) — `reports.report_type_id` aliased as `report_type`.\n- `report_period_type_id` (string) — cadence period type id.\n- `all_store` (integer) — from `all_location` (0/1).\n- `store_list` (array) — objects `{ value: store_id, label: name [+ ' - ' + storeid] }`.\n- `account_list` (array) — objects `{ value: company_id, label: company name }`."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    {\n      \"id\": 12,\n      \"first_name\": \"John\",\n      \"last_name\": \"Doe\",\n      \"email\": \"john@example.com\",\n      \"report_type\": 1,\n      \"report_period_type_id\": 2,\n      \"all_store\": 0,\n      \"store_list\": [\n        { \"value\": 8748, \"label\": \"Downtown Location - LOC-001\" }\n      ],\n      \"account_list\": [\n        { \"value\": 31, \"label\": \"Brand A\" }\n      ]\n    }\n  ],\n  \"links\": {\n    \"first\": \"https://api.example.com/api/v1/email-report/list?page=1\",\n    \"last\": \"https://api.example.com/api/v1/email-report/list?page=5\",\n    \"prev\": null,\n    \"next\": \"https://api.example.com/api/v1/email-report/list?page=2\"\n  },\n  \"meta\": {\n    \"current_page\": 1,\n    \"per_page\": 10,\n    \"total\": 50\n  }\n}"
                    },
                    {
                      "name": "422 Validation error",
                      "status": "Unprocessable Content",
                      "code": 422,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"message\": \"The selected sort field is invalid.\",\n  \"errors\": {\n    \"sort_field\": [\"The selected sort field is invalid.\"],\n    \"limit\": [\"The limit field must not be greater than 100.\"]\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "Email report delete",
                  "request": {
                    "method": "DELETE",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/email-report/delete",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "email-report",
                        "delete"
                      ]
                    },
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"report_id\": 42\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "description": "Deletes an email report together with its schedule and recipient assignments, inside a database transaction.\n\n**Auth:** `auth:api`; under the role-agnostic `account/*` group, so any signed-in user may call it. The report must belong to the caller's own account, or to an account beneath it in the caller's hierarchy — a `report_id` outside that hierarchy is rejected with a `422` and nothing is deleted.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `report_id` | integer | yes | Must be an existing report within the caller's account hierarchy. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `response` (string) — message `Report has been deleted successfully!`. If the report is already gone it falls into the catch and returns the default error response."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": \"Report has been deleted successfully!\"\n  }\n}"
                    },
                    {
                      "name": "422 Validation error",
                      "status": "Unprocessable Content",
                      "code": 422,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"message\": \"The selected report id is invalid.\",\n  \"errors\": {\n    \"report_id\": [\"The selected report id is invalid.\"]\n  }\n}"
                    }
                  ]
                }
              ]
            }
          ]
        },
        {
          "name": "Account",
          "description": "Account-token reports: response reports, brand dashboard, performance, ranking, campaign reporting, QR statistics, insight, competitive.",
          "item": [
            {
              "name": "§ B.5.B Review-response reports",
              "item": [
                {
                  "name": "5.B.1 Review-response feed",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"account_id\": 7,\n    \"review_site_id\": 44,\n    \"rating\": [\n        4,\n        5\n    ],\n    \"status\": \"complete\",\n    \"search\": \"Amanda 883555\",\n    \"limit\": 10,\n    \"page\": 1,\n    \"sort_by\": \"desc\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/partner/report/review-response/feed",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "partner",
                        "report",
                        "review-response",
                        "feed"
                      ]
                    },
                    "description": "Returns a paginated review-response feed across the partner's whole company tree, plus an overall average rating and a per-publisher KPI tile strip (hipages tile uses the bridge formula; all others use a simple AVG of rated rows).\n\n**Auth:** Partner — `auth:api`, mounted under the `partner` prefix (partner-admin user, bundle_id=1). Scope defaults to every store under the authenticated user (`Store::getStoreListByUser`); narrowing params (`store_id`/`client_location_id`, `account_id`/`client_account_id`) must pass the `BelongsToAuthCompany` rule, so the partner can only reach stores/accounts inside its own hierarchy. Same controller method as the brand-side alias (5.B.8); the partner persona simply reaches a wider default scope.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `range_start` | string `Y-m-d H:i:s` | optional | Start of the review-date window; pair with `range_end`. |\n| `range_end` | string `Y-m-d H:i:s` | optional | End of window. If both range bounds are present **and** `limit` is omitted, the feed returns the full result set (no pagination); otherwise it paginates. |\n| `limit` | integer | optional | Page size. Default 10 (25 for hipages users, who are also hard-capped at 25). |\n| `page` | integer | optional | Page number. |\n| `sort_field` | string | optional | Sort column. |\n| `sort_by` | string | optional | One of `asc`,`ASC`,`desc`,`DESC`. |\n| `type` | string | optional | One of `report`,`download`. |\n| `store_id` | integer | optional | Existing `store.id`; must belong to the auth company tree. Narrows scope to one store. |\n| `client_location_id` | string (max 255) | optional | Alternative to `store_id`; resolved to a store via `Store::getStoreIdFromClientLocationId`. Tree-scoped. |\n| `account_id` | integer | optional | Existing `company.id` with `bundle_id=3` (a brand/account), not soft-deleted; tree-scoped. Narrows to that brand's stores. |\n| `client_account_id` | string (max 255) | optional | Alternative to `account_id`; resolved via `Company::getAccountIdFromClientAccountId`. Tree-scoped. |\n| `is_pdf` | integer | optional | `0` or `1`; switches review-site logos to local `asset()` paths. |\n| `review_site_id` | integer | optional | Filter feed to one publisher. |\n| `rating` | integer or array of integers | optional | A scalar is normalized to an array in `prepareForValidation`. Each element must be an integer. |\n| `status` | string | optional | Response state: one of `complete`,`pending`,`escalate pending`,`escalated`,`error`,`flagged`,`removed`. |\n| `search` | string (max 255) | optional | Keyword search over reviewer/customer name, review body, review title, hipages job id, or store/location name. |\n\n**Response** — `{ data: [...] }` envelope (Eloquent resource collection with `additional` meta + pagination meta).\n- `data[]` — one `ResponseFeed` item per review:\n  - `id` (int) — review id.\n  - `source_platform` (string) — originating platform (`review_sites.name` as-is), falling back to the normalised review-site slug.\n  - `review` (object) — `id`, `reviewer`, `rating`, `message`, `review_date` (null when `0000-00-00 00:00:00`).\n  - `reviewsite` (object) — `id`, `name` (Maps.Google→Google), `link`, `review_site_logo`, `review_site_color`, `auth_type`, `scraping_enabled` (bool), `posting_enabled` (bool).\n  - `store` (object) — `id`, `name` (appends ` - {storeid}` when present), `slug`, `address` (composed from company address/city/state).\n  - `store_reviewsite` (object) — `id`, `url`, `landing_page_url`, `connection_status`, `is_connected` (bool, per-site derived: Google id 44 = token present; Facebook id 3 = token+external_id; Caring id 20 = external_id; else `connection_status`==='connected').\n  - `flag` (array) — status-flag chips (`flag`, `title`, `statusColor`, `icon`, optional `links`); `''` when `standalone_type` is set.\n  - `plan` (string) — plan name from the latest `UpdatedReviewResponse`, or `''`.\n  - `review_flag` (object|null) — raw flag model.\n  - `is_flaggable` (bool) — full flag-policy result (`ReviewFlaggingPolicy`).\n  - `flag_status` (string) — flag-status enum value, or `'none'`.\n  - `flag_details` (object|null) — `reason`, `reason_identifier`, `flag_reason_detail`, `flag_date` (ISO-8601), populated only when a flag row exists.\n  - `response_capabilities` (object) — universal `ResponseButtonPolicy` result gating Submit / Request-Revision.\n  - `response` (object|null) — `id`, `status`, `public_contents`, `private_contents`, `response_date` (null when `0000-00-00 00:00:00`), `deleted`; null when absent or an unverified auto-response.\n  - **hipages-only** (present only when the row is a hipages-partner review): `flagging` (`window_days`,`expires_at`,`reason`), `is_legacy`, `is_qualified`, `is_off_platform`, `classification_label`, `job_id`, `sub_dimensions`, `is_profanity`, `badges`, `has_history`, `moderation` (`suppressed_at`/`excluded_from_rating_at`/`hidden_from_public_at`/`released_to_public_at`/`deleted_at`).\n- `avg_rating` (float) — overall average rating across the scoped stores, rounded to 2dp (additional meta).\n- `kpi_by_publisher` (array) — one tile per publisher with reviews in scope: `review_site_id`, `review_site_name`, `review_site_color`, `rating` (float|null), `total_reviews` (int), `was_bridged` (bool; true for the hipages bridge tile). `[]` when no stores in scope (additional meta).\n- Pagination meta (`current_page`, `last_page`, `per_page`, `total`, links) present when the result is paginated."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    {\n      \"id\": 500,\n      \"source_platform\": \"google\",\n      \"review\": {\n        \"id\": 500,\n        \"reviewer\": \"Jane Smith\",\n        \"rating\": 4.5,\n        \"message\": \"Great service!\",\n        \"review_date\": \"2026-03-10T08:00:00Z\"\n      },\n      \"reviewsite\": {\n        \"id\": 44,\n        \"name\": \"Google\",\n        \"review_site_color\": \"#4285F4\"\n      },\n      \"store\": {\n        \"id\": 8748,\n        \"name\": \"Downtown - LOC-001\"\n      },\n      \"response\": {\n        \"id\": 300,\n        \"status\": \"complete\",\n        \"public_contents\": \"Thank you!\",\n        \"private_contents\": null,\n        \"response_date\": \"2026-03-11T10:00:00Z\"\n      },\n      \"flag\": [\n        {\n          \"flag\": \"complete\",\n          \"title\": \"Posted live\",\n          \"statusColor\": \"success\",\n          \"icon\": \"fa-thumbs-up\"\n        }\n      ],\n      \"plan\": \"Premium\",\n      \"is_flaggable\": true,\n      \"flag_status\": \"none\",\n      \"flag_details\": null,\n      \"response_capabilities\": {\n        \"manual\": true,\n        \"ai\": true,\n        \"reason\": null\n      }\n    },\n    {\n      \"id\": 1620,\n      \"source_platform\": \"hipages\",\n      \"review\": {\n        \"id\": 1620,\n        \"reviewer\": \"Alice K\",\n        \"rating\": 4,\n        \"message\": \"Quick response, fair pricing.\",\n        \"review_date\": \"2026-05-12T10:00:00+10:00\"\n      },\n      \"reviewsite\": {\n        \"id\": 100,\n        \"name\": \"hipages\",\n        \"review_site_color\": \"#FF6B00\"\n      },\n      \"store\": {\n        \"id\": 26,\n        \"name\": \"Demo store #1 - DEMO-001\"\n      },\n      \"response\": {\n        \"id\": 5501,\n        \"status\": \"pending\",\n        \"public_contents\": null,\n        \"private_contents\": \"Homeowner mentioned a minor scheduling concern - hipages service team only.\",\n        \"response_date\": \"2026-05-12 10:30:00\"\n      },\n      \"flag\": [],\n      \"plan\": \"Review Management\",\n      \"is_flaggable\": true,\n      \"flag_status\": \"none\",\n      \"flag_details\": null,\n      \"response_capabilities\": {\n        \"manual\": true,\n        \"ai\": true,\n        \"reason\": null\n      },\n      \"flagging\": {\n        \"window_days\": 90,\n        \"expires_at\": \"2026-08-10T10:00:00+10:00\",\n        \"reason\": null\n      },\n      \"is_legacy\": false,\n      \"is_qualified\": true,\n      \"is_off_platform\": false,\n      \"classification_label\": null,\n      \"is_profanity\": false,\n      \"badges\": [],\n      \"has_history\": true,\n      \"moderation\": {\n        \"suppressed_at\": null,\n        \"excluded_from_rating_at\": null,\n        \"hidden_from_public_at\": null,\n        \"released_to_public_at\": null,\n        \"deleted_at\": null\n      }\n    }\n  ],\n  \"avg_rating\": 4.2,\n  \"kpi_by_publisher\": [\n    {\n      \"review_site_id\": 100,\n      \"review_site_name\": \"hipages\",\n      \"review_site_color\": \"#FF6B00\",\n      \"rating\": 4.3,\n      \"total_reviews\": 332,\n      \"was_bridged\": true\n    },\n    {\n      \"review_site_id\": 44,\n      \"review_site_name\": \"Google\",\n      \"review_site_color\": \"#4285F4\",\n      \"rating\": 4.5,\n      \"total_reviews\": 87,\n      \"was_bridged\": false\n    },\n    {\n      \"review_site_id\": 3,\n      \"review_site_name\": \"Facebook\",\n      \"review_site_color\": \"#1877F2\",\n      \"rating\": 4.7,\n      \"total_reviews\": 24,\n      \"was_bridged\": false\n    }\n  ],\n  \"current_page\": 1,\n  \"last_page\": 15,\n  \"per_page\": 10,\n  \"total\": 443\n}"
                    }
                  ]
                },
                {
                  "name": "5.B.2 Response report — KPIs",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/response/kpis",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "response",
                        "kpis"
                      ]
                    },
                    "description": "Returns period-over-period KPI cards (review counts, ratings, response rate, average response time, awaiting-response, sentiment split) for the brand/account-scoped response report.\n\n**Auth:** Both — `auth:api`; under the brand-side `reports/response` group. Backed by `getResponseReportKpis` with `GetBrandResponseReportKpiRequest`. Scope precedence: `store_id` (or `client_location_id`→store) → single store; else `account_id` (or `client_account_id`→account) → `Store::getAllChildStoreByCompanyId`; else the auth user's own tree via `Store::getStoreListByUser`. A brand user normally passes only `store_id`; a partner-context caller can pass `account_id`/`client_account_id` to scope to a child account. All id params guarded by `BelongsToAuthCompany`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n| account_id | integer | no | a company under the auth company |\n| client_account_id | string | no | external account id; resolved to account_id |\n| client_location_id | string | no | external location id; resolved to store_id |\n\n**Response** — `{ data: ... }` envelope.\n- `reviews_current` (int) / `reviews_previous` (int)\n- `avg_rating` (float) / `avg_rating_previous` (float)\n- `response_rate_pct` (float) / `response_rate_pct_previous` (float)\n- `avg_response_time_in_days` (string) — e.g. `\"1.2 Days\"`\n- `awaiting_response` (int)\n- `reviews_sentiments` (object) — `negativeReviews`, `negativeReviews_percent`, `positiveReviews`, `positiveReviews_percent`, `neutralReviews`, `neutralReviews_percent`"
                  },
                  "response": [
                    {
                      "name": "200 Success — KPI swap (2026-06)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"reviews_current\": 13,\n    \"reviews_previous\": 1,\n    \"avg_rating\": 4.2,\n    \"avg_rating_previous\": 2.2,\n    \"response_rate_pct\": 84.62,\n    \"response_rate_pct_previous\": 100.0,\n    \"avg_response_time_in_days\": \"1.2 Days\",\n    \"awaiting_response\": 2,\n    \"reviews_sentiments\": {\n      \"negativeReviews\": 1,\n      \"negativeReviews_percent\": 7.69,\n      \"positiveReviews\": 10,\n      \"positiveReviews_percent\": 76.92,\n      \"neutralReviews\": 2,\n      \"neutralReviews_percent\": 15.39\n    }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "5.B.3 Response report — by location",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1,\n    \"sort_field\": \"total_reviews\",\n    \"sort_by\": \"desc\",\n    \"limit\": 10,\n    \"page\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/response/locationlist",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "response",
                        "locationlist"
                      ]
                    },
                    "description": "Returns a paginated per-location (per-store) response table for the authenticated brand — reviews, responses, average response time, average rating, response rate.\n\n**Auth:** Account — `auth:api`; under the brand-side `reports/response` group. Backed by `getResponseReportLocationList` with `GetBrandResponseReportLocationListRequest`. Scope: `store_id` → single store (guarded by `BelongsToAuthCompany`); else all of the auth user's stores via `Store::getStoreListByUser`. This brand variant has NO `account_id`/`client_*` params — partner-wide scoping uses the `partner/report/review-response/by-location` endpoint instead.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n| sort_field | string | no | `location_name,total_reviews,total_responses,avg_resp_time,avg_rating,response_rate` (default `location_name`) |\n| sort_by | string | no | `asc`/`ASC`/`desc`/`DESC` (default `ASC`) |\n| search_field | string | no | only `location_name` |\n| search_value | string | no | |\n| limit | integer | no | 1–100 (default 10) |\n| page | integer | no | min 1 |\n\n**Response** — paginated `ReviewResponseReport` collection.\n- `data[].id` (int) — store id\n- `data[].location_name` (string) — `name - storeid`\n- `data[].total_reviews` (int) / `data[].total_responses` (int)\n- `data[].avg_resp_time` (float, days) / `data[].avg_rating` (float) / `data[].response_rate` (float, %)\n- `current_page`, `last_page`, `per_page`, `total`"
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": [ { \"id\": 8748, \"location_name\": \"Downtown Location - LOC-001\", \"total_reviews\": 13, \"total_responses\": 12, \"avg_resp_time\": 1.2, \"avg_rating\": 4.31, \"response_rate\": 92.11 } ], \"current_page\": 1, \"last_page\": 2, \"per_page\": 10, \"total\": 16 }"
                    }
                  ]
                },
                {
                  "name": "5.B.4 Response report — by site",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/response/reviews/site",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "response",
                        "reviews",
                        "site"
                      ]
                    },
                    "description": "Returns review/response metrics grouped by review site (publisher) for the authenticated brand — per-site review count, average rating, % of total, response count, response rate.\n\n**Auth:** Account — `auth:api`; under the brand-side `reports/response` group. Backed by `getReviewResponseBySite` with `GetBrandReviewResponseBySiteRequest`. Scope: `store_id` → single store (guarded by `BelongsToAuthCompany`); else all of the auth user's stores via `Store::getStoreListByUser`. No `account_id`/`client_*` params on this brand variant.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n\n**Response** — `{ data: [...] }` envelope; array of per-site rows (passthrough of `ReviewSite::reviewResponsesBySite`).\n- `id` (int) — review_site id\n- `review_site` (string) — lowercased name (`maps.google` → `google`)\n- `review_site_logo` (string|null) — S3 logo URL\n- `total_reviews` (int) / `avg_rating` (float)\n- `per_of_total` (float) — % of total reviews\n- `total_response` (int) / `per_total_response` (float) — response rate %"
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": [ { \"id\": 44, \"review_site\": \"google\", \"review_site_logo\": \"https://example.com/assets/google_ic.png\", \"total_reviews\": 13, \"avg_rating\": 4.4, \"per_of_total\": 95.11, \"total_response\": 11, \"per_total_response\": 84.62 } ] }"
                    }
                  ]
                },
                {
                  "name": "5.B.5 Ranking report (full payload)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/ranking",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "ranking"
                      ]
                    },
                    "description": "Ranks the authenticated brand's locations against each other over a date range — by review volume, average rating, and response rate — and computes a blended overall rank with a letter grade.\n\n**Auth:** Both — `auth:api` (Passport bearer). Inside `rankingReport()` the caller's `person.company.bundle_id` must be `3` (brand) or `2` (location-account); any other persona gets a 403. The brand name is resolved by walking UP via `Company::getParentCompany($user->person->company_id, 'brand')`. Store scope comes from `Store::getStoreListByUser($user, true)` unless `store_id` is supplied. Per-persona difference: reachable stores differ — a location-level user (bundle_id=2 / `assign_location`) sees only its assigned store(s), while a brand-level user sees every store under the brand's location-account children; the optional `store_id` is validated by `BelongsToAuthCompany`, so it must resolve to a store inside the caller's own hierarchy.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (all in JSON body):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `range_start` | string | yes | Date-time, format `Y-m-d H:i:s` (e.g. `2024-01-01 00:00:00`). |\n| `range_end` | string | yes | Date-time, format `Y-m-d H:i:s`. |\n| `store_id` | integer | no | Nullable. Must `exists:store,id` AND pass `BelongsToAuthCompany` (must belong to the caller's company tree). When omitted, all of the caller's reachable stores are ranked. |\n\n**Response** — `{ data: ... }` envelope.\n- `account_name` (string) — the parent brand company name (null if no brand resolved).\n- `interval` (object) — `start` and `end`, echoed verbatim from the request.\n- `locationRank` (object) — four arrays of per-location rows:\n  - `byReview` (array) — locations ranked by `review_count`; each row: `id`, `name`, `review_count`, `response_count`, `response_percent`, `avgRating`, `rank`.\n  - `byRate` (array) — same row shape, ranked by `avgRating`.\n  - `byResponse` (array) — same row shape, ranked by `response_percent`.\n  - `overAll` (array) — each row adds `reviewRank`, `rateRank`, `responseRank`, `totalRank` (sum of the three), `final_grade` (A+/A/B/C/D), `final_grade_point` (5..1); when there are 10+ distinct total ranks, a `percentage_rank` band (e.g. `Top 10%`) is also added.\n- When no locations have data, all four arrays are empty."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"account_name\": \"Burger King\",\n    \"interval\": { \"start\": \"2024-01-01 00:00:00\", \"end\": \"2024-12-31 23:59:59\" },\n    \"locationRank\": {\n      \"byReview\": [\n        { \"id\": 12314, \"name\": \"Burger King 1 - LOC-001\", \"review_count\": 10, \"response_count\": 8, \"response_percent\": 80.0, \"avgRating\": 4.5, \"rank\": 1 }\n      ],\n      \"byRate\": [\n        { \"id\": 12314, \"name\": \"Burger King 1 - LOC-001\", \"review_count\": 10, \"response_count\": 8, \"response_percent\": 80.0, \"avgRating\": 4.5, \"rank\": 1 }\n      ],\n      \"byResponse\": [\n        { \"id\": 12314, \"name\": \"Burger King 1 - LOC-001\", \"review_count\": 10, \"response_count\": 8, \"response_percent\": 80.0, \"avgRating\": 4.5, \"rank\": 1 }\n      ],\n      \"overAll\": [\n        { \"id\": 12314, \"name\": \"Burger King 1 - LOC-001\", \"review_count\": 10, \"response_count\": 8, \"response_percent\": 80.0, \"avgRating\": 4.5, \"reviewRank\": 1, \"rateRank\": 2, \"responseRank\": 1, \"totalRank\": 4, \"final_grade\": \"A+\", \"final_grade_point\": 5 }\n      ]\n    }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "5.B.6 Excel — review-response feed",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1,\n    \"search\": \"\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/excel/review-response-feed",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "excel",
                        "review-response-feed"
                      ]
                    },
                    "description": "Generates a review-response feed Excel workbook for the caller's stores over a date range, stores it on the S3 disk, and returns the public download URL.\n\n**Auth:** Account — `auth:api` bearer token, inside the generic brand-side group (`Route::middleware(['auth:api'])->name('api.')`, prefix `reports/excel`); no partner/Hipages/dormant gating. Scope is the caller's own company: stores resolve via `Store::getStoreListByUser($user, true)` (user-assigned stores, assigned-brand stores, or the full company subtree), and an explicit `store_id` is validated by `BelongsToAuthCompany` so it must sit in the caller's company hierarchy. Header info reads `user->person->company->name`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `range_start` | string | yes | `date_format:Y-m-d H:i:s`; start of range. |\n| `range_end` | string | yes | `date_format:Y-m-d H:i:s`; end of range. |\n| `store_id` | integer | no | Must `exists` in `store` and pass `BelongsToAuthCompany`. When omitted, all of the caller's stores are included. |\n| `search` | string | no | `max:255`. Accepted by the Form Request but not consumed by the controller method. |\n\n**Response** — `{ data: ... }` envelope.\n- `path` (string) — S3 URL to the generated `.xlsx` (`Storage::disk('s3')->url(...)`), stored at `download/excel/{Y-m}/{timestamp}-ReviewResponseReport.xlsx`."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"path\": \"https://s3.amazonaws.com/bucket/download/excel/2024-01/1719300000-ReviewResponseReport.xlsx\" } }"
                    }
                  ]
                },
                {
                  "name": "5.B.7 PDF — performance report",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1,\n    \"is_pdf\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/pdf/performance-report",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "pdf",
                        "performance-report"
                      ]
                    },
                    "description": "Returns the full performance-report dataset for a PDF: header/company block, KPIs (with previous-period comparisons), sentiment split, per-site and per-location review/response breakdowns, and the review-response feed.\n\n**Auth:** Account — `auth:api` bearer token, inside the generic brand-side group (`Route::middleware(['auth:api'])->name('api.')`, prefix `reports/pdf`); no partner/Hipages/dormant gating. Scope is the caller's own company: stores resolve via `Store::getStoreListByUser($user, true)` and an explicit `store_id` is validated by `BelongsToAuthCompany`. The company block adapts to the caller's bundle: a brand (`bundle_id == 3`) reports its own name with an empty `store_name`; otherwise (location-account) it reports the parent company name plus the single store's name. The partner white-label logo comes from `Company::getWhiteLabel($company->id)`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `range_start` | string | yes | `date_format:Y-m-d H:i:s`; start of range. |\n| `range_end` | string | yes | `date_format:Y-m-d H:i:s`; end of range. |\n| `store_id` | integer | no | Must `exists` in `store` and pass `BelongsToAuthCompany`. When omitted, all of the caller's stores are included. |\n| `is_pdf` | integer | no | `in:0,1`. When `1`, `response_feed` is capped at 50 rows (and `ResponseFeed` uses local asset logos instead of S3 URLs). |\n\n**Response** — `{ data: ... }` envelope.\n- `start` / `end` (string) — echoed range bounds.\n- `company` (object) — `account_name` (string), `store_name` (string|null), `logo` (string|null, white-label partner logo).\n- `kpis_data` (object) — from `Review::metricsData(...)`: `total_reviews`, `avg_rating`, `total_responded`, `total_response_rate`, `avg_response_time`, `noLoginNoRespons`, plus previous-period `total_reviews_previous`, `avg_rating_previous`.\n- `reviewsSentiments` (object) — `negativeReviews`/`_percent`, `positiveReviews`/`_percent`, `neutralReviews`/`_percent`.\n- `review_response_by_site` (array, re-indexed via `array_values`) — per site: `id`, `review_site`, `total_reviews`, `avg_rating`, `per_of_total`, `total_response`, `per_total_response`.\n- `review_response_by_location` (array) — per location: `id`, `name`, `total_reviews`, `avg_rating`, `per_of_total`, `total_response`, `per_total_response`, `avg_response_time`.\n- `response_feed` (array) — `ResponseFeed` resource collection; each item carries `source_platform`, `review`, `reviewsite`, `store`, `store_reviewsite`, `flag`, `plan`, `review_flag`, `is_flaggable`, `flag_status`, `flag_details`, `response_capabilities`, and `response` (null when none)."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"start\": \"2024-01-01 00:00:00\", \"end\": \"2024-12-31 23:59:59\", \"company\": { \"account_name\": \"Burger King\", \"store_name\": \"Burger King 1\", \"logo\": \"https://example.com/logo.png\" }, \"kpis_data\": { \"total_reviews\": 5, \"avg_rating\": 4.2, \"total_responded\": 4, \"total_response_rate\": 80.0, \"avg_response_time\": 1.5, \"noLoginNoRespons\": 1, \"total_reviews_previous\": 3, \"avg_rating_previous\": 3.8 }, \"reviewsSentiments\": { \"negativeReviews\": 1, \"negativeReviews_percent\": 20.0, \"positiveReviews\": 3, \"positiveReviews_percent\": 60.0, \"neutralReviews\": 1, \"neutralReviews_percent\": 20.0 }, \"review_response_by_site\": [ { \"id\": 44, \"review_site\": \"google\", \"total_reviews\": 5, \"avg_rating\": 4.2, \"per_of_total\": 100.0, \"total_response\": 4, \"per_total_response\": 80.0 } ], \"review_response_by_location\": [ { \"id\": 8748, \"name\": \"Burger King 1 - San Diego\", \"total_reviews\": 5, \"avg_rating\": 4.2, \"per_of_total\": 100.0, \"total_response\": 4, \"per_total_response\": 80.0, \"avg_response_time\": 1.5 } ], \"response_feed\": [] } }"
                    }
                  ]
                },
                {
                  "name": "5.B.8 Review-response feed (brand-friendly route alias)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": {{storeId}},\n    \"rating\": [\n        4,\n        5\n    ],\n    \"status\": \"complete\",\n    \"search\": \"Amanda\",\n    \"limit\": 10,\n    \"page\": 1,\n    \"sort_by\": \"desc\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/response/feed",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "response",
                        "feed"
                      ]
                    },
                    "description": "> 🧭 **E2E journey 7/9 — Review Feed.** Uses `{{storeId}}`. Captures the first row's `id` → `{{reviewId}}`. Poll until pulled reviews surface (the step-6 pull is async). Each row exposes `reviewsite.posting_enabled` + `store_reviewsite.is_connected` — check these before step 9 (posting is connection-gated).\n\nBrand-friendly alias of the review-response feed (5.B.1) — same controller method (`ResponseFeedController::ReviewResponseFeed`), mounted under `reports/response` so a brand user reading their own reviews and a partner-admin reading across brands both work. Returns the paginated feed, overall `avg_rating`, and the per-publisher KPI strip.\n\n**Auth:** Both — `auth:api`, mounted at the top-level `reports/response` group (no partner/account prefix, role-agnostic). The reachable scope follows the caller's own auth company tree: with no narrowing params the feed defaults to every store under the authenticated user (`Store::getStoreListByUser`), so a brand-tradie sees only their stores while a partner-admin sees their whole tree. `store_id`/`client_location_id` and `account_id`/`client_account_id` are validated by `BelongsToAuthCompany`, which resolves `client_*` identifiers differently for brand (bundle_id=3) vs partner (bundle_id=1) callers — so the *reachable* set differs per persona even though the params are identical.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `range_start` | string `Y-m-d H:i:s` | optional | Start of the review-date window; pair with `range_end`. |\n| `range_end` | string `Y-m-d H:i:s` | optional | End of window. Both bounds present **and** `limit` omitted ⇒ full (unpaginated) result; otherwise paginated. |\n| `limit` | integer | optional | Page size. Default 10 (25 + hard cap 25 for hipages users). |\n| `page` | integer | optional | Page number. |\n| `sort_field` | string | optional | Sort column. |\n| `sort_by` | string | optional | One of `asc`,`ASC`,`desc`,`DESC`. |\n| `type` | string | optional | One of `report`,`download`. |\n| `store_id` | integer | optional | Existing `store.id`; tree-scoped via `BelongsToAuthCompany`. Narrows to one store. |\n| `client_location_id` | string (max 255) | optional | Alternative to `store_id`; resolved via `Store::getStoreIdFromClientLocationId`. Tree-scoped. |\n| `account_id` | integer | optional | Existing `company.id` with `bundle_id=3`, not soft-deleted; tree-scoped. Narrows to that brand's stores. |\n| `client_account_id` | string (max 255) | optional | Alternative to `account_id`; resolved via `Company::getAccountIdFromClientAccountId`. Tree-scoped; brand vs partner resolution differs (see Auth). |\n| `is_pdf` | integer | optional | `0` or `1`; switches review-site logos to local `asset()` paths. |\n| `review_site_id` | integer | optional | Filter feed to one publisher. |\n| `rating` | integer or array of integers | optional | Scalar normalized to array in `prepareForValidation`. |\n| `status` | string | optional | One of `complete`,`pending`,`escalate pending`,`escalated`,`error`,`flagged`,`removed`. |\n| `search` | string (max 255) | optional | Keyword search over reviewer/customer name, review body, review title, hipages job id, or store/location name. |\n\n**Response** — `{ data: [...] }` envelope (identical shape to 5.B.1; same `ResponseFeed` resource + `avg_rating` / `kpi_by_publisher` additional meta + pagination meta).\n- `data[]` — per review: `id`, `source_platform`, `review` (`id`/`reviewer`/`rating`/`message`/`review_date`), `reviewsite` (`id`/`name`/`link`/`review_site_logo`/`review_site_color`/`auth_type`/`scraping_enabled`/`posting_enabled`), `store` (`id`/`name`/`slug`/`address`), `store_reviewsite` (`id`/`url`/`landing_page_url`/`connection_status`/`is_connected`), `flag` (chip array), `plan`, `review_flag`, `is_flaggable`, `flag_status`, `flag_details`, `response_capabilities`, `response` (or null), plus the hipages-only block (`flagging`,`is_legacy`,`is_qualified`,`is_off_platform`,`classification_label`,`job_id`,`sub_dimensions`,`is_profanity`,`badges`,`has_history`,`moderation`) when the row is a hipages review.\n- `avg_rating` (float) — overall average across scoped stores, 2dp.\n- `kpi_by_publisher` (array) — per-publisher tiles (`review_site_id`,`review_site_name`,`review_site_color`,`rating`,`total_reviews`,`was_bridged`); `[]` when no stores in scope.\n- Pagination meta (`current_page`,`last_page`,`per_page`,`total`) when paginated."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    {\n      \"id\": 500,\n      \"source_platform\": \"google\",\n      \"review\": { \"id\": 500, \"reviewer\": \"Jane Smith\", \"rating\": 4.5, \"message\": \"Great service!\", \"review_date\": \"2024-03-10T08:00:00.000000Z\" },\n      \"reviewsite\": { \"id\": 44, \"name\": \"Google\", \"link\": \"https://google.com/maps\", \"review_site_logo\": \"https://cdn.example.com/img-google.png\", \"review_site_color\": \"#4285F4\", \"auth_type\": \"oauth\", \"scraping_enabled\": true, \"posting_enabled\": true },\n      \"store\": { \"id\": 15, \"name\": \"Downtown Location - LOC-001\", \"slug\": \"downtown-location\", \"address\": \"123 Main St, New York, NY\" },\n      \"store_reviewsite\": { \"id\": 88, \"url\": \"https://maps.google.com/...\", \"landing_page_url\": null, \"connection_status\": \"connected\", \"is_connected\": true },\n      \"flag\": [ { \"flag\": \"complete\", \"title\": \"This response has successfully been posted on the review site.\", \"statusColor\": \"success\", \"icon\": \"fa-regular:thumbs-up\" } ],\n      \"plan\": \"Premium\",\n      \"review_flag\": null,\n      \"is_flaggable\": true,\n      \"flag_status\": \"none\",\n      \"flag_details\": null,\n      \"response_capabilities\": { \"can_submit\": true, \"can_request_revision\": false },\n      \"response\": { \"id\": 300, \"status\": \"complete\", \"public_contents\": \"Thank you for your review!\", \"private_contents\": null, \"response_date\": \"2024-03-11T10:00:00.000000Z\", \"deleted\": null }\n    }\n  ],\n  \"avg_rating\": 4.2,\n  \"kpi_by_publisher\": [ { \"review_site_id\": 44, \"review_site_name\": \"Google\", \"review_site_color\": \"#4285F4\", \"rating\": 4.2, \"total_reviews\": 148, \"was_bridged\": false } ],\n  \"current_page\": 1,\n  \"last_page\": 15,\n  \"per_page\": 10,\n  \"total\": 148\n}"
                    }
                  ],
                  "event": [
                    {
                      "listen": "test",
                      "script": {
                        "type": "text/javascript",
                        "exec": [
                          "if (pm.response.code === 200) {",
                          "  const j = pm.response.json();",
                          "  const rows = (j.data && (j.data.data || j.data)) || [];",
                          "  if (Array.isArray(rows) && rows.length && rows[0].id) {",
                          "    pm.environment.set('reviewId', String(rows[0].id));",
                          "    console.log('journey: reviewId =', rows[0].id);",
                          "  }",
                          "}"
                        ]
                      }
                    }
                  ]
                },
                {
                  "name": "Reviews by publisher (brand/account-scoped)",
                  "request": {
                    "method": "POST",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/reports/response/reviews/publisher",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "response",
                        "reviews",
                        "publisher"
                      ]
                    },
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "description": "Returns per-publisher lifetime totals (all-time count + overall rating from the synced pivot, hipages via §5 bridge) plus the selected-period review count and rating, scoped to the authenticated brand/account.\n\n**Auth:** Account — `auth:api`; under the brand-side `reports/response` group. Backed by `getReviewsByPublisher` with `GetBrandReviewResponseBySiteRequest`. Scope: `store_id` → single store (guarded by `BelongsToAuthCompany`); else all of the auth user's stores via `Store::getStoreListByUser`. No `account_id`/`client_*` params — for partner-wide scope use `partner/report/review-response/by-publisher`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n\n**Response** — `{ data: [...] }` envelope; one row per publisher with history, most-reviewed first.\n- `id` (int) — review_site id\n- `review_site` (string) — display name (`maps.google` → `Google`)\n- `review_site_logo` (string) — S3 logo URL\n- `review_site_color` (string|null)\n- `total_reviews` (int) — lifetime total\n- `overall_rating` (float|null) — count-weighted lifetime rating\n- `period_reviews` (int) — reviews within the range\n- `period_rating` (float|null) — average rating within range"
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    {\n      \"id\": 44,\n      \"review_site\": \"Google\",\n      \"review_site_logo\": \"https://s3.amazonaws.com/bucket/assets/review-sites/google_ic.png\",\n      \"review_site_color\": \"#4285F4\",\n      \"total_reviews\": 1320,\n      \"overall_rating\": 4.4,\n      \"period_reviews\": 28,\n      \"period_rating\": 4.7\n    }\n  ]\n}"
                    },
                    {
                      "name": "422 Validation error",
                      "status": "Unprocessable Content",
                      "code": 422,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"message\": \"The range start does not match the format Y-m-d H:i:s.\",\n  \"errors\": {\n    \"range_start\": [\"The range start does not match the format Y-m-d H:i:s.\"]\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "Reviews stats (star breakdown)",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/reviews/stats?store_id={{storeId}}&review_site_id=44",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reviews",
                        "stats"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "44",
                          "disabled": true
                        }
                      ]
                    },
                    "description": "Returns total review count plus a per-star (1..5) breakdown for a single store, plus a canonical average rating and an off-platform review count. Built for hipages consumption (spec §6 / §10.6).\n\n**Auth:** Both — `auth:api` (under the `reviews` group; no `EnsureHipagesPartner` on this specific route). The target store is scoped by `Store::getStoreListByUser($user, true)`: a location-assigned user resolves via `user_stores`, a brand/account user (`assign_brand`) via `assignBrands()`, and a partner user via the full `getStoreListByCompany` tree walk. If the requested `store_id` is not in the caller's accessible set, returns 403 (`Store is outside your access scope.`). What differs per persona: only the reachable `store_id` set — the response shape is identical.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, required) — the store to report on.\n- Query: `review_site_id` (integer, optional) — filter to a single review site (e.g. 44); also drives the per-site average via `RatingBridge`.\n\nSuppressed (`suppressed_at`) and soft-deleted reviews are excluded from all counts. Off-platform reviews are excluded from `total_reviews`/`star_breakdown` and reported separately. Legacy reviews (`is_legacy`) count toward `total_reviews` but contribute 0 to per-star buckets.\n\n**Response** — `{ data: ... }` envelope.\n- `total_reviews` (int) — rating-eligible (non off-platform, non-suppressed) review count.\n- `star_breakdown` (object) — keys `\"1\"`..`\"5\"`, each (int) the count of non-legacy reviews at that star value.\n- `average_rating` (number|null) — canonical average via `RatingBridge::mixedAverage`; `null` when nothing is rating-eligible.\n- `off_platform_count` (int) — off-platform (`is_off_platform=true`) reviews for the store, reported separately."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"success\",\n  \"message\": \"\",\n  \"data\": {\n    \"total_reviews\": 142,\n    \"star_breakdown\": {\n      \"1\": 4,\n      \"2\": 6,\n      \"3\": 11,\n      \"4\": 38,\n      \"5\": 83\n    },\n    \"average_rating\": 4.34,\n    \"off_platform_count\": 5\n  }\n}"
                    },
                    {
                      "name": "403 Out of scope",
                      "status": "Forbidden",
                      "code": 403,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Store is outside your access scope.\",\n  \"errors\": []\n}"
                    },
                    {
                      "name": "422 Validation error",
                      "status": "Unprocessable Content",
                      "code": 422,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"message\": \"The store id field is required.\",\n  \"errors\": {\n    \"store_id\": [\"The store id field is required.\"]\n  }\n}"
                    }
                  ]
                }
              ],
              "description": "Review-response feed, KPI cards, by-location / by-site breakdowns, Excel + PDF exports, ranking-report payload."
            },
            {
              "name": "§ B.5.H Brand dashboard",
              "item": [
                {
                  "name": "5.H.1 Get KPI data counts",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/reviews/count?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "reviews",
                        "count"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Top-row KPI tiles for the account dashboard: total reviews, average rating (bridge-aware), campaign request count, response-rate %, average response time (days), and reviews awaiting a response. When both `range_start` and `range_end` are supplied it also returns a `comparison` block of period-over-period trends against the immediately-preceding window of equal length; otherwise `comparison` is null and tiles are all-time.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's company tree: `store_id` (if given) validated by `BelongsToAuthCompany`; otherwise all of the caller's stores via `Store::getStoreListByUser`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`.\n- Query: `range_start` (string `Y-m-d`, optional) — start of window.\n- Query: `range_end` (string `Y-m-d`, optional) — must be ≥ `range_start`. `awaiting_response` and `comparison` are only populated when BOTH range params are supplied.\n\n**Response** — `{ data: {...} }` envelope.\n- `reviews_count` (integer) — qualifying reviews in window.\n- `avg_rating` (float) — bridge-aware average rating.\n- `request_count` (integer) — sent email + SMS campaign requests.\n- `response_rate_pct` (float) — responses / reviews × 100.\n- `avg_response_time` (float) — average response time in days.\n- `awaiting_response` (integer) — point-in-time backlog of reviews awaiting a response (0 unless a range is supplied).\n- `comparison` (object|null) — null unless both range params given; contains `previous_period` (`{ range_start, range_end }`) plus a comparison cell per windowed metric (`reviews_count`, `avg_rating`, `request_count`, `response_rate_pct`, `avg_response_time`). Each cell = `{ current, previous, change_pct (float|null — null when previous is 0), direction (\"up\"|\"down\"|\"flat\") }`."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"total_reviews\": 1248,\n    \"total_responses\": 932,\n    \"total_review_requests\": 412,\n    \"avg_rating\": 4.34,\n    \"avg_response_time_hours\": 12.4\n  }\n}"
                    },
                    {
                      "name": "200 Success — KPI swap (2026-06)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"reviews_count\": 150,\n    \"avg_rating\": 4.35,\n    \"request_count\": 200,\n    \"response_rate_pct\": 80.0,\n    \"avg_response_time\": 1.5,\n    \"awaiting_response\": 12\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.2 Get average-rating chart",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-averagerating-chart?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-averagerating-chart"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Average-rating line chart for the account dashboard: a cumulative (running) average rating per time bucket across the window. Bucket granularity is chosen automatically from the range span — daily (≤30d), weekly (≤180d), monthly (≤730d), else yearly — and the timeline is gap-filled so every bucket appears. Rating math is bridge-aware (hipages spec §5 CASE math; AVG over qualifying reviews otherwise).\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's company tree: `store_id` (if given) validated by `BelongsToAuthCompany`; otherwise all of the caller's stores via `Store::getStoreListByUser`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`. When omitted, the caller company's industry review sites are used.\n- Query: `range_start` (string `Y-m-d`, optional) — defaults to start of 13 months ago.\n- Query: `range_end` (string `Y-m-d`, optional) — must be ≥ `range_start`; defaults to end of current month.\n\n**Response** — `{ data: [...] }` envelope; an array of buckets in chronological order.\n- `period` (string) — bucket key (e.g. `2025-05` monthly, `2025-05-01` daily, `2025` yearly).\n- `count` (number) — cumulative average rating up to and including this bucket, rounded to 2 decimals (named `count` despite being a rating value)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"period\": \"2026-03\", \"avg_rating\": 4.21 },\n    { \"period\": \"2026-04\", \"avg_rating\": 4.32 },\n    { \"period\": \"2026-05\", \"avg_rating\": 4.40 }\n  ]\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.3 Get total-review chart",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-totalreview-chart?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-totalreview-chart"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Total-reviews line chart for the account dashboard: a cumulative (running) count of reviews per time bucket across the window. Bucket granularity is auto-selected from the range span — daily (≤30d), weekly (≤180d), monthly (≤730d), else yearly — with the timeline gap-filled so each bucket is present.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's company tree: `store_id` (if given) validated by `BelongsToAuthCompany`; otherwise all of the caller's stores via `Store::getStoreListByUser`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`. When omitted, the caller company's industry review sites are used.\n- Query: `range_start` (string `Y-m-d`, optional) — defaults to start of 13 months ago.\n- Query: `range_end` (string `Y-m-d`, optional) — must be ≥ `range_start`; defaults to end of current month.\n\n**Response** — `{ data: [...] }` envelope; an array of buckets in chronological order.\n- `period` (string) — bucket key (monthly `2025-05`, daily `2025-05-01`, weekly `2025-19`, yearly `2025`).\n- `count` (integer) — cumulative review count up to and including this bucket."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"period\": \"2026-03\", \"count\": 312 },\n    { \"period\": \"2026-04\", \"count\": 401 },\n    { \"period\": \"2026-05\", \"count\": 535 }\n  ]\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.4 Get total-response chart",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-totalresponse-chart?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-totalresponse-chart"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Total-responses line chart for the account dashboard: a cumulative (running) count of reviews that have a response, per time bucket across the window. Bucket granularity is auto-selected from the range span (daily ≤30d / weekly ≤180d / monthly ≤730d / else yearly) with the timeline gap-filled. Responses are bucketed by the response's creation date.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's company tree: `store_id` (if given) validated by `BelongsToAuthCompany`; otherwise all of the caller's stores via `Store::getStoreListByUser`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`. When omitted, the caller company's industry review sites are used.\n- Query: `range_start` (string `Y-m-d`, optional) — defaults to start of 13 months ago.\n- Query: `range_end` (string `Y-m-d`, optional) — must be ≥ `range_start`; defaults to end of current month.\n\n**Response** — `{ data: [...] }` envelope; an array of buckets in chronological order.\n- `period` (string) — bucket key.\n- `count` (integer) — cumulative response count up to and including this bucket."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"period\": \"2026-03\", \"count\": 240 },\n    { \"period\": \"2026-04\", \"count\": 318 },\n    { \"period\": \"2026-05\", \"count\": 412 }\n  ]\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.5 Get response-time chart",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-responsetime-chart?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-responsetime-chart"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Response-time line chart for the account dashboard: a cumulative (running) average response time in DAYS per time bucket across the window. Bucket granularity is auto-selected from the range span (daily ≤30d / weekly ≤180d / monthly ≤730d / else yearly) with the timeline gap-filled. Non-responding / auto-imported / pre-dated responses contribute zero seconds.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's company tree: `store_id` (if given) validated by `BelongsToAuthCompany`; otherwise all of the caller's stores via `Store::getStoreListByUser`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`. When omitted, the caller company's industry review sites are used.\n- Query: `range_start` (string `Y-m-d`, optional) — defaults to start of 13 months ago.\n- Query: `range_end` (string `Y-m-d`, optional) — must be ≥ `range_start`; defaults to end of current month.\n\n**Response** — `{ data: [...] }` envelope; an array of buckets in chronological order.\n- `period` (string) — bucket key.\n- `count` (number) — cumulative average response time in days, rounded to 2 decimals (named `count` despite being a days value)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"period\": \"2026-03\", \"avg_hours\": 18.4 },\n    { \"period\": \"2026-04\", \"avg_hours\": 14.1 },\n    { \"period\": \"2026-05\", \"avg_hours\": 11.6 }\n  ]\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.6 Get review-request chart",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-reviewrequests-chart?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-reviewrequests-chart"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Review-requests line chart for the account dashboard: a cumulative (running) count of sent review requests (email + SMS campaign requests combined) per time bucket across the window, bucketed by the request's `sent_at`. Bucket granularity is auto-selected from the range span (daily ≤30d / weekly ≤180d / monthly ≤730d / else yearly) with the timeline gap-filled.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's company tree: `store_id` (if given) validated by `BelongsToAuthCompany`; otherwise all of the caller's stores via `Store::getStoreListByUser`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `range_start` (string `Y-m-d`, optional) — defaults to start of 13 months ago.\n- Query: `range_end` (string `Y-m-d`, optional) — must be ≥ `range_start`; defaults to end of current month.\n- NOTE: `review_site_id` is accepted by the shared form request and may be sent in the URL, but this endpoint does NOT use it (review requests are not platform-specific).\n\n**Response** — `{ data: [...] }` envelope; an array of buckets in chronological order.\n- `period` (string) — bucket key.\n- `count` (integer) — cumulative count of email + SMS review requests up to and including this bucket."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"period\": \"2026-03\", \"sent\": 87, \"converted\": 24 },\n    { \"period\": \"2026-04\", \"sent\": 112, \"converted\": 31 },\n    { \"period\": \"2026-05\", \"sent\": 145, \"converted\": 48 }\n  ]\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.7 Get review-rating breakdown",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-review-rating-breakdown?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-review-rating-breakdown"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Star-rating breakdown for the account dashboard: total qualifying review counts grouped by star rating (1–5). Only rating-eligible reviews are counted (the `qualifyingForRatings` scope drops hipages legacy, off-platform, and non-qualified rows), so star buckets reflect rating-eligible reviews only.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's company tree: `store_id` (if given) validated by `BelongsToAuthCompany`; otherwise all of the caller's stores via `Store::getStoreListByUser`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`.\n- Query: `range_start` (string `Y-m-d`, optional) — both range params must be present for the date filter to apply.\n- Query: `range_end` (string `Y-m-d`, optional) — must be ≥ `range_start`.\n\n**Response** — `{ data: {...} }` envelope; an object keyed by star label `\"<n> Star\"` → review count. Only star ratings that have at least one review appear (no zero-fill); empty object when there are none."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"1 Star\": 18,\n    \"2 Star\": 42,\n    \"3 Star\": 87,\n    \"4 Star\": 312,\n    \"5 Star\": 789\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.8 Get review-site distribution",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-review-site-distribution?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-review-site-distribution"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Review-site distribution for the account dashboard: review counts and average ratings per platform. When no `review_site_id` filter is applied and there are more than 5 platforms, the top 4 are returned plus a rolled-up \"Others\" row (weighted-average rating). Ratings/counts are bridge-aware for hipages platforms.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's company tree: `store_id` (if given) validated by `BelongsToAuthCompany`; otherwise all of the caller's stores via `Store::getStoreListByUser`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`. When set, the top-5/Others rollup is disabled.\n- Query: `range_start` (string `Y-m-d`, optional) — both range params must be present for the date filter to apply.\n- Query: `range_end` (string `Y-m-d`, optional) — must be ≥ `range_start`.\n\n**Response** — `{ data: [...] }` envelope; an array of platform rows ordered by review count desc.\n- `name` (string) — review site name (e.g. `maps.google`, `Facebook`, or `Others`).\n- `total_reviews` (integer) — review count for the platform (bridge-unified for hipages).\n- `avg_rating` (number) — average rating to 2 decimals (bridge-aware for hipages).\n- `logo` (string) — S3 URL of the platform logo image."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"review_site_id\": 100, \"review_site_name\": \"hipages\", \"review_site_color\": \"#FF6B00\", \"count\": 532 },\n    { \"review_site_id\": 44, \"review_site_name\": \"Google\", \"review_site_color\": \"#4285F4\", \"count\": 318 },\n    { \"review_site_id\": 3, \"review_site_name\": \"Facebook\", \"review_site_color\": \"#1877F2\", \"count\": 214 }\n  ]\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.9 Get sentiment overview",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/sentiment-overview?store_id={{storeId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "sentiment-overview"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Sentiment overview for the account dashboard: positive (4–5 star), neutral (3 star), and negative (1–2 star) review counts. Only qualifying, non-hipages-suppressed, rating-eligible reviews are counted. When both `range_start` and `range_end` are supplied it also returns a `comparison` block with per-sentiment period-over-period trends against the immediately-preceding window of equal length; otherwise `comparison` is null.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's own brand company (`brand.id = user.person.company.id`); `store_id` (if given) validated by `BelongsToAuthCompany` and further constrains to that one store; `assign_location` users are limited to their assigned stores.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; constrains results to that store.\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`.\n- Query: `range_start` (string `Y-m-d`, optional) — both range params must be present for the date filter AND the comparison block to apply.\n- Query: `range_end` (string `Y-m-d`, optional) — must be ≥ `range_start`.\n- NOTE: the Postman URL for this endpoint omits `review_site_id`; it remains an accepted optional filter.\n\n**Response** — `{ data: {...} }` envelope.\n- `positive_count` (integer) — reviews with rate > 3.\n- `neutral_count` (integer) — middle band (effectively 3-star).\n- `negative_count` (integer) — rate 1–2 (Facebook only when rate > 0).\n- `comparison` (object|null) — null unless both range params given; contains `previous_period` (`{ range_start, range_end }`) plus a comparison cell per sentiment (`positive_count`, `neutral_count`, `negative_count`). Each cell = `{ current, previous, change_pct (float|null — null when previous is 0), direction (\"up\"|\"down\"|\"flat\") }`."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"positive_count\": 892,\n    \"neutral_count\": 178,\n    \"negative_count\": 78\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.10 Get annual volume + rating stats",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-annual-volume-rating-stats?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-annual-volume-rating-stats"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Annual volume + rating stats for the account dashboard: the last 13 months (fixed window — start of 13 months ago through end of last month) of per-month review counts, response counts, monthly average rating, and a single all-history overall rating. Rating math is bridge-aware for hipages stores.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's company tree: `store_id` (if given) validated by `BelongsToAuthCompany`; otherwise all of the caller's stores via `Store::getStoreListByUser`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`. Passing `9` automatically expands to `[9, 44]`.\n- NOTE: the date window is fixed server-side (last 13 months) — `range_start`/`range_end` are NOT read by this endpoint.\n\n**Response** — `{ data: [...] }` envelope; an array of 13 month rows in chronological order.\n- `month` (string) — `\"Mon YYYY\"` (e.g. `May 2025`).\n- `reviews` (integer) — review count that month (bridge-unified for hipages).\n- `responses` (integer) — response count that month.\n- `monthly_avg_rating` (number) — that month's average rating, rounded to 2 decimals.\n- `overall_rating` (number) — all-history overall rating before the window, rounded to 2 decimals (same value on every row)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"month\": \"2025-06\", \"reviews\": 87, \"avg_rating\": 4.12 },\n    { \"month\": \"2025-07\", \"reviews\": 102, \"avg_rating\": 4.18 },\n    { \"month\": \"2026-05\", \"reviews\": 145, \"avg_rating\": 4.42 }\n  ]\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.11 Get top-performing locations",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-top-performing-locations?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-top-performing-locations"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Top-performing locations for the account dashboard: the top 3 store locations under the caller's brand ranked by total (bridge-aware) review count, each with its average rating. Stores with zero reviews are dropped.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's own brand company (candidate stores = stores whose location-account's parent brand = `user.person.company.id`); `assign_location` users are limited to their assigned stores.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `range_start` (string `Y-m-d`, optional) — start of window (no default; null = all-time).\n- Query: `range_end` (string `Y-m-d`, optional) — must be ≥ `range_start`.\n- NOTE: `store_id` and `review_site_id` are accepted by the shared form request and may be sent in the URL, but this endpoint does NOT use them — it always ranks across the caller's whole brand tree.\n\n**Response** — `{ data: [...] }` envelope; an array of up to 3 stores ordered by review count desc.\n- `store_id` (integer) — store PK.\n- `store_name` (string) — `\"<store name> - <storeid>\"`.\n- `reviews` (integer) — total reviews (bridge-aware).\n- `rating` (number/float) — average rating (bridge-aware)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"store_id\": 42, \"store_name\": \"Downtown - LOC-001\", \"reviews\": 312, \"rating\": 4.78 },\n    { \"store_id\": 87, \"store_name\": \"Northside - LOC-007\", \"reviews\": 248, \"rating\": 4.65 },\n    { \"store_id\": 124, \"store_name\": \"Westview - LOC-012\", \"reviews\": 198, \"rating\": 4.61 }\n  ]\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.12 Get monthly ranking report",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-ranking-report?store_id={{storeId}}&review_site_id={{reviewSiteId}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-ranking-report"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        }
                      ]
                    },
                    "description": "Monthly average-rating ranking report for the account dashboard: the last 4 calendar months' bridge-aware average ratings, broken out per year across roughly the last 3+ years (start of ~3y4m ago through end of this month), grouped by year. Each year row carries the four most-recent month names as columns.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic). Scope is the caller's own brand company (stores whose location-account's parent brand = `user.person.company.id`).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`.\n- NOTE: `store_id` is accepted by the form request (validated by `BelongsToAuthCompany` if present) but the report always aggregates across the caller's whole brand tree; there are no range params on this endpoint (the window is fixed server-side).\n\n**Response** — `{ data: [...] }` envelope; an array of year rows in ascending year order. Each row has `Year` (integer) plus one key per the four most-recent month names (full month name, e.g. `March`) → average rating for that month/year (number; 0 when no data)."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"month\": \"2025-06\", \"rank\": 4 },\n    { \"month\": \"2025-07\", \"rank\": 3 },\n    { \"month\": \"2026-05\", \"rank\": 2 }\n  ]\n}"
                    }
                  ]
                },
                {
                  "name": "5.H.13 Get overall ranking by location (last 12 months)",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/dashboard/get-overall-ranking-bylocation-last12months?store_id={{storeId}}&review_site_id={{reviewSiteId}}&range_start={{rangeStart}}&range_end={{rangeEnd}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "dashboard",
                        "get-overall-ranking-bylocation-last12months"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}"
                        },
                        {
                          "key": "review_site_id",
                          "value": "{{reviewSiteId}}"
                        },
                        {
                          "key": "range_start",
                          "value": "{{rangeStart}}"
                        },
                        {
                          "key": "range_end",
                          "value": "{{rangeEnd}}"
                        }
                      ]
                    },
                    "description": "Overall location ranking for the selected window (default last 12 months): up to 10 store locations ranked and graded by review count, response rate, and average rating (bridge-aware for hipages stores). Same controller route as idx 122; this variant uses the `{{rangeStart}}`/`{{rangeEnd}}` collection variables.\n\n**Auth:** Account — `auth:api`, under the `account/dashboard` group (role-agnostic; no `EnsureHipagesPartner`). Scope is the caller's company tree: `store_id` (if given) validated by `BelongsToAuthCompany`; otherwise all of the caller's stores via `Store::getStoreListByUser` (honouring the user's `assign_location`/`assign_brand` restrictions).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must belong to the caller's company; when omitted, all of the caller's stores are used.\n- Query: `review_site_id` (integer, optional) — filter to one platform; must exist in `review_sites`.\n- Query: `range_start` (string, optional) — REQUIRES full datetime format `Y-m-d H:i:s` (this endpoint's form request rejects bare `Y-m-d`); defaults to start of 13 months ago when omitted.\n- Query: `range_end` (string, optional) — `Y-m-d H:i:s`, must be ≥ `range_start`; defaults to end of last month.\n\n**Response** — `{ data: [...] }` envelope; an array of up to 10 ranked locations (empty array when no qualifying reviews).\n- `id` (string) — `loc_<store id>`.\n- `final_grade` (string) — overall letter grade (e.g. `A+`, `N/A`).\n- `name` (string) — `\"<store name> - <storeid>\"`.\n- `avgRating` (string) — average rating formatted to 2 decimals.\n- `review_count` (integer) — qualifying review count (bridge-unified for hipages).\n- `response_count` (string) — `\"<count> (<percent>%)\"`."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"store_id\": 42, \"store_name\": \"Downtown - LOC-001\", \"months\": [\n      { \"month\": \"2025-06\", \"rank\": 3 },\n      { \"month\": \"2025-07\", \"rank\": 2 },\n      { \"month\": \"2026-05\", \"rank\": 1 }\n    ] }\n  ]\n}"
                    }
                  ]
                }
              ],
              "description": "Brand-side dashboard widget endpoints. All GETs. Common query envelope: `store_id` (optional, scoped by `BelongsToAuthCompany`), `review_site_id` (optional), `range_start` / `range_end` (optional `YYYY-MM-DD`). Auth: `auth:api` bearer. Powers the brand-admin home page tiles + charts. ✅ live"
            },
            {
              "name": "§ B.6 Performance Report (brand)",
              "item": [
                {
                  "name": "6.A.1 KPI cards (brand)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/response/kpis",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "response",
                        "kpis"
                      ]
                    },
                    "description": "Returns period-over-period KPI cards (review counts, ratings, response rate, average response time, awaiting-response, sentiment split) for the brand response report. (Same route/handler as 5.B.2 KPIs — `getResponseReportKpis`.)\n\n**Auth:** Both — `auth:api`; under the brand-side `reports/response` group. Uses `GetBrandResponseReportKpiRequest`. Scope precedence: `store_id` (or `client_location_id`→store) → single store; else `account_id` (or `client_account_id`→account) → `Store::getAllChildStoreByCompanyId`; else the auth user's tree via `Store::getStoreListByUser`. A brand user passes `store_id`; a partner-context caller can scope to a child account via `account_id`/`client_account_id`. All id params guarded by `BelongsToAuthCompany`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n| account_id | integer | no | a company under the auth company |\n| client_account_id | string | no | resolved to account_id |\n| client_location_id | string | no | resolved to store_id |\n\n**Response** — `{ data: ... }` envelope.\n- `reviews_current` / `reviews_previous` (int)\n- `avg_rating` / `avg_rating_previous` (float)\n- `response_rate_pct` / `response_rate_pct_previous` (float)\n- `avg_response_time_in_days` (string) — e.g. `\"1.2 Days\"`\n- `awaiting_response` (int)\n- `reviews_sentiments` (object) — `negativeReviews`, `negativeReviews_percent`, `positiveReviews`, `positiveReviews_percent`, `neutralReviews`, `neutralReviews_percent`"
                  },
                  "response": [
                    {
                      "name": "200 Success — KPI swap (2026-06)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"reviews_current\": 13,\n    \"reviews_previous\": 1,\n    \"avg_rating\": 4.2,\n    \"avg_rating_previous\": 2.2,\n    \"response_rate_pct\": 84.62,\n    \"response_rate_pct_previous\": 100.0,\n    \"avg_response_time_in_days\": \"1.2 Days\",\n    \"awaiting_response\": 2,\n    \"reviews_sentiments\": {\n      \"negativeReviews\": 1,\n      \"negativeReviews_percent\": 7.69,\n      \"positiveReviews\": 10,\n      \"positiveReviews_percent\": 76.92,\n      \"neutralReviews\": 2,\n      \"neutralReviews_percent\": 15.39\n    }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "6.A.2 Locations table (brand)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1,\n    \"sort_field\": \"total_reviews\",\n    \"sort_by\": \"desc\",\n    \"limit\": 10,\n    \"page\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/response/locationlist",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "response",
                        "locationlist"
                      ]
                    },
                    "description": "Returns a paginated per-location (per-store) response table for the authenticated brand — reviews, responses, average response time, average rating, response rate. (Same route/handler as 5.B.3 — `getResponseReportLocationList`.)\n\n**Auth:** Account — `auth:api`; under the brand-side `reports/response` group. Uses `GetBrandResponseReportLocationListRequest`. Scope: `store_id` → single store (guarded by `BelongsToAuthCompany`); else all of the auth user's stores via `Store::getStoreListByUser`. No `account_id`/`client_*` params on this brand variant.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n| sort_field | string | no | `location_name,total_reviews,total_responses,avg_resp_time,avg_rating,response_rate` (default `location_name`) |\n| sort_by | string | no | `asc`/`ASC`/`desc`/`DESC` (default `ASC`) |\n| search_field | string | no | only `location_name` |\n| search_value | string | no | |\n| limit | integer | no | 1–100 (default 10) |\n| page | integer | no | min 1 |\n\n**Response** — paginated `ReviewResponseReport` collection.\n- `data[].id` (int) — store id\n- `data[].location_name` (string) — `name - storeid`\n- `data[].total_reviews` / `data[].total_responses` (int)\n- `data[].avg_resp_time` (float, days) / `data[].avg_rating` (float) / `data[].response_rate` (float, %)\n- `current_page`, `last_page`, `per_page`, `total`"
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": [ { \"id\": 8748, \"location_name\": \"Downtown Location - LOC-001\", \"total_reviews\": 13, \"total_responses\": 12, \"avg_resp_time\": 1.2, \"avg_rating\": 4.31, \"response_rate\": 92.11 } ], \"current_page\": 1, \"last_page\": 2, \"per_page\": 10, \"total\": 16 }"
                    }
                  ]
                },
                {
                  "name": "6.A.3 Reviews-by-Site (brand)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/response/reviews/site",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "response",
                        "reviews",
                        "site"
                      ]
                    },
                    "description": "Returns review/response metrics grouped by review site (publisher) for the authenticated brand — per-site review count, average rating, % of total, response count, response rate. (Same route/handler as 5.B.4 — `getReviewResponseBySite`.)\n\n**Auth:** Account — `auth:api`; under the brand-side `reports/response` group. Uses `GetBrandReviewResponseBySiteRequest`. Scope: `store_id` → single store (guarded by `BelongsToAuthCompany`); else all of the auth user's stores via `Store::getStoreListByUser`. No `account_id`/`client_*` params.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| range_start | string | yes | `Y-m-d H:i:s` |\n| range_end | string | yes | `Y-m-d H:i:s` |\n| store_id | integer | no | belongs to auth company |\n\n**Response** — `{ data: [...] }` envelope; array of per-site rows (passthrough of `ReviewSite::reviewResponsesBySite`).\n- `id` (int) — review_site id\n- `review_site` (string) — lowercased name (`maps.google` → `google`)\n- `review_site_logo` (string|null) — S3 logo URL\n- `total_reviews` (int) / `avg_rating` (float)\n- `per_of_total` (float) — % of total\n- `total_response` (int) / `per_total_response` (float) — response rate %"
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": [ { \"id\": 44, \"review_site\": \"google\", \"review_site_logo\": \"https://example.com/assets/google_ic.png\", \"total_reviews\": 13, \"avg_rating\": 4.4, \"per_of_total\": 95.11, \"total_response\": 11, \"per_total_response\": 84.62 } ] }"
                    }
                  ]
                },
                {
                  "name": "6.A.4 Excel export (brand)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1,\n    \"search\": \"\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/excel/review-response-feed",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "excel",
                        "review-response-feed"
                      ]
                    },
                    "description": "Generates a review-response feed Excel workbook for the caller's stores over a date range, stores it on the S3 disk, and returns the public download URL. (Same route/controller method as idx 232 — `Download\\ReviewResponseController@ReviewResponseFeed`; this is the brand-facing duplicate registration.)\n\n**Auth:** Account — `auth:api` bearer token, inside the generic brand-side group (`Route::middleware(['auth:api'])->name('api.')`, prefix `reports/excel`); no partner/Hipages/dormant gating. Scope is the caller's own company: stores resolve via `Store::getStoreListByUser($user, true)`, and an explicit `store_id` is validated by `BelongsToAuthCompany` so it must sit in the caller's company hierarchy. Header info reads `user->person->company->name`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `range_start` | string | yes | `date_format:Y-m-d H:i:s`; start of range. |\n| `range_end` | string | yes | `date_format:Y-m-d H:i:s`; end of range. |\n| `store_id` | integer | no | Must `exists` in `store` and pass `BelongsToAuthCompany`. When omitted, all of the caller's stores are included. |\n| `search` | string | no | `max:255`. Accepted by the Form Request but not consumed by the controller method. |\n\n**Response** — `{ data: ... }` envelope.\n- `path` (string) — S3 URL to the generated `.xlsx` (`Storage::disk('s3')->url(...)`), stored at `download/excel/{Y-m}/{timestamp}-ReviewResponseReport.xlsx`."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"path\": \"https://s3.amazonaws.com/bucket/download/excel/2024-01/1719300000-ReviewResponseReport.xlsx\" } }"
                    }
                  ]
                },
                {
                  "name": "6.A.5 PDF export (brand)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\",\n    \"store_id\": 1,\n    \"is_pdf\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/pdf/performance-report",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "pdf",
                        "performance-report"
                      ]
                    },
                    "description": "Returns the full performance-report dataset for a PDF: company block, KPIs (with previous-period comparisons), sentiment split, per-site and per-location review/response breakdowns, and the review-response feed. (Same route/controller method as idx 233 — `Download\\ReviewResponseController@performanceReport`; this is the brand-facing duplicate registration.)\n\n**Auth:** Account — `auth:api` bearer token, inside the generic brand-side group (`Route::middleware(['auth:api'])->name('api.')`, prefix `reports/pdf`); no partner/Hipages/dormant gating. Scope is the caller's own company: stores resolve via `Store::getStoreListByUser($user, true)` and an explicit `store_id` is validated by `BelongsToAuthCompany`. The company block adapts to the caller's bundle: a brand (`bundle_id == 3`) reports its own name with an empty `store_name`; otherwise (location-account) it reports the parent company name plus the single store's name. The partner white-label logo comes from `Company::getWhiteLabel($company->id)`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `range_start` | string | yes | `date_format:Y-m-d H:i:s`; start of range. |\n| `range_end` | string | yes | `date_format:Y-m-d H:i:s`; end of range. |\n| `store_id` | integer | no | Must `exists` in `store` and pass `BelongsToAuthCompany`. When omitted, all of the caller's stores are included. |\n| `is_pdf` | integer | no | `in:0,1`. When `1`, `response_feed` is capped at 50 rows (and `ResponseFeed` uses local asset logos instead of S3 URLs). |\n\n**Response** — `{ data: ... }` envelope.\n- `start` / `end` (string) — echoed range bounds.\n- `company` (object) — `account_name` (string), `store_name` (string|null), `logo` (string|null, white-label partner logo).\n- `kpis_data` (object) — from `Review::metricsData(...)`: `total_reviews`, `avg_rating`, `total_responded`, `total_response_rate`, `avg_response_time`, `noLoginNoRespons`, plus previous-period `total_reviews_previous`, `avg_rating_previous`.\n- `reviewsSentiments` (object) — `negativeReviews`/`_percent`, `positiveReviews`/`_percent`, `neutralReviews`/`_percent`.\n- `review_response_by_site` (array, re-indexed via `array_values`) — per site: `id`, `review_site`, `total_reviews`, `avg_rating`, `per_of_total`, `total_response`, `per_total_response`.\n- `review_response_by_location` (array) — per location: `id`, `name`, `total_reviews`, `avg_rating`, `per_of_total`, `total_response`, `per_total_response`, `avg_response_time`.\n- `response_feed` (array) — `ResponseFeed` resource collection; each item carries `source_platform`, `review`, `reviewsite`, `store`, `store_reviewsite`, `flag`, `plan`, `review_flag`, `is_flaggable`, `flag_status`, `flag_details`, `response_capabilities`, and `response` (null when none)."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"start\": \"2024-01-01 00:00:00\", \"end\": \"2024-12-31 23:59:59\", \"company\": { \"account_name\": \"Burger King\", \"store_name\": \"Burger King 1\", \"logo\": \"https://example.com/logo.png\" }, \"kpis_data\": { \"total_reviews\": 5, \"avg_rating\": 4.2, \"total_responded\": 4, \"total_response_rate\": 80.0, \"avg_response_time\": 1.5, \"noLoginNoRespons\": 1, \"total_reviews_previous\": 3, \"avg_rating_previous\": 3.8 }, \"reviewsSentiments\": { \"negativeReviews\": 1, \"negativeReviews_percent\": 20.0, \"positiveReviews\": 3, \"positiveReviews_percent\": 60.0, \"neutralReviews\": 1, \"neutralReviews_percent\": 20.0 }, \"review_response_by_site\": [ { \"id\": 44, \"review_site\": \"google\", \"total_reviews\": 5, \"avg_rating\": 4.2, \"per_of_total\": 100.0, \"total_response\": 4, \"per_total_response\": 80.0 } ], \"review_response_by_location\": [ { \"id\": 8748, \"name\": \"Burger King 1 - San Diego\", \"total_reviews\": 5, \"avg_rating\": 4.2, \"per_of_total\": 100.0, \"total_response\": 4, \"per_total_response\": 80.0, \"avg_response_time\": 1.5 } ], \"response_feed\": [] } }"
                    }
                  ]
                },
                {
                  "name": "6.A.6 Annual volume + rating chart (brand-only)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"account_id\": 1,\n    \"review_site_id\": 44,\n    \"date\": \"2026-06-01\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/graph/annual-volume",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "graph",
                        "annual-volume"
                      ]
                    },
                    "description": "Returns the last 12 months (relative to `date`) of per-month review volume, average rating, cumulative overall rating, and response counts for a brand/account or a single store — used to drive the annual volume + rating chart.\n\n**Auth:** Both — `auth:api` (mounted under the top-level `api.` group, not a `partner/*` prefix). Every identifier param runs through the `BelongsToAuthCompany` rule, which resolves the target to a company id and rejects anything outside the caller's tree via `Company::isBelongsToParentCompany`. A brand/account caller (`bundle_id=3`) passes its own `account_id`/`store_id` or its own `client_account_id`/`client_location_id`; a partner caller (`bundle_id=1`) may pass any account/store under its child tree, and external client ids are resolved against the partner's full descendant set. `account_id` must reference a `bundle_id=3` (brand/account) company row.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `account_id` | integer | required* | A `company.id` with `bundle_id=3` (a brand/account). Required unless one of `store_id` / `client_location_id` / `client_account_id` is supplied. Must belong to the caller's hierarchy. Expands to all child store ids via `Store::getAllChildStoreByCompanyId`. |\n| `store_id` | integer | required* | A single `store.id`. Required unless one of `account_id` / `client_location_id` / `client_account_id` is supplied. Must belong to the caller's hierarchy. |\n| `client_location_id` | string (max 255) | optional | Partner-supplied external store identifier; resolved to a `store_id` via `Store::getStoreIdFromClientLocationId`. Scoped by `BelongsToAuthCompany`. |\n| `client_account_id` | string (max 255) | optional | Partner-supplied external account identifier; resolved to an `account_id` via `Company::getAccountIdFromClientAccountId`. Scoped by `BelongsToAuthCompany`. |\n| `review_site_id` | integer | optional | Filters reviews/responses to one `review_sites.id` (e.g. Google, Yelp). |\n| `date` | string (Y-m-d) | optional | Reference date; the 12-month window ends at this month. Defaults to today. |\n\n*At least one of `account_id`, `store_id`, `client_location_id`, `client_account_id` is required (`required_without_all`).\n\n**Response** — `{ data: ... }` envelope. The payload is `{ status, data }` where `data` is a 12-element array (oldest month first), one entry per month:\n- `status` (string) — always `\"success\"`.\n- `data[].month` (string) — month label formatted `F-Y`, e.g. `\"June-2026\"`.\n- `data[].reviews` (int) — count of qualifying reviews (`Review::qualifyingForRatings()`) in that month.\n- `data[].avg_rating` (float) — average `rate` of that month's reviews (0 if none).\n- `data[].overall_rating` (float) — cumulative average `rate` of all qualifying reviews up to and including the end of that month.\n- `data[].responses` (int) — count of reviews in that month that have a `response_reviews` row."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      { \"month\": \"July-2025\", \"reviews\": 3, \"avg_rating\": 4.55, \"overall_rating\": 4.99, \"responses\": 3 },\n      { \"month\": \"August-2025\", \"reviews\": 0, \"avg_rating\": 0, \"overall_rating\": 4.99, \"responses\": 0 }\n    ]\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "6.A.7 Review-site dropdown",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/account/reviewsite/list",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "reviewsite",
                        "list"
                      ]
                    },
                    "description": "GET variant of the same route (`Route::match(['get','post'], 'list', ...)`) backing the review-site dropdown — returns the selectable review-site catalog (value/label pairs) for an account: the union of review sites on the plans assigned to the account's brand(s), with hipages pinned first, then Google (44) and Yelp (1); legacy Google site id 52 is always excluded.\n\n**Auth:** Both — `auth:api` under the role-agnostic `/account/*` prefix (top-level `auth:api` group). Target account comes from `company_id` (query param here) or falls back to the auth user's own `person.company_id`; `company_id` is scoped by the `BelongsToAuthCompany` rule, so a Partner (`bundle_id=1`) may target any company in its tree while an Account/brand (`bundle_id=3`) is limited to its own subtree.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `company_id` (integer, optional) — `nullable`; must `exists` in `company.id` and pass `BelongsToAuthCompany`. Defaults to the auth user's `person.company_id` when omitted.\n- Query: `except_review_site_id` (array, optional) — `nullable|array` of review-site ids to exclude; each element required when the param is present.\n\n**Response** — `{ data: ... }` envelope.\n- `data.status` (string) — always `\"success\"`.\n- `data.response` (array) — list of review sites; `[]` when no plans/review sites resolve. Each item:\n  - `value` (integer) — review site id (`review_sites.id`).\n  - `label` (string) — review site name; id 44 force-labelled `\"Google\"`, otherwise `review_sites.name`."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"response\": [\n      { \"value\": 44, \"label\": \"Google\" },\n      { \"value\": 1, \"label\": \"Yelp\" }\n    ]\n  }\n}"
                    }
                  ]
                }
              ],
              "description": "Brand-scope dashboard tabs — KPI cards, locations table, reviews-by-site, exports. Excludes partner-aggregate views (those live in P.6)."
            },
            {
              "name": "§ B.7 Ranking Report",
              "item": [
                {
                  "name": "7.A.1 Ranking report",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"range_start\": \"2024-01-01 00:00:00\",\n    \"range_end\": \"2024-12-31 23:59:59\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/reports/ranking",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "ranking"
                      ]
                    },
                    "description": "Ranks the authenticated brand's locations against each other over a date range — by review volume, average rating, and response rate — and computes a blended overall rank with a letter grade. (Same route/controller/Form Request as 5.B.5; this is the minimal-payload example without `store_id`.)\n\n**Auth:** Both — `auth:api` (Passport bearer). Inside `rankingReport()` the caller's `person.company.bundle_id` must be `3` (brand) or `2` (location-account); any other persona gets a 403. The brand name is resolved by walking UP via `Company::getParentCompany($user->person->company_id, 'brand')`. With no `store_id`, the ranked set is `Store::getStoreListByUser($user, true)`. Per-persona difference: a location-level user (bundle_id=2 / `assign_location`) gets only its assigned store(s); a brand-level user gets all stores under the brand's location-account children.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (all in JSON body):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `range_start` | string | yes | Date-time, format `Y-m-d H:i:s` (e.g. `2024-01-01 00:00:00`). |\n| `range_end` | string | yes | Date-time, format `Y-m-d H:i:s`. |\n| `store_id` | integer | no | Nullable. Omitted here. When present must `exists:store,id` AND pass `BelongsToAuthCompany`. |\n\n**Response** — `{ data: ... }` envelope.\n- `account_name` (string) — the parent brand company name (null if no brand resolved).\n- `interval` (object) — `start` and `end`, echoed from the request.\n- `locationRank` (object):\n  - `byReview` (array) — ranked by `review_count`; row: `id`, `name`, `review_count`, `response_count`, `response_percent`, `avgRating`, `rank`.\n  - `byRate` (array) — same shape, ranked by `avgRating`.\n  - `byResponse` (array) — same shape, ranked by `response_percent`.\n  - `overAll` (array) — adds `reviewRank`, `rateRank`, `responseRank`, `totalRank`, `final_grade` (A+/A/B/C/D), `final_grade_point` (5..1); with 10+ distinct total ranks each row also carries a `percentage_rank` band.\n- All four arrays are empty when no location has data in the window."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"account_name\": \"Burger King\",\n    \"interval\": { \"start\": \"2024-01-01 00:00:00\", \"end\": \"2024-12-31 23:59:59\" },\n    \"locationRank\": {\n      \"byReview\": [\n        { \"id\": 12314, \"name\": \"Burger King 1 - LOC-001\", \"review_count\": 10, \"response_count\": 8, \"response_percent\": 80.0, \"avgRating\": 4.5, \"rank\": 1 }\n      ],\n      \"byRate\": [\n        { \"id\": 12314, \"name\": \"Burger King 1 - LOC-001\", \"review_count\": 10, \"response_count\": 8, \"response_percent\": 80.0, \"avgRating\": 4.5, \"rank\": 1 }\n      ],\n      \"byResponse\": [\n        { \"id\": 12314, \"name\": \"Burger King 1 - LOC-001\", \"review_count\": 10, \"response_count\": 8, \"response_percent\": 80.0, \"avgRating\": 4.5, \"rank\": 1 }\n      ],\n      \"overAll\": [\n        { \"id\": 12314, \"name\": \"Burger King 1 - LOC-001\", \"review_count\": 10, \"response_count\": 8, \"response_percent\": 80.0, \"avgRating\": 4.5, \"reviewRank\": 1, \"rateRank\": 2, \"responseRank\": 1, \"totalRank\": 4, \"final_grade\": \"A+\", \"final_grade_point\": 5 }\n      ]\n    }\n  }\n}"
                    }
                  ]
                }
              ],
              "description": "Brand-side ranking-report payload (matches the partner version structurally — segregated here because the canonical caller is a brand user)."
            },
            {
              "name": "§ B.8.D Campaigns — reporting",
              "item": [
                {
                  "name": "8.D.1 Campaign dashboard metrics",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/campaign/dashboard/metrics",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "campaign",
                        "dashboard",
                        "metrics"
                      ]
                    },
                    "description": "Returns aggregated email/SMS campaign performance stats and audience contact counts for the authenticated user's company over a given date range, optionally scoped to one store.\n\n**Auth:** Account — `auth:api` (inside the authenticated `api.` group; route lives under the `campaign/dashboard` brand group). Scope is implicit: the controller filters `RsCampaign` by `company_id = $request->user()->person->company_id`, so a caller only ever sees their own company's campaigns. An optional `store_id` is further validated by the `BelongsToAuthCompany` rule (resolves the store's `company_id` and checks it sits within the auth user's company hierarchy via `Company::isBelongsToParentCompany`).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `start_date` (string `Y-m-d`, required) — start of the metrics window; parsed with `startOfDay()`.\n- Query: `end_date` (string `Y-m-d`, required) — end of the window; must be `after_or_equal:start_date`; parsed with `endOfDay()`.\n- Query: `store_id` (integer, optional / nullable) — scope metrics to a single store; must belong to the auth user's company hierarchy (`BelongsToAuthCompany`).\n\n**Response** — `{ data: ... }` envelope. (Note: the controller returns `successResponse(['status' => 'success', 'data' => [...]])`, so the success payload is nested as `data.data`.)\n- `status` (string) — literal `\"success\"`.\n- `data.email_performance.sent` (int) — non-test emails with `sent_at` in range.\n- `data.email_performance.delivered` (int) — emails with `delivered_at` in range.\n- `data.email_performance.opened` (int) — emails with `opened_at` in range.\n- `data.email_performance.clicked` (int) — emails with `clicked_at` in range.\n- `data.email_performance.unsubscribed` (int) — emails with `unsubscribed_at` in range.\n- `data.email_performance.rates.delivered` (float) — `delivered / sent * 100`, rounded 2dp (0 when sent=0).\n- `data.email_performance.rates.opened` (float) — `opened / sent * 100`.\n- `data.email_performance.rates.clicked` (float) — `clicked / sent * 100`.\n- `data.email_performance.rates.unsubscribed` (float) — `unsubscribed / sent * 100`.\n- `data.email_performance.rates.sent` (float) — `sent / currentEmailValidContacts * 100` (denominator = audience rows in range with `is_email_valid = valid`).\n- `data.sms_performance.sent` (int) — non-test SMS with `sent_at` in range.\n- `data.sms_performance.delivered` (int) — SMS with `delivered_at` in range.\n- `data.sms_performance.link_clicked` (int) — SMS with `link_clicked_at` in range.\n- `data.sms_performance.failed` (int) — SMS where `status = failed` OR `failed_at` set, with `COALESCE(failed_at, sent_at, created_at)` in range.\n- `data.sms_performance.rates.delivered` (float) — `delivered / sent * 100`.\n- `data.sms_performance.rates.link_clicked` (float) — `link_clicked / sent * 100`.\n- `data.sms_performance.rates.failed` (float) — `failed / sent * 100`.\n- `data.sms_performance.rates.sent` (float) — `sent / currentSmsValidContacts * 100` (denominator = audience rows in range with `is_phone_valid = valid`).\n- `data.audience.current_contacts` (int) — valid, non-test `CampaignAudienceData` rows created within the window.\n- `data.audience.previous_contacts` (int) — same count over the immediately preceding window of equal length.\n- `data.audience.change_percent` (float) — `(current - previous) / previous * 100`, rounded 2dp (0 when previous=0)."
                  },
                  "response": [
                    {
                      "name": "201 Created",
                      "status": "Created",
                      "code": 201,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"email_performance\": {\n        \"sent\": 300,\n        \"delivered\": 250,\n        \"opened\": 220,\n        \"clicked\": 200,\n        \"unsubscribed\": 20,\n        \"rates\": {\n          \"delivered\": 83.33,\n          \"opened\": 73.33,\n          \"clicked\": 66.67,\n          \"unsubscribed\": 6.67,\n          \"sent\": 66.67\n        }\n      },\n      \"sms_performance\": {\n        \"sent\": 200,\n        \"delivered\": 190,\n        \"link_clicked\": 180,\n        \"failed\": 10,\n        \"rates\": {\n          \"delivered\": 95,\n          \"link_clicked\": 90,\n          \"failed\": 5,\n          \"sent\": 95\n        }\n      },\n      \"audience\": {\n        \"current_contacts\": 356,\n        \"previous_contacts\": 356,\n        \"change_percent\": 0\n      }\n    }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "8.D.2 Email report (single campaign)",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/campaign/{{campaignId}}/report/email",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "campaign",
                        "{{campaignId}}",
                        "report",
                        "email"
                      ]
                    },
                    "description": "Returns email-channel KPIs (recipients, sent, delivered, opened, clicked, unsubscribed, failed, processed, tests/kiosk counts plus derived rates) and a fixed last-7-day daily timeseries for a single campaign.\n\n**Auth:** Account — `auth:api` bearer token. The campaign is scoped by `RsCampaign.company_id == $user->person->company_id`, so a user can only read reports for campaigns owned by their own company (the brand/location account); a non-matching id returns 404 `Campaign not found`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — campaign id (route-constrained to `[0-9]+`).\n- Query: `start_date` (string `Y-m-d`, optional) — KPI window start; defaults to the campaign's `created_at` start-of-day.\n- Query: `end_date` (string `Y-m-d`, optional) — KPI window end; defaults to now end-of-day. If `start_date > end_date` the two are swapped. Note: the `timeseries.by_day` block always covers the last 7 days (now-6 → end) regardless of the date filter.\n\n**Response** — `{ data: ... }` envelope. The controller returns its own `status`/`data` keys which are then wrapped again by `successResponse`, so the payload is `{ data: { status, data: { kpis, timeseries } } }`.\n- `kpis.recipients_valid` (int) — count of `CampaignAudienceData` with `is_email_valid = 'valid'`, non-test.\n- `kpis.sent` / `delivered` / `opened` / `clicked` / `unsubscribed` (int) — counts of non-test `CampaignEmailRequest` rows whose respective `*_at` timestamp falls in the window.\n- `kpis.failed` (int) — rows with `status = 'failed'` within the window (`failed_at`).\n- `kpis.processed` (int) — non-test rows created in the window.\n- `kpis.tests_sent` (int) — `is_test = true` rows created in the window.\n- `kpis.kiosk_sent` (int) — non-test `is_kiosk = true` rows created in the window.\n- `kpis.rates` (object) — `processed_rate`, `delivery_rate`, `open_rate`, `click_rate`, `unsubscribe_rate`, `sent_vs_audience_rate` (floats, percent rounded to 2dp; 0 when denominator is 0).\n- `timeseries.by_day` (array) — one entry per day for the last 7 days, each `{ date (Y-m-d), sent, delivered, opened, clicked, unsubscribed, failed, processed }` (ints)."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"status\": \"success\", \"data\": {\n  \"kpis\": {\n    \"recipients_valid\": 1200, \"sent\": 1150, \"delivered\": 1100, \"opened\": 650,\n    \"clicked\": 120, \"unsubscribed\": 8, \"failed\": 50, \"processed\": 1200,\n    \"rates\": { \"processed_rate\": 100, \"delivery_rate\": 95.65, \"open_rate\": 56.52, \"click_rate\": 10.43, \"unsubscribe_rate\": 0.7, \"sent_vs_audience_rate\": 95.83 },\n    \"tests_sent\": 5, \"kiosk_sent\": 10\n  },\n  \"timeseries\": { \"by_day\": [\n    { \"date\": \"2026-06-20\", \"sent\": 100, \"delivered\": 98, \"opened\": 60, \"clicked\": 12, \"unsubscribed\": 1, \"failed\": 2, \"processed\": 102 }\n  ] }\n} } }"
                    }
                  ]
                },
                {
                  "name": "8.D.3 Email recipients",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/campaign/{{campaignId}}/recipients/email",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "campaign",
                        "{{campaignId}}",
                        "recipients",
                        "email"
                      ]
                    },
                    "description": "Returns a paginated list of email recipients (one row per `CampaignEmailRequest`, joined to its audience-data row) for a campaign, with optional status filter, search, and sort.\n\n**Auth:** Account — `auth:api` bearer token. Campaign scoped by `RsCampaign.company_id == $user->person->company_id`; non-matching id returns 404 `Campaign not found`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — campaign id (`[0-9]+`).\n- Query: `filter` (string, optional) — one of `processed,pending,sent,delivered,opened,clicked,unsubscribed,failed`; filters by status or by presence of the matching `*_at` timestamp.\n- Query: `search_field` (string, optional) — one of `email,status,message_id,customer_name`; when omitted but `search_value` is present, searches across email/status/message_id/customer_name.\n- Query: `search_value` (string, optional, max 255) — LIKE-matched.\n- Query: `sort_field` (string, optional) — one of `id,email,status,created_at,sent_at,delivered_at,opened_at,clicked_at,unsubscribed_at,failed_at,customer_name`; defaults to `created_at`.\n- Query: `sort_by` (string, optional) — `asc`/`desc` (case-insensitive); defaults to `desc`.\n- Query: `limit` (integer, optional, 1–100) — page size; defaults to 10 (capped at 100).\n- Query: `page` (integer, optional, min 1) — page number.\n\n**Response** — NOT wrapped in a `data` envelope. The method returns the Eloquent paginator directly, so the response is a standard Laravel `LengthAwarePaginator` JSON (top-level `current_page`, `data`, `per_page`, `total`, `last_page`, links, etc.). Each `data[]` item is a selected `CampaignEmailRequest`:\n- `id` (int)\n- `status` (string)\n- `sent_at` / `delivered_at` / `opened_at` / `clicked_at` / `unsubscribed_at` / `failed_at` (datetime, nullable)\n- `unsubscribe_reason` / `failure_reason` (string, nullable)\n- `metadata` (object/array, nullable)\n- `created_at` (datetime)\n- `email` (string)\n- `message_id` (string, nullable)\n- `campaign_audience_data_id` (int, nullable)\n- `next_follow_up_at` (datetime, nullable), `follow_up_count` (int), `parent_id` (int, nullable)\n- `audience_data` (object, nullable) — eager-loaded relation with `id,customer_name,email,phone,is_email_valid,is_phone_valid,validation_status`."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"current_page\": 1,\n  \"data\": [\n    { \"id\": 123, \"status\": \"delivered\", \"sent_at\": \"2026-06-15T13:45:00.000000Z\", \"delivered_at\": \"2026-06-15T13:46:00.000000Z\", \"opened_at\": null, \"clicked_at\": null, \"unsubscribed_at\": null, \"failed_at\": null, \"unsubscribe_reason\": null, \"failure_reason\": null, \"metadata\": null, \"created_at\": \"2026-06-15T13:40:00.000000Z\", \"email\": \"user@example.com\", \"message_id\": \"abc-123\", \"campaign_audience_data_id\": 9, \"next_follow_up_at\": null, \"follow_up_count\": 0, \"parent_id\": null, \"audience_data\": { \"id\": 9, \"customer_name\": \"Jane Doe\", \"email\": \"user@example.com\", \"phone\": \"+15551234567\", \"is_email_valid\": \"valid\", \"is_phone_valid\": \"valid\", \"validation_status\": \"valid\" } }\n  ],\n  \"per_page\": 10, \"total\": 100, \"last_page\": 10\n}"
                    }
                  ]
                },
                {
                  "name": "8.D.4 Email delivered comparison",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/campaign/{{campaignId}}/report/delivered-comparison",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "campaign",
                        "{{campaignId}}",
                        "report",
                        "delivered-comparison"
                      ]
                    },
                    "description": "Returns delivered-count comparison between Email and SMS for a campaign, bucketed by day, week, or month.\n\n**Auth:** Account — `auth:api` bearer token. Campaign scoped by `RsCampaign.company_id == $user->person->company_id`; non-matching id returns 404 `Campaign not found`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — campaign id (`[0-9]+`).\n- Query: `group_by` (string, optional) — `day`, `week`, or `month`; defaults to `day`. `day` buckets are `Y-m-d`-labelled, `week` buckets are Mon–Sun ranges labelled like `Aug 18-24, 2025`, `month` buckets are labelled like `Aug 2025`.\n- Query: `limit` (integer, optional, 1–7) — number of buckets ending at `end_date`; defaults to 7.\n- Query: `end_date` (string `Y-m-d`, optional) — anchor for the most recent bucket; defaults to now end-of-day.\n\n**Response** — `{ data: ... }` envelope (double-wrapped as `{ data: { status, data: [...] } }`). `data` is an ordered array of buckets:\n- `key` (string) — the bucket label (date / week-range / month, per `group_by`).\n- `email` (int) — non-test `CampaignEmailRequest` rows delivered (`delivered_at`) in the bucket.\n- `sms` (int) — non-test `CampaignSmsRequest` rows delivered in the bucket."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"status\": \"success\", \"data\": [\n  { \"key\": \"2026-06-20\", \"email\": 42, \"sms\": 37 },\n  { \"key\": \"2026-06-21\", \"email\": 50, \"sms\": 31 }\n] } }"
                    }
                  ]
                },
                {
                  "name": "8.D.5 Email click URLs",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/campaign/{{campaignId}}/report/email/click-urls",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "campaign",
                        "{{campaignId}}",
                        "report",
                        "email",
                        "click-urls"
                      ]
                    },
                    "description": "Returns per-URL click statistics for the email channel of a campaign — which tracked click URLs were clicked, how many times, by how many distinct IPs, and when last clicked.\n\n**Auth:** Account — `auth:api` bearer token. Campaign scoped by `RsCampaign.company_id == $user->person->company_id`; non-matching id returns 404 `Campaign not found`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — campaign id (`[0-9]+`).\n- Query: `start_date` (string `Y-m-d`, optional) — click-event window start; defaults to campaign `created_at` start-of-day.\n- Query: `end_date` (string `Y-m-d`, optional) — window end; defaults to now end-of-day. Swapped if start > end.\n\n**Response** — `{ data: ... }` envelope (double-wrapped as `{ data: { status, data: [...] } }`). `data` is an array of `CampaignClickUrl` rows (`channel_type = 'email'`), ordered by `click_count` desc, each LEFT-joined to `campaign_click_events` (event=`click`, email events only, in window):\n- `id` (int) — click-URL id.\n- `url_name` (string) — display name; `Maps.Google` is remapped to `Google`, and ` (Follow-up)` is appended when the URL is a follow-up link.\n- `is_follow_up` (bool).\n- `placeholder` (string) — template placeholder token.\n- `destination_url` (string) — target URL; when empty but a `landing_page_id` exists it is built from the white-label URL as `{white_url}/lp/{landing_page_id}?store_id={store_id}&preview=true`.\n- `click_count` (int) — total click events in the window.\n- `unique_ips` (int) — distinct IPs.\n- `last_clicked` (ISO-8601 datetime, nullable) — max event time, or null if never clicked."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"status\": \"success\", \"data\": [\n  { \"id\": 45, \"url_name\": \"Google\", \"is_follow_up\": false, \"placeholder\": \"directTo_google\", \"destination_url\": \"https://maps.google.com/...\", \"click_count\": 25, \"unique_ips\": 20, \"last_clicked\": \"2026-06-15T10:30:00.000000Z\" }\n] } }"
                    }
                  ]
                },
                {
                  "name": "8.D.6 SMS report",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/campaign/{{campaignId}}/report/sms",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "campaign",
                        "{{campaignId}}",
                        "report",
                        "sms"
                      ]
                    },
                    "description": "Returns SMS-channel KPIs (valid recipients, processed, sent, delivered, clicked, failed, tests, kiosk) plus computed rates and a per-day timeseries for one campaign.\n\n**Auth:** Account — `auth:api` bearer token; `GetSmsReportRequest::authorize()` requires the user's own company `bundle_id === 3` (BRAND). The campaign is scoped by `RsCampaign.company_id === user->person->company_id`, so a brand can only read its own campaigns (404 `Campaign not found` otherwise). Non-brand users get 403.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (integer, required) — campaign id; route-constrained to `[0-9]+`.\n- Query: `start_date` (string `Y-m-d`, optional) — defaults to the campaign's `created_at` start-of-day.\n- Query: `end_date` (string `Y-m-d`, optional) — defaults to now end-of-day. If `start_date > end_date` the two are swapped.\n\n**Response** — `{ data: ... }` envelope. Note: the controller passes an array that already contains `status` and `data` keys to `successResponse`, so the literal payload is `{ data: { status: \"success\", data: { kpis, timeseries } } }`.\n- `data.status` (string) — `\"success\"`.\n- `data.data.kpis.recipients_valid` (int) — count of audience rows with `is_phone_valid = 'valid'`, `is_test = false`.\n- `data.data.kpis.sent` / `delivered` / `clicked` / `failed` / `processed` (int) — non-test `CampaignSmsRequest` counts within the date window (sent_at / delivered_at / link_clicked_at / failed_at / created_at respectively; `failed` also requires `status = 'failed'`).\n- `data.data.kpis.tests_sent` (int) — `is_test = true` requests created in window.\n- `data.data.kpis.kiosk_sent` (int) — non-test `is_kiosk = true` requests in window.\n- `data.data.kpis.rates.processed_rate` / `delivery_rate` / `click_rate` / `sent_vs_audience_rate` (float, 2dp) — percentages; 0 when denominator is 0.\n- `data.data.timeseries.by_day[]` (array) — one row per calendar day in range with `date` (`Y-m-d`), `sent`, `delivered`, `clicked`, `failed`, `processed` (all int)."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"kpis\": {\n        \"recipients_valid\": 1400,\n        \"sent\": 1360,\n        \"delivered\": 1320,\n        \"clicked\": 210,\n        \"failed\": 40,\n        \"processed\": 1400,\n        \"rates\": {\n          \"processed_rate\": 100,\n          \"delivery_rate\": 97.06,\n          \"click_rate\": 15.44,\n          \"sent_vs_audience_rate\": 97.14\n        },\n        \"tests_sent\": 5,\n        \"kiosk_sent\": 100\n      },\n      \"timeseries\": {\n        \"by_day\": [\n          { \"date\": \"2025-08-21\", \"sent\": 120, \"delivered\": 118, \"clicked\": 18, \"failed\": 2, \"processed\": 120 }\n        ]\n      }\n    }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "8.D.7 SMS recipients",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/campaign/{{campaignId}}/recipients/sms",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "campaign",
                        "{{campaignId}}",
                        "recipients",
                        "sms"
                      ]
                    },
                    "description": "Returns a paginated list of SMS recipients (one row per `CampaignSmsRequest`) for a campaign, with status filtering, field search, and sorting.\n\n**Auth:** Account — `auth:api` bearer token; `GetSmsRecipientsRequest::authorize()` requires the user's own company `bundle_id === 3` (BRAND). Campaign scoped by `RsCampaign.company_id === user->person->company_id` (404 `Campaign not found` otherwise). Non-brand users get 403.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (integer, required) — campaign id; route-constrained to `[0-9]+`.\n- Query: `filter` (string, optional) — one of `processed`, `pending`, `sent`, `delivered`, `clicked`, `failed`. Maps to a status/timestamp predicate (e.g. `clicked` → `link_clicked_at NOT NULL`).\n- Query: `search_field` (string, optional) — one of `phone_number`, `status`, `message_id`. When set, `LIKE` on that column; when omitted but `search_value` is set, OR-LIKE across all three.\n- Query: `search_value` (string, optional, max 255) — search term.\n- Query: `sort_field` (string, optional) — one of `id`, `phone_number`, `status`, `created_at`, `sent_at`, `delivered_at`, `link_clicked_at`, `failed_at`, `message_id`. Default `created_at`.\n- Query: `sort_by` (string, optional) — `asc`/`ASC`/`desc`/`DESC`. Default `desc`.\n- Query: `limit` (integer, optional, 1–100) — per-page; capped at 100. Default 10.\n- Query: `page` (integer, optional, min 1) — page number. Default 1.\n\n**Response** — raw Laravel paginator (this method returns `$query->paginate()` directly, NOT wrapped by `successResponse`, so there is no `{ data: ... }` envelope — `data` is the paginator's own row array). Each row selects: `id`, `status`, `sent_at`, `delivered_at`, `link_clicked_at`, `failed_at`, `failure_reason`, `metadata`, `created_at`, `dialing_code`, `phone_number`, `message_id`, `campaign_audience_data_id`, and an eager-loaded `audienceData` relation (`id`, `customer_name`, `email`, `phone`, `is_email_valid`, `is_phone_valid`, `validation_status`).\n- `current_page` (int)\n- `data[]` (array) — recipient rows with the selected columns above plus nested `audience_data`.\n- `per_page` (int), `total` (int), `last_page` (int), `from`/`to`, `first_page_url`, `next_page_url`, etc. (standard paginator keys)."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"current_page\": 1,\n  \"data\": [\n    {\n      \"id\": 456,\n      \"status\": \"delivered\",\n      \"sent_at\": \"2026-06-15T10:30:00.000000Z\",\n      \"delivered_at\": \"2026-06-15T10:31:00.000000Z\",\n      \"link_clicked_at\": null,\n      \"failed_at\": null,\n      \"failure_reason\": null,\n      \"metadata\": null,\n      \"created_at\": \"2026-06-15T10:29:00.000000Z\",\n      \"dialing_code\": \"+1\",\n      \"phone_number\": \"5551234567\",\n      \"message_id\": \"SM123abc\",\n      \"campaign_audience_data_id\": 99,\n      \"audience_data\": {\n        \"id\": 99,\n        \"customer_name\": \"Jane Doe\",\n        \"email\": \"jane@example.com\",\n        \"phone\": \"5551234567\",\n        \"is_email_valid\": \"valid\",\n        \"is_phone_valid\": \"valid\",\n        \"validation_status\": \"validated\"\n      }\n    }\n  ],\n  \"per_page\": 10,\n  \"total\": 100\n}"
                    }
                  ]
                },
                {
                  "name": "8.D.8 SMS click URLs",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/campaign/{{campaignId}}/report/sms/click-urls",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "campaign",
                        "{{campaignId}}",
                        "report",
                        "sms",
                        "click-urls"
                      ]
                    },
                    "description": "Returns SMS click-URL statistics for a campaign — one row per `campaign_click_urls` (channel `sms`) with click count, distinct-IP count, and last-clicked timestamp over the date window.\n\n**Auth:** Account — `auth:api` bearer token; `GetSmsClickUrlsRequest::authorize()` requires the user's own company `bundle_id === 3` (BRAND). Campaign scoped by `RsCampaign.company_id === user->person->company_id` (404 `Campaign not found` otherwise). Non-brand users get 403.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `{id}` (integer, required) — campaign id; route-constrained to `[0-9]+`.\n- Query: `start_date` (string `Y-m-d`, optional) — defaults to campaign `created_at` start-of-day.\n- Query: `end_date` (string `Y-m-d`, optional) — defaults to now end-of-day. If `start_date > end_date` the two are swapped.\n\n**Response** — `{ data: ... }` envelope. The controller passes an array containing `status` and `data` to `successResponse`, so the literal payload is `{ data: { status: \"success\", data: [ ...rows ] } }`.\n- `data.status` (string) — `\"success\"`.\n- `data.data[]` (array) — per click-URL row:\n  - `id` (int) — `campaign_click_urls.id`.\n  - `url_name` (string) — the URL's `name`.\n  - `placeholder` (string) — template placeholder token.\n  - `destination_url` (string) — `campaign_click_urls.url`; if empty and a `landing_page_id` exists, synthesized as `{white_url}/lp/{landing_page_id}?store_id={store_id}&preview=true` from `Company::getWhiteLabel()`.\n  - `click_count` (int) — count of `campaign_click_events` with `event = 'click'`, non-null `sms_request_id`, in window.\n  - `unique_ips` (int) — distinct `ip` count over those events.\n  - `last_clicked` (string ISO-8601 or null) — max event `created_at`. Rows sorted by `click_count` desc."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": [\n      {\n        \"id\": 45,\n        \"url_name\": \"Direct to Manager\",\n        \"placeholder\": \"directTo_manager\",\n        \"destination_url\": \"https://example.com/manager\",\n        \"click_count\": 25,\n        \"unique_ips\": 20,\n        \"last_clicked\": \"2024-01-15T10:30:00.000000Z\"\n      }\n    ]\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "8.D.9 Click URL events",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/campaign/click-url/9999/events",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "campaign",
                        "click-url",
                        "9999",
                        "events"
                      ]
                    },
                    "description": "Returns paginated individual click events for one tracked click URL (across both email and SMS requests), with per-event metadata and the associated customer/recipient data.\n\n**Auth:** Account — `auth:api` bearer token. The click URL is scoped via its parent campaign: `whereHas('campaign', company_id == $user->person->company_id)`, so only click URLs belonging to the caller's own company are reachable; otherwise 404 `Click URL not found` (the example id `9999` is a not-found probe).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `click_url_id` (integer, required) — click-URL id (route-constrained to `[0-9]+`).\n- Query: `start_date` (string `Y-m-d`, optional) — event window start; defaults to the click URL's `created_at` start-of-day.\n- Query: `end_date` (string `Y-m-d`, optional) — window end; defaults to now end-of-day. Swapped if start > end.\n- Query: `page` (integer, optional, min 1) — page number.\n- Query: `limit` (integer, optional, 1–100) — page size; defaults to 20 (capped at 100).\n\n**Response** — `{ data: ... }` envelope (double-wrapped as `{ data: { status, data: {...} } }`). `data` is a hand-built pagination object (only `event = 'click'` events in window, newest first):\n- `current_page` (int), `per_page` (int), `total` (int), `last_page` (int).\n- `click_url` (object) — `{ id, name, placeholder, url, channel_type }`.\n- `events` (array) — each event: `id` (int), `event` (string, always `click`), `ip` (string), `user_agent` (string), `referer` (string), `metadata` (object, nullable), `created_at` (ISO-8601). When the event links an email request, `customer_data` = `{ id, email, status, sent_at (ISO/nullable), customer_name, requestor_name }`; when it links an SMS request, `customer_data` = `{ id, phone_number, status, sent_at, customer_name, requestor_name }`; `customer_data` is absent when neither relation is present."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{ \"data\": { \"status\": \"success\", \"data\": {\n  \"current_page\": 1, \"per_page\": 20, \"total\": 150, \"last_page\": 8,\n  \"click_url\": { \"id\": 45, \"name\": \"Direct to Manager\", \"placeholder\": \"directTo_manager\", \"url\": \"https://example.com/manager\", \"channel_type\": \"email\" },\n  \"events\": [\n    { \"id\": 123, \"event\": \"click\", \"ip\": \"192.168.1.1\", \"user_agent\": \"Mozilla/5.0...\", \"referer\": \"https://gmail.com\", \"metadata\": null, \"created_at\": \"2026-06-15T10:30:00.000000Z\", \"customer_data\": { \"id\": 88, \"email\": \"user@example.com\", \"status\": \"clicked\", \"sent_at\": \"2026-06-15T09:00:00.000000Z\", \"customer_name\": \"Jane Doe\", \"requestor_name\": \"Front Desk\" } }\n  ]\n} } }"
                    }
                  ]
                }
              ],
              "description": "Dashboard metrics, per-channel reports (email / SMS), recipient lists, click-URL events."
            },
            {
              "name": "§ B.9.C QR statistics",
              "item": [
                {
                  "name": "9.C.1 QR code statistics",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/qr-codes/stats",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "qr-codes",
                        "stats"
                      ]
                    },
                    "description": "Returns aggregate QR code counts for the authenticated brand-user's company: total, a breakdown by destination type, and a count of those created in the last 30 days. Optionally scope to a store and/or a created-at date range.\n\n**Auth:** Account — `auth:api`; base query is `where('company_id', $request->user()->person->company_id)`. The optional `store_id` is validated by `BelongsToAuthCompany` (hierarchy walk). Note this route is declared before `qr-codes/{id}`, so `stats` is matched as a literal path, not an `{id}`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — `exists:store,id` + must belong to the auth company hierarchy.\n- Query: `start_date` (string `Y-m-d`, optional) — filters `created_at` from this day.\n- Query: `end_date` (string `Y-m-d`, optional) — filters `created_at` to this day.\n\n**Response** — `{ data: ... }` envelope wrapping an array.\n- `data.status` (string) — `\"success\"`.\n- `data.data.total_qr_codes` (int)\n- `data.data.by_destination_type` (object) — keyed by destination type (`landing_page`, `review_site`, `custom_url`) → count; only types present in the data appear.\n- `data.data.recently_created` (int) — created in the last 30 days.\n- `data.message` (string) — `\"QR code statistics retrieved successfully\"`."
                  },
                  "response": [
                    {
                      "name": "201 Created",
                      "status": "Created",
                      "code": 201,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"total_qr_codes\": 120,\n      \"by_destination_type\": {\n        \"landing_page\": 80,\n        \"review_site\": 30,\n        \"custom_url\": 10\n      },\n      \"recently_created\": 5\n    },\n    \"message\": \"QR code statistics retrieved successfully\"\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "9.C.2 QR template statistics",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/qr-codes/template/stats",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "qr-codes",
                        "template",
                        "stats"
                      ]
                    },
                    "description": "Returns aggregate QR template counts for the authenticated user's company — total templates, a breakdown by template type, and a count of those created in the last 30 days; optionally scoped by store and/or created_at date range.\n\n**Auth:** Account — `auth:api`; all aggregates are scoped to `company_id = $user->person->company_id` (own company only). The optional `store_id` is additionally validated by `BelongsToAuthCompany`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, optional) — must exist in `store` and belong to the auth company (BelongsToAuthCompany).\n- Query: `start_date` (string `Y-m-d`, optional) — filters `created_at` from; swapped with `end_date` if reversed.\n- Query: `end_date` (string `Y-m-d`, optional) — filters `created_at` to.\n\n**Response** — `{ data: ... }` envelope wrapping the controller array:\n- `status` (string) — `\"success\"`.\n- `data` (object):\n  - `total_templates` (integer) — count matching the scope/filters.\n  - `by_template_type` (object) — map of `template_type` → count (only types present appear as keys).\n  - `recently_created` (integer) — count with `created_at >= now()-30 days` (within the same scope).\n- `message` (string) — `\"QR code template statistics retrieved successfully\"`."
                  },
                  "response": [
                    {
                      "name": "201 Created",
                      "status": "Created",
                      "code": 201,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"total_templates\": 25,\n      \"by_template_type\": { \"standard\": 15, \"hangtag\": 7, \"custom\": 3 },\n      \"recently_created\": 3\n    },\n    \"message\": \"QR code template statistics retrieved successfully\"\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "9.C.3 QR code scan history",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/qr-codes/{{qrCodeId}}/scan-history",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "qr-codes",
                        "{{qrCodeId}}",
                        "scan-history"
                      ]
                    },
                    "description": "Returns paginated scan history (newest first by `scanned_at`) for a QR code belonging to the authenticated brand-user, along with a small summary of the QR code itself.\n\n**Auth:** Account — `auth:api`; QR code scoped by `where('company_id', $request->user()->person->company_id)->find($id)` (404 if outside the brand company). `QRCodeScanHistoryRequest` injects the route `id` into validation data and asserts it `exists` on the `qr_codes` table.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — QR code ID (route `[0-9]+`; also validated `exists:qr_codes,id`).\n- Query: `per_page` (integer, optional, 1–100, default 15).\n\n**Response** — `{ data: ... }` envelope wrapping an array (so the paginator nests at `data.data`).\n- `data.status` (string) — `\"success\"`.\n- `data.data` (object) — Laravel paginator of QrCodeScanHistory rows. Each row: `id`, `qr_code_id`, `qr_code_template_id` (nullable), `ip_address`, `user_agent`, `referer`, `device_type`, `browser`, `os`, `latitude`, `longitude`, `country`, `city`, `metadata`, `scanned_at`. Includes paginator keys `current_page`, `per_page`, `total`, `last_page`.\n- `data.message` (string) — `\"QR code scan history retrieved successfully\"`.\n- `data.qr_code` (object) — `qrCode->only(['id', 'name', 'destination_type'])`."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"current_page\": 1,\n      \"data\": [\n        {\n          \"id\": 123,\n          \"qr_code_id\": 200,\n          \"qr_code_template_id\": null,\n          \"ip_address\": \"203.0.113.5\",\n          \"device_type\": \"mobile\",\n          \"browser\": \"Chrome\",\n          \"os\": \"iOS\",\n          \"country\": \"US\",\n          \"city\": \"New York\",\n          \"scanned_at\": \"2026-06-15T10:30:00.000000Z\"\n        }\n      ],\n      \"per_page\": 15,\n      \"total\": 42,\n      \"last_page\": 3\n    },\n    \"message\": \"QR code scan history retrieved successfully\",\n    \"qr_code\": {\n      \"id\": 200,\n      \"name\": \"Lobby QR Code\",\n      \"destination_type\": \"landing_page\"\n    }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "9.C.4 QR template scan history",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/qr-codes/template/{{templateId}}/scan-history",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "qr-codes",
                        "template",
                        "{{templateId}}",
                        "scan-history"
                      ]
                    },
                    "description": "Returns paginated scan-history records for a QR code template (by path ID), newest first by `scanned_at`, alongside a minimal template stub; 404s if the template does not belong to the authenticated user's company.\n\n**Auth:** Account — `auth:api`; the template is loaded via `QrCodeTemplate::where('company_id', $user->person->company_id)->find($id)` (own company only). The `{id}` route segment is digit-constrained and supplied to the Form Request via `validationData()`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Path: `id` (integer, required) — QR code template ID; must exist in `qr_code_templates`.\n- Query: `per_page` (integer, optional, 1–100, default 15) — page size.\n\n**Response** — `{ data: ... }` envelope wrapping the controller array:\n- `status` (string) — `\"success\"`.\n- `data` (object) — a Laravel paginator of scan rows: `current_page`, `data` (array of `qr_code_scan_history` records — fields such as `id`, `qr_code_template_id`, `ip_address`, `user_agent`, `referer`, `scanned_at`), `per_page`, `total`, `last_page`, etc.\n- `message` (string) — `\"QR code template scan history retrieved successfully\"`.\n- `qr_code_template` (object) — `{ id, name }` of the parent template (`$template->only(['id','name'])`)."
                  },
                  "response": [
                    {
                      "name": "202 Accepted",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"data\": {\n      \"current_page\": 1,\n      \"data\": [\n        {\n          \"id\": 123,\n          \"qr_code_template_id\": 10,\n          \"ip_address\": \"192.168.1.1\",\n          \"user_agent\": \"Mozilla/5.0\",\n          \"referer\": \"https://example.com\",\n          \"scanned_at\": \"2026-06-15T10:30:00.000000Z\"\n        }\n      ],\n      \"per_page\": 15,\n      \"total\": 42,\n      \"last_page\": 3\n    },\n    \"message\": \"QR code template scan history retrieved successfully\",\n    \"qr_code_template\": { \"id\": 10, \"name\": \"Standard A6 Template\" }\n  }\n}"
                    }
                  ]
                }
              ],
              "description": "Scan-count stats + per-scan history for both individual codes and templates."
            },
            {
              "name": "§ B.10 Insight Report",
              "item": [
                {
                  "name": "B.10.1 Submit insight report (generate)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"store_id\": 42,\n    \"review_site_id\": 7,\n    \"year\": 2026,\n    \"month\": 5,\n    \"theme\": \"general\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/account/insight-report",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "insight-report"
                      ]
                    },
                    "description": "Queues an async ReviewData insight report for one store + publisher and counts 1 unit against the store's `insight_report` plan quota at submit time. Returns immediately with a `task_id` and `status: queued`; the completed report arrives later via the INSIGHT_REPORT_RESULT webhook (poll B.10.3 for the result). If a non-failed report already exists for the same (store, review site, period, theme), it is returned as-is without an RD call — set `force` to bypass dedup and bill a fresh run.\n\n**Auth:** Both — `auth:api`; target store scoped by `BelongsToAuthCompany` on `store_id`, which walks the auth user's company tree (`Company::isBelongsToParentCompany`). A Partner reaches every store in its partner sub-tree; an Account/Brand reaches only stores under its own company. The service resolves the parent partner via `Company::getParentCompany(store.company_id, 'partner')` to gate quota and pick the RD API key.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (all top-level, JSON):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | Must `exists:stores,id` and belong to the auth company hierarchy. |\n| `review_site_id` | integer | no | `exists:review_sites,id`. Must be linked to the store via `store_review_sites`. Omit for an all-publisher report (publisher resolves to `all`); when omitted the store must have at least one linked review site with a configured `publisher`. |\n| `year` | integer | yes | `between:2000,2100`. |\n| `month` | integer | no | `between:1,12`. Omit for a full-year report. |\n| `theme` | string | yes | `max:64`. RD KPI theme/set, e.g. `general`. |\n| `force` | boolean | no | Explicit recall — bypasses dedup and bills a fresh RD run. (This endpoint sends `false`; see B.10.2 for `true`.) |\n\n**Response** — `{ data: ... }` envelope. HTTP 202 on a fresh queue; HTTP 200 when an existing report is returned (deduped).\n- `insight_report_id` (integer) — our `insight_reports` row id.\n- `store_id` (integer)\n- `review_site_id` (integer|null)\n- `publisher` (string) — publisher slug, or `all` when no review site given.\n- `year` (integer)\n- `month` (integer|null)\n- `theme` (string)\n- `status` (string) — `queued` (or RD-returned status) on a fresh submit; existing status when deduped.\n- `task_id` (string|null) — RD task id (`rd_task_id`).\n- `review_count` (integer|null)\n- `result_summary` (object|null)\n- `failure_reason` (string|null)\n- `failure_details` (object|null)\n- `completed_at` (string|null) — ISO-8601.\n- `remaining` (integer|null) — quota units remaining after this submit.\n- `overage` (boolean) — true if billed as overage (soft-stop plan).\n- `usage_log_id` (present only on fresh submit) / `deduped: true` (present only when an existing report is returned).\n\nQuota-exceeded / feature-disabled returns 403 (`insight_report_quota_exceeded`); RD upstream issues return 502/504/429."
                  },
                  "response": [
                    {
                      "name": "202 Queued",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"insight_report_id\": 4,\n    \"store_id\": 10,\n    \"review_site_id\": 44,\n    \"publisher\": \"maps.google.com\",\n    \"year\": 2026,\n    \"month\": null,\n    \"theme\": \"general\",\n    \"status\": \"queued\",\n    \"task_id\": \"9a893013-9aa8-4b8f-b3ff-6fd72f00a6ce\",\n    \"review_count\": null,\n    \"result_summary\": null,\n    \"failure_reason\": null,\n    \"failure_details\": null,\n    \"completed_at\": null,\n    \"remaining\": 4,\n    \"overage\": false\n  }\n}"
                    },
                    {
                      "name": "403 Quota exceeded / feature disabled",
                      "status": "Forbidden",
                      "code": 403,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Insight report quota exceeded for this store.\",\n  \"errors\": []\n}"
                    },
                    {
                      "name": "422 Review site not linked to store",
                      "status": "Unprocessable Entity",
                      "code": 422,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"review_site_id\": [\n      \"The selected review site is not linked to the given store.\"\n    ]\n  }\n}"
                    }
                  ],
                  "event": [
                    {
                      "listen": "test",
                      "script": {
                        "type": "text/javascript",
                        "exec": [
                          "if (pm.response.code === 202 || pm.response.code === 200) {",
                          "  const j = pm.response.json();",
                          "  if (j.data && j.data.insight_report_id) {",
                          "    pm.environment.set('insightReportId', String(j.data.insight_report_id));",
                          "    console.log('Saved insight_report_id to {{insightReportId}} =', j.data.insight_report_id);",
                          "  }",
                          "}"
                        ]
                      }
                    }
                  ]
                },
                {
                  "name": "B.10.2 Regenerate insight report (force)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"store_id\": 42,\n    \"review_site_id\": 7,\n    \"year\": 2026,\n    \"month\": 5,\n    \"theme\": \"general\",\n    \"force\": true\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/account/insight-report",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "insight-report"
                      ]
                    },
                    "description": "Force-regenerates an insight report for a store + publisher, bypassing the dedup guard so a brand-new RD run is started and billed even when a non-failed report already exists for the same (store, review site, period, theme). Same endpoint and handler as B.10.1 (`InsightReportController::submit` / `SubmitInsightReportRequest`); the only difference is `force: true`. Returns 202 with `status: queued` and a fresh `task_id`; poll B.10.3 for the result.\n\n**Auth:** Both — `auth:api`; target store scoped by `BelongsToAuthCompany` on `store_id` (auth-company-tree walk). A Partner reaches every store in its sub-tree; an Account/Brand reaches only its own stores. Parent partner is resolved server-side for quota gating and the RD API key.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (all top-level, JSON):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | `exists:stores,id` + belongs to auth company hierarchy. |\n| `review_site_id` | integer | no | `exists:review_sites,id`, must be linked to the store; omit for an `all`-publisher report (store must then have a linked site with a publisher). |\n| `year` | integer | yes | `between:2000,2100`. |\n| `month` | integer | no | `between:1,12`. Omit for full-year. |\n| `theme` | string | yes | `max:64`. |\n| `force` | boolean | yes (`true` here) | Set `true` to skip dedup and bill a fresh RD run. With `false`/omitted the call behaves like B.10.1 and may return an existing report. |\n\n**Response** — `{ data: ... }` envelope. HTTP 202 (fresh run always started because dedup is bypassed). Identical shape to B.10.1: `insight_report_id`, `store_id`, `review_site_id`, `publisher`, `year`, `month`, `theme`, `status`, `task_id`, `review_count`, `result_summary`, `failure_reason`, `failure_details`, `completed_at`, plus `remaining` (integer|null), `overage` (boolean), `usage_log_id`. No `deduped` flag is returned on a forced run. 403 on quota exceeded; 502/504/429 on RD upstream errors."
                  },
                  "response": [
                    {
                      "name": "202 Re-queued",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"insight_report_id\": 4,\n    \"store_id\": 10,\n    \"review_site_id\": 44,\n    \"publisher\": \"maps.google.com\",\n    \"year\": 2026,\n    \"month\": null,\n    \"theme\": \"general\",\n    \"status\": \"queued\",\n    \"task_id\": \"9a893013-9aa8-4b8f-b3ff-6fd72f00a6ce\",\n    \"review_count\": null,\n    \"result_summary\": null,\n    \"failure_reason\": null,\n    \"failure_details\": null,\n    \"completed_at\": null,\n    \"remaining\": 3,\n    \"overage\": false\n  }\n}"
                    }
                  ],
                  "event": [
                    {
                      "listen": "test",
                      "script": {
                        "type": "text/javascript",
                        "exec": [
                          "if (pm.response.code === 202 || pm.response.code === 200) {",
                          "  const j = pm.response.json();",
                          "  if (j.data && j.data.insight_report_id) {",
                          "    pm.environment.set('insightReportId', String(j.data.insight_report_id));",
                          "    console.log('Saved insight_report_id to {{insightReportId}} =', j.data.insight_report_id);",
                          "  }",
                          "}"
                        ]
                      }
                    }
                  ]
                },
                {
                  "name": "B.10.3 Check status (poll until complete)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"store_id\": 42,\n    \"report_id\": 1001\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/account/insight-report/status",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "insight-report",
                        "status"
                      ]
                    },
                    "description": "Returns the current state of a previously-queued insight report, read entirely from our `insight_reports` table by row id scoped to the store. This is a pure DB read — it never calls ReviewData and never consumes usage. The completed result is delivered out-of-band by the INSIGHT_REPORT_RESULT webhook; the UI polls this endpoint while `status` is `pending`/`queued`/`processing` until it becomes `complete` or `failed`.\n\n**Auth:** Both — `auth:api`; `store_id` validated by `BelongsToAuthCompany` (auth-company-tree walk), and the lookup further requires the report row's `store_id` to match. A Partner can poll any report under a store in its sub-tree; an Account/Brand only reports for its own stores. (`report_id` alone is not enough — it is filtered by the scoped `store_id`.)\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (all top-level, JSON):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | `exists:stores,id` + belongs to auth company hierarchy. |\n| `report_id` | integer | yes | `exists:insight_reports,id`. Must belong to the given `store_id` or the call 404s (`insight_report_not_found`). |\n\n**Response** — `{ data: ... }` envelope, HTTP 200. Same `present()` payload as the other endpoints (without the submit-only quota fields):\n- `insight_report_id` (integer)\n- `store_id` (integer)\n- `review_site_id` (integer|null)\n- `publisher` (string)\n- `year` (integer)\n- `month` (integer|null)\n- `theme` (string)\n- `status` (string) — `pending` | `queued` | `processing` | `complete` | `failed`.\n- `task_id` (string|null) — RD task id.\n- `review_count` (integer|null)\n- `result_summary` (object|null) — populated by the webhook on completion.\n- `failure_reason` (string|null)\n- `failure_details` (object|null)\n- `completed_at` (string|null) — ISO-8601.\n\nReturns 404 (`insight_report_not_found`) when the report does not exist for this store."
                  },
                  "response": [
                    {
                      "name": "200 Success (report ready)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"insight_report_id\": 4,\n    \"store_id\": 10,\n    \"review_site_id\": 44,\n    \"publisher\": \"maps.google.com\",\n    \"year\": 2026,\n    \"month\": null,\n    \"theme\": \"general\",\n    \"status\": \"success\",\n    \"task_id\": \"9a893013-9aa8-4b8f-b3ff-6fd72f00a6ce\",\n    \"review_count\": 6,\n    \"result_summary\": {\n      \"hero\": {\n        \"verdict\": \"Customers recognise strong technical ability and reliable execution on delivered work, but equally report systemic failures in communication and delivery readiness that have led to serious dissatisfaction. Fixing transparency, single-point accountability and release-readiness will convert the existing strengths into consistent customer outcomes.\",\n        \"heroTitle\": \"hipages 540362 (SAU178040716910) \\u2014 Strong Delivery, Fragile Follow\\u2011through\",\n        \"anchorQuote\": \"reliable execution, timely delivery, and professional communication\",\n        \"closingLine\": \"This quarter prioritise a single-point-of-contact + response SLA program and a mandatory production-readiness checklist to stop repeat communication and delivery failures.\"\n      },\n      \"meta\": {\n        \"mode\": \"single\",\n        \"version\": \"1.0\",\n        \"reportType\": \"general-insights\"\n      },\n      \"themes\": [\n        {\n          \"id\": \"customer-experience\",\n          \"name\": \"Customer Experience\",\n          \"loves\": [\n            {\n              \"text\": \"Felt like a true collaboration \\u2014 the team was patient, collaborative, and willing to refine things.\",\n              \"keyword\": \"Collaboration\"\n            }\n          ],\n          \"quote\": {\n            \"text\": \"felt like a true collaboration\"\n          },\n          \"status\": \"CRITICAL\",\n          \"actions\": [\n            {\n              \"text\": \"Assign a named account owner to every client, require a 24-hour acknowledgement and a substantive update within 3 business days, and publish a live project timeline (visible to client) with change logs.\"\n            }\n          ],\n          \"mentions\": 6,\n          \"frictions\": [\n            {\n              \"text\": \"Customers reported a lack of transparency and follow-up, saying communication is the most basic expectation and it was not met.\",\n              \"keyword\": \"Transparency\"\n            }\n          ],\n          \"sentimentScore\": 50,\n          \"negativeMentions\": 3,\n          \"positiveMentions\": 3\n        },\n        {\n          \"id\": \"food-and-beverage\",\n          \"name\": \"Food and Beverage\",\n          \"loves\": [],\n          \"quote\": null,\n          \"status\": \"NO_DATA\",\n          \"actions\": [\n            {\n              \"text\": \"Begin collecting Food & Beverage-specific feedback by enabling the category in post-service surveys and prompting clients for a short review when the category applies; this will create a baseline for future action.\"\n            }\n          ],\n          \"mentions\": 0,\n          \"frictions\": [],\n          \"sentimentScore\": 0,\n          \"negativeMentions\": 0,\n          \"positiveMentions\": 0\n        },\n        {\n          \"id\": \"service-quality\",\n          \"name\": \"Service Quality\",\n          \"loves\": [\n            {\n              \"text\": \"Customers praise strong technical proficiency and the ability to deliver clean, user-friendly designs with thorough testing.\",\n              \"keyword\": \"Technical expertise\"\n            }\n          ],\n          \"quote\": {\n            \"text\": \"team stayed responsive and solution-oriented\"\n          },\n          \"status\": \"CRITICAL\",\n          \"actions\": [\n            {\n              \"text\": \"Introduce a mandatory production-readiness checklist and require end-to-end QA sign-off before handover, plus assign a single accountable technical lead for every project to prevent diffused responsibility.\"\n            }\n          ],\n          \"mentions\": 6,\n          \"frictions\": [\n            {\n              \"text\": \"Critical features failed to work properly and multiple handovers between project managers meant no single team was accountable for a production-ready product.\",\n              \"keyword\": \"Delivery failure\"\n            }\n          ],\n          \"sentimentScore\": 50,\n          \"negativeMentions\": 3,\n          \"positiveMentions\": 3\n        },\n        {\n          \"id\": \"wait-times\",\n          \"name\": \"Wait Times\",\n          \"loves\": [\n            {\n              \"text\": \"Some customers reported timely delivery and quick turnaround on updates.\",\n              \"keyword\": \"Timeliness\"\n            }\n          ],\n          \"quote\": {\n            \"text\": \"timely delivery\"\n          },\n          \"status\": \"CRITICAL\",\n          \"actions\": [\n            {\n              \"text\": \"Define and enforce SLAs for status updates (acknowledge within 24 hours, substantive update every 3 business days), implement automated milestone notifications, and run a weekly audit to surface stalled items.\"\n            }\n          ],\n          \"mentions\": 4,\n          \"frictions\": [\n            {\n              \"text\": \"Other customers had to repeatedly reach out for updates and experienced no timely responses for weeks.\",\n              \"keyword\": \"No follow-up\"\n            }\n          ],\n          \"sentimentScore\": 50,\n          \"negativeMentions\": 2,\n          \"positiveMentions\": 2\n        }\n      ],\n      \"summary\": {\n        \"positiveRate\": 50,\n        \"ratingStatus\": \"CRITICAL\",\n        \"totalReviews\": 6,\n        \"averageRating\": 3.2,\n        \"negativeReviews\": 3,\n        \"positiveReviews\": 3\n      },\n      \"appendix\": {\n        \"generatedBy\": \"Shout About Us\",\n        \"methodology\": \"This report analyses customer reviews using AI-powered sentiment analysis. Each review is categorised into themes, scored for positive or negative sentiment, and aggregated to produce the scores and insights above. Themes are generated from the actual review content, not from a fixed list.\",\n        \"reviewSource\": \"Google Maps\",\n        \"analysisPeriod\": \"Jan 01, 2026 - Dec 31, 2026\",\n        \"totalReviewsAnalyzed\": 6\n      },\n      \"business\": {\n        \"name\": \"hipages 540362 - site 2954679\",\n        \"address\": \"Stub Address, Ashk\\u0101sham, Badakhshan 2000, Afghanistan\",\n        \"category\": null,\n        \"reportPeriod\": {\n          \"end\": \"2026-12-31\",\n          \"start\": \"2026-01-01\",\n          \"display\": \"Jan 01, 2026 - Dec 31, 2026\"\n        },\n        \"reviewSource\": \"Google Maps\"\n      },\n      \"actionPlan\": {\n        \"monitoring\": [],\n        \"priorities\": [\n          {\n            \"title\": \"Fix client communication and ownership\",\n            \"urgency\": \"IMMEDIATE\",\n            \"description\": \"Assign a named account owner for all active projects, enforce a 24-hour acknowledgement and 3-business-day substantive update SLA, and log every client interaction so nothing falls through the cracks.\",\n            \"relatedTheme\": \"Customer Experience\"\n          },\n          {\n            \"title\": \"Enforce production-readiness\",\n            \"urgency\": \"IMMEDIATE\",\n            \"description\": \"Require a documented production-readiness checklist and mandatory end-to-end QA sign-off before any client handover to eliminate releases with broken critical features.\",\n            \"relatedTheme\": \"Service Quality\"\n          },\n          {\n            \"title\": \"Operationalise update SLAs\",\n            \"urgency\": \"IMMEDIATE\",\n            \"description\": \"Implement automated milestone notifications, weekly client status audits, and escalate any task with no update within 3 business days to a single accountable lead.\",\n            \"relatedTheme\": \"Wait Times\"\n          }\n        ]\n      }\n    },\n    \"failure_reason\": null,\n    \"failure_details\": null,\n    \"completed_at\": null\n  }\n}"
                    },
                    {
                      "name": "200 Pending (still processing)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"insight_report_id\": 4,\n    \"store_id\": 10,\n    \"review_site_id\": 44,\n    \"publisher\": \"maps.google.com\",\n    \"year\": 2026,\n    \"month\": null,\n    \"theme\": \"general\",\n    \"status\": \"pending\",\n    \"task_id\": \"9a893013-9aa8-4b8f-b3ff-6fd72f00a6ce\",\n    \"review_count\": null,\n    \"result_summary\": null,\n    \"failure_reason\": null,\n    \"failure_details\": null,\n    \"completed_at\": null\n  }\n}"
                    },
                    {
                      "name": "404 Report not found for this store",
                      "status": "Not Found",
                      "code": 404,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Insight report not found for this store.\",\n  \"errors\": []\n}"
                    }
                  ]
                },
                {
                  "name": "B.10.4 Get latest stored report",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"store_id\": 42,\n    \"review_site_id\": 7,\n    \"year\": 2026,\n    \"month\": 5,\n    \"theme\": \"general\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/account/insight-report/latest",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "insight-report",
                        "latest"
                      ]
                    },
                    "description": "Returns the most recent stored insight report for a target identity (store + review site + period + theme), read entirely from our database — it never calls ReviewData and never consumes usage. When no matching report exists, `data.found` is `false`, signalling the UI to offer a Generate action. Backed by `InsightReportController::latest`, which reuses `SubmitInsightReportRequest` (same validation rules as B.10.1, but `force` is unused here).\n\n**Auth:** Both — `auth:api`; `store_id` scoped by `BelongsToAuthCompany` (auth-company-tree walk). A Partner reaches any store in its sub-tree; an Account/Brand only its own stores. The match also keys on `review_site_id` (null vs specific), `year`, `month` (null = full-year), and `theme`, returning the latest (`id`) match.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (all top-level, JSON):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | `exists:stores,id` + belongs to auth company hierarchy. |\n| `review_site_id` | integer | no | `exists:review_sites,id`, must be linked to the store; omit to match the all-publisher report (matched as `review_site_id IS NULL`). |\n| `year` | integer | yes | `between:2000,2100`. |\n| `month` | integer | no | `between:1,12`. Omit to match the full-year report (`period_month IS NULL`). |\n| `theme` | string | yes | `max:64`. |\n\n**Response** — `{ data: ... }` envelope, HTTP 200.\n- When no report exists: `{ \"found\": false }` only.\n- When found: `found` (boolean, `true`) plus the full `present()` payload: `insight_report_id` (integer), `store_id` (integer), `review_site_id` (integer|null), `publisher` (string), `year` (integer), `month` (integer|null), `theme` (string), `status` (string), `task_id` (string|null), `review_count` (integer|null), `result_summary` (object|null), `failure_reason` (string|null), `failure_details` (object|null), `completed_at` (string|null, ISO-8601)."
                  },
                  "response": [
                    {
                      "name": "200 Found",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"insight_report_id\": 4,\n    \"store_id\": 10,\n    \"review_site_id\": 44,\n    \"publisher\": \"maps.google.com\",\n    \"year\": 2026,\n    \"month\": null,\n    \"theme\": \"general\",\n    \"status\": \"success\",\n    \"task_id\": \"9a893013-9aa8-4b8f-b3ff-6fd72f00a6ce\",\n    \"review_count\": 6,\n    \"result_summary\": {\n      \"hero\": {\n        \"verdict\": \"Customers recognise strong technical ability and reliable execution on delivered work, but equally report systemic failures in communication and delivery readiness that have led to serious dissatisfaction. Fixing transparency, single-point accountability and release-readiness will convert the existing strengths into consistent customer outcomes.\",\n        \"heroTitle\": \"hipages 540362 (SAU178040716910) \\u2014 Strong Delivery, Fragile Follow\\u2011through\",\n        \"anchorQuote\": \"reliable execution, timely delivery, and professional communication\",\n        \"closingLine\": \"This quarter prioritise a single-point-of-contact + response SLA program and a mandatory production-readiness checklist to stop repeat communication and delivery failures.\"\n      },\n      \"meta\": {\n        \"mode\": \"single\",\n        \"version\": \"1.0\",\n        \"reportType\": \"general-insights\"\n      },\n      \"themes\": [\n        {\n          \"id\": \"customer-experience\",\n          \"name\": \"Customer Experience\",\n          \"loves\": [\n            {\n              \"text\": \"Felt like a true collaboration \\u2014 the team was patient, collaborative, and willing to refine things.\",\n              \"keyword\": \"Collaboration\"\n            }\n          ],\n          \"quote\": {\n            \"text\": \"felt like a true collaboration\"\n          },\n          \"status\": \"CRITICAL\",\n          \"actions\": [\n            {\n              \"text\": \"Assign a named account owner to every client, require a 24-hour acknowledgement and a substantive update within 3 business days, and publish a live project timeline (visible to client) with change logs.\"\n            }\n          ],\n          \"mentions\": 6,\n          \"frictions\": [\n            {\n              \"text\": \"Customers reported a lack of transparency and follow-up, saying communication is the most basic expectation and it was not met.\",\n              \"keyword\": \"Transparency\"\n            }\n          ],\n          \"sentimentScore\": 50,\n          \"negativeMentions\": 3,\n          \"positiveMentions\": 3\n        },\n        {\n          \"id\": \"food-and-beverage\",\n          \"name\": \"Food and Beverage\",\n          \"loves\": [],\n          \"quote\": null,\n          \"status\": \"NO_DATA\",\n          \"actions\": [\n            {\n              \"text\": \"Begin collecting Food & Beverage-specific feedback by enabling the category in post-service surveys and prompting clients for a short review when the category applies; this will create a baseline for future action.\"\n            }\n          ],\n          \"mentions\": 0,\n          \"frictions\": [],\n          \"sentimentScore\": 0,\n          \"negativeMentions\": 0,\n          \"positiveMentions\": 0\n        },\n        {\n          \"id\": \"service-quality\",\n          \"name\": \"Service Quality\",\n          \"loves\": [\n            {\n              \"text\": \"Customers praise strong technical proficiency and the ability to deliver clean, user-friendly designs with thorough testing.\",\n              \"keyword\": \"Technical expertise\"\n            }\n          ],\n          \"quote\": {\n            \"text\": \"team stayed responsive and solution-oriented\"\n          },\n          \"status\": \"CRITICAL\",\n          \"actions\": [\n            {\n              \"text\": \"Introduce a mandatory production-readiness checklist and require end-to-end QA sign-off before handover, plus assign a single accountable technical lead for every project to prevent diffused responsibility.\"\n            }\n          ],\n          \"mentions\": 6,\n          \"frictions\": [\n            {\n              \"text\": \"Critical features failed to work properly and multiple handovers between project managers meant no single team was accountable for a production-ready product.\",\n              \"keyword\": \"Delivery failure\"\n            }\n          ],\n          \"sentimentScore\": 50,\n          \"negativeMentions\": 3,\n          \"positiveMentions\": 3\n        },\n        {\n          \"id\": \"wait-times\",\n          \"name\": \"Wait Times\",\n          \"loves\": [\n            {\n              \"text\": \"Some customers reported timely delivery and quick turnaround on updates.\",\n              \"keyword\": \"Timeliness\"\n            }\n          ],\n          \"quote\": {\n            \"text\": \"timely delivery\"\n          },\n          \"status\": \"CRITICAL\",\n          \"actions\": [\n            {\n              \"text\": \"Define and enforce SLAs for status updates (acknowledge within 24 hours, substantive update every 3 business days), implement automated milestone notifications, and run a weekly audit to surface stalled items.\"\n            }\n          ],\n          \"mentions\": 4,\n          \"frictions\": [\n            {\n              \"text\": \"Other customers had to repeatedly reach out for updates and experienced no timely responses for weeks.\",\n              \"keyword\": \"No follow-up\"\n            }\n          ],\n          \"sentimentScore\": 50,\n          \"negativeMentions\": 2,\n          \"positiveMentions\": 2\n        }\n      ],\n      \"summary\": {\n        \"positiveRate\": 50,\n        \"ratingStatus\": \"CRITICAL\",\n        \"totalReviews\": 6,\n        \"averageRating\": 3.2,\n        \"negativeReviews\": 3,\n        \"positiveReviews\": 3\n      },\n      \"appendix\": {\n        \"generatedBy\": \"Shout About Us\",\n        \"methodology\": \"This report analyses customer reviews using AI-powered sentiment analysis. Each review is categorised into themes, scored for positive or negative sentiment, and aggregated to produce the scores and insights above. Themes are generated from the actual review content, not from a fixed list.\",\n        \"reviewSource\": \"Google Maps\",\n        \"analysisPeriod\": \"Jan 01, 2026 - Dec 31, 2026\",\n        \"totalReviewsAnalyzed\": 6\n      },\n      \"business\": {\n        \"name\": \"hipages 540362 - site 2954679\",\n        \"address\": \"Stub Address, Ashk\\u0101sham, Badakhshan 2000, Afghanistan\",\n        \"category\": null,\n        \"reportPeriod\": {\n          \"end\": \"2026-12-31\",\n          \"start\": \"2026-01-01\",\n          \"display\": \"Jan 01, 2026 - Dec 31, 2026\"\n        },\n        \"reviewSource\": \"Google Maps\"\n      },\n      \"actionPlan\": {\n        \"monitoring\": [],\n        \"priorities\": [\n          {\n            \"title\": \"Fix client communication and ownership\",\n            \"urgency\": \"IMMEDIATE\",\n            \"description\": \"Assign a named account owner for all active projects, enforce a 24-hour acknowledgement and 3-business-day substantive update SLA, and log every client interaction so nothing falls through the cracks.\",\n            \"relatedTheme\": \"Customer Experience\"\n          },\n          {\n            \"title\": \"Enforce production-readiness\",\n            \"urgency\": \"IMMEDIATE\",\n            \"description\": \"Require a documented production-readiness checklist and mandatory end-to-end QA sign-off before any client handover to eliminate releases with broken critical features.\",\n            \"relatedTheme\": \"Service Quality\"\n          },\n          {\n            \"title\": \"Operationalise update SLAs\",\n            \"urgency\": \"IMMEDIATE\",\n            \"description\": \"Implement automated milestone notifications, weekly client status audits, and escalate any task with no update within 3 business days to a single accountable lead.\",\n            \"relatedTheme\": \"Wait Times\"\n          }\n        ]\n      }\n    },\n    \"failure_reason\": null,\n    \"failure_details\": null,\n    \"completed_at\": null,\n    \"found\": true\n  }\n}"
                    },
                    {
                      "name": "200 Not found (offer Generate)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"found\": false\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "B.10.5 AI insights report (spec §10.8)",
                  "request": {
                    "method": "GET",
                    "header": [],
                    "url": {
                      "raw": "{{baseUrl}}/reports/insights?store_id={{storeId}}",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "reports",
                        "insights"
                      ],
                      "query": [
                        {
                          "key": "store_id",
                          "value": "{{storeId}}",
                          "description": "Required — must be in caller scope"
                        },
                        {
                          "key": "platform",
                          "value": "",
                          "description": "Optional — review_site name filter (e.g. \"hipages\", \"Google\")",
                          "disabled": true
                        }
                      ]
                    },
                    "description": "Returns the §10.8 AI-insights report envelope for a single store: a live per-platform performance breakdown computed from the latest 30 non-suppressed, on-platform reviews within a 6-month window, plus stubbed narrative fields (`summary`, `key_insights`) reserved for a later AI-generation work-stream.\n\n**Auth:** Both — `auth:api`. No prefix/`EnsureHipagesPartner`; scoping is in-controller via `Store::getStoreListByUser($user, true)`. The `store_id` query value must be in the caller's accessible store set or a 403 is returned. As an account/brand user, the reachable set is the stores under the brand's location-account children (or the user's directly-assigned stores); the same endpoint serves both personas — only which `store_id`s are reachable differs.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `store_id` (integer, required) — target store PK; must resolve inside the caller's access scope.\n- Query: `platform` (string, optional) — filters reviews to a single `review_sites.name` (e.g. `\"hipages\"`, `\"Google\"`).\n\n**Response** — `{ data: ... }` envelope.\n- `summary` (string|null) — narrative summary; currently the stub string `\"AI insights pending generation.\"` when reviews exist, otherwise `null`.\n- `key_insights` (array) — AI narrative bullet objects; currently always `[]`.\n- `category_performance` (object) — keyed by platform name (`reviewsite.name`, or `\"Unknown\"`); each value has `count` (int), `avg_rating` (float|null, rounded to 2dp over rates > 0), `positive_count` (int, rate ≥ 4), `negative_count` (int, rate 1–2).\n- `review_count_used` (int) — number of reviews aggregated (0–30).\n- `lookback_days` (int) — days from oldest used review to now; defaults to 180 when no reviews found.\n- `generated_at` (string) — ISO-8601 timestamp of generation."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"summary\": \"AI insights pending generation.\",\n    \"key_insights\": [],\n    \"category_performance\": {\n      \"hipages\": {\n        \"count\": 12,\n        \"avg_rating\": 4.5,\n        \"positive_count\": 9,\n        \"negative_count\": 1\n      }\n    },\n    \"review_count_used\": 28,\n    \"lookback_days\": 154,\n    \"generated_at\": \"2026-06-10T09:14:00+00:00\"\n  }\n}"
                    },
                    {
                      "name": "403 Store out of scope",
                      "status": "Forbidden",
                      "code": 403,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Store is outside your access scope.\",\n  \"errors\": []\n}"
                    }
                  ]
                },
                {
                  "name": "B.10.6 Generate previous month's insight report",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n  \"store_id\": \"{{storeId}}\"\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/account/insight-report/generate",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "insight-report",
                        "generate"
                      ]
                    },
                    "description": "Backs the UI 'Generate report' button, shown only for the PREVIOUS calendar month when the scheduled run produced no report (it failed, or the location was onboarded mid-month). Always targets last month. If a non-failed report for that month already exists it is returned unchanged (no regeneration, §5.4); otherwise it behaves like the submit endpoint — queues an async ReviewData run and **bills 1 unit** against the store's `insight_report` quota. The store must have enough recent reviews (`SubmitInsightReportRequest`).\n\n## Auth\n- Bearer **account / partner** token (`auth:api`). `store_id` scoped by `BelongsToAuthCompany`.\n\n## Rate limit\n- No rate limit (no `throttle` middleware on this route).\n\n## Body\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | Required | `exists:stores,id`, in caller's hierarchy. Must have >= the minimum recent reviews and >= 1 linked review site with a configured `publisher`. Year/month are forced to last month; other body fields are ignored on this route. |\n\n## Behaviour\n- `202` — fresh report queued (`{ data: {..., status: 'queued', task_id } }`). Poll B.10.3 for the result.\n- `200` — an existing non-failed report for the month is returned as-is (deduped).\n- Emits `X-Feature-*` usage headers.\n- `403` — quota exceeded (hard-stop) / feature disabled.\n- `422` — not enough recent reviews, or store has no publisher-linked review site.\n\n✅ live ([routes/api_v1.php:846](routes/api_v1.php#L846) → `InsightReportController@generatePreviousMonth`)"
                  },
                  "response": [
                    {
                      "name": "202 Queued",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"insight_report_id\": 1,\n    \"store_id\": 42,\n    \"review_site_id\": null,\n    \"publisher\": \"all\",\n    \"year\": 2026,\n    \"month\": 6,\n    \"theme\": \"general\",\n    \"status\": \"queued\",\n    \"task_id\": \"rd-task-abc123\",\n    \"remaining\": 4,\n    \"overage\": false\n  }\n}"
                    },
                    {
                      "name": "200 Existing report (deduped)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"insight_report_id\": 1,\n    \"store_id\": 42,\n    \"review_site_id\": null,\n    \"publisher\": \"all\",\n    \"year\": 2026,\n    \"month\": 6,\n    \"theme\": \"general\",\n    \"status\": \"completed\",\n    \"task_id\": \"rd-task-abc123\",\n    \"remaining\": 4,\n    \"overage\": false\n  }\n}"
                    },
                    {
                      "name": "422 Not enough reviews",
                      "status": "Unprocessable Content",
                      "code": 422,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"message\": \"This location needs at least 5 reviews in the last 12 months before an insight report can be generated.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"store_id\": [\n      \"This location needs at least 5 reviews in the last 12 months before an insight report can be generated.\"\n    ]\n  }\n}"
                    }
                  ]
                }
              ]
            },
            {
              "name": "§ B.11 Competitive Report",
              "item": [
                {
                  "name": "B.11.1 Submit competitive report (generate)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"store_id\": 42,\n    \"review_site_id\": 7,\n    \"year\": 2026,\n    \"month\": 5,\n    \"theme\": \"general\",\n    \"competitors\": [\n        {\n            \"name\": \"Pacific Breeze Heating & Air\",\n            \"profile_key\": \"https://www.google.com/maps/place/pacific-breeze\",\n            \"phone\": \"+15551234567\",\n            \"address\": {\n                \"street\": \"123 Main St\",\n                \"city\": \"San Diego\",\n                \"state\": \"CA\",\n                \"zip\": \"92101\",\n                \"country\": \"US\"\n            }\n        }\n    ],\n    \"force\": false\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/account/competitive-report",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "competitive-report"
                      ]
                    },
                    "description": "Queues an async ReviewData competitive report comparing one store location against 1-2 competitors on one shared publisher (review site). Counts 1 unit against the store's `competitive_analysis` plan quota at submit; hard-stop plans 403 once exhausted, soft-stop plans bill the call as overage. Deduped against an identical non-failed report unless `force` is set. Returns immediately with `status: queued` + `task_id`; the completed report arrives via the COMPETITIVE_REPORT_RESULT webhook (poll B.11.2).\n\n**Auth:** Both — `auth:api`. The route lives directly under the authenticated group (not behind `EnsureHipagesPartner`), so any authenticated persona (Partner or Account) may call it; the target store is scoped by the `BelongsToAuthCompany` rule on `store_id`, which walks the auth user's company hierarchy (`Company::isBelongsToParentCompany`) — so a Partner reaches any store in its tree, an Account only its own. `review_site_id` must additionally be linked to the store via `store_review_sites` (checked in `withValidator`).\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (success response wrapped as `{ data: ... }` by `ApiResponses::successResponse`, HTTP 202):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | `exists:stores,id` + `BelongsToAuthCompany` (must be in caller's company hierarchy). |\n| `review_site_id` | integer | yes | `exists:review_sites,id`; must also be linked to the store via `store_review_sites`. |\n| `year` | integer | yes | `between:2000,2100`. |\n| `month` | integer | no | `between:1,12`; omit for a full-year report. |\n| `theme` | string | yes | `max:64`. A theme slug from B.11.4 (e.g. `general`). |\n| `competitors` | array | yes | 1-2 items. |\n| `competitors.*.name` | string | yes | `max:255`. |\n| `competitors.*.profile_key` | string (url) | yes | `max:1024`. Competitor's review-page URL on the publisher. |\n| `competitors.*.id` | string | no | `max:255`. |\n| `competitors.*.phone` | string | no | `max:64`. |\n| `competitors.*.description` | string | no | `max:2048`. |\n| `competitors.*.tags` | array | no | `max:30`, each tag string `max:64`. |\n| `competitors.*.address` | object | no | optional `street`/`city`/`state`/`zip`/`country` strings. |\n| `force` | boolean | no | Explicit recall — bypasses dedup and bills a fresh RD run. |\n\n**Response** — `{ data: ... }` envelope (HTTP 202; also carries plan feature headers via `withFeatureHeaders` for `competitive_analysis`). Built by `present()`.\n- `competitive_report_id` (integer) — our `competitive_reports` row id; use it with B.11.2.\n- `store_id` (integer)\n- `review_site_id` (integer|null)\n- `publisher` (string) — resolved publisher slug.\n- `year` (integer)\n- `month` (integer|null)\n- `theme` (string)\n- `status` (string) — typically `queued` (or `pending`/existing status on dedup).\n- `task_id` (string|null) — RD task id.\n- `competitors` (array) — normalised competitor blocks (`competitors_json`).\n- `competitor_count` (integer)\n- `review_count` (integer|null)\n- `report_data` (object|null) — null until the webhook lands.\n- `failure_reason` (string|null)\n- `failure_details` (mixed|null)\n- `completed_at` (string|null, ISO 8601)\n- `remaining` (integer|null) — quota left after this submit (merged via `extra`).\n- `overage` (boolean) — whether this call was billed as overage."
                  },
                  "response": [
                    {
                      "name": "202 Queued",
                      "status": "Accepted",
                      "code": 202,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"competitive_report_id\": 1001,\n    \"store_id\": 42,\n    \"review_site_id\": 7,\n    \"publisher\": \"google\",\n    \"year\": 2026,\n    \"month\": 5,\n    \"theme\": \"general\",\n    \"status\": \"queued\",\n    \"task_id\": \"9a893013-9aa8-4b8f-b3ff-6fd72f00a6ce\",\n    \"competitor_count\": 1,\n    \"remaining\": 4,\n    \"overage\": false\n  }\n}"
                    },
                    {
                      "name": "403 Quota exceeded / feature disabled",
                      "status": "Forbidden",
                      "code": 403,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Competitive analysis quota exceeded for this store.\",\n  \"errors\": []\n}"
                    },
                    {
                      "name": "422 Validation failed",
                      "status": "Unprocessable Entity",
                      "code": 422,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"competitors\": [\n      \"At least one competitor is required.\"\n    ]\n  }\n}"
                    }
                  ],
                  "event": [
                    {
                      "listen": "test",
                      "script": {
                        "type": "text/javascript",
                        "exec": [
                          "if (pm.response.code === 202 || pm.response.code === 200) {",
                          "  const j = pm.response.json();",
                          "  if (j.data && j.data.competitive_report_id) {",
                          "    pm.environment.set('competitiveReportId', String(j.data.competitive_report_id));",
                          "    console.log('Saved competitive_report_id to {{competitiveReportId}} =', j.data.competitive_report_id);",
                          "  }",
                          "}"
                        ]
                      }
                    }
                  ]
                },
                {
                  "name": "B.11.2 Check status (poll until complete)",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"store_id\": 42,\n    \"report_id\": 1001\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/account/competitive-report/status",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "competitive-report",
                        "status"
                      ]
                    },
                    "description": "Polls the current status of a previously-queued competitive report, read straight from our database (the completed report lands via the COMPETITIVE_REPORT_RESULT webhook). Pure read — never calls ReviewData and never counts usage.\n\n**Auth:** Both — `auth:api` (route sits directly under the authenticated group, not behind `EnsureHipagesPartner`). The store is scoped by `BelongsToAuthCompany` on `store_id` (hierarchy walk), and `report_id` must `exists:competitive_reports,id`; the service loads the row scoped to that store, so a Partner can poll any store in its tree while an Account is limited to its own.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (success wrapped as `{ data: ... }`, HTTP 200):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | `exists:stores,id` + `BelongsToAuthCompany`. |\n| `report_id` | integer | yes | `exists:competitive_reports,id`; the `competitive_report_id` from B.11.1 / B.11.3. |\n\n**Response** — `{ data: ... }` envelope (HTTP 200), the full `present()` shape (same fields as B.11.1's data, minus `remaining`/`overage`).\n- `competitive_report_id` (integer)\n- `store_id` (integer)\n- `review_site_id` (integer|null)\n- `publisher` (string)\n- `year` (integer)\n- `month` (integer|null)\n- `theme` (string)\n- `status` (string) — e.g. `queued`, `processing`, `succeeded`, `failed`.\n- `task_id` (string|null)\n- `competitors` (array)\n- `competitor_count` (integer)\n- `review_count` (integer|null)\n- `report_data` (object|null) — populated once the webhook completes the report.\n- `failure_reason` (string|null)\n- `failure_details` (mixed|null)\n- `completed_at` (string|null, ISO 8601)"
                  },
                  "response": [
                    {
                      "name": "200 Pending (still processing)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"competitive_report_id\": 1001,\n    \"store_id\": 42,\n    \"status\": \"pending\",\n    \"task_id\": \"9a893013-9aa8-4b8f-b3ff-6fd72f00a6ce\",\n    \"result_summary\": null,\n    \"failure_reason\": null,\n    \"completed_at\": null\n  }\n}"
                    },
                    {
                      "name": "404 Report not found for this store",
                      "status": "Not Found",
                      "code": 404,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"status\": \"error\",\n  \"message\": \"Competitive report not found for this store.\",\n  \"errors\": []\n}"
                    }
                  ]
                },
                {
                  "name": "B.11.3 List stored competitive reports",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"store_id\": 42,\n    \"status\": \"succeeded\",\n    \"publisher\": \"google\",\n    \"theme\": \"general\",\n    \"foreign_key\": \"competitor_1001\",\n    \"per_page\": 50,\n    \"page\": 1\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/account/competitive-report/list",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "competitive-report",
                        "list"
                      ]
                    },
                    "description": "Lists every competitive report stored for one store location, newest first (paginated), so the brand can re-open a past report (use each row's `competitive_report_id` with B.11.2 to fetch the full payload). DB-only read — never calls ReviewData and never counts usage. Optional `status`/`publisher`/`theme`/`foreign_key` filters narrow the list; `summary` carries store-wide status counts and the total reviews scored for the page header.\n\n**Auth:** Both — `auth:api` (directly under the authenticated group, not behind `EnsureHipagesPartner`). The store is scoped by `BelongsToAuthCompany` on `store_id` (hierarchy walk); reports are filtered to that one store, so a Partner can list any store in its tree, an Account only its own.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (success wrapped as `{ data: ... }`, HTTP 200):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | `exists:stores,id` + `BelongsToAuthCompany`. |\n| `status` | string | no | `max:32`. Listing chip; mapped to raw DB statuses (`queued`→queued/pending, `in_progress`→waiting_for_scrape/processing/running, `succeeded`→succeeded/success/complete/completed, `failed`→failed/error). |\n| `publisher` | string | no | `max:64`. Exact match. |\n| `theme` | string | no | `max:64`. Exact match. |\n| `foreign_key` | string | no | `max:64`. Accepts `competitor_{id}` or a bare id; non-numeric matches nothing. |\n| `per_page` | integer | no | `min:1,max:100`; defaults to 50. |\n| `page` | integer | no | `min:1`; defaults to 1. |\n\n**Response** — `{ data: ... }` envelope (HTTP 200) with `reports`, `summary`, `meta`. Each `reports[]` row is the lightweight `presentRow()` (heavy `report_data` omitted — fetch via B.11.2).\n- `reports[]` (array of objects):\n  - `competitive_report_id` (integer)\n  - `foreign_key` (string) — `competitor_{id}`.\n  - `store_id` (integer)\n  - `business` (string|null) — store `saucode`.\n  - `business_name` (string|null) — store name.\n  - `review_site_id` (integer|null)\n  - `publisher` (string)\n  - `year` (integer)\n  - `month` (integer|null)\n  - `theme` (string)\n  - `status` (string) — raw DB status.\n  - `competitor_count` (integer)\n  - `competitors` (array of string) — competitor names only.\n  - `review_count` (integer|null)\n  - `owner_email` (string|null) — requesting user email.\n  - `requested_by_user_id` (integer|null)\n  - `created_at` (string|null, ISO 8601)\n  - `completed_at` (string|null, ISO 8601)\n- `summary` (object):\n  - `status_counts` (object) — map of raw status → count, across ALL the store's reports.\n  - `reviews_scored` (integer) — sum of `review_count` across all the store's reports.\n  - `total` (integer) — total report count for the store.\n- `meta` (object): `current_page`, `last_page`, `per_page`, `total` (all integers)."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"data\": [\n      {\n        \"competitive_report_id\": 1001,\n        \"store_id\": 42,\n        \"publisher\": \"google\",\n        \"year\": 2026,\n        \"month\": 5,\n        \"theme\": \"general\",\n        \"status\": \"success\",\n        \"competitor_count\": 1,\n        \"completed_at\": \"2026-06-12T10:00:00Z\"\n      }\n    ],\n    \"summary\": {\n      \"total\": 1,\n      \"by_status\": {\n        \"success\": 1\n      }\n    }\n  }\n}"
                    }
                  ]
                },
                {
                  "name": "B.11.4 List available themes",
                  "request": {
                    "method": "POST",
                    "header": [
                      {
                        "key": "Content-Type",
                        "value": "application/json"
                      }
                    ],
                    "body": {
                      "mode": "raw",
                      "raw": "{\n    \"store_id\": 42\n}",
                      "options": {
                        "raw": {
                          "language": "json"
                        }
                      }
                    },
                    "url": {
                      "raw": "{{baseUrl}}/account/competitive-report/themes",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "account",
                        "competitive-report",
                        "themes"
                      ]
                    },
                    "description": "Returns the live competitive-report theme list (slug + label + KPIs) from ReviewData for the store's partner, falling back to the shipped defaults (`general` / `home-services`) when RD is unreachable or the partner has no RD api key. Pure read — never counts usage.\n\n**Auth:** Both — `auth:api` (directly under the authenticated group, not behind `EnsureHipagesPartner`). Unlike the other three, this method has no dedicated Form Request — it validates inline in the controller: `store_id` is `required|integer|exists:stores,id` plus the `BelongsToAuthCompany` rule (hierarchy walk). The service resolves the store's parent partner (`Company::getParentCompany(..., 'partner')`) and uses that partner's RD api key, so the reachable theme set follows the store's partner regardless of persona.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Body (success wrapped as `{ data: ... }`, HTTP 200):\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `store_id` | integer | yes | `exists:stores,id` + `BelongsToAuthCompany`. |\n\n**Response** — `{ data: ... }` envelope (HTTP 200).\n- `themes` (array of objects) — from RD when available, else the defaults. Each default item has:\n  - `slug` (string) — e.g. `general`, `home-services`.\n  - `label` (string) — display label.\n  - `kpis` (array) — KPI definitions (empty `[]` in the defaults; populated when RD returns live themes)."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    {\n      \"slug\": \"general\",\n      \"label\": \"General\",\n      \"kpis\": []\n    },\n    {\n      \"slug\": \"home-services\",\n      \"label\": \"Home Services\",\n      \"kpis\": []\n    }\n  ]\n}"
                    }
                  ]
                }
              ],
              "description": "Brand-side async competitive report vs 1-2 competitors on a shared publisher (plan-gated, billed at submit). submit → poll status; list/themes are DB/provider reads. Completed reports land via the COMPETITIVE_REPORT_RESULT webhook."
            }
          ]
        }
      ]
    }
  ]
}