> ## Documentation Index
> Fetch the complete documentation index at: https://docs.theroyalglow.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Leads

> Public Meta-ad lead capture plus the admin lead pipeline (list, detail, status updates, notes).

# Leads

The lead surface has two halves. `POST /api/leads` on the customer site
(`theroyalglow.in`) is the **only** unauthenticated write endpoint in this
phase — it backs the `/book` Meta-ad landing page. The admin lead pipeline is a
separate app served from `admin.theroyalglow.in` (its endpoints are
`admin.theroyalglow.in/api/leads*`, with **no `/admin` path prefix** — the
subdomain is the namespace) and requires a Better Auth session with at least the
`receptionist` role.

<Info>
  **Base URLs:** customer capture is `https://theroyalglow.in/api/leads`; the
  admin pipeline endpoints are served from `https://admin.theroyalglow.in`
  (e.g. `admin.theroyalglow.in/api/leads`, `.../api/leads/[id]`). There is **no
  `/api/admin/` segment** anywhere. Roles are hierarchical: `customer` \<
  `staff` \< `receptionist` \< `manager` \< `owner` \< `developer`. A handler that
  calls `requireRole('receptionist')` also admits `manager`, `owner`, and
  `developer`. Phone numbers are normalised to canonical `+91XXXXXXXXXX` in the
  business layer before storage. Lead pipeline:
  `new → contacted → follow_up → booked → won/lost`.
</Info>

## POST `theroyalglow.in/api/leads`

Public lead capture from the Meta-ad landing page. The endpoint is per-IP
rate-limited and strictly Zod-validated. It deliberately echoes back nothing
beyond the created `leadId` (no PII). The `source` defaults to `meta_ad` when
omitted.

**Minimum role:** Public — no session required (rate-limited per IP: 5 requests
per 60s window).

```http theme={null}
POST /api/leads
Content-Type: application/json
```

### Request body

Validated by `createLeadSchema` (`@rgss/types`).

| Name                  | Type     | Required | Default   | Description                                                                        |
| --------------------- | -------- | -------- | --------- | ---------------------------------------------------------------------------------- |
| `name`                | `string` | Yes      |           | Trimmed, 1–120 characters.                                                         |
| `phone`               | `string` | Yes      |           | Indian 10-digit mobile, optionally +91 or 0 prefixed. Normalised to +91XXXXXXXXXX. |
| `email`               | `string` | No       |           | Must be a valid email if present.                                                  |
| `serviceInterestedId` | `string` | No       |           | Non-empty service id the lead is interested in.                                    |
| `source`              | `string` | No       | `meta_ad` | Max 40 chars.                                                                      |
| `utmSource`           | `string` | No       |           | Max 120 chars.                                                                     |
| `utmMedium`           | `string` | No       |           | Max 120 chars.                                                                     |
| `utmCampaign`         | `string` | No       |           | Max 120 chars.                                                                     |
| `utmContent`          | `string` | No       |           | Max 120 chars.                                                                     |
| `utmTerm`             | `string` | No       |           | Max 120 chars.                                                                     |

```json theme={null}
{
  "name": "Priya Sharma",
  "phone": "9876543210",
  "email": "priya@example.com",
  "serviceInterestedId": "svc_facialglow01",
  "utmSource": "facebook",
  "utmMedium": "paid_social",
  "utmCampaign": "monsoon_glow_2026",
  "utmContent": "carousel_v2",
  "utmTerm": "facial"
}
```

### Response

Returns `201 Created` with only the new lead id.

```json theme={null}
{
  "success": true,
  "data": {
    "leadId": "ld_7Qx2aMce91"
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="RATE_LIMITED — 429">
    More than 5 requests from the same IP within the 60s window. `retryable: true`.
  </Accordion>

  <Accordion title="VALIDATION_ERROR — 400">
    Body fails `createLeadSchema`. `details` carries the per-field errors.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## GET `admin.theroyalglow.in/api/leads`

Returns the lead pipeline, newest first. Each row is a flat lead enriched with
the service-interest name plus two computed fields: `daysSinceCapture` (whole
days since `createdAt`) and `isStale` (whether the lead has gone too long
without movement for its status). Pass `?status=` to bucket a single column of
the kanban board.

**Minimum role:** `receptionist` (`requireRole('receptionist')`)

```http theme={null}
GET /api/leads?status=new
```

### Query parameters

| Name     | Type     | Required | Description                                                               |
| -------- | -------- | -------- | ------------------------------------------------------------------------- |
| `status` | `string` | No       | One of new, contacted, follow\_up, booked, won, lost. Omit for all leads. |

### Response

```json theme={null}
{
  "success": true,
  "data": {
    "leads": [
      {
        "id": "ld_7Qx2aMce91",
        "name": "Priya Sharma",
        "phone": "+919876543210",
        "email": "priya@example.com",
        "serviceInterestedId": "svc_facialglow01",
        "status": "new",
        "source": "meta_ad",
        "utmCampaign": "monsoon_glow_2026",
        "utmMedium": "paid_social",
        "utmSource": "facebook",
        "utmContent": "carousel_v2",
        "utmTerm": "facial",
        "assignedTo": null,
        "convertedBookingId": null,
        "lastContactedAt": null,
        "createdAt": "2026-06-01T05:30:00.000Z",
        "updatedAt": "2026-06-01T05:30:00.000Z",
        "serviceName": "Glow Facial",
        "daysSinceCapture": 0,
        "isStale": false
      }
    ]
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `receptionist`.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## POST `admin.theroyalglow.in/api/leads`

Creates a lead manually from inside the admin portal (e.g. a phone enquiry).
Identical body to the public endpoint, but `source` is fixed to `manual` by
`manualLeadSchema` and the handler — it always overrides `source` to `manual`.

**Minimum role:** `receptionist` (`requireRole('receptionist')`)

```http theme={null}
POST /api/leads
Content-Type: application/json
```

### Request body

Validated by `manualLeadSchema` (`@rgss/types`) — extends `createLeadSchema`
with `source: 'manual'` (literal, defaults to `manual`).

| Name                  | Type       | Required | Default  | Description                                                                      |
| --------------------- | ---------- | -------- | -------- | -------------------------------------------------------------------------------- |
| `name`                | `string`   | Yes      |          | Trimmed, 1–120 characters.                                                       |
| `phone`               | `string`   | Yes      |          | Indian 10-digit mobile. Normalised to +91XXXXXXXXXX.                             |
| `email`               | `string`   | No       |          | Valid email if present.                                                          |
| `serviceInterestedId` | `string`   | No       |          | Non-empty service id.                                                            |
| `source`              | `"manual"` | No       | `manual` | Literal; defaults to manual and is forced to manual by the handler.              |
| `utm* fields`         | `string`   | No       |          | utmSource / utmMedium / utmCampaign / utmContent / utmTerm — max 120 chars each. |

```json theme={null}
{
  "name": "Walk-in Enquiry",
  "phone": "+919812345678",
  "serviceInterestedId": "svc_haircut001"
}
```

### Response

Returns `201 Created` with the new lead id.

```json theme={null}
{
  "success": true,
  "data": {
    "leadId": "ld_3Kp9bWfa20"
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `receptionist`.
  </Accordion>

  <Accordion title="VALIDATION_ERROR — 400">
    Body fails `manualLeadSchema`. `details` carries per-field errors.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## GET `admin.theroyalglow.in/api/leads/[id]`

Returns a single lead enriched with its service-interest name, assigned-to user
name, and the converted booking number (each `null` when absent), together with
its notes (newest first).

**Minimum role:** `receptionist` (`requireRole('receptionist')`)

```http theme={null}
GET /api/leads/ld_7Qx2aMce91
```

### Path parameters

| Name | Type     | Required | Description        |
| ---- | -------- | -------- | ------------------ |
| `id` | `string` | Yes      | Path. The lead id. |

### Response

```json theme={null}
{
  "success": true,
  "data": {
    "lead": {
      "id": "ld_7Qx2aMce91",
      "name": "Priya Sharma",
      "phone": "+919876543210",
      "email": "priya@example.com",
      "serviceInterestedId": "svc_facialglow01",
      "status": "contacted",
      "source": "meta_ad",
      "utmCampaign": "monsoon_glow_2026",
      "utmMedium": "paid_social",
      "utmSource": "facebook",
      "utmContent": "carousel_v2",
      "utmTerm": "facial",
      "assignedTo": "usr_reception01",
      "convertedBookingId": null,
      "lastContactedAt": "2026-06-01T09:10:00.000Z",
      "createdAt": "2026-06-01T05:30:00.000Z",
      "updatedAt": "2026-06-01T09:10:00.000Z",
      "serviceName": "Glow Facial",
      "assignedToName": "Reception Desk",
      "convertedBookingNumber": null
    },
    "notes": [
      {
        "id": "ldn_1aB2cD3eF4",
        "leadId": "ld_7Qx2aMce91",
        "content": "Called, interested in a weekend slot.",
        "authorId": "usr_reception01",
        "authorName": "Reception Desk",
        "createdAt": "2026-06-01T09:10:00.000Z"
      }
    ]
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `receptionist`.
  </Accordion>

  <Accordion title="NOT_FOUND — 404">
    No lead with that id.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## PATCH `admin.theroyalglow.in/api/leads/[id]`

Updates a lead's status, enforcing the pipeline state machine. When the new
status is `contacted`, the handler also stamps `lastContactedAt`. Marking a lead
`lost` requires a non-empty `reason`.

**Minimum role:** `receptionist` (`requireRole('receptionist')`)

The allowed transitions (`assertLeadTransition`) are:

| From        | Allowed to                    |
| ----------- | ----------------------------- |
| `new`       | `contacted`, `booked`, `lost` |
| `contacted` | `follow_up`, `booked`, `lost` |
| `follow_up` | `booked`, `lost`              |
| `booked`    | `won`, `lost`                 |
| `won`       | *(terminal — no moves)*       |
| `lost`      | *(terminal — no moves)*       |

Same-status moves are not in the map and are therefore rejected.

```http theme={null}
PATCH /api/leads/ld_7Qx2aMce91
Content-Type: application/json
```

### Path parameters

| Name | Type     | Required | Description        |
| ---- | -------- | -------- | ------------------ |
| `id` | `string` | Yes      | Path. The lead id. |

### Request body

Validated by `updateLeadStatusSchema` (`@rgss/types`).

| Name     | Type     | Required | Description                                              |
| -------- | -------- | -------- | -------------------------------------------------------- |
| `status` | `enum`   | Yes      | One of new, contacted, follow\_up, booked, won, lost.    |
| `reason` | `string` | No       | Max 500 chars. Required (non-empty) when status is lost. |

```json theme={null}
{
  "status": "lost",
  "reason": "Chose a competitor closer to home."
}
```

### Response

Returns the updated lead row.

```json theme={null}
{
  "success": true,
  "data": {
    "lead": {
      "id": "ld_7Qx2aMce91",
      "name": "Priya Sharma",
      "phone": "+919876543210",
      "email": "priya@example.com",
      "serviceInterestedId": "svc_facialglow01",
      "status": "lost",
      "source": "meta_ad",
      "utmCampaign": "monsoon_glow_2026",
      "utmMedium": "paid_social",
      "utmSource": "facebook",
      "utmContent": "carousel_v2",
      "utmTerm": "facial",
      "assignedTo": "usr_reception01",
      "convertedBookingId": null,
      "lastContactedAt": "2026-06-01T09:10:00.000Z",
      "createdAt": "2026-06-01T05:30:00.000Z",
      "updatedAt": "2026-06-02T11:00:00.000Z"
    }
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `receptionist`.
  </Accordion>

  <Accordion title="VALIDATION_ERROR — 400">
    Body fails `updateLeadStatusSchema`, or `status` is `lost` without a non-empty
    `reason`.
  </Accordion>

  <Accordion title="NOT_FOUND — 404">
    No lead with that id.
  </Accordion>

  <Accordion title="BUSINESS_RULE_VIOLATION — 409">
    Illegal status transition (move not allowed by the state machine, including
    same-status moves).
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## POST `admin.theroyalglow.in/api/leads/[id]/notes`

Appends a note to a lead. The note's author is taken from the session, not the
request body.

**Minimum role:** `receptionist` (`requireRole('receptionist')`)

```http theme={null}
POST /api/leads/ld_7Qx2aMce91/notes
Content-Type: application/json
```

### Path parameters

| Name | Type     | Required | Description        |
| ---- | -------- | -------- | ------------------ |
| `id` | `string` | Yes      | Path. The lead id. |

### Request body

Validated by `addLeadNoteSchema` (`@rgss/types`).

| Name      | Type     | Required | Description                 |
| --------- | -------- | -------- | --------------------------- |
| `content` | `string` | Yes      | Trimmed, 1–1000 characters. |

```json theme={null}
{
  "content": "Left a voicemail, will retry tomorrow morning."
}
```

### Response

Returns `201 Created` with the new note.

```json theme={null}
{
  "success": true,
  "data": {
    "note": {
      "id": "ldn_5gH6iJ7kL8",
      "leadId": "ld_7Qx2aMce91",
      "authorId": "usr_reception01",
      "content": "Left a voicemail, will retry tomorrow morning.",
      "createdAt": "2026-06-02T04:00:00.000Z"
    }
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `receptionist`.
  </Accordion>

  <Accordion title="VALIDATION_ERROR — 400">
    Body fails `addLeadNoteSchema` (empty or over-long content).
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

<Warning>
  Unlike `GET`/`PATCH` on `/api/leads/[id]`, this handler does **not**
  pre-check that the lead exists. Posting a note for a non-existent `id` fails on
  the foreign-key constraint and surfaces as `INTERNAL_ERROR` (500) rather than
  `NOT_FOUND`.
</Warning>


## Related topics

- [CRM & Lead Pipeline](/content/docs/features/crm-leads.md)
- [Admin & Staff Guide](/content/docs/product/admin-staff-guide.md)
- [Low-Level Design](/content/docs/system-design/low-level-design.md)
- [Analytics](/content/docs/analytics.md)
- [FAQ](/content/docs/faq.md)
