{
  "info": {
    "_postman_id": "aff4b2cf-8240-4064-a266-7914401fb766",
    "name": "Shout About Us API (global)",
    "description": "## Shout About Us 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. Set `{{bearerToken}}` to a **Personal access token** (generated on the dashboard's API Keys page) or to an App credentials (OAuth 2.0) access token.\n3. All other authenticated requests use `{{bearerToken}}` automatically.\n\n### Credentials (development)\n\nCredentials are **not** distributed with this collection. Use the Personal access token issued for your sandbox account as `bearerToken`.\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 the password-grant request, or set to a Personal access token) | 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 (personal access tokens, app credentials, 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.\n\nErrors: `422` on validation failure.",
                "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.\n  - `value` (string) — ISO country 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.\n  - `label` (string) — state name.\n\nErrors: `422` on validation failure.",
                "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.\n  - `label` (string) — city name.\n\nErrors: `422` on validation failure.",
                "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  \"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** by our OAuth 2.0 authorization server (RS256-signed JWTs). Every protected endpoint expects an `Authorization: Bearer <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** (Signup, Discover, OTP, SSO redirect/exchange, webhooks).\n- To authenticate the collection, set `{{bearerToken}}` to a **Personal access token** (generated on the dashboard's API Keys page) or to an App credentials (OAuth 2.0) access token.\n- `{{baseUrl}}` points at the development host by default; change it to target another environment.\n\n## Ways to obtain a token\n\n1. **Personal access token (simplest)** — generate it on the dashboard's **API Keys** page (API Keys → Manage token → Generate token). It never expires, stays viewable on that page, and acts as the user who generated it. Regenerate at any time to rotate it — the previous token stops working immediately.\n2. **App credentials (OAuth 2.0)** — a browser sign-in: other people sign in to your app; they enter their password on our page, never in yours, and your server receives the token. See § 1.D.1.\n3. **SSO — JumpCloud (OIDC)** — full handoff: the user signs in through JumpCloud and lands inside the portal; your app never receives the token. See § 1.D.2.\n4. **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).\n\nEvery token acts as the user behind it: an account user's token sees that account; a partner Owner or Admin token has partner-level access.\n\n## Using the token\n\nSend it on every protected call:\n\n```\nAuthorization: Bearer <bearer-token>\n```\n\n## Token lifecycle\n\n- **Personal access tokens** do not expire — rotate them from the API Keys page (regenerating revokes the previous token immediately).\n- **App credentials (OAuth 2.0) access tokens** last **1 day**; their **refresh tokens** last **30 days** (see § 1.D.1 for the refresh call).\n- Check any token with `GET /verify-token` — it returns the user and company while the token is valid, and **401** once it expires or is revoked.\n- Revoke a token at any time (for example on sign-out) with `GET /sso/revoking-token`, sending the token as the Bearer header.\n\n## Locked accounts\n\nFive wrong password attempts temporarily lock an account (starting at 1 minute and growing to a 15-minute maximum, lifting automatically). While locked, new sign-ins are refused; tokens issued earlier keep working until they expire or are revoked.\n\n## Public vs protected\n\nPublic (no token): signup, `POST /auth/discover`, email OTP verification, the SSO redirect / callback / exchange legs, and the OAuth endpoints at the server root (`/oauth/authorize`, `/oauth/token`). Everything else requires a Bearer token.\n\n## Before you begin\n\nCreate your app credentials (`client_id` + `client_secret`) on the dashboard's **API Keys** page — they are needed for App credentials (OAuth 2.0). The client secret is shown **once**, at creation. Personal access tokens need no app credentials.",
          "item": [
            {
              "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 account 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 <bearer-token>` (required).\n- No path params, query params, or body.\n\n**Response** — `{ data: ... }` envelope.\n- `token` (string) — the same bearer token you sent, echoed back\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.D.1 SSO — Shout About Us login (OAuth 2.0)",
              "description": "## Browser sign-in — Shout About Us login (OAuth 2.0)\n\nStandard OAuth Authorization Code grant. Use this option when other people sign in to your app: they enter their password on our page — never in yours — and your server receives the access token. The token never leaves your backend except for the one-shot portal 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 app credentials on the dashboard's **API Keys** page. |\n| `client_secret` | Returned once when the credentials are 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 Shout About Us** — 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). `scope` must be `*`.\n2. **Receive the code** — Shout About Us bounces the user back to your registered `redirect_uri` with `?code=…&state=…`. Verify `state` matches what you stored. The code is single-use and expires after about 10 minutes.\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 portal at `<frontend_url>/rs-sso-login/?token=<access_token>`. The portal validates the token via the \"SSO portal login\" sub-request and starts the user's session.\n\n### Locked accounts\n\nA user whose account is temporarily locked after repeated failed sign-ins cannot pass the sign-in screen until the lock clears (locks start at 1 minute and grow to a 15-minute maximum, then lift automatically).\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 — access tokens last **1 day**. Use the `refresh_token` against `POST /oauth/token` with `grant_type=refresh_token` (valid for **30 days**) to get a new access token without asking the user to sign in again.\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` from the API Keys page.\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",
                          "description": "Required — the id of the OAuth client you registered."
                        },
                        {
                          "key": "redirect_uri",
                          "value": "https://yourapp.com/callback",
                          "description": "Required — must match a redirect URI registered against the client, character for character."
                        },
                        {
                          "key": "response_type",
                          "value": "code",
                          "description": "Required — always `code` for the authorization-code flow."
                        },
                        {
                          "key": "scope",
                          "value": "*",
                          "description": "Optional — space-separated scopes to request. Defaults to `*`."
                        },
                        {
                          "key": "state",
                          "value": "RANDOM_STRING",
                          "description": "Required — an unguessable random string. Store it in the user's session and compare it when the callback arrives, to prevent CSRF."
                        }
                      ]
                    },
                    "description": "Browser-initiated top-level redirect — NOT a fetch/XHR call from your app. The user must hit this URL in their browser so Shout About Us 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_SHOUT_ABOUT_US&state=RANDOM_STRING",
                      "protocol": "https",
                      "host": [
                        "yourapp",
                        "com"
                      ],
                      "path": [
                        "callback"
                      ],
                      "query": [
                        {
                          "key": "code",
                          "value": "THE_CODE_FROM_SHOUT_ABOUT_US",
                          "description": "Required — the one-time authorization code Shout About Us appended to your registered redirect_uri."
                        },
                        {
                          "key": "state",
                          "value": "RANDOM_STRING",
                          "description": "Required — echoed back verbatim. Compare it against the value you 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**. Shout About Us 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 Shout About Us's `/oauth/authorize` and follows it to *your* `redirect_uri`. Your application is the receiver — Shout About Us 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\": 86400,\n  \"access_token\": \"<access-token>\",\n  \"refresh_token\": \"<refresh-token>\"\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": "SSO portal 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 account (`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/account 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\": \"<access-token>\",\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": "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, Shout About Us 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. There is nothing to create on the API Keys page for this flow.\n\n### Flow\n\n1. You send the user to the JumpCloud entrypoint (sub-request \"Begin SSO redirect\") as a **top-level browser navigation** — never a background request or an iframe.\n2. Shout About Us redirects to JumpCloud; the user signs in there.\n3. JumpCloud returns to Shout About Us with an OIDC code (sub-request \"JumpCloud OIDC callback\" — browser leg, documentation only).\n4. Shout About Us validates the user, mints a **single-use exchange code valid for about 2 minutes**, 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 (super-admins are hard-blocked from SSO).\n- Account is not locked or inactive.\n\nJumpCloud sign-in is available for every eligible account by default — no per-user setup is needed.\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 Shout About Us account matches the user's JumpCloud email. |\n| `sso_not_enabled` | JumpCloud sign-in isn't enabled for this user. |\n| `no_company` | The user has no company set up. |\n| `role_not_allowed` | User is a super-admin; SSO is blocked for that tier. |\n| `account_locked` | Too many failed logins — the temporary lockout also 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 the browser sign-in flow (§ 1.D.1) if you need full client control.\n\n**Can I switch to browser sign-in 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 — a JumpCloud sign-in creates a session that lasts **24 hours**, with no refresh token. When it ends, the user simply signs in through JumpCloud again (one click on the same link).",
              "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 stateful Socialite 302 redirect (session middleware round-trips the OIDC `state`) 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, account, store) with `jumpcloud` in their `auth_methods` (`User::canUseJumpCloud()`) and an associated company are allowed through.\n\n**Rate limit:** 10 requests/min per IP.\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 (a standard 302 redirect response).\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": "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- 10 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": "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- 10 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`)\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "302 Redirect (success)",
                      "status": "Found",
                      "code": 302,
                      "_postman_previewlanguage": "text",
                      "header": [
                        {
                          "key": "Location",
                          "value": "https://app.example.com/rs-sso-login/?token=<access-token>"
                        }
                      ],
                      "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}"
                    }
                  ]
                },
                {
                  "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 at 10/min per IP. 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:** 10 requests/min per IP.\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`\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Both methods available",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"methods\": [\n      \"password\",\n      \"jumpcloud\"\n    ],\n    \"preferred\": \"password\",\n    \"sso_url\": \"https://production-api.shoutaboutus.com/api/v1/auth/sso/jumpcloud/redirect\"\n  }\n}"
                    },
                    {
                      "name": "200 SSO-only user",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"methods\": [\n      \"jumpcloud\"\n    ],\n    \"preferred\": \"jumpcloud\",\n    \"sso_url\": \"https://production-api.shoutaboutus.com/api/v1/auth/sso/jumpcloud/redirect\"\n  }\n}"
                    },
                    {
                      "name": "200 Unknown email (no enumeration)",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"methods\": [\n      \"password\"\n    ],\n    \"preferred\": \"password\",\n    \"sso_url\": null\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 10 requests/min per-IP throttle 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:** 10 requests/min per IP.\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`.\n\nErrors: `422` on validation failure."
              },
              "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\": \"<access-token>\" } }"
                }
              ]
            },
            {
              "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:** 3 requests per 3 minutes.\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.`\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "Created",
                  "code": 200,
                  "_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\": \"<new-password>\"\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`).\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"response\": \"Password has been updated successfully\", \"token\": \"<access-token>\" } }"
                }
              ]
            },
            {
              "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\": \"<new-password>\",\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!`\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{ \"data\": { \"status\": \"success\", \"response\": \"Otp has been sent successfully!\" } }"
                }
              ]
            }
          ]
        },
        {
          "name": "§ P.1 Auth — brand-owner impersonation",
          "item": [
            {
              "name": "1.A.4 Mint account-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 account (account-owner impersonation), for server-to-server calls to account-scoped endpoints on behalf of a tenant account. Rotates: every other non-revoked token of the same name for that owner is revoked on each mint (at most one active account-owner token per owner).\n\n**Auth:** Partner — `auth:api`, inside the `partner/` prefix; partner-agnostic (any partner, not only 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` (an account) — 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 account 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 account company id (must `exist` in `company.id` with `deleted_at` null). Despite the field name, it must be an account-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 account owner.\n- `account_owner` (object):\n  - `user_id` (integer) — the account owner's user id (`owner->id`).\n  - `account_id` (integer) — the account 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\": \"<access-token>\",\n    \"account_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\": \"<access-token>\",\n    \"account_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  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Only partner users can mint brand-owner tokens.\"\n}"
                },
                {
                  "name": "403 Brand not in caller's tree",
                  "status": "Forbidden",
                  "code": 403,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Brand is not under your partner tree.\"\n}"
                },
                {
                  "name": "404 Brand not found / not a brand / no owner",
                  "status": "Not Found",
                  "code": 404,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"Not Found\",\n  \"status\": \"error\",\n  \"errors\": \"Brand has no owner user.\"\n}"
                },
                {
                  "name": "422 Validation",
                  "status": "Unprocessable Entity",
                  "code": 422,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\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 account owner so server-to-server callers can drive account-scoped endpoints on behalf of a tenant account. Pattern B: every mint also revokes any previously-active token under the same name (one active token per partner-account 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. 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 account 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\": \"<your-password>\",\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. 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": "Completes self-service signup on the Google path. Call this after the user picks one of their Google Business Profile locations.\n\nIt attaches that location to the signup, resolves the city, state and country from the address, then provisions the account, its location, its store and its plan. Finally it marks the signup **Active** and returns the full signup snapshot.\n\nThe call is synchronous and runs inside a database transaction, so a failure at any step rolls the whole thing back and creates nothing.\n\n## Auth\n- Public — send no `Authorization` header. The `signup_account_id` identifies the in-progress signup.\n\n## Rate limit\n- 10 requests/min per IP.\n\n> 📘 **Field naming.** Most fields use `snake_case`, but `mapsUri` and `newReviewUri` are `camelCase`. That is deliberate: those two come straight from Google's Business Profile API, so this endpoint accepts them under Google's own names. Send them exactly as shown.\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- **200** returns the signup snapshot, now including a `business` object.\n- **400** when the selected Google location has no city, state or country the platform can resolve.\n- **500** when provisioning fails. The transaction rolls back, so no partial account is left behind.\n\nThis call also creates the account, location, store and plan records, queues the first review pull for the new store, and emails a referral notification when the signup came through a referral link.\n\n> 🚧 The `business.google_access_token` field carries a live Google OAuth access token, which the signup wizard uses to read the user's Google locations from the browser. Treat it as a credential: do not log it, and do not persist it outside the signup session.\n\nErrors: `422` on validation failure."
              },
              "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\": \"<google-access-token>\",\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. 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. 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."
        }
      ]
    },
    {
      "name": "2. Partner Endpoints",
      "description": "Endpoints runnable with a PARTNER access token — account 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 accounts",
              "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 account by its internal account id. Scoped to your own accounts, 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 account by your own external id (client_account_id, unique per partner). Scoped to your own accounts; an unknown/cross-tenant value returns an empty page. Either-or with account_id.",
                      "disabled": true
                    }
                  ]
                },
                "description": "> 🧭 **E2E journey 2/9 — List Accounts (verify).** Confirms the account from step 1 (`id == {{brandId}}`) persisted.\n\nReturns a paginated list of 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 account 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 accounts yields an empty page.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `limit` (integer, optional) — page size, 10–100 (values below 10 are raised to 10; above 100 returns `422`); 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 accounts 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 account).\n- Query: `account_id` (integer, optional) — filter to one account 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 account within the partner only.\n\n**Response** — `{ data: ... }` envelope. Default: a paginated collection of `PartnerBrandListResource` (paginator: `current_page`, `data[]`, `per_page`, `total`, `links`, etc.). Each item:\n- `id` (integer) — account company id.\n- `company_name` (string) — account 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 account.\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) — account-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}, ...] }`.\n\nErrors: `422` on validation failure."
              },
              "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\": [\n        15,\n        16,\n        17\n      ],\n      \"plan_name\": \"Pro Monthly\",\n      \"sub_partner_name\": null\n    }\n  ],\n  \"links\": {\n    \"first\": \"https://production-api.shoutaboutus.com/api/v1/partner/account/list?page=1\",\n    \"last\": \"https://production-api.shoutaboutus.com/api/v1/partner/account/list?page=3\",\n    \"prev\": null,\n    \"next\": \"https://production-api.shoutaboutus.com/api/v1/partner/account/list?page=2\"\n  },\n  \"meta\": {\n    \"current_page\": 1,\n    \"from\": 1,\n    \"last_page\": 3,\n    \"path\": \"https://production-api.shoutaboutus.com/api/v1/partner/account/list\",\n    \"per_page\": 10,\n    \"to\": 10,\n    \"total\": 24\n  }\n}"
                }
              ]
            },
            {
              "name": "2.A.2 Create account",
              "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\": \"<your-password>\",\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 Account.** 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 account (bundle_id=3) account under the authenticated partner together with its owner user/person, links an account-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 account's parent is `parent_company_id` when supplied, otherwise the caller's own partner company (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. Account/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; the submitted value is used as-is; the owning partner's industry is only a fallback when none is submitted (`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 account name.\n- `accountId.value` (integer) — created account company id.\n\nErrors: `422` on validation failure."
              },
              "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 account",
              "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 account company row plus its owner user/person and account company_options; field names passed in `override_locations` are cascaded down to the account'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 account's partner) else 403. `owner_email` uniqueness ignores the account's current owner user.\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. |\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 account'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) — account name.\n- `accountId.value` (integer) — account company id.\n- `stores_updated` (integer) — count of child stores touched by the cascade.\n- `overrides_applied` (string[]) — the override field names received.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `brandId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "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 account",
              "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 account 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\n**Response** — `{ data: ... }` envelope wrapping an inner `data` object.\n- `data.account` (object) — full `Company` model row for the account (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 account has no `company_plans.plan_id`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `brandId` | integer | Required | Digits only. |"
              },
              "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 account 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 accounts — a bogus or cross-tenant value returns 404.",
                      "disabled": true
                    }
                  ]
                },
                "description": "Loads a single account with everything the account-edit form needs: account row + company_options + city/state/country, the owner (name/email/phone), and the account's current plan_id. Returns 404 if the target is missing or not an account (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 account 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- 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 account 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 account.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `brandId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"account\": {\n      \"id\": 42,\n      \"name\": \"Acme Brand\",\n      \"bundle_id\": 3,\n      \"companyindustry_id\": 7,\n      \"companyindustry\": {\n        \"id\": 7,\n        \"name\": \"Trades & Services\"\n      },\n      \"inherited_industry\": null\n    },\n    \"owner\": {\n      \"first_name\": \"John\",\n      \"email\": \"john@acmebrand.com\"\n    },\n    \"plan_id\": 10\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 accounts 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 accounts (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 account tree, matched via `partner.id = company.id OR partner.company_id = company.id`; the result excludes any account 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, 10–100 (values below 10 are raised to 10; above 100 returns `422`); default 10.\n- Query: `page` (integer, optional) — page number.\n\n**Response** — `{ data: ... }` envelope = a paginator (`current_page`, `per_page`, `total`, `links`, `data[]`, etc.). Each item:\n- `id` (integer) — account company id.\n- `company_name` (string) — account 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 account plan name.\n\nErrors: `422` on validation failure."
              },
              "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 an account",
              "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 an account and propagates it to every store under that account: existing active/expired store_plans are closed and a fresh active store_plan (running to month-end) is created per store, and the account-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 an account), 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- 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 account.\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.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `brandId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure.",
                "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 record, 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\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Location has been deleted successfully!`.\n- `storeId` (integer) — the deleted store's id.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `storeId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "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 an account 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 request injects the path id as `account_id` and validates it as an existing account inside your own account tree. 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 account is soft-deleted.\n\n**Rate limit:** No rate limit.\n\n**Request**\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `message` (string) — `Account has been deleted successfully!`.\n- `account_id` (integer) — the deleted account id (from `AccountDeletionService` result).\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `brandId` | integer | Required | Digits only. |"
              },
              "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 + 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: account 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 a temporary password and must set their own at first login.\n\n## Auth\n- Partner — `auth:api`. Caller must be a partner company (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 | Account 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 | Account 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 — account website. |\n| `billing_id` | Optional | max 100. |\n| `owner_last_name` | Optional | Account 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\" } }` — the response returns immediately with an `import_id`; the import itself runs in the background, so poll the status endpoint rather than treating 202 as \"import finished\".\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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `bulkImportId` | integer | Required | Digits only. |\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 accounts the partner owns. Accounts 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/account/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`)\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `planId` | integer | Required | Digits 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, url_finder_review_site, url_finder_landing_page` |\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`.\n\nErrors: `422` on validation failure."
              },
              "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 are fully replaced each call (old `PlanFeature` rows deleted and re-inserted). Review sites are synced in place: surviving rows keep their ids/timestamps, and only sites removed from the payload are deleted.\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`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `planId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "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 be an existing plan belonging to the partner. |\n\n**Response** — `data.message`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `planId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "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 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 be a plan assignment belonging to 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 | 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 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 account'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` (account) Company passing `BelongsToAuthCompany`; (2) the controller additionally requires the auth user's `company_id` to equal the account id **or** the account's parent company id — else **403** (`Permission denied`). So an Account user cancels their own account; the parent Partner can cancel a child account. **404** if the account 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 | 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`.\n\nErrors: `422` on validation failure.",
                "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}}` — a Personal access token from the dashboard's API Keys page, or an App credentials (OAuth 2.0) access token). The token user MUST belong to a company with `bundle_id=1` (partner-admin). Account-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 account 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": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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://example.com/hooks/review-events\",\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 one of the event names from the events catalog (`GET /api/v1/partner/webhooks/events`). |\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": "201 Created",
                  "status": "Created",
                  "code": 201,
                  "_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) — `ingest`, `flag`, `response`, `connection`, `usage`, `billing`; partner-specific events are listed only for the partner they apply to\n- `data.data[].delivered_as` (string) — the event name that actually arrives on your endpoint: the event's own name for `immediate` events, always `cache_invalidation` for `batch` events\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 partner-specific review was ingested via the partner API, hosted form, or import. Only fires for partner-specific reviews; edits arrive as review_updated.\",\n        \"default_kind\": \"batch\",\n        \"delivered_as\": \"cache_invalidation\"\n      },\n      {\n        \"value\": \"review_pulled_from_platform\",\n        \"category\": \"ingest\",\n        \"description\": \"A new review was pulled from a connected review site (Google, Facebook, Caring, or any ReviewData-routed site). Fires for every partner.\",\n        \"default_kind\": \"batch\",\n        \"delivered_as\": \"cache_invalidation\"\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        \"delivered_as\": \"cache_invalidation\"\n      },\n      {\n        \"value\": \"review_updated\",\n        \"category\": \"ingest\",\n        \"description\": \"An existing review’s rating or content changed, on any review site (partner-specific included).\",\n        \"default_kind\": \"batch\",\n        \"delivered_as\": \"cache_invalidation\"\n      },\n      {\n        \"value\": \"review_deleted\",\n        \"category\": \"ingest\",\n        \"description\": \"A review was removed. Universal across every source — partner-admin moderation, a Google location switch, a Facebook object-gone response, or any other site. Always a soft delete, so the row and its history remain.\",\n        \"default_kind\": \"immediate\",\n        \"delivered_as\": \"review_deleted\"\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        \"delivered_as\": \"platform_removal_detected\"\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        \"delivered_as\": \"profanity_detected\"\n      },\n      {\n        \"value\": \"flag_submitted\",\n        \"category\": \"flag\",\n        \"description\": \"A removal request (flag) was raised on a review; flag_status is now pending.\",\n        \"default_kind\": \"immediate\",\n        \"delivered_as\": \"flag_submitted\"\n      },\n      {\n        \"value\": \"flag_denied\",\n        \"category\": \"flag\",\n        \"description\": \"The removal request was declined and the review stays visible; flag_status is now denied.\",\n        \"default_kind\": \"immediate\",\n        \"delivered_as\": \"flag_denied\"\n      },\n      {\n        \"value\": \"flag_approved_removed\",\n        \"category\": \"flag\",\n        \"description\": \"The review was removed following the removal request; flag_status is now approved_removed.\",\n        \"default_kind\": \"immediate\",\n        \"delivered_as\": \"flag_approved_removed\"\n      },\n      {\n        \"value\": \"flag_submitted_to_platform\",\n        \"category\": \"flag\",\n        \"description\": \"The removal request was sent to the source platform; flag_status is now submitted_to_platform.\",\n        \"default_kind\": \"immediate\",\n        \"delivered_as\": \"flag_submitted_to_platform\"\n      },\n      {\n        \"value\": \"response_drafted\",\n        \"category\": \"response\",\n        \"description\": \"A response draft was created on a review.\",\n        \"default_kind\": \"immediate\",\n        \"delivered_as\": \"response_drafted\"\n      },\n      {\n        \"value\": \"response_updated\",\n        \"category\": \"response\",\n        \"description\": \"A response’s content was edited, or a newer reply to an existing response was received from the source platform.\",\n        \"default_kind\": \"immediate\",\n        \"delivered_as\": \"response_updated\"\n      },\n      {\n        \"value\": \"response_received\",\n        \"category\": \"response\",\n        \"description\": \"A reply was received from the source platform for an existing review that had no response.\",\n        \"default_kind\": \"immediate\",\n        \"delivered_as\": \"response_received\"\n      },\n      {\n        \"value\": \"response_deleted\",\n        \"category\": \"response\",\n        \"description\": \"A response was removed (soft or hard delete).\",\n        \"default_kind\": \"immediate\",\n        \"delivered_as\": \"response_deleted\"\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        \"delivered_as\": \"ai_response_generated\"\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        \"delivered_as\": \"response_submitted_to_platform\"\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        \"delivered_as\": \"response_post_succeeded\"\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        \"delivered_as\": \"response_post_failed\"\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        \"delivered_as\": \"response_status_changed\"\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        \"delivered_as\": \"response_resubmitted\"\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        \"delivered_as\": \"review_pull_connected\"\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        \"delivered_as\": \"review_pull_disconnected\"\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        \"delivered_as\": \"response_post_connected\"\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        \"delivered_as\": \"response_post_disconnected\"\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        \"delivered_as\": \"usage.threshold_warning\"\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        \"delivered_as\": \"usage.limit_reached\"\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        \"delivered_as\": \"plan.cancelled\"\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        \"delivered_as\": \"brand_plan.cancelled\"\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        \"delivered_as\": \"plan.changed\"\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\n**Response** — `{ data: { data: {...} } }` envelope. Same presenter fields as **List my subscriptions**: `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`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `webhookSubscriptionId` | integer | Required | Digits only. |"
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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- 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 one of the event names from the events catalog (`GET /api/v1/partner/webhooks/events`). |\n| `payload_mode` | string | no | `full` \\| `minimal` |\n\n**Response** — `{ data: { data: {...} } }` envelope (HTTP 200). Same presenter fields as **Show one subscription**, without `hmac_secret`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `webhookSubscriptionId` | integer | Required | Digits only. |",
                "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": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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- 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\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `webhookSubscriptionId` | integer | Required | Digits only. |",
                "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": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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- No body.\n\n**Response** — `{ data: { data: {...} } }` envelope (HTTP 200). All presenter fields from **Show one subscription**, PLUS:\n- `hmac_secret` (string) — the current raw secret (decrypted from `hmac_secret_encrypted`). No `hmac_secret_warning` field on this endpoint.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `webhookSubscriptionId` | integer | Required | Digits only. |",
                "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": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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- 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\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `webhookSubscriptionId` | integer | Required | Digits only. |",
                "body": {
                  "mode": "raw",
                  "raw": "{}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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- Query: `limit` (int, optional) — default 50; 10–100 (values below 10 are raised to 10; above 100 returns `422`).\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)\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `webhookSubscriptionId` | integer | Required | Digits only. |"
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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- No body.\n\n**Response** — `{ data: { data: {...} } }` envelope (HTTP 200).\n- `data.data.subscription_id` (int)\n- `data.data.status` (string) — `deleted`\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `webhookSubscriptionId` | integer | Required | Digits only. |"
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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, 10–100 (values below 10 are raised to 10; above 100 returns `422`); default 5 when omitted (an explicit `limit` below 10, including `5`, becomes 10).\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) — 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).\n\nErrors: `422` on validation failure."
              },
              "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. `notification_id` must be the id of an existing notification. The record is marked as notified only when it belongs to your own company; otherwise it comes back unchanged.\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`.\n\nErrors: `422` on validation failure."
              },
              "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. `notification_id` must be an integer. An id with no matching notification returns `400` (`No notification is available`).\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, 10–100 (values below 10 are raised to 10; above 100 returns `422`); 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) — the parent 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`.\n\nErrors: `422` on validation failure."
              },
              "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.`\n\nErrors: `422` on validation failure.",
                "body": {
                  "mode": "raw",
                  "raw": "{\n    \"notification_id\": 1215,\n    \"note_content\": \"Raised with the 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.`\n\nErrors: `422` on validation failure.",
                "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.\n\nErrors: `422` on validation failure.",
                "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!`\n\nErrors: `422` on validation failure.",
                "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, accounts)",
              "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 accounts. Only partner (`bundle_id=1`) and account (`bundle_id=3`) companies may call it.\n\n## Auth\n- Partner / account company-admin — collection bearer token (`auth:api`). In-controller gate: the caller's 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/account company.\n\n✅ live ([routes/api_v1.php:1129](routes/api_v1.php#L1129) → `UserController@users`)\n\nErrors: `422` on validation failure."
              },
              "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\": \"<your-password>\",\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 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` | 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, 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` | 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 accounts. Only partner/account companies may call it, and the target must belong to the caller's company tree (`BelongsToAuthCompany`).\n\n## Auth\n- Partner / account — 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 from the assignment flags: `basic` when the user covers all accounts, `assign_brand` when specific accounts are assigned, otherwise `admin`.\n- `404 User not found.` when the id resolves to no non-deleted person.\n- `403` when the caller is not a partner/account 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; accounts (`bundle_id=3`) may impersonate users of their parent partner. Every attempt (grant + denial) is audited (`AUTH_IMPERSONATE`). Send `user_id` or `company_id` (each is `required_without` the other); if both are sent, `user_id` takes precedence.\n\n## Auth\n- Partner or account — 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\": \"<access-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/account company-admin user management (bearer token whose user's company is a partner `bundle_id=1` or account `bundle_id=3`). List all users in the company with roles/locations/accounts (`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": "Moves up to 1,000 stores into one tier in a single call. Safe to re-run: sending the same list again moves nothing further, so a retry after a timeout will not double-charge. Stores outside your partner account are skipped rather than rejected — read `skipped_out_of_scope` in the response.\n\n## Auth\n- Bearer — **partner** token. The platform resolves the partner account your user belongs to, and only stores under that account can be moved.\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`. Ask your account manager which value matches each subscription. |\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). Safe to re-run: repeating the call for a store already on the target tier clears only `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  \"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": "Moves one store to any tier, including `dormant`. The change takes effect immediately: the store's current plan is cancelled and the new one starts straight away, with no partial refund or charge for the unused part of the period.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `storeId` | integer | Required | Digits only. |\n\n## Auth\n- Bearer — **partner** token. The store must belong to an account underneath your own partner account, otherwise the call returns `403 Forbidden`.\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`. Ask your account manager which value matches each subscription. |\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  \"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": "Takes one store out of service by moving it to the `dormant` tier. This is the only way to stop a store — there is no separate pause action. The current plan is cancelled and the dormant plan starts immediately.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `storeId` | integer | Required | Digits only. |\n\n## Auth\n- Bearer — **partner** token. The store must belong to an account underneath your own partner account.\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": "Brings a dormant store back into service on an active tier. This endpoint does not accept `dormant`; use the dormant endpoint to stop a store and this one to restart it.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `storeId` | integer | Required | Digits only. |\n\n## Auth\n- Bearer — **partner** token. The store must belong to an account underneath your own partner account.\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  \"errors\": {\n    \"tier\": [\n      \"The selected tier is invalid.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Move stores between service tiers — as a bulk operation, or one store at a time.\n\nUse these endpoints when a store's subscription changes: moving it up a tier, moving it down, taking it out of service, or bringing it back.\n\n## Prerequisites\n\n- A **partner** bearer token. If the platform cannot resolve a partner account for your user, every endpoint here returns `403 Forbidden`.\n- The `id` of each store you want to move. You can only move stores belonging to accounts underneath your own partner account.\n\n## The tier field\n\nEvery endpoint takes a `tier` value. The accepted values are:\n\n`core` · `mgmt` · `plus` · `dormant`\n\nAsk your account manager which value corresponds to each of your subscriptions. Anything else returns `400`.\n\n## What a tier change actually does\n\nEach call cancels the store's current plan and starts the new one straight away.\n\nThere is no partial refund or partial charge for the unused part of the current period — the switch takes effect immediately at full price. Plan accordingly when moving a store mid-cycle.\n\nTo take a store out of service, move it to `dormant`. There is no separate \"pause\" action.\n\n## Choosing bulk or per-store\n\n| Situation | Endpoint |\n|---|---|\n| Moving many stores at once, up to **1,000** per call | **Bulk-provision stores into a tier** |\n| Moving one store | **Provision a single store into a tier** |\n| Taking one store out of service | **Reclassify a store as dormant** |\n| Bringing a store back into service | **Reactivate a dormant store** |\n\nThe two modes handle stores you do not own differently, and this catches people out:\n\n- **Bulk** never fails on them. It returns two lists — `provisioned` for the stores it moved, and `skipped_out_of_scope` for ids it could not touch. Always read `skipped_out_of_scope`; an empty `provisioned` list with a 200 response means nothing moved.\n- **Per-store** rejects them outright with `403 Forbidden`.\n\nBulk is also safe to re-run. Sending the same list again moves nothing further, so a retry after a timeout neither double-charges nor duplicates anything.\n\n## Errors\n\n| Status | Message | Cause |\n|---|---|---|\n| `400` | `Unknown tier: <value>` | The `tier` value is not one of the accepted values. |\n| `403` | `Partner scope could not be resolved for this user.` | Your token is not a partner token. |\n| `403` | `This store is outside your partner scope.` | Per-store only. The store belongs to another partner. |\n| `404` | `Store not found.` | Per-store only. No store exists with that id. |\n\nEach endpoint page below carries its own request body, response example and full field list.\n"
        },
        {
          "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 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 10–100, values below 10 are raised to 10; above 100 returns `422` (default 10). |\n\n## Behaviour\n- 200 with `{ kpis, events }`. `events` is a 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`)\n\nErrors: `422` on validation failure."
              },
              "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  \"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, 10–100 (values below 10 are raised to 10; above 100 returns `422`). 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\": \"https://production-api.shoutaboutus.com/api/v1/referral/partner-info?page=1\",\n    \"last\": \"https://production-api.shoutaboutus.com/api/v1/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 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 account the partner owns), each row joined to its 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 yield an empty page.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `limit` (integer, optional) — page size, 10–100 (values below 10 are raised to 10; above 100 returns `422`); 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) — accepted but ignored by this endpoint; use `plan_subscription` to filter by plan state.\n- Query: `account_id` (integer, optional) — 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 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.\n- `data[].sub_partner_name` (string|null) — owning sub-partner's name when the store belongs to one, else `null`.\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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, \"sub_partner_name\": 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 Account-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, account, and geographic detail (city → state → country) plus the active `store_plan` with its plan features, and a generated `connect_page_url`. Backs `getUserStoreWithLocation`.\n\n**Auth:** Both (Partner or Account 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 an account user is confined to its own account'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 paginator (`current_page`, `data[]`, `per_page`, `total`, page urls, `links`).\n- `data.data[].id` (integer) — store id.\n- `data.data[].account_name` (string) — parent account 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 account 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 }] } }`.\n\nErrors: `422` on validation failure."
              },
              "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 Account-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 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 an account user only its own account'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- Query: `search` (string, optional, max 255) — switches to a filtered name lookup; the `All` row is omitted while searching.\n- Query: `limit` (integer, optional) — page size for the filtered lookup; 10–50 (values below 10 are raised to 10; above 50 returns `422`).\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.\n\nErrors: `422` on validation failure."
              },
              "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` account). Auto-seeds blank review-site rows from the account's plan. Returns no id — capture it in step 4.\n\nCreates a new store location under an account in one transaction: a bundle_id=2 location-account record + its `company` options, the `store` row, and a `store_options` row (inheriting `who_will_pay`). If the account 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 account is `account_id`, validated by `StoreCreateRequest`: must `exists` in `company` with `bundle_id=3` and not deleted, AND pass `BelongsToAuthCompany` (must be an account 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 account 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\nA duplicate location currently comes back as `500` with `{ status: \"error\", message: \"This location is already registered!\" }`.\n\nErrors: `422` on validation failure."
              },
              "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": "500 Duplicate",
                  "status": "Internal Server Error",
                  "code": 500,
                  "_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 record 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 accounts.\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- 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).\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `storeId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure.\n\nErrors: `403` when you do not have access to the store; `404` when the store id does not exist or its data is incomplete."
              },
              "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 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 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, account users only their own.\n\n**Rate limit:** No rate limit.\n\n**Request**\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 account 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, city: { id, name, state_id, state: { id, name, country_id, code, country: { id, name, code } } } }`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `storeId` | integer | Required | Digits only. |"
              },
              "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  \"status\": \"error\",\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 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  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"error\": \"Sample file not found\"\n  }\n}"
                }
              ]
            },
            {
              "name": "Import Locations Excel (Account – 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 account — 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 account).\n\n**Auth:** Account — `auth:api` (route is inside the authenticated group). `ImportLocationsExcelRequest::authorize()` requires the user's company `bundle_id === 3` (ACCOUNT); a non-account user gets 403. Scope is the caller's own account: 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  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": [\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` (Account), 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` (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 Account under their tree; a Account user can target Accounts 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 be an existing, non-deleted account, 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\nThe endpoint validates each row: `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  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": {\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` (Account), not soft-deleted, and pass `BelongsToAuthCompany` (hierarchy walk via `Company::isBelongsToParentCompany`). A Partner (bundle_id=1) reaches Accounts under their tree; a Account user reaches Accounts 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 be an existing, non-deleted account, 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 `POST /api/v1/bulk-upload/locations`: 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\": \"OK\",\n  \"status\": \"error\",\n  \"errors\": \"The Excel file is invalid. The header names and order must match those in the template file.\"\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 + account-user listing. Partners can also call these via account-owner impersonation; account owners use them directly with their own login."
        },
        {
          "name": "§ B.2 Plans (brand view)",
          "item": [
            {
              "name": "3.B.1 Plan list (account)",
              "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) 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). Pass the account company id as `?company_id={{brandId}}`.\n\n**Rate limit:** 100 requests/min (`throttle:100,1`).\n\n**Request**\n- Query: `company_id` (integer, optional) — company (account) id to list plans for; must be an existing account. 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": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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 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, account 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`).\n\nErrors: `422` on validation failure."
              },
              "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      \"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 account 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  \"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 account** (`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/account 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/account ('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\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"Permission denied\"\n}"
                }
              ]
            }
          ],
          "description": "Account-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/logo/auth_type) available to an account — the union of review sites attached (via plan_review_sites) to the plans assigned to the account's account(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 account (`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 accounts 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`.\n      - `logo` (string) — logo image URL (`ReviewSite::logoUrl()`: stored override, else the name-derived default).\n      - `auth_type` (string|null) — how the site connects, which tells you the connection flow to show: `oauth` — sign in with the platform (e.g. Google, Facebook); `cookie` or `credential` — manager invite: the business adds our address as a manager on its listing (get the address from `POST /api/v1/store-review-site/{id}/connect`); `default` — the site's username and password. This flow is for sites that don't support adding a manager; it isn't available yet and will be introduced in a future release; `restapi` — connected through the platform's own API, nothing for the business to do; `null` — no connection flow is available for the site. Fixed per review site; it does not vary by account or plan.\n\nErrors: `422` on validation failure."
              },
              "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\", \"logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/assets/backend/images/logos/google_ic.png\", \"auth_type\": \"oauth\" },\n      { \"value\": 1, \"label\": \"Yelp\", \"logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/assets/backend/images/logos/yelp_ic.png\", \"auth_type\": \"cookie\" }\n    ]\n  }\n}"
                }
              ]
            },
            {
              "name": "4.A.2 Bulk-update credentials across the account",
              "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\": \"<encrypted-password>\",\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 an 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` account-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 account under their tree while an Account user can only target its own account.\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 be an existing review site. |\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 | Account-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 account resolves to zero stores or the review site is invalid.\n\nErrors: `422` on validation failure."
              },
              "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/account 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 be an existing account and belong to the caller's hierarchy.\n- Query: `review_site_id` (integer, required) — must be an existing review site.\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.\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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?id={{storeReviewSiteId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "store-review-site",
                    "company",
                    "credentials"
                  ],
                  "query": [
                    {
                      "key": "id",
                      "value": "{{storeReviewSiteId}}",
                      "description": "Required — the saved credential row to delete."
                    }
                  ]
                },
                "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 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 company id; must be an existing account, not 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.\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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 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 company id; must be an existing account, 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.\"\n\nErrors: `422` on validation failure."
              },
              "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, account, 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 that **Locations** lists.\n\n**Auth:** Both (Partner or Account 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 account 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 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 }`).\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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 an account, Excel export / import, credential cleanup. Account-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 account'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 be an existing location 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.\n\nErrors: `422` on validation failure."
              },
              "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 account's.\n\n**Rate limit:** 30 requests/min (`throttle:30,1`).\n\n**Request**\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`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `storeReviewSiteId` | string | Required | Identifier for the record you are targeting. |\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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\": \"<your-password>\"\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 account'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. Written unconditionally — omitting it clears the stored review-site URL (and the landing-page URL when `landing_page_url` is also omitted). |\n| `landing_page_url` | string (url) | optional | 5–2000; defaults to `url` when omitted. |\n| `username` | string | optional | 2–1000; `NameNoAngleBrackets`. Written unconditionally — omitting it clears the stored username. Only `password` is preserved when omitted. |\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!`.\n\nErrors: `422` on validation failure."
              },
              "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!`.\n\nErrors: `422` on validation failure."
              },
              "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| `review_site_url` | string (url) | conditional | Persisted to the row. Required when enabling (`sendto_scrapper=Y`) if the row has no stored URL — else 422. |\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.\n\nErrors: `422` on validation failure."
              },
              "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 account'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).\n\nErrors: `422` on validation failure."
              },
              "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, an account user only its own; an empty accessible set returns an empty page. Optional `account_id` further narrows to one account by re-scoping to `bundle_id=2` location-companies whose parent is that account — a cross-tenant id 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 | 10–100 (values below 10 are raised to 10; above 100 returns `422`); 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`; account 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`.\n\nErrors: `422` on validation failure."
              },
              "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 account'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    \"status\": \"success\",\n    \"delivered\": true,\n    \"delivery_status\": \"sent\",\n    \"skip_reason\": null,\n    \"message\": \"Invitation email sent successfully.\",\n    \"connect_url\": \"https://app.example.com/connect-review-sites/<invite-token>\",\n    \"token\": \"<invite-token>\",\n    \"recipient\": \"owner@tradies-shop.com\"\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 account'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, account only its own; empty set → empty page. Optional `account_id` narrows to one account'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 | 10–100 (values below 10 are raised to 10; above 100 returns `422`); 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`; account 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\": \"cookie\",\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- 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 review-site 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.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `hash` | string | Required | Must match `[a-zA-Z0-9]+`. |"
              },
              "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      \"display_as_connected\": false,\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  \"status\": \"error\",\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, account-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; an 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`.\n\nErrors: `422` on validation failure.\n\nErrors: `403` (`Access denied`) when the review's store sits outside your account tree."
              },
              "response": [
                {
                  "name": "200 Success — HiPages partner store",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"id\": 13383822,\n    \"source_platform\": \"HiPages\",\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    },\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, account-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; an 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.\n\nErrors: `402` when the response feature limit of your plan is reached."
              },
              "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 account-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.\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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 (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 be an existing review, 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` (integer|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.\n\nErrors: `422` on validation failure."
              },
              "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      \"usage_log_id\": 5123\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  \"message\": \"AI response not allowed for this store.\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"remaining\": 0\n  }\n}"
                },
                {
                  "name": "502 Upstream AI provider unreachable",
                  "status": "Bad Gateway",
                  "code": 502,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"message\": \"AI response provider is currently unreachable.\",\n  \"status\": \"error\",\n  \"errors\": []\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 (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 be an existing review 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.\n\nErrors: `422` on validation failure."
              },
              "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 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- 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 | conditional | Required only when `review_site=indeed`; no email-format check is applied. |\n\n**Response** — `{ data: ... }` envelope. Also adds plan feature-usage headers.\n- `status` (string) — `success`.\n- `message` (string) — `Review has been Flagged Successfully`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `reviewId` | integer | Required | Digits only. |\n\nErrors: `404` when the review id does not exist."
              },
              "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 on a review, or a `none` state when the review has never been flagged.\n\nUse it to show moderation state next to a review, or to check whether a flag you submitted has been actioned yet.\n\n## Auth\n- Both partner-level and account-level users can call it.\n\n## Rate limit\n- No rate limit.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `reviewId` | integer | Required | The review's numeric id. Digits only. |\n\n## Behaviour\n- **200** always returns `review_id`, `is_flagged` and `flag_status`.\n- When a flag exists, `flag` holds the full record and `flag_status` is one of `pending`, `denied`, `approved_removed` or `submitted_to_platform`.\n- When no flag exists, `flag` is `null` and `flag_status` is `none`. This is still a **200**, not a 404 — a review with no flag is a valid result, not a missing one.\n\n## Errors\n| Status | Message | Cause |\n|---|---|---|\n| `401` | `Unauthenticated` | The bearer token is missing, expired or invalid. |\n| `404` | `Review not found` | No review exists with that id. |\n| `500` | — | Unexpected server error. |\n"
              },
              "response": [
                {
                  "name": "200 Flag exists",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"review_id\": 13383822,\n    \"is_flagged\": true,\n    \"flag_status\": \"pending\",\n    \"flag\": {\n      \"id\": 5012,\n      \"review_id\": 13383822,\n      \"reason\": \"Review contains swear words…\",\n      \"reason_identifier\": \"Profanity\",\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      \"created_at\": \"2026-05-07T11:35:00.000000Z\",\n      \"updated_at\": \"2026-05-07T11:35:00.000000Z\"\n    }\n  }\n}"
                },
                {
                  "name": "200 No flag exists",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"review_id\": 13383822,\n    \"is_flagged\": false,\n    \"flag_status\": \"none\",\n    \"flag\": null\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 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 paginator page; default 1.\n- Query: `limit` (integer, optional) — per-page size, 10–100 (values below 10 are raised to 10; above 100 returns `422`); default 20.\n\n**Response** — `{ data: ... }` envelope wrapping a 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": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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://production-api.shoutaboutus.com/api/v1/reviews/flag/history?page=1\", \"from\": 1, \"last_page\": 1, \"last_page_url\": \"https://production-api.shoutaboutus.com/api/v1/reviews/flag/history?page=1\", \"links\": [], \"next_page_url\": null, \"path\": \"https://production-api.shoutaboutus.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": "Account-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 ACCOUNT-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) — 10–100 (values below 10 are raised to 10; above 100 returns `422`), 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 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, clientLocationId }`\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.\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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\", \"clientLocationId\": null },\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": "Returns one campaign, including its channels, follow-up template, email and SMS requests, and file-header mapping — all in a single response, so you do not need follow-up calls to assemble the full picture.\n\nDespite the path name, this endpoint takes the campaign's **numeric id**, not a code. A separate public endpoint accepts the alphanumeric campaign code.\n\n## Auth\n- The campaign must belong to a location inside your own account. If it does not, the call returns `400` rather than revealing anything about the campaign.\n\n## Rate limit\n- No rate limit.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | The campaign's numeric id. Digits only — a non-numeric value does not match this route. |\n\n## Behaviour\n- **200** returns the campaign under `data`, with `channels`, `email_requests`, `sms_requests` and `file_header` included. Each channel carries the id of the campaign it belongs to.\n- **400** when the campaign belongs to a location outside your account.\n- **404** when no campaign exists with that id.\n- If the campaign resolves but has since disappeared, `data` comes back as an empty array rather than an error.\n"
              },
              "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`, ACCOUNT-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\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.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "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`, ACCOUNT-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\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 the campaign detail response. 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.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "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      \"request_file_path\": 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/{{uniqueCode}}/remove/followup",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "campaign",
                    "channel",
                    "{{uniqueCode}}",
                    "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 → account → partner) in the company tree. Lives in the account-operated `campaign` route group.\n\n**Rate limit:** No rate limit.\n\n**Request**\n\n**Response** — `{ data: ... }` envelope.\n- `message` (string) — confirmation text `\"Follow up template removed successfully\"`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `uniqueCode` | string | Required | The channel's unique code, not a numeric campaign id. |\n\nErrors: `422` on validation failure."
              },
              "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`, ACCOUNT-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.\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      \"request_file_path\": 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`, ACCOUNT-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-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- 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.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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-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- Query: `page` (integer, optional) — page number, `min:1`.\n- Query: `limit` (integer, optional) — page size, 10–100 (values below 10 are raised to 10; above 100 returns `422`), default 10.\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 the default `paginate()` page resolution; pagination metadata comes from the underlying LengthAwarePaginator on `data.audiences` (resource-collection shape: `data`, `links`, `meta`).\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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\": [\n        \"Good email reach potential\",\n        \"Moderate SMS campaign potential\"\n      ],\n      \"data_quality_score\": {\n        \"score\": 67.46,\n        \"grade\": \"C\",\n        \"status\": \"good\"\n      }\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  \"links\": {\n    \"first\": \"https://production-api.shoutaboutus.com/api/v1/campaign/55/audiences?page=1\",\n    \"last\": \"https://production-api.shoutaboutus.com/api/v1/campaign/55/audiences?page=3\",\n    \"prev\": null,\n    \"next\": \"https://production-api.shoutaboutus.com/api/v1/campaign/55/audiences?page=2\"\n  },\n  \"meta\": {\n    \"current_page\": 1,\n    \"from\": 1,\n    \"last_page\": 3,\n    \"path\": \"https://production-api.shoutaboutus.com/api/v1/campaign/55/audiences\",\n    \"per_page\": 10,\n    \"to\": 10,\n    \"total\": 24\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`, ACCOUNT-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).\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "Created",
                  "code": 200,
                  "_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`, ACCOUNT-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).\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "Created",
                  "code": 200,
                  "_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`, ACCOUNT-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.\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "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": "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 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 an account 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 account 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\"`.\n\nErrors: `422` on validation failure.\n\nErrors: `400` when the store has no active plan or no valid channel; `403` when the caller is not an account user."
              },
              "response": [
                {
                  "name": "200 Success",
                  "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}"
                }
              ]
            }
          ],
          "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 solicitation emails sent per `store_plan_id`), used for the account-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 account/store id (reaching its whole tree); an Account login is limited to its own account and stores under it.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `company_id` (integer, optional) — filter by account/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 list (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, 10–100 (values below 10 are raised to 10; above 100 returns `422`); default 20.\n\n**Response** — `{ data: [...] }` envelope (paginator: `data`, `links`, `meta`).\n- `id` (integer) — store_plan id (also surfaced as `store_plan_id`).\n- `account_name` (string) — account company name, falling back to the store's location-company name.\n- `store_name` (string) — `\"{store.name} - {store.storeid}\"` when `storeid` is set, else `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 solicitation emails sent for that store plan (0 when none).\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"current_page\": 1,\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    \"first_page_url\": \"https://production-api.shoutaboutus.com/api/v1/solicitation/email-overage-summary?page=1\",\n    \"from\": 1,\n    \"last_page\": 1,\n    \"last_page_url\": \"https://production-api.shoutaboutus.com/api/v1/solicitation/email-overage-summary?page=1\",\n    \"links\": [\n      { \"url\": null, \"label\": \"&laquo; Previous\", \"active\": false },\n      { \"url\": \"https://production-api.shoutaboutus.com/api/v1/solicitation/email-overage-summary?page=1\", \"label\": \"1\", \"active\": true },\n      { \"url\": null, \"label\": \"Next &raquo;\", \"active\": false }\n    ],\n    \"next_page_url\": null,\n    \"path\": \"https://production-api.shoutaboutus.com/api/v1/solicitation/email-overage-summary\",\n    \"per_page\": 20,\n    \"prev_page_url\": null,\n    \"to\": 1,\n    \"total\": 1\n  }\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 solicitation SMS sent per `store_plan_id`), used for the account-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 account/store id (reaching its whole tree); an Account login is limited to its own account and stores under it.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `company_id` (integer, optional) — filter by account/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 list (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, 10–100 (values below 10 are raised to 10; above 100 returns `422`); default 20.\n\n**Response** — `{ data: [...] }` envelope (paginator: `data`, `links`, `meta`).\n- `id` (integer) — store_plan id (also surfaced as `store_plan_id`).\n- `account_name` (string) — account company name, falling back to the store's location-company name.\n- `store_name` (string) — `\"{store.name} - {store.storeid}\"` when `storeid` is set, else `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 solicitation SMS sent for that store plan (0 when none).\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"current_page\": 1,\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    \"first_page_url\": \"https://production-api.shoutaboutus.com/api/v1/solicitation/sms-overage-summary?page=1\",\n    \"from\": 1,\n    \"last_page\": 1,\n    \"last_page_url\": \"https://production-api.shoutaboutus.com/api/v1/solicitation/sms-overage-summary?page=1\",\n    \"links\": [\n      { \"url\": null, \"label\": \"&laquo; Previous\", \"active\": false },\n      { \"url\": \"https://production-api.shoutaboutus.com/api/v1/solicitation/sms-overage-summary?page=1\", \"label\": \"1\", \"active\": true },\n      { \"url\": null, \"label\": \"Next &raquo;\", \"active\": false }\n    ],\n    \"next_page_url\": null,\n    \"path\": \"https://production-api.shoutaboutus.com/api/v1/solicitation/sms-overage-summary\",\n    \"per_page\": 20,\n    \"prev_page_url\": null,\n    \"to\": 1,\n    \"total\": 1\n  }\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 account-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 to the caller's own company (the account 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`, plus `id`.\n- Query: `sort_by` (string, optional) — `asc`/`ASC`/`desc`/`DESC` (defaults `DESC`).\n- Query: `per_page` (integer, optional, 10–100, default 15; values below 10 are raised to 10; above 100 returns `422`).\n\n**Response** — `{ data: ... }` envelope wrapping a 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.\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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        \"unique_code\": \"a1B2c3d4\",\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, including its destination, customization and image URL. The QR code must belong to your own account — one belonging to another account is treated as not found rather than forbidden, so the response never reveals whether that ID exists.\n\n## Auth\n- Account — results are scoped to the caller's own account automatically; there is no parameter to query another account.\n\n## Rate limit\n- No rate limit. This endpoint is a single indexed read, so it is not throttled — but treat it as a normal API call and cache the result rather than polling it.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `qrCodeId` | integer | Required | ID of the QR code to retrieve. Digits only. |\n\n## Behaviour\n- **200** returns `status`, `message`, and `data` — the QR code with its `company`, `store`, `landingPage` and `reviewSite` relations, plus the generated `qr_code_image_url`.\n- **401** when the bearer token is missing or invalid.\n- **404** `QR code not found` when no QR code with that ID exists in your account.\n- **500** on an unexpected server error.\n\n✅ live"
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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      \"unique_code\": \"a1B2c3d4\",\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      \"landing_page\": { \"id\": 1 },\n      \"review_site\": 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 account-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 account 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- 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\"`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `qrCodeId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure.\n\nErrors: `400` when the store has no plan or review solicitation is not enabled for it."
              },
              "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 account-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 account company). The request injects the route `id` into validation data and asserts it is an existing QR code. Emits `AuditAction::QR_CODE_DELETED`.\n\n**Rate limit:** No rate limit.\n\n**Request**\n\n**Response** — `{ data: ... }` envelope wrapping an array.\n- `data.status` (string) — `\"success\"`.\n- `data.message` (string) — `\"QR code deleted successfully\"`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `qrCodeId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "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 account-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 account 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- 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\"`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `qrCodeId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure.",
                "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 account. 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** (account) 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 the QR for sibling stores whose plan carries the solicitation feature (checked by feature name; the enabled flag is not re-checked per sibling). |\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  \"errors\": {\n    \"name\": [\n      \"QR code name is required.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "List / get / update / delete / duplicate the QR codes an account 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 be an existing location 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) — the earliest `created_at` to include; swapped with `end_date` when the two are reversed.\n- Query: `end_date` (string `Y-m-d`, optional) — the latest `created_at` to include.\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, 10–100, default 15; values below 10 are raised to 10; above 100 returns `422`).\n\n**Response** — `{ data: ... }` envelope wrapping a 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)\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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        \"unique_code\": \"t9X8y7z6\",\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 scoped to the auth user's own company, 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\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\"`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `templateId` | integer | Required | Digits only. |"
              },
              "response": [
                {
                  "name": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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      \"unique_code\": \"t9X8y7z6\",\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 be an existing QR template. 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 be an existing QR code. |\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\"`.\n\nErrors: `422` on validation failure."
              },
              "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      \"unique_code\": \"t9X8y7z6\",\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?id={{templateId}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "qr-codes",
                    "template",
                    "delete"
                  ],
                  "query": [
                    {
                      "key": "id",
                      "value": "{{templateId}}",
                      "description": "Required — the QR template to 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 takes it from the request body or query string.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `id` (integer, required) — template ID; must be an existing QR template.\n\n**Response** — `{ data: ... }` envelope wrapping the controller array:\n- `status` (string) — `\"success\"`.\n- `message` (string) — `\"QR code template deleted successfully\"`.\n\nErrors: `422` on validation failure."
              },
              "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\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\"`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `templateId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
              },
              "response": [
                {
                  "name": "201 Created",
                  "status": "Created",
                  "code": 201,
                  "_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      \"unique_code\": \"t9X8y7z6\",\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 account. 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** (account) 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  \"errors\": {\n    \"name\": [\n      \"Template name already exists.\"\n    ]\n  }\n}"
                }
              ]
            }
          ],
          "description": "Reusable QR templates — design / colours / logo. Accounts 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:** Public — no auth middleware. Registered outside the authenticated group, rate-limited to 30 requests per minute per IP. 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 QR code or QR template.\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\"`.\n\nErrors: `404` when no QR code or QR template matches the unique code.",
                "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": "200 Success",
                  "status": "OK",
                  "code": 200,
                  "_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 (accounts/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 account assignments, results are further restricted to the ids returned by `$user->assignBrands()`. The persona only differs in reachable scope: a partner user sees its account/location children, an account 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` with `Company not found.` in `errors` if the authenticated user has no associated company.\n\nErrors: `422` on validation failure."
              },
              "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  \"message\": \"Not Found\",\n  \"status\": \"error\",\n  \"errors\": \"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 to the primary last-12-months ranking page — 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 endpoint 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 be an existing review site.\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>%)\"`.\n\nErrors: `422` on validation failure."
              },
              "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 be an existing account.\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 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 be an existing account.\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.\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 be an existing account.\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 blocks 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 be an existing account. |\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 FAQ entries 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 be an existing account. |\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 blocks 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 be an existing account. |\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 be an existing account and belong to the auth company hierarchy.\n- Query: `type` (string, required) — one of `partners`, `campaign`.\n- Query: `per_page` (integer, optional) — 10–100 (values below 10 are raised to 10; above 100 returns `422`); defaults to 10; only honored when `type=campaign` (the paginated branch).\n\n**Response** — `{ data: ... }` envelope.\n- For `type=partners`, `data.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.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 be an existing account 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 be an existing account and belong to the auth company hierarchy. |\n| `image_id` | integer | Yes | Must be an existing image; 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  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": \"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 an account 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`: an account-bundle company (`bundle_id` = ACCOUNT) lists its own widgets, otherwise it lists widgets of all child companies (`childids`). A partner reaches a wider tree than an account 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 | 10–100 (values below 10 are raised to 10; above 100 returns `422`); 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 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, suffixed with the client location id when one is set (else the storeid)\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, an account 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\n\nErrors: `402` when creating a new widget while the review-widgets limit of your plan is reached."
              },
              "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  \"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  \"status\": \"error\",\n  \"errors\": []\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": "402 Feature limit reached",
                  "code": 402,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"status\": \"error\",\n  \"message\": \"Review Widgets limit reached for this period.\",\n  \"feature\": \"review_widgets\",\n  \"remaining\": 0,\n  \"hard_stop\": true\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 account 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\"`.\n\nErrors: `403` when `company_id` is sent without a bearer token or the account sits outside the caller's own tree."
              },
              "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/account/location company beneath it, while an account 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://production-api.shoutaboutus.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://production-api.shoutaboutus.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  \"status\": \"error\",\n  \"errors\": []\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": "Sets up or updates your own account's white-label branding: the custom subdomain, the display title, and up to four account images.\n\nThe call registers `domain_prefix.domain_name` as a custom subdomain with AWS Amplify — the CDN that serves the white-labelled app. Amplify's response decides the saved `status`: `active` once the domain is available, `inactive` while it is still provisioning.\n\nIt always targets **your own** account. There is no parameter to name a different one.\n\n## Auth\n- Callable by **partner** accounts and **account** accounts.\n- **Location accounts cannot call this** and receive `403` with the message `Sub-partner/Brand user can access this endpoint.` That message names the roles that *are* allowed, so read it as \"only partner and account users can access this endpoint\".\n\n## Rate limit\n- No rate limit.\n\n## Body\n\n**Choose the content type to match what you are sending:**\n\n| You are sending | Content type |\n|---|---|\n| Domain and title only | `application/json` |\n| Any image file | `multipart/form-data` |\n\nBoth accept the same fields. Use `multipart/form-data` whenever you attach an image, because a file cannot travel in a JSON body. Every image is optional — send only the ones you are changing.\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `domain_name` | string | Required | Max 100 characters, no angle brackets. Saved lowercased. |\n| `domain_prefix` | string | Required | Saved lowercased. The resulting `domain_prefix.domain_name` must not already be taken by another account. |\n| `title` | string | Optional | Max 100 characters, no angle brackets. Shown as the browser title. |\n| `favicon` | file | Optional | JPEG, PNG or ICO. Max **10 KB**. |\n| `logo` | file | Optional | JPEG or PNG. Max 400 KB. |\n| `login_logo` | file | Optional | JPEG or PNG. Max 400 KB. |\n| `mobile_logo` | file | Optional | JPEG or PNG. Max 400 KB. |\n\nNote the favicon limit is 10 KB, far smaller than the 400 KB allowed for the other three.\n\n### Uploading images\n\nSend each field as its own form part and let curl set the boundary — do not set `Content-Type` yourself:\n\n```bash\ncurl --request POST \\\n  --url \"https://production-api.shoutaboutus.com/api/v1/account/white-label/update\" \\\n  --header 'Authorization: Bearer <bearer-token>' \\\n  --header 'Accept: application/json' \\\n  --form 'domain_name=example.com' \\\n  --form 'domain_prefix=reviews' \\\n  --form 'title=Acme Reviews' \\\n  --form 'logo=@/path/to/logo.png' \\\n  --form 'favicon=@/path/to/favicon.ico'\n```\n\n## Behaviour\n- **200** returns only a `status` + `message` confirmation — the saved record is not echoed back.\n- **403** when a location account calls it — see **Auth** above.\n- **422** when validation fails: a missing `domain_name` or `domain_prefix`, a subdomain already used by another account, or an image over its size limit or in an unsupported format.\n- A new subdomain usually starts as `inactive` and flips to `active` once Amplify finishes provisioning it, so re-read the record shortly after saving rather than expecting `active` straight away.\n"
              },
              "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\": \"Forbidden\",\n  \"status\": \"error\",\n  \"errors\": \"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 the theme options for one account, and optionally uploads a theme logo at the same time. Call it whenever the account changes its palette, layout or logo — it creates the theme record on first save and updates it on every save after that.\n\n## Prerequisites\n\n- A Bearer token for a Partner or Account user (see **Authentication** below).\n- The `company_id` of the account you are theming. It must sit inside your own hierarchy.\n- To upload a logo: a JPEG or PNG no larger than 400 KB.\n\n## Auth\n- Both — `auth:api`, under the role-agnostic `account/white-label` prefix.\n- `company_id` must sit within your own account hierarchy, so a partner can theme any account beneath it while an account user reaches only its own subtree.\n- `user_id` only has to be an existing user; the platform does not hierarchy-check it. It records who saved the theme.\n\n## Rate limit\n- No rate limit.\n\n## Body\n\n**Choose the content type to match what you are sending:**\n\n| You are sending | Content type |\n|---|---|\n| Theme options only | `application/json` |\n| Theme options **and** a logo file | `multipart/form-data` |\n\nBoth accept the same fields. Use `multipart/form-data` only when you attach `logo_image`, because a file cannot travel in a JSON body.\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `user_id` | integer | Required | Must be an existing user. Recorded as the author of the save. |\n| `company_id` | integer | Required | The account to theme. Must be in your own hierarchy. |\n| `theme_options` | string | Required | A JSON-encoded string, stored verbatim and returned unchanged. Note this is a **string**, not a nested object. |\n| `logo_image` | file | Optional | JPEG or PNG, max 400 KB. Replaces the account's white-label logo. |\n\n### Uploading a logo\n\nSend each field as a separate form part and let curl set the boundary — do not set `Content-Type` yourself:\n\n```bash\ncurl --request POST \\\n  --url \"https://production-api.shoutaboutus.com/api/v1/account/white-label/theme/save\" \\\n  --header 'Authorization: Bearer <bearer-token>' \\\n  --header 'Accept: application/json' \\\n  --form 'user_id=3' \\\n  --form 'company_id=5' \\\n  --form 'theme_options={\"mode\":\"system\",\"primaryColor\":\"#f1874c\"}' \\\n  --form 'logo_image=@/path/to/logo.png'\n```\n\n## Behaviour\n- **200** returns the saved theme row plus the account's white-label record, with every image field rewritten to a full URL (or `null` where unset).\n- **422** when validation fails — for example a missing `theme_options`, a `company_id` outside your hierarchy, or a logo over 400 KB or in an unsupported format.\n- Uploading a logo also creates the account's white-label record if it does not exist yet, which is why the `white_label` object can come back with most fields empty on a first upload.\n"
              },
              "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 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\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": \"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 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\": \"Shout About Us\",\n        \"tracking_url\": \"track.shoutaboutus.com\"\n      }\n    },\n    \"instruction_file\": \"https://production-api.shoutaboutus.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 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 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\": \"Shout About Us\",\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  \"message\": \"Bad Request\",\n  \"status\": \"error\",\n  \"errors\": {\n    \"status\": \"error\",\n    \"message\": \"Invalid Domain\"\n  }\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).\n\n**Choose the content type to match what you are sending:**\n\n| You are sending | Content type |\n|---|---|\n| Theme options only | `application/json` |\n| Theme options **and** a logo file | `multipart/form-data` |\n\nBoth accept the same fields. Use `multipart/form-data` only when you attach `logo_image`, because a file cannot travel in a JSON body.\n\n## Auth\n- Account bearer (`auth:api`). `company_id` is scoped by `BelongsToAuthCompany` — partner can target any company beneath it; account 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 user may pass any `company_id` inside their own company hierarchy. (Partner persona can reach more companies down the tree; an account 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 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 account 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- `data.status` (string) — `success`.\n- `data.message` (string) — `Email template saved successfully.`\n\nErrors: `403` when the template type is platform-managed or the email-template section is disabled for your account."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Email template saved successfully.\"\n  }\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.\n\nErrors: `403` when the template type is platform-managed or the email-template section is disabled for your account."
              },
              "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>Shout About Us</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.`\n\nErrors: `403` when the template type is platform-managed or the email-template section is disabled for your account."
              },
              "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.`\n\nErrors: `403` when the template type is platform-managed or the email-template section is disabled for your account."
              },
              "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.`\n\nErrors: `403` when the template type is platform-managed or the email-template section is disabled for your account."
              },
              "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; 10–100 (values below 10 are raised to 10). Defaults to 25. |\n\n**Response** — `{ data: ... }` envelope with nested `status` + `data`, where `data` is a standard 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`.\n- Each log row carries `id`, `partner_company_id`, `store_id`, `template_type`, `recipient_email`, `delivery_method`, `status`, `skip_reason`, `error_message`, `subject`, `payload_json`, `response_json`, `webhook_delivery_id`, `message_id`, `sent_at`, `created_at`, `updated_at`."
              },
              "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          \"store_id\": null,\n          \"template_type\": \"user_welcome\",\n          \"recipient_email\": \"jane@example.com\",\n          \"delivery_method\": \"email\",\n          \"status\": \"sent\",\n          \"skip_reason\": null,\n          \"error_message\": null,\n          \"subject\": \"Welcome aboard, Jane!\",\n          \"payload_json\": null,\n          \"response_json\": null,\n          \"webhook_delivery_id\": null,\n          \"message_id\": 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          \"store_id\": null,\n          \"template_type\": \"account_disconnected\",\n          \"recipient_email\": \"owner@brightsmile.com\",\n          \"delivery_method\": \"webhook\",\n          \"status\": \"failed\",\n          \"skip_reason\": null,\n          \"error_message\": \"Webhook responded 500\",\n          \"subject\": \"Action needed \\u2014 your Google connection at Downtown Clinic\",\n          \"payload_json\": null,\n          \"response_json\": null,\n          \"webhook_delivery_id\": null,\n          \"message_id\": null,\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://production-api.shoutaboutus.com/api/v1/account/white-label/email-templates/logs?page=1\",\n      \"from\": 1,\n      \"last_page\": 1,\n      \"last_page_url\": \"https://production-api.shoutaboutus.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://production-api.shoutaboutus.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://production-api.shoutaboutus.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- `data.status` (string) — `success`.\n- `data.message` (string) — `Template restored to default.`\n\nErrors: `403` when the template type is platform-managed or the email-template section is disabled for your account."
              },
              "response": [
                {
                  "name": "200 OK",
                  "status": "OK",
                  "code": 200,
                  "_postman_previewlanguage": "json",
                  "header": [],
                  "body": "{\n  \"data\": {\n    \"status\": \"success\",\n    \"message\": \"Template restored to default.\"\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": "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>`.\n\nErrors: `403` when the template type is platform-managed or the email-template section is disabled for your account."
              },
              "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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `channelUniqueCode` | string | Required | Identifier for the record you are targeting. |\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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `landingPageUniqueCode` | string | Required | Identifier for the record you are targeting. |\n\n## Auth\n- Account — `auth:api`, ACCOUNT-scope group (requires an 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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `clickHashId` | string | Required | Identifier for the record you are targeting. |\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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `clickHashId` | string | Required | Identifier for the record you are targeting. |\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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `emailRequestUniqueCode` | string | Required | Identifier for the record you are targeting. |\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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `emailRequestUniqueCode` | string | Required | Identifier for the record you are targeting. |\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  \"message\": \"Email template not found\",\n  \"status\": \"error\",\n  \"errors\": []\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, server-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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `unsubscribeToken` | string | Required | Identifier for the record you are targeting. |\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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `unsubscribeToken` | string | Required | Identifier for the record you are targeting. |\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 an 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 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 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 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 and against global (shared) templates (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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `emailTemplateId` | integer | Required | Digits only. Must belong to your own company. |\n\n## Auth\n- Bearer token, **account 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| `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\": \"Email template not found\",\n  \"status\": \"error\",\n  \"errors\": []\n}"
                }
              ]
            }
          ],
          "description": "Account-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 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 account(s), **excludes** global templates, paginates, and adds per-row campaign-usage counts. A partner (bundle_id=1) sees all their accounts, or one account via `account_id`; an account user sees their own account.\n\nRows carry `company_name` (owning account name; null for globals) and the appended `unique_code`.\n\n## Auth\n- Bearer token, **account or partner persona** — `auth:api`. Account 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 accounts. Ignored if not the caller's. |\n| `page` | integer | Optional | Account-level pagination; min 1. |\n| `per_page` | integer | Optional | Account-level pagination; 10–100 (values below 10 are raised to 10; above 100 returns `422`), 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 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 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 and against global (shared) templates (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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `landingPageId` | integer | Required | Digits only. |\n\n## Auth\n- Bearer token, **account 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| `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 belongs to another account.\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-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 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 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 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 and against global (shared) templates (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## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `smsTemplateId` | integer | Required | Digits only. Must belong to your own company. |\n\n## Auth\n- Bearer token, **account 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| `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-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 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, 10–100 (values below 10 are raised to 10; above 100 returns `422`). Default 15. |\n\n## Behaviour\n- `200` with a standard length-aware paginator under `data.data` (the outer `data` is the project envelope). Each campaign row carries a nullable `file_header` object.\n- `422` if `per_page` is above 100 or `page` is not a positive integer.\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://production-api.shoutaboutus.com/api/v1/campaign/automate/list?page=1\",\n      \"from\": 1,\n      \"last_page\": 1,\n      \"last_page_url\": \"https://production-api.shoutaboutus.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://production-api.shoutaboutus.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://production-api.shoutaboutus.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 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 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| `campaignId` | 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 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| `fileHeaderId` | 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`)\n\nErrors: `422` on validation failure."
              },
              "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 be an existing file-header configuration. Emits an audit event (`CAMPAIGN_FILE_HEADERS_DELETED`).\n\n## Auth\n- Account 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| `fileHeaderId` | 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 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| `fileHeaderId` | 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 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.\n\n**Choose the content type to match what you are sending:**\n\n| You are sending | Content type |\n|---|---|\n| Settings only | `application/json` |\n| Any image (`logoImage`, `youtubeThumbnail`, `footerLogo`) | `multipart/form-data` |\n\nAll three images are optional — send only the ones you are changing. Use `multipart/form-data` only when attaching one, because a file cannot travel in a JSON body.\n\n## Auth\n- Account bearer (`auth:api`). `company_id` is scoped by `BelongsToAuthCompany` — a partner can target any company beneath it; an account 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 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 fails validation (422). Omitting it (or sending null) 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": "Account-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 account 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\": \"<your-password>\",\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 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- Account admin — collection bearer token (`auth:api`). FormRequest `authorize()` requires the caller's company `bundle_id` to be ACCOUNT, 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 account (`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 (account 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 an account 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 account 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 account 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 account company.\n\n## Auth\n- Account 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 account. |\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 an account / 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 account 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 accounts. `user_id` must belong to the caller's company (`BelongsToAuthCompany`).\n\n## Auth\n- Authenticated account 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`)\n\nErrors: `422` on validation failure."
              },
              "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": "Account-side user management and self-service profile (bearer token). Account company-admins add (`POST user/add`) and edit (`POST user/edit`) account 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 only 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`)\n\nErrors: `422` on validation failure."
              },
              "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\": \"<google-access-token>\",\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\": \"Demo Store\",\n      \"url\": \"https://maps.google.com/?cid=1234567890\"\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`)\n\nErrors: `422` on validation failure."
              },
              "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  \"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  \"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`)\n\nAn invalid token returns an empty 200 response (no JSON body) — treat an empty body as \"token rejected\"."
              },
              "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  \"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| `fbpage_name` | string (≤255) | Optional | Facebook page display name, recorded with the connection event. |\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`)\n\nErrors: `422` on validation failure."
              },
              "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  \"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/account 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** (account) persona (`auth:api`). Registered under the account group: a valid 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, 10–100 (values below 10 are raised to 10; above 100 returns `422`); when omitted, 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`)\n\nErrors: `422` on validation failure."
              },
              "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      \"source_platform\": \"hipages\",\n      \"review\": {\n        \"id\": 1546,\n        \"reviewer\": \"Jane D.\",\n        \"rating\": 5,\n        \"message\": \"Fantastic service!\",\n        \"review_date\": \"2026-06-14 12:00:00\"\n      },\n      \"reviewsite\": {\n        \"id\": 100,\n        \"name\": \"hipages\",\n        \"link\": \"https://hipages.com.au\",\n        \"review_site_logo\": \"https://s3.amazonaws.com/bucket/logos/hipages.png\",\n        \"review_site_color\": \"#0f68fa\",\n        \"auth_type\": \"credentials\",\n        \"scraping_enabled\": true,\n        \"posting_enabled\": false\n      },\n      \"store\": {\n        \"id\": 26,\n        \"name\": \"Demo Store\",\n        \"slug\": \"demo-store\",\n        \"address\": \"123 Main St\"\n      },\n      \"store_reviewsite\": {\n        \"id\": 91,\n        \"url\": \"https://hipages.com.au/business/demo\",\n        \"landing_page_url\": null,\n        \"connection_status\": \"connected\",\n        \"is_connected\": true\n      },\n      \"flag\": \"\",\n      \"plan\": \"Gold\",\n      \"review_flag\": null,\n      \"is_flaggable\": false,\n      \"flag_status\": \"none\",\n      \"flag_details\": null\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\": \"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  \"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** (account) 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  \"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** (account) 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`)\n\nErrors: `422` on validation failure."
              },
              "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 account bearer token is required even though the widget renders on public pages. Account-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` (`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  \"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 (fields: `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  \"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 the store's `who_will_pay` is `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` account 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 accounts under the partner; `0` = only `account_ids`. |\n| `account_ids` | integer[] | Required if `all_account = 0` | Each a `bundle_id = 3` account 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  \"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/account); `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 account) → `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 | an account (`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\n\nErrors: `422` on validation failure."
                  },
                  "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` (account) → `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 | account (`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** — paginated resource collection, `data` array + paginator meta.\n- `data[].id` (int) — store id\n- `data[].location_name` (string) — `name - storeid` (or the name alone)\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)\n\nErrors: `422` on validation failure."
                  },
                  "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` (account) → `Store::getSubPartnerStoresList`; else all stores via `Store::getStoreListByUser`.\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 | account (`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 %\n\nErrors: `422` on validation failure."
                  },
                  "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 `500` with `Access denied. Partner plan required.` otherwise). Store set precedence: `account_id` (account) → `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 | account (`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. Reserved — the current handler ignores this field |\n\n**Response** — `{ data: ... }` envelope.\n- `path` (string) — S3 URL of the generated `.xlsx` (`download/excel/YYYY-MM/<time>-ReviewResponseReport.xlsx`)\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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 `500` with `Access denied. Partner plan required.` otherwise). Store set precedence: `account_id` (account) → `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 | account (`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 accounts, truncated to 3 + \"... and N More Account(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)\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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 per-store review-site connections, 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` (account) → `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 | account (`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 total: the platform's own count where it has been synced (Google, and ReviewData-routed sites after each pull), otherwise the number of reviews we hold\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 account + location in the partner tree. The account-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, 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; accounts are bundle_id=3 rows under the company OR under its sub-partners; locations are bundle_id=2 rows under those account IDs. If the user has `assign_brand` set, account/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 accounts in scope.\n- `accounts.last_30_days` (int) — accounts created in the last 30 days.\n- `accounts.before_30_days` (int) — 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 account 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.\n\nErrors: `422` on validation failure."
                  },
                  "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 accounts.\n- `accounts` (array) — enriched per-account 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 account×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`.\n\nErrors: `422` on validation failure."
                  },
                  "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 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 accounts.\n- `accounts` (array) — per-account 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`).\n\nErrors: `422` on validation failure."
                  },
                  "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 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 `{brandId}` account 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- 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`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `brandId` | integer | Required | Digits only. |"
                  },
                  "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- 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).\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `storeId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
                  },
                  "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-account Excel locations export, and the partner/account \"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, rounded to 2dp — sum of positive rates ÷ the platform's full review count, so unrated reviews dilute it), `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.\n\nErrors: `422` on validation failure."
                  },
                  "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 account, 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` (ACCOUNT); a non-account user gets 403. Scope is the caller's own company tree: store ids are validated against it.\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`.\n\nErrors: `422` on validation failure."
                  },
                  "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 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 account 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 company ids; each must be an existing account 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.\n\nErrors: `422` on validation failure.",
                    "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 targeting; report type and recipient are not changed here.\n\n**Auth:** Partner — `auth:api`; under the `partner/*` group (partner-admin scope, no Hipages gate). Targeting is scoped by `BelongsToAuthCompany` on each `account.*` id (must be a company within the partner's own tree). The presence of `report_id` switches `PartnerUserReportRequest` into edit-mode rules (report existence + 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 exist in `reports`. Required to enter edit branch. |\n| `reportPeriodType` | string | yes | `day`, `week`, or `month`; validated against the existing report via `ReportPeriodTypeRule(null, report_id)`. |\n| `all_account` | integer | yes | `1` = all accounts, `0` = specific accounts. Rule `required\\|nullable\\|in:0,1`. |\n| `account` | int[] | required_if `all_account=0` | Account 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!`.\n\nErrors: `422` on validation failure.",
                    "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 an account 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 account-level email report on a daily/weekly/monthly cadence, scoped to all stores under the account 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 an account (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 be an existing location 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!`.\n\nErrors: `422` on validation failure.",
                    "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 an account 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 account email report's frequency and store assignments (recipient and report type unchanged).\n\n**Auth:** Account — `auth:api`; under the `user/*` group. `EditBrandUserReportRequest::authorize()` requires the auth user's company `bundle_id === CompanyBundle::BRAND` (account user only, else 403). `store_ids.*` are scoped via `BelongsToAuthCompany` to the caller's company tree; existing `report_stores` rows are deleted then re-inserted.\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 via `ReportPeriodTypeRule(null, report_id)`. |\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 and pass `BelongsToAuthCompany`. |\n\n**Response** — `{ data: ... }` envelope.\n- `status` (string) — `success`.\n- `response` (string) — message `Report has been updated successfully!`.\n\nErrors: `422` on validation failure.",
                    "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 an account 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 and all of its associated rows (recipients, store links, user links, account links) plus the report itself, inside a DB 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 the call returns `500` with `{ status: \"error\", message }`.",
                    "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 company, so a partner sees partner-level reports and an account user sees account-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, 10–100 (values below 10 are raised to 10; above 100 returns `422`); 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) — 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 and all of its associated rows (recipients, store links, user links, account links) plus the report itself, inside a DB 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, account 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 account-side alias (5.B.8); the partner persona 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, 10–100 (values below 10 are raised to 10; above 100 returns `422`). Default 10. hipages users default to 25, and a larger value is lowered to 25 without an error. |\n| `page` | integer | optional | Page number. |\n| `sort_field` | string | optional | Sort key, applied only together with `sort_by`. One of `review_date`, `rating`, `rate` (same as `rating`), `reviewer`, `created_at`, `response_date`, `store_name`. Default order is `review_date` descending. Any other value returns `422`. |\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` (an account), not soft-deleted; tree-scoped. Narrows to that account'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`,`error`,`flagged`,`removed`,`awaiting_response`. `awaiting_response` returns reviews with no response yet that the location may respond to under its plan (the same set as the dashboard's Awaiting Response count). |\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.\n\nErrors: `422` on validation failure."
                  },
                  "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\": \"Shout About Us\",\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 account-scoped response report.\n\n**Auth:** Both — `auth:api`; under the account-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`. An account 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`\n\nErrors: `422` on validation failure."
                  },
                  "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 account — reviews, responses, average response time, average rating, response rate.\n\n**Auth:** Account — `auth:api`; under the account-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 account 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 | 10–100, values below 10 are raised to 10 (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`\n\nErrors: `422` on validation failure."
                  },
                  "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 account — per-site review count, average rating, % of total, response count, response rate.\n\n**Auth:** Account — `auth:api`; under the account-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 account 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 %\n\nErrors: `422` on validation failure."
                  },
                  "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 account'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 company `bundle_id` must be `3` (account) or `2` (location-account); any other persona gets a 403. The account 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 an account-level user sees every store under the account'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 account company name (null if no account 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.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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 account-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-account 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. The header row shows the caller's account 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 be an existing location 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`.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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 account-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: an account (`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 be an existing location 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).\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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 (account-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\nAccount-friendly alias of the review-response feed (5.B.1) — same controller method (`ResponseFeedController::ReviewResponseFeed`), mounted under `reports/response` so an account user reading their own reviews and a partner-admin reading across accounts 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 an account-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 account (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, 10–100 (values below 10 are raised to 10; above 100 returns `422`). Default 10. hipages users default to 25, and a larger value is lowered to 25 without an error. |\n| `page` | integer | optional | Page number. |\n| `sort_field` | string | optional | Sort key, applied only together with `sort_by`. One of `review_date`, `rating`, `rate` (same as `rating`), `reviewer`, `created_at`, `response_date`, `store_name`. Default order is `review_date` descending. Any other value returns `422`. |\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 account's stores. |\n| `client_account_id` | string (max 255) | optional | Alternative to `account_id`; resolved via `Company::getAccountIdFromClientAccountId`. Tree-scoped; account 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`,`error`,`flagged`,`removed`,`awaiting_response`. `awaiting_response` returns reviews with no response yet that the location may respond to under its plan (the same set as the dashboard's Awaiting Response count). |\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.\n\nErrors: `422` on validation failure."
                  },
                  "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 (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 account.\n\n**Auth:** Account — `auth:api`; under the account-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: the platform's own count where it has been synced (Google, and ReviewData-routed sites after each pull), otherwise the number of reviews we hold\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`, an 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 be an existing review site.\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\") }`.\n\nErrors: `422` on validation failure."
                  },
                  "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 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 be an existing review site. 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).\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"period\": \"2026-03\", \"count\": 4.21 },\n    { \"period\": \"2026-04\", \"count\": 4.32 },\n    { \"period\": \"2026-05\", \"count\": 4.4 }\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 be an existing review site. 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.\n\nErrors: `422` on validation failure."
                  },
                  "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) response-rate percentage (responded ÷ total, ×100) per time bucket across the window, bucketed by review date. 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 be an existing review site. 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.\n\nErrors: `422` on validation failure."
                  },
                  "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 be an existing review site. 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).\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"period\": \"2026-03\", \"count\": 1.85 },\n    { \"period\": \"2026-04\", \"count\": 1.42 },\n    { \"period\": \"2026-05\", \"count\": 1.16 }\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.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"period\": \"2026-03\", \"count\": 87 },\n    { \"period\": \"2026-04\", \"count\": 112 },\n    { \"period\": \"2026-05\", \"count\": 145 }\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 be an existing review site.\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.\n\nErrors: `422` on validation failure."
                  },
                  "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 be an existing review site. 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.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    { \"name\": \"hipages\", \"total_reviews\": 532, \"avg_rating\": 4.31, \"logo\": \"https://cdn.example.com/logos/hipages.png\" },\n    { \"name\": \"Google\", \"total_reviews\": 318, \"avg_rating\": 4.52, \"logo\": \"https://cdn.example.com/logos/google.png\" },\n    { \"name\": \"Others\", \"total_reviews\": 214, \"avg_rating\": 4.08, \"logo\": \"https://cdn.example.com/logos/others.png\" }\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 account 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 be an existing review site.\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\") }`.\n\nErrors: `422` on validation failure."
                  },
                  "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 be an existing review site. 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).\n\nErrors: `422` on validation failure."
                  },
                  "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 account 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 account company (candidate stores = stores whose location-account's parent account = `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 account 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).\n\nErrors: `422` on validation failure."
                  },
                  "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 account company (stores whose location-account's parent account = `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 be an existing review site.\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 account 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).\n\nErrors: `422` on validation failure."
                  },
                  "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 endpoint as the primary last-12-months ranking page; this variant demonstrates passing an explicit `range_start`/`range_end` window.\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 be an existing review site.\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>%)\"`.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": [\n    {\n      \"id\": \"loc_42\",\n      \"final_grade\": \"A\",\n      \"name\": \"Downtown - LOC-001\",\n      \"avgRating\": \"4.48\",\n      \"review_count\": 196,\n      \"response_count\": \"181 (92%)\"\n    }\n  ]\n}"
                    }
                  ]
                }
              ],
              "description": "Account-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 account-admin home page tiles + charts. ✅ live"
            },
            {
              "name": "§ B.6 Performance Report (brand)",
              "item": [
                {
                  "name": "6.A.1 KPI cards (account)",
                  "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 account response report. (Same route/handler as 5.B.2 KPIs — `getResponseReportKpis`.)\n\n**Auth:** Both — `auth:api`; under the account-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`. An account 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`\n\nErrors: `422` on validation failure."
                  },
                  "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 (account)",
                  "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 account — 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 account-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 account 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 | 10–100, values below 10 are raised to 10 (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`\n\nErrors: `422` on validation failure."
                  },
                  "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 (account)",
                  "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 account — 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 account-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 %\n\nErrors: `422` on validation failure."
                  },
                  "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 (account)",
                  "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 account-facing duplicate registration.)\n\n**Auth:** Account — `auth:api` bearer token, inside the generic account-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. The header row shows the caller's account 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 be an existing location 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`.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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 (account)",
                  "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 account-facing duplicate registration.)\n\n**Auth:** Account — `auth:api` bearer token, inside the generic account-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: an account (`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 be an existing location 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).\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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 (account-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 an 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`. An 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` (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` (an 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 recorded response.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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/logo/auth_type) for an account: the union of review sites on the plans assigned to the account's account(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 (`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`.\n      - `logo` (string) — logo image URL (`ReviewSite::logoUrl()`: stored override, else the name-derived default).\n      - `auth_type` (string|null) — how the site connects, which tells you the connection flow to show: `oauth` — sign in with the platform (e.g. Google, Facebook); `cookie` or `credential` — manager invite: the business adds our address as a manager on its listing (get the address from `POST /api/v1/store-review-site/{id}/connect`); `default` — the site's username and password. This flow is for sites that don't support adding a manager; it isn't available yet and will be introduced in a future release; `restapi` — connected through the platform's own API, nothing for the business to do; `null` — no connection flow is available for the site. Fixed per review site; it does not vary by account or plan.\n\nErrors: `422` on validation failure."
                  },
                  "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\", \"logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/assets/backend/images/logos/google_ic.png\", \"auth_type\": \"oauth\" },\n      { \"value\": 1, \"label\": \"Yelp\", \"logo\": \"https://d2ny6zb7otrnhl.cloudfront.net/assets/backend/images/logos/yelp_ic.png\", \"auth_type\": \"cookie\" }\n    ]\n  }\n}"
                    }
                  ]
                }
              ],
              "description": "Account-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 account'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 company `bundle_id` must be `3` (account) or `2` (location-account); any other persona gets a 403. The account name is resolved by walking UP via `Company::getParentCompany($user->person->company_id, 'brand')`. With no `store_id`, the ranked set is every store in the caller's scope. Per-persona difference: a location-level user (bundle_id=2 / `assign_location`) gets only its assigned store(s); an account-level user gets all stores under the account'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 account company name (null if no account 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.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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": "Account-side ranking-report payload (matches the partner version structurally — segregated here because the canonical caller is an account 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?start_date=2026-01-01&end_date=2026-01-31",
                      "host": [
                        "{{baseUrl}}"
                      ],
                      "path": [
                        "campaign",
                        "dashboard",
                        "metrics"
                      ],
                      "query": [
                        {
                          "key": "start_date",
                          "value": "2026-01-01",
                          "description": "Required — first day of the reporting window (`YYYY-MM-DD`)."
                        },
                        {
                          "key": "end_date",
                          "value": "2026-01-31",
                          "description": "Required — last day of the reporting window (`YYYY-MM-DD`)."
                        }
                      ]
                    },
                    "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` account 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 the failure/send/creation timestamp 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).\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_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 account/location account); a non-matching id returns 404 `Campaign not found`.\n\n**Rate limit:** No rate limit.\n\n**Request**\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).\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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- 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, 10–100) — page size; defaults to 10 (values below 10 are raised to 10; above 100 returns `422`).\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 length-aware paginator 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`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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- 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.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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- 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.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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` (ACCOUNT). The campaign is scoped by `RsCampaign.company_id === user->person->company_id`, so an account can only read its own campaigns (404 `Campaign not found` otherwise). Non-account users get 403.\n\n**Rate limit:** No rate limit.\n\n**Request**\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).\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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` (ACCOUNT). Campaign scoped by `RsCampaign.company_id === user->person->company_id` (404 `Campaign not found` otherwise). Non-account users get 403.\n\n**Rate limit:** No rate limit.\n\n**Request**\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, 10–100) — per-page; values below 10 are raised to 10; above 100 returns `422`. Default 10.\n- Query: `page` (integer, optional, min 1) — page number. Default 1.\n\n**Response** — raw 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).\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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` (ACCOUNT). Campaign scoped by `RsCampaign.company_id === user->person->company_id` (404 `Campaign not found` otherwise). Non-account users get 403.\n\n**Rate limit:** No rate limit.\n\n**Request**\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.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `campaignId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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, 10–100) — page size; defaults to 20 (values below 10 are raised to 10; above 100 returns `422`).\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.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "status": "OK",
                      "code": 200,
                      "_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 account-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`; the base query is scoped to the caller's own company. 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\"`.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_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 be an existing location and belong to the auth company (BelongsToAuthCompany).\n- Query: `start_date` (string `Y-m-d`, optional) — the earliest `created_at` to include; swapped with `end_date` when the two are reversed.\n- Query: `end_date` (string `Y-m-d`, optional) — the latest `created_at` to include.\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\"`.\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_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 account-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 account company). The request injects the route `id` into validation data and asserts it is an existing QR code.\n\n**Rate limit:** No rate limit.\n\n**Request**\n- Query: `per_page` (integer, optional, 10–100, default 15; values below 10 are raised to 10; above 100 returns `422`).\n\n**Response** — `{ data: ... }` envelope wrapping an array (so the paginator nests at `data.data`).\n- `data.status` (string) — `\"success\"`.\n- `data.data` (object) — a paginator of scan-history 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'])`.\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `qrCodeId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "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\": 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- Query: `per_page` (integer, optional, 10–100, default 15; values below 10 are raised to 10; above 100 returns `422`) — page size.\n\n**Response** — `{ data: ... }` envelope wrapping the controller array:\n- `status` (string) — `\"success\"`.\n- `data` (object) — a 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'])`).\n\n## Path params\n| Parameter | Type | Required | Notes |\n|---|---|---|---|\n| `templateId` | integer | Required | Digits only. |\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 Success",
                      "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\": 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\": \"home-services\"\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 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. 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 | no | `between:2000,2100`. Optional — defaults to the previous calendar month when omitted. |\n| `month` | integer | no | `between:1,12`. Omit for a full-year report. |\n| `theme` | string | no | `max:64`. Optional — resolved from the account's industry when omitted (falling back to the system default, `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 report record 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.\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.\n\nErrors: `422` on validation failure."
                  },
                  "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\": \"home-services\",\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  \"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\": \"home-services\",\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 an account-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 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 | no | `between:2000,2100`. Optional — defaults to the previous calendar month when omitted. |\n| `month` | integer | no | `between:1,12`. Omit for full-year. |\n| `theme` | string | no | `max:64`. Optional — resolved from the account's industry when omitted. |\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.\n\nErrors: `422` on validation failure."
                  },
                  "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\": \"home-services\",\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 stored report record by id scoped to the store. It reads the stored report record and, while a report is in-flight and its status is stale, may poll ReviewData once per throttle window as a missed-webhook fallback; it 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 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.\n\nErrors: `422` on validation failure."
                  },
                  "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\": \"home-services\",\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\": \"home-services\",\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\": \"home-services\"\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 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 | no | `between:2000,2100`. Optional — defaults to the previous calendar month when omitted. |\n| `month` | integer | no | `between:1,12`. Omit to match the full-year report. |\n| `theme` | string | no | `max:64`. Optional — resolved from the account's industry when omitted. |\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).\n\nErrors: `422` on validation failure."
                  },
                  "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\": \"home-services\",\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 user, the reachable set is the stores under the account'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, rounded to 2dp — sum of positive rates ÷ the platform's full review count, so unrated reviews dilute it), `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.\n\nErrors: `422` on validation failure."
                  },
                  "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\": \"home-services\",\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\": \"home-services\",\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 10 reviews in the last 48 months before an insight report can be generated.\",\n  \"errors\": {\n    \"store_id\": [\n      \"This location needs at least 10 reviews in the last 48 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\": \"home-services\",\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 (checked during validation).\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. |\n| `year` | integer | no | `between:2000,2100`. Optional — defaults to the previous calendar month when omitted. |\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. `home-services`); `general` is no longer accepted by the reporting engine. |\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.\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\": \"home-services\",\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  \"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)\n\nErrors: `422` on validation failure."
                  },
                  "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\": \"home-services\",\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 account 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 | 10–100 (values below 10 are raised to 10; above 100 returns `422`); 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).\n\nErrors: `422` on validation failure."
                  },
                  "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\": \"home-services\",\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 (`home-services` / `food-dining`) 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. `home-services`, `food-dining`.\n  - `label` (string) — display label.\n  - `kpis` (array) — KPI definitions (empty `[]` in the defaults; populated when RD returns live themes).\n\nErrors: `422` on validation failure."
                  },
                  "response": [
                    {
                      "name": "200 OK",
                      "status": "OK",
                      "code": 200,
                      "_postman_previewlanguage": "json",
                      "header": [],
                      "body": "{\n  \"data\": {\n    \"themes\": [\n      {\n        \"slug\": \"home-services\",\n        \"label\": \"Home Services\",\n        \"kpis\": []\n      },\n      {\n        \"slug\": \"food-dining\",\n        \"label\": \"Food Dining\",\n        \"kpis\": []\n      }\n    ]\n  }\n}"
                    }
                  ]
                }
              ],
              "description": "Account-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."
            }
          ]
        }
      ]
    }
  ]
}
