> ## 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.

# Query related records in one request with GraphQL

> Fetch a contact together with its conversations and messages — or a segment with its contacts — in a single GraphQL call instead of fanning out over per-resource REST endpoints.

# Query the GraphQL surface

The REST API maps one endpoint to one resource: fetch a contact, then fetch its conversations, then fetch each conversation's messages. The developer GraphQL surface is the shaped-read counterpart — you describe the tree you want, and one request returns a contact with its conversations and messages already nested, or a segment with its contacts pulled in a single round-trip.

The surface is read-only and deliberately small. It covers the four highest-traffic CPaaS/CDP resource types — contacts, conversations, messages, and segments — so resolution-shaped reads stop needing client-side joins. For everything else (sending messages, managing channels, mutating anything), use the REST API.

## When to use GraphQL vs REST

| Situation                                                       | Reach for                                                 |
| --------------------------------------------------------------- | --------------------------------------------------------- |
| Fetch a contact plus its conversations and messages in one call | GraphQL `contact`                                         |
| List a segment's contacts without a second lookup               | GraphQL `segments`                                        |
| Browse contacts or conversations with a lifecycle/status filter | GraphQL `contacts` / `conversations`                      |
| Send a message, update a contact, manage any resource           | REST per-resource endpoint                                |
| Bulk export of a whole collection                               | REST list endpoints with [pagination](/guides/pagination) |

## Auth & roles

The GraphQL endpoints sit behind the same guard as every other developer route: authenticate with an API key (`X-API-Key: dv_live_sk_…`) or a dashboard session, and the calling key or session must carry an `owner`, `admin`, or `developer` role. See [Authentication](/authentication).

## Your first request

The query endpoint is `POST /api/v1/developer/graphql`, with a `{ "query", "variables?", "operationName?" }` JSON body. This query fetches one contact with its conversations and each conversation's recent messages in a single round-trip:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/developer/graphql \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query ContactExport($id: ID!, $conversationLimit: Int) { contact(id: $id) { id displayName phone email lifecycleStage conversations(limit: $conversationLimit) { id channel status lastMessageAt messages(limit: 20) { id direction body status sentAt } } } }",
    "variables": { "id": "contact_01JABC...", "conversationLimit": 10 },
    "operationName": "ContactExport"
  }'
```

The same call with the Node SDK's underlying fetch:

```typescript theme={null}
const response = await fetch(
  "https://api.orbit.devotel.io/api/v1/developer/graphql",
  {
    method: "POST",
    headers: {
      "X-API-Key": process.env.ORBIT_API_KEY ?? "",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      query: `
        query ContactExport($id: ID!) {
          contact(id: $id) {
            id
            displayName
            conversations(limit: 10) {
              id
              channel
              messages(limit: 20) { id direction body status }
            }
          }
        }
      `,
      variables: { id: "contact_01JABC..." },
      operationName: "ContactExport",
    }),
  },
);
const { data, errors } = await response.json();
if (errors) throw new Error(errors.map((e: { message: string }) => e.message).join("; "));
```

Always check the `errors` array. Per GraphQL-over-HTTP convention a query that parses but fails during execution still returns `200 OK` with `data: null` (or partial data) and an `errors` list — it does not come back as a REST-style error envelope.

## Schema introspection

The full schema is published as an SDL document, served alongside the OpenAPI spec, AsyncAPI contract, and Postman collection:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/developer/graphql/schema
```

It renders these types — `Query` with entry points `contact`, `contacts`, `conversation`, `conversations`, and `segments`, plus `Contact`, `Conversation`, `Message`, and `Segment`:

```graphql theme={null}
"""Read-only entry points over the highest-traffic CPaaS/CDP resources."""
type Query {
  """Fetch a single contact by its id."""
  contact(id: ID!): Contact
  """List contacts, most recently created first."""
  contacts(limit: Int, offset: Int, lifecycleStage: String): [Contact]
  """List conversations, most recently active first."""
  conversations(limit: Int, offset: Int, status: String): [Conversation]
  """List contact segments, most recently created first."""
  segments(limit: Int, offset: Int): [Segment]
}

"""A CDP contact profile."""
type Contact {
  id: ID
  displayName: String
  phone: String
  email: String
  lifecycleStage: String
  """This contact's conversations, most recently active first."""
  conversations(limit: Int): [Conversation]
  """This contact's messages, most recent first."""
  messages(limit: Int): [Message]
}
```

The SDL is rendered from the same descriptor that drives execution, so what you download is what the server enforces. Pin requests against the downloaded SDL — a field that isn't in the schema is rejected, so no request can silently start returning a renamed shape.

<Note>
  The surface runs on a deliberately minimal query language: a single `query` operation, variables with defaults, field arguments, nested selection sets, and aliases. Mutations, subscriptions, fragments, directives, and `__schema` introspection are not supported — the SDL endpoint above is the schema contract.
</Note>

## Variables, operation names, and limits

* `query` — the GraphQL document string, capped at **20 KB**.
* `variables` — a JSON object of values referenced as `$name` in the query. Variable definitions in the query can declare defaults; a request-supplied value overrides the default.
* `operationName` — required when the document contains more than one operation. If it names an operation that isn't in the document, you get `Unknown operation named "…"` in `errors`.

Two static guards bound the work a query can trigger, checked before anything executes:

* **Depth cap** — a selection set nested more than 8 levels is rejected.
* **Complexity cap** — each resolver-backed field costs one unit, and every list field multiplies everything beneath it by its (bounded) page size; a selection set estimated over 1000 units is rejected with `Query is too complex: …`. Reduce nesting, lower the `limit` arguments, or drop repeated aliased subtrees.

Both guards return the standard GraphQL envelope — `200 OK` with `{ "data": null, "errors": [{ "message": "…" }] }`.

Inspector responses carry `Cache-Control: no-store` and are never cached.

## Prototype in the dashboard Explorer

Before wiring code, open **Developer → GraphQL** in the dashboard. The Explorer runs queries against your live tenant with your session, renders the schema next to the editor, and shows the exact `data`/`errors` envelope your code would receive. Iterate on the query shape there, then paste the finished document into your integration.

## Pagination & nested collections

List arguments are clamped server-side; values outside the clamp fall back to the defaults below.

| Field                                                       | Default | Max |
| ----------------------------------------------------------- | ------- | --- |
| `contacts(limit:)`                                          | 25      | 100 |
| `conversations(limit:)`                                     | 25      | 100 |
| `segments(limit:)`                                          | 25      | 100 |
| `Contact.conversations(limit:)`                             | 10      | 50  |
| `Contact.messages(limit:)`, `Conversation.messages(limit:)` | 20      | 100 |

Top-level lists page by `offset` (`0` starts at the newest row by creation time; conversations order by last activity). Nested collections take a `limit` only — page their parent (e.g. further conversations) rather than the nested list itself.

**Tenant isolation:** execution is always scoped to the calling key's tenant. A GraphQL request can never reach another tenant's data, and there is no cross-tenant query mode.

## Walkthrough A — export a contact with all conversations and messages

One REST join costs three sequential requests (`GET /contacts/:id`, `GET /conversations?contact_id=…`, then `GET /messages?conversation_id=…` per conversation). The GraphQL version is a single request:

```graphql theme={null}
query ContactExport($id: ID!) {
  contact(id: $id) {
    id
    externalId
    displayName
    phone
    email
    lifecycleStage
    conversations(limit: 50) {
      id
      channel
      status
      lastMessageAt
      messages(limit: 100) {
        id
        direction
        body
        status
        sentAt
      }
    }
  }
}
```

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/developer/graphql \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "query": "…", "variables": { "id": "contact_01JABC..." }, "operationName": "ContactExport" }'
```

The response nests each conversation's messages under its conversation:

```json theme={null}
{
  "data": {
    "contact": {
      "id": "contact_01JABC...",
      "displayName": "Ada Meyer",
      "conversations": [
        {
          "id": "conv_01KXYZ...",
          "channel": "sms",
          "status": "open",
          "messages": [
            { "id": "msg_01HXYZ...", "direction": "inbound", "status": "delivered" }
          ]
        }
      ]
    }
  }
}
```

If the contact doesn't exist, `data.contact` is `null` with no `errors` entry — check for null before unpacking.

## Walkthrough B — traverse to a segment's contacts

Resolve segment ids, then pull contacts on the members' lifecycle stage — the query asks for the exact shape so the response carries just the fields you consume:

```graphql theme={null}
query SegmentExport($limit: Int) {
  segments(limit: $limit) {
    id
    name
    description
    contactCount
  }
  contacts(lifecycleStage: "customer", limit: 100) {
    id
    displayName
    phone
    company
  }
}
```

For a deeper traversal anchor the segment to one contact and walk out through that contact's conversations and message bodies — `contact(id:) → conversations → messages` — all in the same request, still one round-trip end to end.

## Errors & failure modes

GraphQL responses differ from the REST envelope in one important way: **a query-level failure is not an HTTP error.** Parse failures, validation rejections, and per-field resolver errors all return `200 OK` with an `errors` array next to `data` (which is `null` or partially populated on failure). Transport-level failures — auth, body validation, rate limits — still come back as HTTP error codes.

| Failure                                                                      | Status                   | Shape                                        | Fix                                                                    |
| ---------------------------------------------------------------------------- | ------------------------ | -------------------------------------------- | ---------------------------------------------------------------------- |
| Missing/invalid API key or insufficient role                                 | `401`/`403`              | REST `error` envelope                        | Check `Settings → API Keys`; the key needs `owner`/`admin`/`developer` |
| Body fails validation (empty `query`, `query` over 20 KB, wrong field types) | `422` `VALIDATION_ERROR` | REST envelope with `details.issues`          | Send `{ query, variables?, operationName? }` as JSON                   |
| Query won't parse (syntax error)                                             | `200`                    | `errors: [{ "message": "Syntax error: …" }]` | Fix the document syntax                                                |
| Query too deep or too complex                                                | `200`                    | `errors: [{ "Query is too complex: …" }]`    | Reduce nesting, lower `limit`s, drop aliasing repeats                  |
| Unknown field on a type                                                      | `200`                    | per-field `errors` entry                     | Check the field against the SDL endpoint                               |
| Rate limit exceeded                                                          | `429` `RATE_LIMITED`     | REST envelope with `details.retry_after`     | Respect the retry hint; see below                                      |

Use [REST error-handling patterns](/guides/error-handling-examples) for the HTTP-level failures, and treat the `errors` array as the GraphQL-level failure channel.

### Rate limits

Both endpoints share the tenant-scoped authenticated write bucket — **60 requests per minute per tenant**. Buckets are per tenant, so a heavy integration cannot crowd out your dashboard, but your own scripts share the same window. On `429`, read `details.retry_after` (seconds) and back off instead of retrying on a fixed schedule.

Cheap request shapes stay cheap: batch the reads you need into one nested query rather than hammering the endpoint with single-field requests.

## Frequently asked questions

### Can I write through GraphQL?

No. The surface is read-only — there are no mutations. Sends, updates, and administrative actions all stay on the REST API.

### Why did my query return `200` with an `errors` array?

That's the GraphQL-over-HTTP convention: the request was well-formed at the HTTP layer, and the failure is inside the query. Read `errors[0].message`; common causes are a syntax error, an unknown field, or the complexity cap.

### How do I list more than 100 conversations?

Page the parent: `conversations(limit: 100, offset: …)` for the next batch. Nested collections carry at most one page per parent by design — they're shaped reads, not bulk export. For bulk export use the REST list endpoints with [pagination](/guides/pagination).

## See also

* [API integration](/guides/api-integration) — the REST counterpart these queries complement
* [Developer portal](/guides/developer-portal) — the try-it console, usage stats, key governance, and SDK catalogs
* [Pagination](/guides/pagination) — cursor paging on the REST list endpoints
* [Error handling examples](/guides/error-handling-examples) — REST envelope failures share shape with this surface's transport-level errors
