GraphQL API
The Foir GraphQL API is the primary interface for querying content, managing records, executing operations, searching, and authenticating customers. Your project’s GraphQL schema is generated from your models, so every model you define becomes directly queryable.
Schema Generation
When you create models in the Foir dashboard, the GraphQL schema is automatically generated for your project. Each model produces:
- A singular query (e.g.,
page,product) that returns a single typed record - A plural query (e.g.,
pages,products) that returns a cursor-paginated connection - Create, update, delete, and (for versioned/publishable models) publish and unpublish mutations
- Typed input types matching your model’s fields, including
where,orderBy, and update-operator inputs
Each generated record type exposes your declared fields directly at the top level alongside a small set of underscore-prefixed system fields (_id, _naturalKey, _modelKey, _createdAt, _updatedAt, _hasDraft). There is no data wrapper.
You can explore your generated schema using the GraphiQL explorer at https://api.foir.dev/graphiql or by fetching the schema from GET https://api.foir.dev/schema.
Every query and mutation is generated per model from your schema. Each model gets its own typed fields (e.g. page, pages, createPage), and system fields are _-prefixed (_id, _metadata). These typed per-model fields are the recommended interface — use them. A generic record(id:) / records(modelKey:) pair also exists as a raw-JSON escape hatch (gated on records:read), but there is no generic recordByKey or createRecord: fetch-by-natural-key and writes go through the typed per-model fields.
Request Headers
Every request accepts these headers. Authentication is required; the others are optional opt-ins.
| Header | Purpose |
|---|---|
Content-Type: application/json | Required — all requests are JSON. |
x-api-key: <key> | API-key authentication. Use a pk_… key for public reads, a sk_… key for writes or draft access. See Authentication. |
Authorization: Bearer <token> | Admin session token. Used by the admin app and the CLI; not for storefront traffic. See Authentication. |
X-Foir-Schema-Channel: draft | Read the draft schema and content instead of published. Requires a secret key with the drafts:read scope; silently ignored on public keys. See Schema Publishing. |
To personalise reads (variants, segments), pass the contexts query argument — there is no context request header.
Responses that hit a rate limit include X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and (on 429) Retry-After — see Errors & Rate Limits.
Querying Records
Every public model gets a typed singular query (named after the model, e.g. page) and a typed plural query (the model’s plural, e.g. pages). Both return the model’s typed object: your declared fields are merged at the top level, alongside the underscore-prefixed system fields. There is no data or metadata wrapper.
Single Record
Fetch a single record by natural key (slug or handle) or by ID:
query GetPage {
page(naturalKey: "homepage") {
_id
_naturalKey
_createdAt
_updatedAt
title
heroImage {
url
alt
}
}
}query GetPageById {
page(id: "rec_abc123") {
_id
title
}
}You must supply either naturalKey or id. The query returns null when no record matches.
List Records
The plural query returns a Relay-style cursor connection. Use where to filter, orderBy to sort, and first/after (or last/before) to paginate:
query ListProducts {
products(
where: { category: { eq: "electronics" }, price: { gte: 100 } }
orderBy: [{ createdAt: DESC }]
first: 10
) {
edges {
node {
_id
_naturalKey
title
price
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}To fetch the next page, pass the previous page’s pageInfo.endCursor as after.
Query Arguments
Single-record query (<model>):
| Argument | Type | Description |
|---|---|---|
naturalKey | String | Natural key / slug |
id | String | Record ID |
locale | String | Locale for content resolution (e.g., "en-US", "fr-FR") |
preview | Boolean | Return draft content instead of published (requires the drafts:read scope) |
contexts | ResolveContextInput | Context dimensions for variant resolution |
List query (<models>):
| Argument | Type | Description |
|---|---|---|
where | <Model>WhereInput | Typed filter tree (see Filtering) |
orderBy | [<Model>OrderByInput] | Sort order (see Sorting) |
first | Int | Forward page size |
after | String | Opaque cursor — fetch rows after this position |
last | Int | Backward page size |
before | String | Opaque cursor — fetch rows before this position |
locale | String | Locale for content resolution |
preview | Boolean | Return draft content instead of published |
contexts | ResolveContextInput | Context dimensions for variant resolution |
Content Resolution
Content resolution — variant selection, locale handling, and reference resolution — happens automatically based on the query arguments. There is no separate resolved sub-field: the resolved values are returned directly on the typed fields.
localeselects the localized value for translatable fields.contextsdrives variant matching (see Context).preview: truereturns the latest draft instead of the published version. This requires a secret key holding thedrafts:readscope; on a publishable key the flag is rejected.- Reference fields resolve to the typed target record, so you project the referenced record’s fields inline (e.g.
author { _id name }). Polymorphic references resolve to a union — use inline fragments (... on Author { name }).
System Fields
Every typed record exposes these underscore-prefixed system fields. They never collide with your declared fields:
| Field | Type | Description |
|---|---|---|
_id | String! | Unique record identifier |
_naturalKey | String | Slug or handle |
_modelKey | String! | Model this record belongs to |
_createdAt | DateTime! | When the record was created |
_updatedAt | DateTime! | When the record was last modified |
_hasDraft | Boolean | Whether unpublished changes exist. Populated only for keys holding drafts:read; otherwise null. |
Your declared model fields are queried directly by their own keys at the top level of the object.
Version History
For versioned or publishable models, browse version history with recordVersions. It returns a cursor connection of RecordVersion:
query GetVersions {
recordVersions(parentId: "rec_abc123", first: 10) {
edges {
node {
id
versionNumber
changeDescription
data
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}Required scope: records:read
Filtering
Each model has a generated <Model>WhereInput — a typed operator tree. Top-level fields are combined with AND. Filter on declared fields by key, plus the always-available direct columns: id, naturalKey, createdAt, updatedAt, ownerId, createdBy, updatedBy.
query FilteredProducts {
products(
where: {
category: { eq: "electronics" }
price: { gte: 100 }
inStock: { eq: true }
}
orderBy: [{ createdAt: DESC }]
) {
edges {
node {
_id
_naturalKey
title
}
}
totalCount
}
}Operators by Field Type
Operators available on a field depend on its type:
| Field type | Operators |
|---|---|
| Text | eq, ne, in, nin, lt, gt, lte, gte, contains, startsWith, endsWith, like, ilike, isNull, isNotNull |
| Number | eq, ne, in, nin, lt, gt, lte, gte, isNull, isNotNull |
| Date | eq, ne, in, nin, lt, gt, lte, gte, isNull, isNotNull |
| Boolean | eq, ne, isNull, isNotNull |
ID (id, ownerId, …) | eq, ne, in, nin, isNull, isNotNull |
| Select / Enum | eq, ne, in, nin, isNull, isNotNull (values are the field’s typed options) |
Combining Conditions
Use AND, OR, and NOT to compose conditions at any level:
query {
products(
where: {
OR: [
{ category: { eq: "electronics" } }
{ category: { eq: "computers" } }
]
NOT: { status: { eq: "archived" } }
}
) {
edges { node { _id title } }
}
}List and Reference Fields
- List fields accept
{ some, every, none }, each taking the item’s where shape — e.g.tags: { some: { eq: "featured" } }. - Reference fields accept the referenced model’s where shape directly — e.g.
author: { naturalKey: { eq: "jane-doe" } }. Polymorphic references use oneon<Model>branch per allowed target.
Sorting
Sort the plural query with orderBy, a list of <Model>OrderByInput. Each entry sets one field to ASC or DESC. Orderable direct columns are createdAt, updatedAt, naturalKey, and id; orderable scalar fields you declared can also be used.
query {
products(orderBy: [{ createdAt: DESC }, { id: ASC }]) {
edges { node { _id title } }
}
}Context
Context lets you deliver personalized content based on dimensions like device, locale, or custom attributes. Pass contexts (a ResolveContextInput) to any per-model query to trigger variant matching; the resolved values come back directly on the typed fields.
query PersonalizedContent {
page(
naturalKey: "homepage"
locale: "en-US"
contexts: { device: MOBILE, platform: IOS }
) {
_id
title
hero {
heading
}
}
}Built-in Context Dimensions
| Dimension | Type | Example Values |
|---|---|---|
locale | String | "en-US", "fr-FR" |
device | DeviceType enum | DESKTOP, MOBILE, TABLET |
platform | PlatformType enum | WEB, IOS, ANDROID |
Custom Dimensions
You can define custom context dimensions in your project settings (e.g. market, userSegment, channel). Each appears as a field on ResolveContextInput, typed as an enum of its configured values when you have defined them. Customer-derived dimensions such as authentication status and segment membership are filled in server-side from the customer token and are not client inputs.
Mutations
Each public model gets typed create<Model>, update<Model>, updateMany<Model>, and delete<Model> mutations. Versioned or publishable models also get publish<Model>Version and unpublish<Model>. All mutations return the typed record (or, where noted, a payload or Boolean).
Create a Record
create<Model>(input: Create<Model>Input!) takes the model’s declared fields flattened at the top level, plus two optional system keys: naturalKey and _metadata (underscore-prefixed so it never collides with a declared metadata field). It returns the created record.
mutation CreatePage {
createPage(input: {
naturalKey: "about"
title: "About Us"
body: "Welcome to our company..."
}) {
_id
_naturalKey
_createdAt
title
}
}Required scope: records:write
Update a Record
update<Model>(where: <Model>WhereInput!, data: <Model>UpdateInput!) identifies the target with where (which must match exactly one record) and applies a typed operator tree in data. For versioned models, the update creates a new version automatically.
Rather than flat values, each field in <Model>UpdateInput takes an operator object:
- Scalars (text/date/select/enum/boolean):
{ set }or{ setNull: true }. - Numbers: also
{ increment },{ decrement },{ multiply }. - References:
{ connect: { naturalKey: "..." } },{ disconnect: true }, or{ setNull: true }. - Lists:
{ set },{ push },{ pull },{ splice: { start, deleteCount, insert } },{ removeWhere }. - Composite (inline) fields:
{ update: { ... } }to patch in place, or{ set: { ... } }to replace wholesale.
mutation UpdatePage {
updatePage(
where: { naturalKey: { eq: "about" } }
data: {
title: { set: "About Us -- Updated" }
body: { set: "Updated content..." }
}
changeDescription: "Updated page title"
) {
_id
_updatedAt
title
}
}changeDescription is accepted on versioned models; variantKey is accepted on variant models to route the write to a variant child (created on demand). The direct column naturalKey is settable; id is never settable.
Required scope: records:write
Update Many Records
updateMany<Model>(where: <Model>WhereInput, data: <Model>UpdateInput!) applies the same typed update to every record matching the predicate. where is optional — omitting it matches every record in the model, so always scope it. It returns { count, ids }.
mutation MarkOnSale {
updateManyProduct(
where: { category: { eq: "electronics" } }
data: { price: { multiply: 0.9 } }
) {
count
ids
}
}Required scope: records:write
Atomic Field Operations
Atomic field updates are expressed through the update operator tree, not a separate operations array. They are evaluated server-side without a read-modify-write round trip:
mutation IncrementViewCount {
updateArticle(
where: { id: { eq: "rec_abc123" } }
data: {
viewCount: { increment: 1 }
tags: { push: ["featured"] }
lastViewed: { set: "2026-04-01T12:00:00Z" }
}
) {
_id
viewCount
tags
}
}| Operator | Applies to | Effect |
|---|---|---|
set | any field | Replace the value |
setNull | any field | Clear the value (set to null) |
increment / decrement / multiply | number | Adjust the numeric value in place |
push | list | Append items |
pull | list | Remove matching items |
splice | list | Insert/remove at an index (start, deleteCount, insert) |
removeWhere | list | Remove items matching the item’s where shape |
connect / disconnect | reference | Point at / clear a referenced record |
Delete a Record
delete<Model>(id: String!) permanently deletes a record and returns true. This cannot be undone.
mutation DeletePage {
deletePage(id: "rec_abc123")
}Required scope: records:delete
Publish a Version
For versioned or publishable models, publish a specific version to make it available via live keys. Returns true on success.
mutation PublishPage {
publishPageVersion(versionId: "ver_xyz789")
}Required scope: records:publish
Unpublish a Record
Remove a record from the published API. Returns true on success.
mutation UnpublishPage {
unpublishPage(id: "rec_abc123")
}Required scope: records:publish
Batch Operations
batchRecordOperations processes multiple create, update, and delete operations across any models in a single request. Each operation declares its type and the fields that type needs:
mutation BatchOps {
batchRecordOperations(input: {
operations: [
{
type: CREATE
modelKey: "product"
naturalKey: "blue-widget"
data: { name: "Blue Widget", price: 29.99 }
}
{
type: UPDATE
modelKey: "product"
id: "rec_existing123"
data: { price: 24.99 }
}
{
type: DELETE
id: "rec_old456"
}
]
}) {
created
updated
deleted
errors {
type
index
message
}
}
}modelKey is required for CREATE and UPDATE; id is required for UPDATE and DELETE. The result reports per-type counts plus an errors list with the failing operation’s index and message.
Required scope: records:write
Operations API
The Operations API lets you execute custom server-side operations from your frontend or backend. Operations are registered endpoints that extend the platform with custom logic like data processing, AI workflows, or business automation.
Prerequisites
- Register the operation in the dashboard with an endpoint URL
- Enable the API touch point on the operation
- Create an API key with the
operations:executescope
Execute an Operation
Each operation gets its own typed mutation named execute<Operation>, where <Operation> is the PascalCase form of the operation key. An operation with the key contact-form-handler becomes executeContactFormHandler. The input argument is a typed input object generated from the operation’s input schema.
mutation RunContactForm {
executeContactFormHandler(
input: {
name: "Jane Doe"
email: "jane@example.com"
message: "I have a question about your enterprise plan..."
}
mode: SYNC
) {
execution {
id
operationKey
status
}
result
}
}Arguments:
| Argument | Type | Description |
|---|---|---|
input | Execute<Operation>Input | Typed input matching the operation’s declared input schema |
mode | ExecutionMode | SYNC (default) blocks until the operation completes; ASYNC returns the execution row immediately so you can poll for the result. ASYNC is only accepted by operations that support it. |
The mutation returns an Execute<Operation>Payload envelope:
execution— the execution row (always present). Useexecution.idto poll the status later.result— the typed result, populated onSYNCcompletion andnullonASYNCdispatch. The result shape is generated from the operation’s declared output schema; operations without one expose aresult: JSONfield.
A SYNC operation that fails returns a GraphQL error rather than a payload.
Check Execution Status
For ASYNC operations, poll the execution status using the id returned in execution:
query GetExecution {
operationExecution(id: "exec_abc123") {
id
operationKey
status
result
error
startedAt
completedAt
createdAt
}
}result is JSON (the raw operation output) and error is a String message present when status is FAILED.
Execution statuses: PENDING, RUNNING, COMPLETED, FAILED, CANCELLED
Cancel an Execution
Use the generic cancelOperationExecution mutation, which returns a Boolean indicating success. It is idempotent.
mutation CancelExecution {
cancelOperationExecution(id: "exec_abc123")
}List Executions
operationExecutions returns a Relay-style cursor connection. Filter by operationKey and/or status, and page with first/after.
query ListExecutions {
operationExecutions(
operationKey: "contact-form-handler"
status: COMPLETED
first: 20
) {
edges {
node {
id
operationKey
status
createdAt
completedAt
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}Polling for Results
async function waitForResult(client, executionId) {
const MAX_ATTEMPTS = 30;
const POLL_INTERVAL = 2000;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
const { data } = await client.query({
query: GET_EXECUTION,
variables: { id: executionId },
fetchPolicy: "network-only",
});
const execution = data.operationExecution;
if (execution.status === "COMPLETED") return execution.result;
if (execution.status === "FAILED") throw new Error(execution.error);
if (execution.status === "CANCELLED") throw new Error("Operation cancelled");
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
}
throw new Error("Timed out waiting for operation result");
}Search and Embeddings
Foir provides search powered by vector embeddings, allowing you to find content by meaning rather than exact keywords. Search queries are available for models that have embeddings enabled in their configuration.
Search a Model
Each model with embeddings enabled gets a typed search<Model>s query (the model’s plural, e.g. searchProducts). It takes a natural-language query and returns the matching records ranked by similarity.
query SearchProducts {
searchProducts(query: "lightweight shoes for trail running", first: 10) {
score
record {
_id
title
}
}
}Arguments:
| Argument | Type | Description |
|---|---|---|
query | String! | Natural-language search query |
first | Int | Maximum number of matches to return (default: 10) |
locale | String | Optional locale for the matched records |
Response fields:
| Field | Type | Description |
|---|---|---|
score | Float! | Similarity score |
record | the model’s typed object | The matching record |
Required scope: search:read:<modelKey>
Search Across Models
searchRecords runs the same similarity search and returns generic results, optionally scoped to a single model:
query SearchEverything {
searchRecords(query: "trail running", modelKey: "product", first: 10) {
recordId
modelKey
naturalKey
score
content
}
}The result fields are recordId (String!), modelKey (String!), naturalKey (String), score (Float!), and content (JSON, the matched record’s data).
Required scope: search:read
Search by Embedding Vector
If you already have an embedding vector, searchEmbeddings finds similar records directly. findSimilarRecords does the same starting from an existing record. Both return the same generic search-result shape as searchRecords.
query SimilarByVector {
searchEmbeddings(input: {
embedding: [0.0123, -0.045, 0.067]
modelKeys: ["product"]
first: 10
}) {
recordId
modelKey
score
content
}
}
query SimilarToRecord {
findSimilarRecords(input: { recordId: "rec_abc123", modelKey: "product", first: 10 }) {
recordId
modelKey
score
content
}
}SearchEmbeddingsInput fields: embedding ([Float!]!), modelKeys ([String!]), key (String), first (Int), threshold (Float), hybridWeight (Float), textQuery (String). FindSimilarRecordsInput fields: recordId (ID!), modelKey (String), first (Int).
Required scope: search:semantic:read
Read a Record’s Embeddings
embeddingsForRecord lists the stored embeddings for a record:
query RecordEmbeddings {
embeddingsForRecord(recordId: "rec_abc123") {
id
recordId
modelKey
key
createdAt
}
}Required scope: search:semantic:read
Write and Generate Embeddings
To supply your own vectors, use writeEmbeddings; to queue server-side generation for a record, use generateEmbedding; to remove them, use deleteEmbeddings. Each returns a Boolean.
mutation StoreEmbeddings {
writeEmbeddings(input: {
entries: [
{ recordId: "rec_abc123", embedding: [0.0123, -0.045, 0.067] }
]
})
}
mutation GenerateEmbedding {
generateEmbedding(recordId: "rec_abc123", modelKey: "product")
}
mutation RemoveEmbeddings {
deleteEmbeddings(input: { recordId: "rec_abc123" })
}EmbeddingEntryInput fields: recordId (ID!), embedding ([Float!]!), key (String), contentHash (String), metadata (JSON). generateEmbedding also accepts an optional source (String).
Required scope: search:semantic:write
Customer Authentication
The Customer Auth API provides authentication flows for end-user customers, supporting password, OTP, and OAuth/OIDC methods. For the hosted Login with Foir flow (Foir hosts the login UI so you don’t rebuild it), see the Login with Foir guide.
Every token-issuing mutation returns the same CustomerAuthResult:
type CustomerAuthResult {
token: String! # short-lived EdDSA access token — send as a Bearer
refreshToken: String!
customerId: String!
email: String!
}Login with Password
mutation CustomerLogin {
customerLogin(email: "customer@example.com", password: "securepassword") {
token
refreshToken
customerId
email
}
}Register a Customer
mutation CustomerRegister {
customerRegister(email: "new@example.com", password: "securepassword") {
token
refreshToken
customerId
email
}
}Logout
Invalidates the refresh-token family server-side. Returns Boolean.
mutation CustomerLogout {
customerLogout
}OTP Authentication
Request a one-time password (returns Boolean):
mutation RequestOTP {
customerRequestOTP(email: "customer@example.com")
}Login with the OTP:
mutation LoginWithOTP {
customerLoginOTP(email: "customer@example.com", otp: "123456") {
token
refreshToken
customerId
email
}
}Token Refresh
Refresh tokens rotate on every use — save the new one before responding to the user.
mutation RefreshToken {
customerRefreshToken(refreshToken: "eyJ...") {
token
refreshToken
customerId
email
}
}Get Current User
Requires a valid customer access token in the Authorization header:
query CurrentUser {
currentUser {
id
email
status
emailVerifiedAt
createdAt
}
}Password Management
Request a password reset (returns Boolean):
mutation RequestReset {
customerRequestPasswordReset(email: "customer@example.com")
}Reset with the emailed token — returns a CustomerAuthResult (the customer is logged in on success):
mutation ResetPassword {
customerResetPassword(token: "reset_token_here", newPassword: "newsecurepassword") {
token
refreshToken
customerId
email
}
}Update password (authenticated; returns Boolean):
mutation UpdatePassword {
customerUpdatePassword(currentPassword: "oldpassword", newPassword: "newpassword")
}Email Verification
Returns Boolean:
mutation VerifyEmail {
customerVerifyEmail(token: "verification_token")
}Auth Providers (OAuth/OIDC)
List available authentication providers:
query ListProviders {
authProviders {
id
key
name
type
enabled
isDefault
priority
}
}Login with a provider — returns a server-built redirectUrl; send the browser there:
mutation ProviderLogin {
customerLoginWithProvider(input: {
providerKey: "google"
returnTo: "https://mysite.com/callback"
}) {
redirectUrl
token
}
}Per-call redirectUri override
Multi-environment storefronts (e.g. eide.clothing plus preview.eide.clothing) can share a single upstream OAuth client by passing redirectUri on the mutation input:
mutation ProviderLogin {
customerLoginWithProvider(input: {
providerKey: "shopify"
redirectUri: "https://preview.eide.clothing/auth/callback?provider=shopify"
}) {
redirectUrl
}
}The supplied URI is validated against an allowlist on the provider’s config:
- It must equal the configured primary
redirect_uriexactly, or appear in theadditional_redirect_urisarray. - It must be
https://, have a host, and contain no fragment. - It must also be pre-registered with the upstream IdP (Shopify, Google, etc.) — that side of the allowlist is your responsibility.
If redirectUri is omitted, the provider’s primary redirect_uri is used (existing behaviour, no breaking change). If supplied but not in the allowlist, the mutation fails with InvalidArgument before the IdP is contacted, so an attacker holding an API key cannot point the OAuth flow at an arbitrary URL.
Configure additional URIs in the dashboard’s Auth Provider editor under “Additional Callback URLs” (one per line), or directly via the provider’s additional_redirect_uris config field.
Handle the OAuth callback:
mutation ProviderCallback {
customerProviderCallback(input: {
providerKey: "google"
code: "auth_code_from_redirect"
state: "state_parameter"
}) {
token
refreshToken
customerId
email
}
}Auth Configuration
Check what authentication methods are available for your project:
query AuthConfig {
authConfig {
passwordEnabled
otpEnabled
providers {
key
name
type
}
}
}Schemas API
The Schemas API returns your project’s model and block type definitions, designed for code generation and the Foir CLI.
Required scope: schemas:read
Note: This query requires a secret API key (sk_* prefix).
query GetProjectSchemas {
projectSchemas {
models {
id
key
name
pluralName
description
fields {
id
key
type
label
required
isTranslatable
helpText
placeholder
defaultValue
options
validation {
rule
value
message
}
}
}
blockTypes {
id
key
name
description
category
schema
}
}
}Field Types
Common field types returned in schema definitions:
| Type | Description |
|---|---|
text | Single-line text |
richtext | Translatable rich-text prose (resolves to { html, json, markdown }) |
flexible | Structured array of blocks |
number | Numeric value |
boolean | True/false |
date | Date (can include time) |
select | Reference to records of an option model |
enum | Choice from inline { label, value } options |
image | Image file |
video | Video file |
file | Generic file |
json | Raw JSON |
list | Ordered list of items |
model | Embedded inline model |
reference | Relation to another record |
Using Schemas for Code Generation
Use the schema response to generate typed interfaces for your application:
curl -X POST https://api.foir.dev/graphql \
-H "Content-Type: application/json" \
-H "x-api-key: sk_your_secret_key" \
-d '{"query": "{ projectSchemas { models { key name fields { key type required } } } }"}'The Foir CLI provides built-in support for schema-driven code generation:
foir models list --json
foir models get blog-post --jsonModels Query
List the models available in your project. models returns a plain list of Model (no pagination wrapper) and accepts an optional category filter. It requires a key with the schemas:read scope.
query GetModels {
models {
key
name
pluralName
pluralKey
description
category
capabilities {
isVersioned
isPublishable
hasVariants
}
fields {
key
label
type
required
helpText
config
}
}
}Each Model exposes key, name, description, pluralName, pluralKey, category, a capabilities object (isVersioned, isPublishable, hasVariants), and the model’s fields. Each field carries key, label, type, required, helpText, and a config JSON blob. Only models exposed to the public API are returned.
Secret Vault
Server-side integrations can store and read project-scoped secrets through the vault. All three fields require a SECRET (sk_) key holding the matching scope — a publishable key cannot be granted these scopes. The plaintext travels base64-encoded over the String scalar so binary secrets survive the round trip.
# Store a secret — returns an opaque ref; persist only the ref, never the plaintext
mutation {
putSecret(plaintextBase64: "c3VwZXItc2VjcmV0", label: "stripe-webhook-key") {
ref
}
}
# Read it back by ref
query {
getSecret(ref: "sec_abc123", purpose: "webhook-verify") {
plaintextBase64
label
lastWrittenAt
}
}
# Soft-delete
mutation {
deleteSecret(ref: "sec_abc123")
}| Field | Scope | Notes |
|---|---|---|
getSecret(ref, purpose) | secrets:read.project | Returns SecretPlaintext { plaintextBase64, label, lastWrittenAt }. |
putSecret(plaintextBase64, label, expiresAt) | secrets:put.project | Returns PutSecretResult { ref }. Always stored project-owned. |
deleteSecret(ref) | secrets:delete.project | Returns Boolean!. |
Files
File uploads, the File type, listing, and the lifecycle mutations (deleteFile, restoreFile, permanentlyDeleteFile) are covered in the Files API. Image transforms on File.url live in the Media API.
Type Reference
Typed record objects
Each model produces its own typed object. There is no shared generic Record type. Your declared fields appear at the top level alongside the system fields. For a Page model with title and body fields:
type Page {
# System fields (on every record type)
_id: String!
_naturalKey: String
_modelKey: String!
_createdAt: DateTime!
_updatedAt: DateTime!
_hasDraft: Boolean
# Declared fields
title: String
body: String
}Connection types
Plural queries return a Relay connection:
type PageConnection {
edges: [PageEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PageEdge {
node: Page!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}RecordVersion
type RecordVersion {
id: String!
versionNumber: Int!
changeDescription: String
data: JSON
createdAt: DateTime!
}Operation execute payload
Each execute<Operation> mutation returns its own Execute<Operation>Payload carrying the execution row and the typed result:
type ExecuteContactFormHandlerPayload {
execution: OperationExecution!
result: ContactFormHandlerOutput
}
type OperationExecution {
id: String!
operationKey: String!
status: OperationExecutionStatus!
result: JSON
error: String
startedAt: DateTime
completedAt: DateTime
createdAt: DateTime!
}The result field on the payload is typed from the operation’s declared output schema (operations without one expose result: JSON). OperationExecution is also the type returned by the operationExecution query and the nodes of the operationExecutions connection.
CustomerAuthResult
Returned by every token-issuing customer mutation (customerLogin, customerRegister, customerLoginOTP, customerRefreshToken, customerResetPassword, customerProviderCallback).
type CustomerAuthResult {
token: String!
refreshToken: String!
customerId: String!
email: String!
}CurrentUser
Returned by the currentUser query.
type CurrentUser {
id: String!
email: String!
status: CustomerStatus!
emailVerifiedAt: DateTime
createdAt: DateTime!
}