Skip to content

GraphQL API

Requires a license with the graphql feature. See pricing.

The GraphQL API serves your content types as one GraphQL schema at /api/v1/graphql, beside the REST Content API. Every content type gets a list query, a single-entry query and create, update and delete mutations, and the schema follows your content types without a restart. A client asks for exactly the fields it needs, and subscriptions tell it when content changes.

The schema is generated from your content types and rebuilt when one is created or changed. For a content type named post:

FieldArgumentsReturns
postlimit (default 50, 1 to 500), offset, where, localeA list of Post
post_oneid, localeOne Post, or null when it does not exist
createPostinputThe created Post
updatePostid, inputThe updated Post, or null when it does not exist
deletePostidtrue when an entry was deleted
Content type nameListSingleType and mutation suffix
postpostpost_onePost
articlesarticlesarticleArticles
blog-postblog_postblog_post_oneBlogPost
newsnewsnews_oneNews

The list field is the name with - turned into _. The single field drops a final s when at least three characters remain and the name does not end in ss, us, is or ws, and adds _one otherwise. Types and mutations use the name in PascalCase, never singularized, so articles gives createArticles. Introspection, or the GraphQL page in the admin console, shows the exact names.

Every type has id and, when the content type keeps them, created_at and updated_at. Fields map by field_type:

field_typeGraphQL type
numberFloat
booleanBoolean
datetimeDateTime, an RFC 3339 string
jsonJSON, passed through
uidID
every other type, such as text, rich_text, email, url and mediaString

A required field is non-null. A belongs_to relation appears as <field>_id, the related entry's id, and can be filtered. Every type also has resolved_locale, set when the read asked for a locale.

A content type's transports block decides whether GraphQL serves it: r gives queries only, w mutations only, and off removes the type from the schema and from subscriptions. See Transports.

You need a token in TOKEN (the quickstart shows how to get one) and a content type named post, such as the one in Create your first content type.

  1. Check the endpoint is on:

    Terminal window
    curl http://localhost:3002/api/v1/graphql -H "Authorization: Bearer $TOKEN"
    { "message": "GraphQL endpoint ready. POST a query to this URL.", "plugin": "graphql", "version": "..." }
  2. Create an entry. A mutation needs the editor, admin or super_admin role:

    Terminal window
    curl -X POST http://localhost:3002/api/v1/graphql \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"query": "mutation { createPost(input: {title: \"From GraphQL\", slug: \"from-graphql\"}) { id title } }"}'
    { "data": { "createPost": { "id": "6265e2d6-a68e-4c93-b18a-d25f424cd6a8", "title": "From GraphQL" } } }
  3. Query it with a filter:

    Terminal window
    curl -X POST http://localhost:3002/api/v1/graphql \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"query": "{ post(limit: 5, where: {slug: \"from-graphql\"}) { id title } }"}'
    { "data": { "post": [ { "id": "6265e2d6-a68e-4c93-b18a-d25f424cd6a8", "title": "From GraphQL" } ] } }
  4. Register that query as persisted, on the Admin API:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/graphql/persisted-queries \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"query": "{ post(limit: 10) { id title } }", "operation_name": "LatestPosts"}'

    The answer is 201 with query_hash, the SHA-256 of the query text.

  5. Run it by hash alone:

    Terminal window
    curl -X POST http://localhost:3002/api/v1/graphql \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"extensions": {"persistedQuery": {"version": 1, "sha256Hash": "<query_hash>"}}}'

    The answer is the same data as sending the text.

  6. Open Delivery > GraphQL in the admin console to see the root fields and the persisted queries.

  • limit defaults to 50 and is held between 1 and 500. A negative offset counts as 0.
  • where matches fields by equality, combined with AND. id and <field>_id relations can always be filtered. Media and JSON fields cannot.
  • locale, or an Accept-Language header, serves translated fields. See Localization.

A mutation's update input needs at least one field. On a content type with soft delete, delete sets deleted_at instead of removing the row, and update answers null for a deleted entry. A content type with no fields of its own gets no create mutation. Queries and mutations only reach the caller's tenant.

Every /api/v1/graphql route needs a signed-in caller or an API key, with the same token you use for REST.

  • Mutations need the editor, admin or super_admin role. An API key needs the graphql:write scope for a mutation, and graphql:read for the route itself.
  • Introspection is open to admins only by default. GRAPHQL_INTROSPECTION changes that.
  • The body must be Content-Type: application/json, or the answer is 415. Browser forms cannot send that type, and cross-origin JSON needs a preflight that LyEve refuses, which closes cross-site requests.

Connect a WebSocket to /api/v1/graphql/ws with the graphql-transport-ws subprotocol, the protocol of the graphql-ws client library.

  • Authenticate the upgrade request itself, with an Authorization header or the session cookie, like any other call. Then send connection_init with an empty payload, and the server answers connection_ack.
  • The upgrade needs an Origin header that is in CORS_ORIGINS or matches the instance's own host, or it is refused with 403 origin not allowed.
SubscriptionFires whenFields
schemaChangedA content type is created or changed.schema, action, timestamp
contentChanged(schema: "post")An entry of that type is created, updated or deleted. Leave out schema for every type.schema, action, recordId, data, timestamp

data is the stored entry, its system fields included: after the change, or as it was on a delete. schemaChanged reports every change with the action value create, and deleting a content type sends nothing.

Persisted queries follow the Automatic Persisted Queries protocol: a client sends extensions.persistedQuery.sha256Hash instead of the text. Each tenant has its own list.

  • An unknown hash answers an error whose extensions.code is PERSISTED_QUERY_NOT_FOUND.
  • With GRAPHQL_REQUIRE_PERSISTED=true, a request without a hash answers PERSISTED_QUERY_REQUIRED, so only approved operations run.
  • With GRAPHQL_ALLOW_AUTO_REGISTER=true, a client that sends the hash and the text together adds the query itself, up to GRAPHQL_MAX_AUTO_REGISTERED per tenant. Keep it off in production.
Persisted query routes
MethodPathPurpose
GET/api/admin/graphql/persisted-queriesList, with search, limit and offset. Answers {"data", "total", "limit", "offset"}.
POST/api/admin/graphql/persisted-queriesRegister query, with operation_name and description. Answers 201.
GET/api/admin/graphql/persisted-queries/{hash}One query.
DELETE/api/admin/graphql/persisted-queries/{hash}Remove a query. Answers 204.
PATCH/api/admin/graphql/persisted-queries/{hash}/toggleTurn a query on or off. Answers {"query_hash", "enabled"}.

These routes need the admin role.

When flows run beside GraphQL, the schema gains a runFlow mutation and a flows query. Neither exists otherwise.

mutation {
runFlow(slug: "order-totals", input: { order: 7 }) { runId status headers body }
}
  • runFlow starts a published flow of the caller's tenant whose HTTP trigger lists graphql in protocols, with input as the trigger body. It answers the run id (empty when the flow's cache answered), the status, the headers and the body.
  • flows lists the flows callable here: slug, name, description, auth and inputSchema.
  • The flow's auth setting decides who may run it: public and auth flows answer, admin flows do not. Its rate limit and cache count GraphQL callers apart from REST callers. runFlow does not need the editor role, but a document that also selects a content mutation still does.
  • Starting a flow over GraphQL needs flow-pro.

A refusal is a GraphQL error whose extensions.code is NOT_FOUND, PAYMENT_REQUIRED, RATE_LIMITED, LOOP_DETECTED, BAD_USER_INPUT (input is not an object) or UNAVAILABLE.

The graphql.query node runs a query against the tenant's own schema from a flow, with query, variables and operation_name, and outputs { data, errors }. It refuses mutations, so writes from a flow go through its content node.

Each setting can also be written in the configuration file under graphql, such as graphql.max_query_depth.

VariableWhat it doesDefault
GRAPHQL_INTROSPECTIONon, off or admin-only. Another value means admin-only.admin-only
GRAPHQL_MAX_QUERY_DEPTHDeepest allowed nesting7
GRAPHQL_MAX_QUERY_COSTHighest allowed query cost, where list fields cost more1000
GRAPHQL_QUERY_TIMEOUTLongest a query may run, such as 10s30s
GRAPHQL_REQUIRE_PERSISTEDRefuse every query that is not persistedfalse
GRAPHQL_ALLOW_AUTO_REGISTERLet clients register persisted queries themselvesfalse
GRAPHQL_MAX_AUTO_REGISTEREDMost self-registered queries per tenant1000
CORS_ORIGINSOrigins a browser may open a subscription fromnone

A query may select at most 500 fields, and a request body is at most 256 KiB. If you set LYEVE_PLUGINS to choose which features start, include graphql. See Licensing and tiers.

Errors arrive as {"errors": [{"message": "..."}]}.

StatusMessageCause
400max query depth 7 exceeded: 9The query nests too deeply.
400max query cost 1000 exceeded: 1450The query costs too much.
403mutation requires editor, admin, or super_admin roleA viewer sent a mutation.
415unsupported media type, expected application/json or multipart/form-data, or Content-Type must be application/jsonThe body is not application/json.
Every other error
StatusMessageCause
400max field count 500 exceeded: 612The query selects too many fields.
400introspection requires admin authenticationIntrospection by a non-admin in admin-only mode.
400introspection is disabledIntrospection with GRAPHQL_INTROSPECTION=off.
400invalid request body, query is requiredThe body does not parse, or has no query.
200code PERSISTED_QUERY_REQUIREDA plain query while persisted queries are required.
200code PERSISTED_QUERY_NOT_FOUNDAn unknown hash.
404The requested endpoint does not exist.The license does not include graphql, so the feature does not start.
403mutation requires the graphql:write scopeAn API key without write scope sent a mutation.
404persisted query not foundA wrong hash on the admin routes.
  • Content API: the REST version of these queries and mutations.
  • Data model: the content types the schema is built from.
  • Client libraries: @lyeve-labs/client-graphql wraps queries and subscriptions.
  • Flows: the flows runFlow starts.