The developer GraphQL shaped-read model
REST endpoints on Orbit return one resource per response. When a read is shaped like a tree — a contact with its conversations, and each conversation’s messages — the per-resource pattern becomes a chain of requests: N+1 round-trips and a client-side join. The developer GraphQL surface is the answer to that shape: you describe the tree, one request returns it nested. This page is the model for that surface: what it is, what it refuses, and which invariants every query rides on. For endpoint-by-endpoint walkthroughs and copy-pasteable queries in seven SDK languages, use the GraphQL querying guide — this page explains the design, that guide shows the calls.Why a shaped-read surface exists alongside REST
Orbit’s machine-readable surface already speaks four contracts for the per-resource world: the REST API and its OpenAPI document, the AsyncAPI contract for events, and the Postman collection that regenerates from OpenAPI. Those contracts answer “give me resource X” — precisely, one endpoint at a time. A shaped read is the case where none of them fit: the response you want is a contact joined to two other collections, and building it from per-resource endpoints means three sequential requests plus glue code that re-implements a database join in your application. The GraphQL surface covers exactly one such tree — the four highest-traffic CPaaS/CDP resources, contacts, conversations, messages, and segments — so resolution-shaped reads become one request. Everything else, and every write, still belongs to REST: mutations are not part of the surface at all, which is what keeps it small enough to hold invariants over.A deliberately minimal grammar
The surface accepts a strict subset of the GraphQL language. Supporting the subset — rather than shipping a general GraphQL server — is the point: every rejected feature below is a behavior class the surface can then promise never to have. Supported:- One
queryoperation per document, named or anonymous shorthand - Variable definitions with
$varreferences and default values - Field arguments over scalars, lists, objects, enums, and null
- Nested selection sets, field aliases, and the
__typenamemeta-field
- Mutations and subscriptions — the surface is read-only; writes go through the REST endpoints
- Fragments (
fragment,...spread) — the parser rejects the keyword outright - Directives (
@include,@skip, custom) — not part of the grammar __schema/__typeintrospection — only__typenameexists; the schema travels as a served SDL document instead
The served SDL is the executed schema
Introspection is absent because the schema arrives out of band:GET /api/v1/developer/graphql/schema returns the full SDL document as
plain text, served alongside the OpenAPI, AsyncAPI, and Postman
contracts.
The SDL and the executor render from the same source. One schema
descriptor drives both paths: execution validates every field, argument,
and selection against it, and the SDL endpoint prints it. Documentation
can never drift from behavior, because there is only one descriptor.
That makes pin to the SDL the integration contract: a field listed
in the served SDL with arguments it declares will execute; anything not
listed fails validation instead of silently returning null. Updates to
the surface land in both places at once, and the served document is the
diff you integrate against.
Complexity accounting
Depth limits alone do not bound what a query will cost. A selection only three levels deep — 100 contacts, each with 50 conversations, each with 100 messages — fans out to thousands of per-parent resolver calls, and aliases let a query repeat that subtree arbitrarily many times. Left unbounded, that is a cross-tenant denial-of-service vector. So every query pays for what it asks before it runs. The engine statically estimates resolver-backed work: each object-typed field costs one unit per parent that resolves it, a list field multiplies everything beneath it by its page size (limit, clamped to the field’s maximum),
and scalar leaves cost nothing because they read from an already-fetched
row. Aliased repeats count fully. When the estimate exceeds 1000 units
the request is rejected in validation with Query is too complex —
before any resolver executes. Nesting depth separately caps at eight
levels.
The result envelope, and where it stops
Query-level failures return HTTP 200 with a{ data, errors } body:
each failing field comes back null at its path with a corresponding
entry in errors, so partial results survive one broken subtree.
Validation verdicts — an unknown field, a missing sub-selection, the
complexity rejection, a parse failure on an otherwise well-formed
request — land in the same envelope.
That convention stops at the query boundary. Transport failures still
answer as ordinary HTTP errors: authentication and role checks reject
with 401/403, malformed bodies with 422, and rate limiting with 429.
Result-envelope errors never mask those, and you should treat both
channels in client code: HTTP status first, then errors inside a 200.
Safety invariants, inherited from the developer routes
The surface holds the same guarantees as every other developer route, because it sits behind the same guards:- Authentication and role — an API key or dashboard session with an
owner,admin, ordeveloperrole, exactly as the REST developer routes require - Tenant isolation — resolvers close over one tenant-schema reference derived from the caller’s context and validated as a safe identifier; every statement is parameterized. A query can only ever read the calling organization’s own data
- Read-only — no write of any kind, and the surface touches no outbound sending path at all: it reads contacts, conversations, messages, and segments