Skip to content

GraphQL API

AnvilBase exposes a GraphQL endpoint over every table in your project, served by pg_graphql running inside the project database. It speaks the standard GraphQL-over-HTTP protocol, so any GraphQL client — or a plain fetch — works against it by POSTing to the endpoint.

The GraphQL schema is reflected from your database — tables, columns, relationships, and enums become GraphQL types automatically. No schema to define or keep in sync.

Base path:

/v1/graphql/<project_id>

When you configure a client with the project base URL http://<host>/v1/<project_id> and append graphql/v1 (the conventional pg_graphql path), requests land at /v1/<project_id>/graphql/v1. Both URL shapes hit the same handler.

Authentication & RLS

GraphQL uses the same authentication and the same Row Level Security context as the REST API. Send your project key in the apikey header, and (for a signed-in user) the JWT in Authorization:

apikey: anvilbase_anon_<slug>_…
Authorization: Bearer <user-jwt> # optional; makes the request "authenticated"
ScopeHeader valueRLS
anonthe anon keyenforced (public rows only)
authenticatedanon key + user JWTenforced (the user’s rows)
service_rolethe service_role keybypassed (admin)

The resolver runs inside a transaction with SET LOCAL role and request.jwt.claims set exactly as REST does, so auth.uid(), auth.role(), and every policy evaluate identically. A GraphQL query can never see rows a REST query with the same key couldn’t — RLS is enforced in one place, the database.

A missing or invalid credential returns 401 (transport-level), before any GraphQL resolution happens. See API Keys & Scopes.

Request shape

POST a standard GraphQL-over-HTTP body:

{
"query": "{ ... }", // required — the GraphQL document
"variables": { }, // optional — variable values (object)
"operationName": "MyQuery", // optional — selects one of multiple operations
"extensions": { } // optional — protocol extensions
}

The response is the GraphQL spec envelope, { "data": …, "errors": … }, returned verbatim from pg_graphql.

GraphQL-over-HTTP convention: a query that is malformed or fails validation returns HTTP 200 with an errors array in the body — not a 500. Only transport/auth/infrastructure failures return a non-200 status. Inspect the errors field, not the HTTP status, for query-level problems.

Naming conventions

pg_graphql reflects your schema with predictable names:

  • A table widgets becomes a collection field widgetsCollection returning a Relay-style connection (edges { node { … } }, pageInfo, etc.).
  • Columns keep their database names (e.g. is_public, not isPublic) unless your project enables inflection via a @graphql comment directive on the schema.
  • Foreign keys become nested fields you can traverse in a single query.

See the pg_graphql API reference for the full set of fields, filters (filter: { col: { eq: … } }), ordering, and pagination arguments, and for how to customize names with comment directives.

Examples

curl

Terminal window
curl "http://localhost:39001/v1/graphql/<project_id>" \
-H "apikey: $ANON_KEY" \
-H "content-type: application/json" \
-d '{ "query": "{ widgetsCollection { edges { node { id name } } } }" }'

With variables

Terminal window
curl "http://localhost:39001/v1/graphql/<project_id>" \
-H "apikey: $ANON_KEY" \
-H "content-type: application/json" \
-d '{
"query": "query ($n: String!) { widgetsCollection(filter: { name: { eq: $n } }) { edges { node { id } } } }",
"variables": { "n": "anvil" }
}'

A GraphQL client or fetch pointed at ${baseUrl}/graphql/v1

pg_graphql has no dedicated SDK method — you POST GraphQL to the graphql/v1 URL. With fetch:

const baseUrl = 'http://localhost:39001/v1/<project_id>'
const res = await fetch(`${baseUrl}/graphql/v1`, {
method: 'POST',
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${ANON_KEY}`, // or the user's JWT for an authenticated request
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `{ widgetsCollection { edges { node { id name } } } }`,
// variables: { … },
}),
})
const { data, errors } = await res.json()

Any GraphQL client works the same way — point its endpoint at ${baseUrl}/graphql/v1 and set the apikey (and optionally Authorization) headers. For example, with graphql-request:

import { GraphQLClient } from 'graphql-request'
const client = new GraphQLClient(`${baseUrl}/graphql/v1`, {
headers: { apikey: ANON_KEY },
})
const data = await client.request(`{ widgetsCollection { edges { node { id name } } } }`)

Introspection

The standard introspection query works for any valid key:

{ __schema { queryType { name } } }

How it works

Under the hood the endpoint executes graphql.resolve(query, variables, operationName, extensions) against your project database over the same per-project connection pool the REST engine uses, inside a transaction carrying the per-request RLS role and claims. pg_graphql parses the document, plans it against the live schema, and returns the JSON envelope — there is no separate GraphQL service to run or scale.

The pg_graphql extension is installed in every project database, and the anon / authenticated / service_role roles are granted access to its resolver at provisioning time (and back-filled for projects created before this feature). Those grants are least-privilege: USAGE on the graphql schema plus EXECUTE on its functions (and read-only SELECT on its internal tables) — the roles get no write privileges on pg_graphql’s objects, because the resolver is read-only and reflects your schema from the Postgres system catalogs. Row visibility is still decided entirely by RLS under the active role.

Read on: