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.
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.
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).
POST /api/leads
Content-Type: application/jsonRequest body
Validated by createLeadSchema (@rgss/types).
Prop
Type
{
"name": "Priya Sharma",
"phone": "9876543210",
"email": "[email protected]",
"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.
{
"success": true,
"data": {
"leadId": "ld_7Qx2aMce91"
}
}Errors
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'))
GET /api/leads?status=newQuery parameters
Prop
Type
Response
{
"success": true,
"data": {
"leads": [
{
"id": "ld_7Qx2aMce91",
"name": "Priya Sharma",
"phone": "+919876543210",
"email": "[email protected]",
"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
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'))
POST /api/leads
Content-Type: application/jsonRequest body
Validated by manualLeadSchema (@rgss/types) — extends createLeadSchema
with source: 'manual' (literal, defaults to manual).
Prop
Type
{
"name": "Walk-in Enquiry",
"phone": "+919812345678",
"serviceInterestedId": "svc_haircut001"
}Response
Returns 201 Created with the new lead id.
{
"success": true,
"data": {
"leadId": "ld_3Kp9bWfa20"
}
}Errors
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'))
GET /api/leads/ld_7Qx2aMce91Path parameters
Prop
Type
Response
{
"success": true,
"data": {
"lead": {
"id": "ld_7Qx2aMce91",
"name": "Priya Sharma",
"phone": "+919876543210",
"email": "[email protected]",
"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
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.
PATCH /api/leads/ld_7Qx2aMce91
Content-Type: application/jsonPath parameters
Prop
Type
Request body
Validated by updateLeadStatusSchema (@rgss/types).
Prop
Type
{
"status": "lost",
"reason": "Chose a competitor closer to home."
}Response
Returns the updated lead row.
{
"success": true,
"data": {
"lead": {
"id": "ld_7Qx2aMce91",
"name": "Priya Sharma",
"phone": "+919876543210",
"email": "[email protected]",
"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
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'))
POST /api/leads/ld_7Qx2aMce91/notes
Content-Type: application/jsonPath parameters
Prop
Type
Request body
Validated by addLeadNoteSchema (@rgss/types).
Prop
Type
{
"content": "Left a voicemail, will retry tomorrow morning."
}Response
Returns 201 Created with the new note.
{
"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
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.
Was this page helpful?