Data API
Status: Draft Last Updated: 2026-06-15 Audience: customer
This is the complete reference for querying an end user’s data with an end-user token (cct_…). It covers every query you can run, how results are scoped to what the user has granted, how pagination works, and how to read an empty result correctly. If you have not yet obtained an end-user token, start with Authorization Flow; for token mechanics see Authentication.
All example values are synthetic. Replace them with your own.
Endpoint and authentication
- Endpoint:
POST /graphql/v1 - Authentication: an end-user token in the
Authorizationheader.
Authorization: Bearer cct_syn_2a7b9c1d_K8mP3qR6sT0uV4wX7yZ1aB5cD9eF2gHAn end-user token is scoped to exactly one (your account, one end user) pair. It can read only the sources that user has actively granted. There is nothing you can do with this token to reach another user’s data or another account.
Always check grants first
Before you query any entity, ask which sources the user has actually authorized. The grants query returns one entry per active grant — revoked grants never appear.
curl -X POST https://api.example-unbound.com/graphql/v1 \ -H "Authorization: Bearer cct_syn_2a7b9c1d_K8mP3qR6sT0uV4wX7yZ1aB5cD9eF2gH" \ -H "Content-Type: application/json" \ -d '{"query": "{ grants { source grantedAt } }"}'{ "data": { "grants": [ { "source": "gmail", "grantedAt": "2026-06-13T17:04:05Z" }, { "source": "imessage", "grantedAt": "2026-06-13T17:05:12Z" } ] }}| Field | Type | Meaning |
|---|---|---|
source | String! | The source identifier — e.g. gmail, imessage. Matches the strings in Data Sources. |
grantedAt | DateTime! | When the grant was first issued (RFC 3339). |
grants is your source of truth for what data is reachable. An empty list ([]) means the user has revoked everything, or never authorized — see The empty-result contract.
Source scoping
The end-user token reads only the sources with an active grant. A source the user has not granted is invisible, never an error:
- Queries succeed and simply return no data from non-granted sources.
- You will never receive a
403/PERMISSION_DENIEDfor “this source isn’t granted.” Absence of a grant looks exactly like absence of data. - There is no cross-source leakage: a
gmail-only user’smessagesresults contain only Gmail messages.
This is why you call grants first: it is the only way to distinguish “this user has no Gmail” from “this user never connected Gmail.”
Source vs. platform in the schema. Each entity carries a
platformfield that holds the source identifier (e.g."gmail"). When you filter a list by source, the filter argument is namedplatforms(a list of source-identifier strings). The values are the same source strings everywhere — only the field name differs by context (grants.source,Message.platform,filter.platforms).
Available queries
All list queries are Relay-style cursor connections (see Pagination). The examples below show representative fields — request only the fields you need.
messages
Messages across all granted sources.
{ messages(first: 5, sort: NEWEST_FIRST, filter: { platforms: ["gmail"] }) { edges { node { id body sentAt platform sender { __typename ... on Person { displayName } } recipients { __typename } } cursor } pageInfo { hasNextPage endCursor } totalCount }}Key Message fields:
| Field | Type | Notes |
|---|---|---|
id | ID! | Stable message identifier. |
body | String | Message text (may be null for non-text content). |
sentAt | DateTime! | When the message was sent. |
platform | String! | The source identifier this message came from. |
contentType | ContentType! | The kind of content. |
conversation | Conversation! | The thread this message belongs to. |
sender | Person | PlatformIdentity | Union — narrow with ... on Person / ... on PlatformIdentity. |
recipients | [Recipient!]! | The message recipients. |
attachments | [Attachment!]! | Attachments, if any. |
MessageFilter accepts platforms: [String!], conversationIds, senderPersonIds, recipientPersonIds, contentTypes, before/after (DateTime), a free-text search, and an optional intent (see Search).
messages also takes a top-level sort: SortOrder argument (alongside filter, first, after) — NEWEST_FIRST (the default) or OLDEST_FIRST. This is unique to messages; the other entity queries return their default ordering.
conversations
Conversation threads.
{ conversations(first: 10) { edges { node { id title type platform lastMessageAt messageCount participants { __typename } } } pageInfo { hasNextPage endCursor } }}Key Conversation fields: id, title (String), type (ConversationType!), platform (String!), lastMessageAt, messageCount, participantCount, participants, and a nested messages(...) connection so you can page a single thread. ConversationFilter accepts platforms, types, participantPersonIds, before/after, search, and intent.
persons
The people known from granted sources. (The query is persons, not people.)
{ persons(first: 50) { edges { node { id displayName platforms identities { __typename } connections(first: 5) { edges { node { person { displayName } metrics { __typename } } } } } } pageInfo { hasNextPage endCursor } }}Key Person fields: id, displayName (String), identities ([PlatformIdentity!]!), platforms ([String!]! — the sources this person appears in), connections(...) (a connection of related people — each edge node is a ConnectionSummary with a person: Person! and a metrics: ConnectionMetrics!, so reach the related person via node.person, not node directly), and nested messages/conversations/events/documents sub-queries. PersonFilter accepts platforms, identityHandle, before/after, search, and intent.
events
Calendar events.
{ events(first: 10) { edges { node { id title startAt endAt allDay platform participants { __typename } } } pageInfo { hasNextPage endCursor } }}Key Event fields: id, title (String), description, startAt (DateTime!), endAt, allDay (Boolean!), platform (String!), participants. EventFilter accepts platforms, before/after, allDay, search, and intent.
documents
Documents and local files.
{ documents(first: 10) { edges { node { id title bodyExcerpt contentType platform authorDisplayName sourceModifiedAt } } pageInfo { hasNextPage endCursor } }}Key Document fields: id, title (String), bodyExcerpt (String — a text excerpt, not the full body), contentType (String), platform (String!), authorDisplayName, sourceCreatedAt, sourceModifiedAt. DocumentFilter accepts platforms, contentTypes, before/after, search, and intent.
timeline
A chronological, cross-entity view — messages, events, and documents interleaved by time. timeline requires a filter with a bounded time window (after and before are required).
{ timeline( filter: { after: "2026-06-01T00:00:00Z", before: "2026-06-13T23:59:59Z" } first: 20 ) { edges { node { __typename ... on Message { body sentAt platform } ... on Event { title startAt platform } ... on Document { title platform } } } pageInfo { hasNextPage endCursor } }}Each node is one of Message, Event, or Document — narrow with __typename and inline fragments. TimelineFilter accepts entityTypes, platforms, personIds, the required after/before window, search, an optional intent, and a strategy (see Search).
Search
Every list filter accepts a free-text search: String. Supplying it ranks results by relevance to the query text:
{ messages(first: 10, filter: { search: "dinner reservation" }) { edges { node { body sentAt platform } } }}Every filter also accepts an optional intent: SearchIntent — a hint about what you are trying to accomplish (CATCH_UP, RESEARCH_TOPIC, FIND_ACTION_ITEMS) that lets the API tune ranking. It is never required, and you can always override ordering explicitly (e.g. messages’ sort).
The timeline query additionally exposes a strategy on its filter, letting you choose how a text search is executed:
{ timeline( filter: { after: "2026-01-01T00:00:00Z" before: "2026-06-13T23:59:59Z" search: "quarterly planning" strategy: HYBRID } first: 20 ) { edges { node { __typename ... on Message { body sentAt } } } }}strategy values: KEYWORD, SEMANTIC, HYBRID, TEMPORAL, FUZZY. When you supply a search on the timeline without a strategy, it defaults to HYBRID (keyword and semantic results fused). The per-entity filters (messages, conversations, etc.) expose search but not strategy.
Pagination
All list queries use the same Relay-style cursor pagination:
- Request:
first: N(page size) andafter: <cursor>(the cursor to resume from). - Page size: defaults to 25 when omitted; the maximum is 100 (larger values are silently clamped).
- Response: an
edgesarray ({ node, cursor }), apageInfo({ hasNextPage, endCursor }), and a nullabletotalCount.
Cursors are opaque — do not parse them. Pass endCursor from one page as after on the next:
# Page 1{ messages(first: 50) { edges { node { id } } pageInfo { hasNextPage endCursor } } }
# Page 2 — feed the previous endCursor back in{ messages(first: 50, after: "c3Vu...") { edges { node { id } } pageInfo { hasNextPage endCursor } } }Loop until pageInfo.hasNextPage is false. totalCount may be null when an exact count is unavailable — never block your pagination loop on it.
Mutations
revokeGrant
Disconnect a source on behalf of the end user. Use this to power a “Disconnect Gmail” button in your UI.
mutation { revokeGrant(grantId: "018f0a2b-7c3d-7e4f-8a1b-2c3d4e5f6071") { __typename ... on RevokeGrantSuccess { grantId revokedAt } ... on NotFoundError { code message } ... on PermissionError { code message } ... on ValidationError { code message } }}revokeGrant returns a RevokeGrantResult union — always check __typename:
| Result type | Meaning |
|---|---|
RevokeGrantSuccess | The grant is revoked. Returns the grantId and revokedAt. |
NotFoundError | No grant with this grantId belongs to you (unknown or another customer’s grant). |
PermissionError | This token may not revoke grants. |
ValidationError | The grantId was malformed. |
Revocation is idempotent. Revoking a grant you own returns RevokeGrantSuccess whether or not it was already revoked; a repeat call returns the original revokedAt (the first revocation’s timestamp), not a new one. Re-revoking is never a NotFoundError — that result is reserved for a grantId that isn’t yours at all. So you can safely retry a “Disconnect” action without special-casing the already-disconnected state.
After a successful revoke, that source’s data stops appearing in queries and the grant disappears from grants. Revocation does not invalidate the token itself — see the empty-result contract below.
The empty-result contract
When an end user has revoked all access, the end-user token remains valid and data queries keep succeeding — they simply return empty lists. They do not return 401/403.
This is deliberate: returning an auth error on a revoked grant would leak the user’s grant state to anyone holding the token. So an empty result is ambiguous on its own:
- The user has no data yet (e.g. an asynchronous first import hasn’t landed) → empty list.
- The user revoked the grant → empty list.
To tell them apart, call grants first:
grantsreturns the source → data exists or is on its way (use webhooks to know when an async import lands).grantsis[]→ the user has revoked all access or never authorized. Treat as “disconnected,” not “error.”
Common patterns
“Has this user connected Gmail?”
{ grants { source } }Check the returned list for "gmail".
“Get this user’s recent iMessages.”
{ messages(first: 20, filter: { platforms: ["imessage"] }) { edges { node { body sentAt } } pageInfo { hasNextPage endCursor } } }“List everyone this user knows.”
{ persons(first: 50) { edges { node { displayName platforms } } pageInfo { hasNextPage endCursor } } }“Disconnect a source.” Call revokeGrant with the grantId, then re-query grants to confirm it is gone.
Error reference
Validation, lookup, and permission failures arrive as GraphQL errors with a machine-readable extensions.code:
extensions.code | Meaning | What to do |
|---|---|---|
VALIDATION_ERROR | A query argument was malformed (bad cursor, invalid filter value). | Fix the request. |
NOT_FOUND | A by-id lookup (e.g. message(id:)) found nothing. | The entity does not exist or is not visible to this user. |
PERMISSION_DENIED | The operation is not permitted for this token. | Check you are using the right token tier. |
Authentication failures (missing, malformed, or revoked token) are returned before GraphQL executes, as HTTP 401 with an AUTH_INVALID_KEY / AUTH_EXPIRED_KEY body — see Authentication. Remember: a revoked grant is not an auth failure; it yields empty results, not a 401.
Related Documents
- Authentication — Obtaining and sending the end-user token; auth error codes.
- Authorization Flow — How the end-user token is produced via the consent flow.
- Management API — The account-wide, server-to-server view (uses a management token instead).
- Webhooks — Push notifications for when an asynchronous import completes.
- Data Sources — The source identifiers used in
grants,platform, andfilter.platforms. - Quickstart — The end-to-end walkthrough these queries fit into.