Visitor accounts
Register, verify by emailed code, sign in, reset a password — and what the visitor bearer token unlocks under /api/public/me.
A website visitor can have an account of their own: favourites, saved searches,
notifications and viewing requests that follow them to another device.
Registering also creates a contact in the CRM at lifecycle stage LEAD,
which is the whole reason the website runs on the CRM rather than beside it.
All of /public/auth/* is anonymous. Everything under /public/me/* needs the
visitor's token.
The flow
register ──▶ verify-otp ──▶ token
│ ▲
│ │
└──▶ resend-otp
login ──▶ token
forgot-password ──▶ (email) ──▶ reset-password ──▶ login| Method | Path | Body | Answer |
|---|---|---|---|
| POST | /public/auth/register | { email, password, firstName?, lastName?, phone? } | 201 { visitorId } |
| POST | /public/auth/verify-otp | { visitorId, code } (six digits) | { token, visitor } |
| POST | /public/auth/resend-otp | { visitorId } | { sent: true } |
| POST | /public/auth/login | { email, password } | { token, visitor } |
| POST | /public/auth/logout | — | { loggedOut: true } |
| POST | /public/auth/forgot-password | { email } | { sent: true } |
| POST | /public/auth/reset-password | { token, password } | — |
Passwords are at least 8 characters. register and forgot-password are
bot-protected.
The token
verify-otp and login return a bearer token. Send it on every /public/me/*
call:
Authorization: Bearer <token>- It lasts 7 days and is stateless. "Logging out" is your client dropping it;
POST /public/auth/logoutreturns{ loggedOut: true }and does nothing else. - It is not a CRM staff token. The two are signed with the same key and separated by an audience claim, so neither works where the other belongs.
- Store it where you would store any session credential. Never put it in a cookie that your own server code reads as an authorisation — the API guard is the security boundary, not your middleware.
visitor is deliberately small and has no name field:
{
"id": "…", "email": "…", "firstName": "Sara", "lastName": "Kamal",
"phone": "…", "emailVerified": true, "contactId": "…"
}Derive a display name yourself: first + last, else the email local part, else a fixed label.
What the token unlocks
| Path | What it is |
|---|---|
GET /public/me | Profile, preferences and the counts your account sidebar badges render. |
PATCH /public/me, DELETE /public/me | Update or delete the account. |
POST /public/me/password, POST /public/me/avatar | Change password, upload an avatar (2 MB cap). |
GET/PUT/DELETE /public/me/favorites… | Favourites, capped at 10 per type. |
GET/POST/PATCH/DELETE /public/me/saved-searches… | Saved searches and their alert frequency. |
GET /public/me/notifications, POST …/read | In-app notifications. |
GET /public/me/viewing-requests, POST …/{id}/cancel | The visitor's own viewings. |
GET /public/me/favorites, /saved-searches and /viewing-requests return a
bare array in data; /public/me/notifications is paginated. See the
API reference for each shape.
Error codes
| Code | Status | What the UI does |
|---|---|---|
VISITOR_EMAIL_TAKEN | 400 | A verified account already exists. Offer sign-in. |
VISITOR_CREDENTIALS_INVALID | 401 | Wrong email or wrong password — indistinguishable on purpose. Show one message. |
VISITOR_EMAIL_NOT_VERIFIED | 401 | The account exists but was never verified. Send them to the code screen and offer resend-otp. |
OTP_INVALID | 401 | Wrong code. |
OTP_EXPIRED | 401 | Codes live 10 minutes. |
OTP_LOCKED | 401 | Five wrong guesses. Only resend-otp clears it. |
RESET_TOKEN_INVALID | 400 | Reset tokens live 30 minutes. |
PASSWORD_CURRENT_INVALID | 401 | Changing a password with the wrong current one. |
VISITOR_TOKEN_REQUIRED / VISITOR_TOKEN_INVALID | 401 | No token, or an expired one, on a /public/me/* call. Sign the visitor out and re-prompt. |
Two behaviours worth knowing
Registering over an unverified account succeeds, and looks exactly like a
first-time registration. An unverified row is not somebody's account — one is
created for whatever email an owner types into the listing form — so refusing
forever would let anyone lock a stranger out of their own address. The new
password replaces the old one and a fresh code is sent; emailVerified is
untouched, so the emailed code remains the only thing that can set it. Your UI
cannot tell the two cases apart and should not try.
Login never issues a code. A response carrying a visitorId and no token is
the registration challenge and nothing else.
Verification and reset emails are sent by a separate CRM service. If that service is not running in your environment, the codes never arrive and the rows sit queued. Do not build a signup flow whose local development depends on an email landing.
Errors
The two error shapes the public API returns, and a table of every code you can branch on — site resolution, keys, bot checks, viewings, validation and rate limits.
Rate limits
The per-IP floor, the per-site ceiling, the tighter per-route limits on writes, what a secret key buys, and how to handle a 429.