> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Customer-360 snapshot aggregation model: parallel fan-out, error isolation, and caching

> The architecture behind the Customer-360 snapshot — one GET call that replaces a dozen round-trips, every source fetched in parallel with its own 3-second budget, per-source failure isolated to a null or empty sentinel, and a 30-second tenant-scoped cache.

# Customer-360 snapshot aggregation model

The Customer-360 workspace is built on a **snapshot model**: one endpoint returns everything a full-page agent view needs, instead of a dozen round-trips the client has to stitch together. This page documents that model as a contract — why the response is error-isolated, how caching and tenant scoping work, what gets truncated, and how to integrate against it. For the step-by-step workspace walkthrough, see [Use the Customer-360 workspace](/guides/customer-360-workspace); for how CRM data that feeds the snapshot syncs, see [CRM integration model](/concepts/crm-integration-model).

## 1. What the snapshot is

`GET /api/v1/customer-360/contacts/{id}/snapshot` is a single call that replaces the sequential queries an agent's full-page view would otherwise issue: contact profile, recent conversations, recent calls, message statistics, AI-assist suggestions, knowledge articles, CRM context, goal conversions, active journey enrollments, answered surveys, lifecycle history, native tickets, video sessions, and operator notes.

The endpoint is read-only and available to the `owner`, `admin`, `developer`, and `viewer` roles. A missing contact returns `404`; a malformed id returns `422`. Everything else — an unreachable CRM, an empty knowledge base, a contact with zero conversations — returns `200` with a full-envelope body and the affected section set to its sentinel.

## 2. Parallel fan-out

The endpoint fetches every source **concurrently** rather than one after another. After the contact row is loaded (the only sequential step, and it fast-fails to `404` if the contact is missing), all remaining sources start at once and race together:

* conversations, recent calls, message summary, knowledge articles, CRM context
* goal conversions, active drip enrollments, survey responses, lifecycle history
* native tickets, video sessions, operator notes

The target is a **P95 latency budget of roughly 300 ms** for the full response. The bottleneck is the CRM leg: it is the only source that may need to wait on an external upstream, so it carries its own **3-second timeout**. The CRM timeout bounds the whole request — no other source can push total latency past that ceiling on its own, because they all share the same race.

## 3. Per-source error isolation

Every source is wrapped so that a throw or timeout in one section **never fails the request or corrupts another section**. A failed source collapses to its null/empty sentinel:

* array sections (`conversations`, `recent_calls`, `knowledge.suggested_articles`, `tickets`, `video_sessions`, `notes`, and the journey fields) degrade to `[]`
* the `messages_summary` object degrades to zeroed counters
* each CRM provider section (`crm.salesforce`, `crm.hubspot`, `crm.zendesk`) degrades to `null`

The response **always carries the full envelope shape** regardless of which sources succeeded. This is a deliberate contract: your UI never has to guess whether an empty section is "no data" or "section didn't ship." It does mean two states share the same representation — a source that timed out and a contact with genuinely no conversations both arrive as `[]` — so treat an empty section as "not available right now," not as proof of absence.

<Warning>
  Check every key independently. Never assume that because one section is populated another one succeeded, and never let one section's renderer throw on the full envelope — a CRM outage must not blank the conversations timeline.
</Warning>

PII handling is per-caller on top of the base envelope: on tenants that opt into redaction, phone and email positions in the contact row, call parties, conversation previews, and suggested-action values are masked for unprivileged roles and unmasked (with an audit-log entry) for privileged callers passing the reveal flag. The cached payload stores the unmasked shape; visibility is re-applied on every read so two callers with different roles can share one cache entry.

## 4. Caching

The built snapshot is cached for **30 seconds per contact**. The cache key includes your tenant scope, so one tenant's snapshot can never be served to another tenant on a multi-tenant cluster — there is no shared-key path where tenant A's contact id collides with tenant B's.

The first request in a TTL window builds the snapshot (the \~300 ms path); requests for the same contact inside the window are served from the cache at a fraction of that cost. This is what lets the workspace refresh on every keystroke without hammering your connected CRM upstream — the thousands of refreshes an agent fires during a session collapse into one build.

The cache is deliberately not invalidated by contact writes. A contact edit, a new message, or a fresh call lands in the snapshot on the next TTL expiry (up to 30 seconds stale). If you need zero-staleness after a mutation, read the dedicated endpoint for that facet instead of the snapshot.

## 5. Truncation and "get the next page"

To keep the cached payload small, each list facet returns its most recent **slice** rather than the full history. Current caps:

| Facet                                                      | Cap     | Full-history endpoint                                |
| ---------------------------------------------------------- | ------- | ---------------------------------------------------- |
| Conversations                                              | 10      | `GET /api/v1/conversations`                          |
| Recent calls                                               | 5       | `GET /api/v1/voice/calls`                            |
| Knowledge articles                                         | 3       | `GET /api/v1/knowledge/documents` (or vector search) |
| Operator notes                                             | 3       | `GET /api/v1/contacts/{id}/notes`                    |
| Tickets (native & per CRM provider)                        | 5       | `GET /api/v1/inbox/tickets`                          |
| Video sessions                                             | 5       | video sessions list endpoint                         |
| Goal conversions / journey enrollments / lifecycle history | 10 each | journey-events endpoint                              |
| Survey responses                                           | 5       | survey responses endpoint                            |

CRM arrays are capped at 5 items per provider on both the deals and tickets/cases legs, sized to the "recent activity" panel the workspace renders.

To page past the cap, call the facet's dedicated list endpoint — the snapshot is a rendering surface, not a bulk-read API. Pick the facet endpoint, page it with its own cursor, and keep using the snapshot for the frame around it.

## 6. Integrating against the snapshot

**When to call it.** Use the snapshot when you need the full picture of one contact in one shot — rendering an agent workspace, a customer-detail overlay in your own tooling, or any surface that would otherwise fan several API calls out per contact. Do not use it for per-contact iteration over your whole contact book (list endpoints + targeted reads are cheaper per record) or for data that must be mutation-fresh (see the TTL note above).

**Idempotency.** The endpoint is a read: safe to call repeatedly, no side effects, no request body, no idempotency key. The only state it touches is the cache entry it writes — and even that is overwrite-on-miss, never a correctness dependency. Retrying a timed-out snapshot is always safe; the worst outcome is paying one extra build.

**AI-assist derivation.** The `ai_assist` block does not run a model per request. Suggested actions are derived synchronously from fields already in the envelope — the contact's phone decides whether a `call` action is offered, the presence of an open conversation decides whether `escalate` is offered, and lifecycle stage shapes the placeholder reply set. The full LLM-backed reply-coach is a different surface: open a conversation in the inbox and call `GET /api/v1/inbox/{conversationId}/reply-suggestions` for generated suggestions against the live thread. The workspace treats `ai_assist` as a launchpad into that inbox surface, not a replacement for it.

## 7. Worked example

A minimal workspace integration: fetch the snapshot, render the frame unconditionally, and branch per section — including the CRM-timeout null branch.

```ts theme={null}
const res = await fetch(
  `https://api.orbit.devotel.io/api/v1/customer-360/contacts/${contactId}/snapshot`,
  { headers: { "X-API-Key": process.env.ORBIT_API_KEY! } },
);
if (!res.ok) throw new Error(`snapshot ${res.status}`); // 404: contact, 422: id shape
const snapshot = (await res.json()).data;

// Frame always renders — the envelope is complete even under degradation.
renderIdentityColumn(snapshot.contact);
renderStats(snapshot.messages_summary);

// Each section checks its own sentinel, and reads "not available" distinctly
// from the forbidden assumption that empty means "no data".
if (snapshot.conversations.length > 0) renderThreads(snapshot.conversations);
else renderEmptySection("Conversations temporarily unavailable — retry shortly");

if (snapshot.recent_calls.length > 0) renderCalls(snapshot.recent_calls);

if (snapshot.knowledge.suggested_articles.length > 0)
  renderArticles(snapshot.knowledge.suggested_articles);

// The CRM-timeout null branch: one provider section can be null while the
// others resolved, and all three can be null when nothing is connected.
renderCrm(snapshot.crm.salesforce, "Salesforce");
renderCrm(snapshot.crm.hubspot, "HubSpot");
renderCrm(snapshot.crm.zendesk, "Zendesk");

function renderCrm(section: unknown, label: string) {
  if (section === null)
    renderEmptySection(`${label} not available — connect the CRM or retry`);
  else renderCrmCard(label, section);
}
```

Within 30 seconds of the first call for a contact, your second call is a cache hit. After a contact mutation you care about, wait out the TTL (or read the facet's dedicated endpoint for that field) before reconciling.

## 8. Tenant-owned data controls

Everything the snapshot returns is **tenant-scoped data you control in your own tenant** — contact records, conversations, call logs, notes a teammate wrote, CRM rows synced from your own connected app under your own credentials. The endpoint exposes no cross-tenant aggregate and no platform-side control over the data it serves. Permission to **see** it is role-gated inside your tenant, and PII masking/redaction is a tenant-level opt-in you set; callers with the unmask permission leave an audit-log entry per reveal. Deleting the contact, disconnecting the CRM, or redacting a note changes what the snapshot serves on the next TTL window.

Outbound traffic is not involved here: the snapshot is a read path only. Outbound voice and SMS are governed by Devotel's routing policy (outbound exits exclusively through the Devotel softswitch), and this endpoint triggers no SMS or call regardless of what it returns.

## Related pages

* [Use the Customer-360 workspace](/guides/customer-360-workspace) — operator walkthrough of the screen this model backs
* [CRM integration model](/concepts/crm-integration-model) — how the CRM legs of the snapshot get their data
* [Contact timeline](/guides/contact-timeline) — the unified journey-replay view the center tab renders
* [Tenant isolation](/concepts/tenant-isolation) — the tenant-scoping rule the cache key and queries enforce
* [Idempotency and safe retries](/concepts/idempotency-and-safe-retries) — why the snapshot-as-a-read needs no idempotency key
