WzGate
Build your own website

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
MethodPathBodyAnswer
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/logout returns { 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

PathWhat it is
GET /public/meProfile, preferences and the counts your account sidebar badges render.
PATCH /public/me, DELETE /public/meUpdate or delete the account.
POST /public/me/password, POST /public/me/avatarChange 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 …/readIn-app notifications.
GET /public/me/viewing-requests, POST …/{id}/cancelThe 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

CodeStatusWhat the UI does
VISITOR_EMAIL_TAKEN400A verified account already exists. Offer sign-in.
VISITOR_CREDENTIALS_INVALID401Wrong email or wrong password — indistinguishable on purpose. Show one message.
VISITOR_EMAIL_NOT_VERIFIED401The account exists but was never verified. Send them to the code screen and offer resend-otp.
OTP_INVALID401Wrong code.
OTP_EXPIRED401Codes live 10 minutes.
OTP_LOCKED401Five wrong guesses. Only resend-otp clears it.
RESET_TOKEN_INVALID400Reset tokens live 30 minutes.
PASSWORD_CURRENT_INVALID401Changing a password with the wrong current one.
VISITOR_TOKEN_REQUIRED / VISITOR_TOKEN_INVALID401No 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.

On this page