Skip to main content

Per-user state persistence model

Your saved AI campaign drafts, your generated report history, and your dialpad favorites all live on your Orbit account. This page explains why those surfaces moved out of browser storage, what each /me endpoint stores, and what to do if an older integration or browser profile of yours still keeps a local copy.

The problem with browser-bound state

These surfaces first shipped writing to the browser’s localStorage. That made “draft saved” true only for this browser, on this profile, on this device:
  • A draft saved on the office workstation did not exist on the laptop at home.
  • Clearing site data, a private-browsing window, or a fresh browser profile erased the history.
  • On a shared workstation, the next operator to sign in saw the previous operator’s drafts, or lost their own.
The dashboard said “draft saved”, and the sentence was only true about one machine. Account-bound storage fixes that by making your account, not your browser, the source of truth.

What the platform now persists per surface

Each surface has a dedicated /me endpoint, and each stores per signed-in user: The drafts, reports, and recommendations entries are stored as keys in the preferences record that already lives on your user row. The keys are:
  • saved_ai_campaign_drafts
  • generated_reports
  • recommendations_tester_draft
  • inbox_show_archived_tags (your Inbox archived-tags toggle)
  • inbox_signature (your Inbox reply signature)
That storage shape was chosen deliberately:
  • No schema change was required. The preferences column already existed and already held inbox_signature and inbox_show_archived_tags, so each new surface shipped without a migration window and without touching unrelated data.
  • Writes never clobber each other. Every update merges exactly its own key into the record and leaves every sibling key untouched, so saving your report history cannot erase a draft, a signature, or a toggle.
  • The pattern is a template. If you build tooling on the API that keeps per-operator state, treat these endpoints as the model: one namespaced key per surface, merged writes, and defensive reads.

The GET/PUT shape

GET /v1/me/campaign-drafts answers { "data": { "drafts": [...] } }, where each draft carries id, name, brief, draft (the generated blueprint, or null when saved before generation), channels, createdAt, and updatedAt. PUT sends the complete list, { "drafts": [...] }, and stores exactly what you send, newest first, capped at 50. Generated reports mirror the shape: GET answers { "data": { "reports": [...] } } and PUT accepts { "reports": [...] }, each entry carrying id, name, type, generatedAt, and columns, never rows. Both sides read defensively: entries with missing or drifted fields are skipped rather than treated as errors, so an older client and a newer server keep working against each other.

Lifting state you already saved in the browser

The platform will not read your browser’s storage for you; there is no server-side auto-migration. If your team built tooling or a custom build that persisted these surfaces locally, run a one-time lift-and-shift from the client: read the local key once on app start, PUT it up to the matching /me endpoint, then clear the local copy. The local key names the platform surfaces wrote before account-bound storage: The dashboard still uses these keys as a fast local cache it reconciles against the account on every load, with the account copy winning. If your own tooling wrote to them, treat them as a read-once input for the lift, not as an authoritative store.

Sync then clear

The sequence the dashboard itself runs at start, in full:
The rules encoded there are the contract:
  1. Read the local key once on app start.
  2. Fetch the account copy first; never PUT before that GET settles.
  3. Union by id, server copy winning.
  4. Clear the local copy only after the PUT succeeds; on any failure leave it in place and retry on the next start.

Cross-device behaviour after the lift

Once state is lifted, or simply once you use the surfaces normally (the dashboard syncs on every change), the account copy is what every session reads:
  • Sign in on a second machine and your saved drafts, your report history, and your pinned favorites are already there.
  • The mobile dashboard reads the same account store, so the dialpad favorites on your phone are the same pins as on the web dialpad.
  • Clearing browser data no longer erases history; it only drops the local cache the dashboard uses to paint instantly before the network answers.
Restored report entries hydrate with their metadata: a historical generated report opens with its columns and an empty result set, and you regenerate it for fresh figures. That matches how the pre-persistence page behaved, and it follows from the PII rule below.

PII: report history is metadata-only

A generated report’s rows can carry your customers’ phone numbers and email addresses. The /v1/me/generated-reports PUT contract therefore has no rows field at all: only id, name, type, generatedAt, and columns are accepted and stored. The dashboard strips rows before every write, and the API contract enforces the same rule, so report history persists per user without persisting customer PII. If you extend the pattern in your own tooling, keep that discipline: persist the pointer, not the payload.

Speed-dial favorites: a real table, not a preferences key

/v1/me/favorites is the one surface in this model that does not live in the preferences record. Favorites are rows: each pin records what it points at (a number or a contact) and a sort_order, which is what lets you reorder the pinned strip on the dialpad. The API caps how many favorites one user may pin and answers 409 FAVORITE_LIMIT_REACHED when the cap is reached, and a duplicate pin of the same target is rejected. Favorites give every operator a personal speed dial on the shared dialpad, on web and mobile alike. The distinction is the design lesson: a preferences key suits a bounded blob that one surface reads and writes; a real table is the right shape the moment a per-user feature needs ordering, per-row operations, or per-row limits.

Extending the pattern in your own tooling

When your integration keeps per-operator state (a composer draft, a saved filter, a console layout), follow the same contract the platform uses:
  • Key state to the signed-in user, never to the browser alone; where you keep a local cache for instant paint, key it by user id and treat the account copy as authoritative.
  • One namespaced key per surface, merged on write, never a whole-record write-back.
  • Decide what the payload may contain before you persist it, and strip contact-level data at the boundary the way report history does.
  • Read defensively: skip unknown or drifted fields instead of erroring, so older clients survive newer payloads.

See also

  • Developer console overview — the hub that groups the operator and developer surfaces built on the same API.
  • Dashboard home — the landing surface whose per-user widgets read from the account store described here.