Current-user preferences model
Every dashboard surface that remembers your setup — the AI campaign draft you saved and closed, the report you generated yesterday, the favorites on your dialpad, the ring pattern for an important caller — hangs off one prefix:/api/v1/me. That prefix is the “current user” surface, and this
page explains what it resolves to, where it persists, and how the facets
fit together. For route-by-route request and response shapes, see the
Me API reference.
What “me” means — JWT-scoped user resolution
No/me route takes a user id in the path. The caller is resolved from
the credential, not the URL: a dashboard session token carries the signed
Clerk user id, and the auth middleware resolves that id to the request
context before any handler runs. The handler then reads that user’s own
row — never anyone else’s. A request from a different session resolves to
a different user; there is no /me/{user_id} form to target another person.
The same row is matched two ways, because a user provisioned through
SCIM or enterprise SSO carries a Clerk clerk_user_id that differs from
their internal users.id. Every /me query matches on
clerk_user_id = $caller OR id = $caller so both classes of user find
their own row. A leaked row id from another user therefore never resolves
here — the caller’s own id is the only key that can open their preferences.
An API key is an authenticated principal too, but it has no human
users row. /me synthesizes a machine identity for it rather than
failing, so server-to-server callers can still bootstrap their tenant
context. The per-user facets below are a human-dashboard concern, so a
machine principal’s preferences are empty by construction — the
preferences blob is never read off a row that does not exist.
The users.preferences JSONB column
Most facets on this surface persist to a single column:
users.preferences, a JSONB blob on the public users row. The column
already existed before any of these features shipped, so adding a facet
to it is migration-free — a new key is merged in without altering the
schema. The shape is a flat object, one key per facet:
inbox_signature, onboarding state,
any other facet) is preserved untouched. This is why the facets are
independent: each one writes only its own key, and none of them ever
re-sends the whole blob.
The one exception: favorites
Favorites are the exception. Speed-dial pins live in a dedicated table (user_call_favorites) with their own columns — target_kind,
target_value, label, sort_order — because the dialpad treats them
as per-row records with create, relabel, reorder, and delete operations,
not a replaceable array. The /me/favorites routes run full CRUD
against that table; the WHERE clause on every query pins both id and
user_id, so a row id leaked from another user matches zero rows and
returns 404, never another user’s data. Every other facet on this
page is a JSONB key; favorites are the only table-backed one.
The facets
Each facet below is one key underusers.preferences (or, for favorites,
one table), persisted from the authenticated caller’s session and scoped
so no other user can read or write it. The read path on the hottest route
— GET /api/v1/me — projects only the two scalar keys it needs
(inbox_signature, inbox_show_archived_tags) out of the blob, so the
rest of the facets add no cost to the dashboard bootstrap.
Campaign drafts
GET / PUT /api/v1/me/campaign-drafts — your saved AI campaign briefs.
The AI Studio “Brief to draft” flow kept its “Save draft and close”
history in browser storage, so a draft saved on one device never
appeared on another. The history now persists server-side under the
saved_ai_campaign_drafts key — an array of up to 50 drafts, each
carrying its id, name, brief, the generated draft blueprint, the
channels it covers (sms, rcs, whatsapp, email, push), and
timestamps. The PUT replaces the whole array; the GET returns it
ordered as saved. Only the operator’s own campaign brief and generated
copy is stored — never another tenant’s customer data — and a total-size
guard bounds the blob so a heavy draft cannot bloat the row.
Generated-report history
GET / PUT /api/v1/me/generated-reports — your Insights report
history.
The Insights → Reports page kept generated-report history in browser
localStorage, so clearing site data or signing in from another device
wiped it. The history now persists under generated_reports — an
array of up to 50 report metadata entries. Only report metadata is
stored (id, name, type, generated timestamp, columns); row-level data,
which can carry customer phone numbers and email addresses, is stripped
at the trust boundary and never persisted. The accepted report types are
message_delivery, channel_performance, cost_analysis,
contact_growth, and campaign_roi; an unrecognized type is rejected
so a drifted client cannot smuggle arbitrary strings into the blob.
Recommendations tester draft
GET / PUT /api/v1/me/recommendations-draft — your Audience
recommendations tester state.
The Audience → Recommendations tester held its catalog, profile
signals, and scoring weights in ephemeral React state and then in
localStorage, so a product added on one device vanished on a fresh
session. The draft now persists under recommendations_tester_draft —
the full editable input set: the catalog (up to 200 items), the signals
(up to 200), the profile id, the limit and half-life inputs, the
“suppress purchased” toggle, and the per-factor weight strings. A
non-empty catalog is required, because the tester grid always renders at
least one product row; an empty catalog is not a restorable state.
Favorites
GET / POST / PATCH / DELETE /api/v1/me/favorites — your
speed-dial pins.
The dialpad’s Favorites tab reads this list to render a one-tap call
surface for your top contacts. Each pin carries a target_kind
(pstn, extension, contact_id, or sip), the target_value in the
format that kind implies (E.164 for pstn, a sip:-prefixed URI for
sip), a display label, and a sort_order. The list is capped at 50
pins per user; a duplicate target is rejected. Favorites store dial
intent only — placing a call to a pinned target routes through the
normal outbound calling surface, so this controller never touches a
termination provider. As noted above, favorites are the table-backed
facet, not a JSONB key.
Distinctive-ring rules
GET / PUT /api/v1/me/ring-rules — per-caller ring patterns for
your registered device.
A distinctive-ring rule assigns a ring pattern (VIP, priority, urgent, …)
to a specific caller number or number prefix, so an important inbound
call can be told apart by sound — long-standing PBX parity. The rules
array persists under distinctive_ring_rules; the PUT replaces the
whole list atomically (validated per-rule, de-duplicated by target, then
merged in). The GET also returns the pattern catalog the picker
renders. The controller only governs how an inbound caller’s ring is
presented on the callee’s own device; it never originates a leg and
never touches a termination provider.
Voicemail-to-email
PATCH /api/v1/me — your voicemail email routing.
Two fields control voicemail-to-email dispatch:
voicemail_to_email_enabled (boolean, defaults false) and
voicemail_to_email_address (an alternate routing address, or null
to fall back to your users.email). The address is validated as an
email by the route’s Zod schema — a strict second line of defence behind
the database check constraint. The PATCH /me route is also the write
surface for softphone_layout (your softphone widget layout, or null
to revert to the platform default) and the inbox_signature /
inbox_show_archived_tags keys. All of these merge into the
preferences blob (or the dedicated columns, for the voicemail fields)
without clobbering siblings.
PATCH semantics and the NULL-to-revert convention
The write contract for this column has three modes per field, and they are not interchangeable:- A value present in the body writes that value.
- The key omitted from the body is a no-op — the existing value is
left untouched. The handler checks
in patch, not truthiness, so an explicitfalseornullstill flows through. - The key present with
nullreverts that field. Forsoftphone_layout,nullresets to the platform default. Forvoicemail_to_email_address,nullclears the alternate address and falls back to your auth email. Forinbox_signature,null(or a whitespace-only string) deletes the key from the blob entirely.
campaign-drafts, generated-reports,
recommendations-draft, ring-rules) use PUT rather than PATCH
because they are whole-list replaces — the client sends its full,
validated list on every settled change, and the server re-validates and
re-bounds it before the merge.
Multi-org picker consequences
The user row spans organizations: a member of more than one org picks which one to operate against through the multi-org picker, and the active org’s id rides on the session. Preferences, however, are per-user, not per-org — your saved campaign drafts, your signature, your distinctive-ring rules travel with you across the orgs you belong to, because they all key off the sameusers row. Switching orgs
changes which tenant schema the rest of the API reads; it does not
change whose preferences the /me surface returns. The organizations
list in the GET /me response is what the picker renders, and the
preferences blob is independent of which entry is active.
Related pages
- Authentication and session model —
the credential classes and the resolution rule that resolves the
signed-in user for every
/merequest. - Roles, teams, and permissions —
the role vocabulary the per-role 2FA gate on
GET /mereferences. - Me API — the route-by-route request and
response reference for every
/meendpoint.