VeroXM Docs

GraphQL API

Alongside the REST v2 API, VeroXM exposes a public, read-only GraphQL API for pulling content, media, and collection metadata into any client. It is code-first (the schema below is generated from typed model classes, not hand-written SDL), lives on its own endpoint, and reuses the exact same content-reading logic as REST v2 - same cache, same field-shaping, same locale-fallback behavior. Nothing about what content comes back differs between the two transports; GraphQL's advantage is letting a client ask for exactly the fields it needs in one round trip, including nested relations, instead of over-fetching a fixed REST shape.

Why GraphQL

  • One round trip, exactly the fields you need. A REST response has a fixed shape - you either get more than you asked for or make a second request for a related field. A GraphQL query gets the entry, its related entries, and their media in a single request, shaped exactly like the query that asked for it.
  • A typed, introspectable schema. Point any GraphQL client or IDE plugin at the endpoint and it can show you every query, argument, and field VeroXM exposes - no separate reference to keep in sync by hand.
  • Pay only for what you use. totalCount on a list query only runs its count query when your selection set actually asks for it (see below) - a cost REST always pays, every time, whether you need the number or not.

What makes VeroXM's GraphQL API different

Many platforms bolt a GraphQL layer onto an existing REST API as a second implementation, with its own caching, its own field-shaping, and its own permission checks that can quietly drift from what REST does. VeroXM does not do that:

  • No consistency gap. The resolvers below are a thin routing layer over the same PublicContentService REST v2 already uses - same cache-aside reads, same locale fallback, same trashed-row handling. Content published through the dashboard shows up identically on either transport at the same time, because there is only one place that decides what "the current version of this entry" means.
  • One token, one set of rules, both transports. The same project-scoped API token, the same abilities (read/create/update/delete), and the same per-token rate limiting apply whether you call REST or GraphQL - there is no separate GraphQL-only auth system to provision or audit.
  • Guarded by default, not as an afterthought. A public-facing GraphQL schema is an easy target for a pathologically deep or wide query. Every request here is checked against a query complexity budget before any resolver runs (see Query complexity limits below) - you do not have to remember to add that protection yourself before going to production.
  • Part of one experience platform, not a bolt-on API. The same query resolves the right locale variant and can sit behind the same Content Experiments data your REST integration uses - GraphQL is another way to read the platform's content, not a separate product living beside it.

Endpoint & authentication

Endpoint
POST https://<your-veroxm-host>/public/v2/graphql

Authenticate with a project-scoped API token (see Authentication), sent as a bearer token - the same token type the REST v2 API uses:

Header
Authorization: Bearer <your-api-token>

The token is the request's only source of project identity. Unlike REST v2, there is no project uuid anywhere in the URL or arguments - every query reads from whichever project the token belongs to. A token scoped to the read ability can run every query on this page; a token without it is rejected with 403 Forbidden before any resolver runs. Every request also passes through the same per-token rate limiting REST v2 uses - a 429 response carries a Retry-After header when you are over the limit.

Schema

schema.graphql
type Query {
  collection(slug: String!): CollectionMeta
  collections: [CollectionMeta!]!
  entry(collection: String!, id: Int!, timestamps: Boolean = false): Entry
  entries(
    collection: String!
    where: [WhereClauseInput!]
    sort: String
    locale: String
    state: String
    offset: Int
    limit: Int
    timestamps: Boolean = false
  ): EntryConnection!
  media(id: Int!): MediaView
}

type Entry {
  id: ID!
  locale: String
  createdAt: DateTime
  updatedAt: DateTime
  publishedAt: DateTime
  data: JSONObject!
}

type EntryConnection {
  items: [Entry!]!
  totalCount: Int
}

type CollectionMeta {
  slug: String!
  name: String!
  description: String
  fields: [CollectionFieldMeta!]!
}

type CollectionFieldMeta {
  name: String!
  label: String!
  type: String!
}

type MediaView {
  id: ID!
  fileName: String!
  fullUrl: String!
  thumbUrl: String
  thumbWebpUrl: String
  fullWebpUrl: String
  caption: String
  tags: [String!]!
  size: Int
  width: Int
  height: Int
}

input WhereClauseInput {
  field: String!
  op: String
  value: JSONObject
}

data on Entry is a JSON blob shaped exactly like the REST API's per-entry response, rather than per-collection typed fields - collections are user-defined and can change field shape at any time, so the schema cannot commit to a fixed type per collection.

Queries

QueryReturns
collection(slug)One collection's field list (name, label, type per field). null if the slug does not exist in this project.
collectionsEvery collection's field list - useful for building a dynamic renderer without hardcoding collection shapes.
entry(collection, id, timestamps?)A single entry by id. Only ever returns a published entry - there is no state argument on this query, unlike entries.
entries(collection, ...)A page of entries, matching REST v2's list parameters field for field: where, sort, locale, state, offset, limit, timestamps.
media(id)A single media item by id, including its already-generated thumbnail and WebP variants.

where takes an array of { field, op, value } clauses, every clause AND'ed together. op matches the REST operator vocabulary - like, not, in, not_in, lt, lte, gt, gte, between, not_between, null, not_null. Omit op entirely for a plain equality match. OR semantics (REST's {or: {...}} form) have no typed GraphQL equivalent yet - use the REST v2 API for that query if you need them.

totalCount is lazy

EntryConnection.totalCount only triggers a count query server-side if your selection set actually asks for it:

Only pay for totalCount when you select it
query {
  entries(collection: "posts", limit: 10) {
    items { id data }
    totalCount   # costs one extra round trip - omit it if you don't need a total
  }
}

A query that never selects totalCount never pays for that extra lookup - the concrete payoff of asking for only what you need, compared to a REST response that always computes it.

Example queries

A filtered, sorted page of entries
query RecentPosts {
  entries(
    collection: "posts"
    where: [{ field: "status", value: "featured" }]
    sort: "created_at:desc"
    limit: 10
  ) {
    items {
      id
      data
      publishedAt
    }
    totalCount
  }
}
One entry with timestamps
query OnePost {
  entry(collection: "posts", id: 42, timestamps: true) {
    id
    data
    createdAt
    updatedAt
  }
}
A collection's field list, for a dynamic renderer
query PostsSchema {
  collection(slug: "posts") {
    name
    description
    fields { name label type }
  }
}
A media item
query CoverImage {
  media(id: 17) {
    fullUrl
    fullWebpUrl
    width
    height
    caption
  }
}

Query complexity limits

Because relation fields inside data can nest, and this is a public-facing API, every request is checked against a complexity budget before any resolver runs. Every field defaults to a complexity of 1, so a query's complexity is effectively its total field count - deep or unusually wide queries are rejected outright rather than allowed to run an expensive resolver chain:

429 response body
{
  "errors": [
    {
      "message": "Query is too complex: 1240. Maximum allowed complexity: 1000.",
      "extensions": { "code": "QUERY_TOO_COMPLEX" }
    }
  ]
}

If you hit this, narrow the query - select fewer fields, paginate with a smaller limit, or split one large query into several smaller ones.

Errors

GraphQL errors follow the standard { errors: [{ message, extensions: { code } }] } shape. The codes you will see from this API:

  • UNAUTHENTICATED - missing or invalid bearer token.
  • FORBIDDEN - the token is valid but lacks the ability the query requires (e.g. a write-only token calling a read query).
  • QUERY_TOO_COMPLEX - see above.
  • Standard GraphQL validation errors (unknown field, wrong argument type, etc.) for a malformed query, caught before any resolver runs.

GraphQL vs. REST v2

Same underlying content, same auth model, same rate limits - pick whichever fits the client:

  • GraphQL - one round trip for exactly the fields you need, typed where filters, a schema you can introspect. Best for a client that wants to shape its own queries.
  • REST v2 - simpler for a one-off curl/webhook-style integration, or a client that does not want a GraphQL dependency at all.

No consistency gap between transports

Both APIs read through the same cache and the same content-reading service, so there is nothing to keep in sync - content published through the dashboard shows up identically on either transport at the same time.