Skip to main content

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:
The write contract is patch-by-merge, not replace-the-column. Every write merges the one key a facet owns into the existing blob — the key is overwritten while every sibling key (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 under users.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 explicit false or null still flows through.
  • The key present with null reverts that field. For softphone_layout, null resets to the platform default. For voicemail_to_email_address, null clears the alternate address and falls back to your auth email. For inbox_signature, null (or a whitespace-only string) deletes the key from the blob entirely.
This is why the client can patch one field without re-sending the rest: the merge writes only the keys you supplied, and the blob’s siblings survive. The array facets (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 same users 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.