Skip to Content
API ReferenceGraphQL API

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) for fetching a single record
  • A plural query (e.g., pages, products) for listing records
  • Create, update, delete, publish, and unpublish mutations
  • Input types matching your model’s fields

You can explore your generated schema using the GraphiQL explorer at https://api.foir.io/graphiql or by fetching the schema from GET https://api.foir.io/schema.

In addition to generated per-model queries, Foir provides generic record, recordByKey, and records queries that work across all models.

Request Headers

Every request accepts these headers. Authentication is required; the others are optional opt-ins.

HeaderPurpose
Content-Type: application/jsonRequired — 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: draftRead 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.
X-Foir-Context: key=value;…Override context dimensions for personalised reads (variants, segments).

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

Single Record by Natural Key

Fetch a single record using its model key and natural key (slug or handle):

query GetPage { recordByKey(modelKey: "page", naturalKey: "homepage") { id modelKey naturalKey data metadata createdAt updatedAt } }

Single Record by ID

query GetRecord { record(id: "rec_abc123") { id modelKey naturalKey data publishedVersionNumber publishedAt } }

List Records

Fetch multiple records with pagination, filtering, and sorting:

query ListProducts { records( modelKey: "product" limit: 10 offset: 0 sort: { field: "createdAt", direction: DESC } filters: [ { field: "data.category", operator: EQ, value: "electronics" } ] ) { items { id naturalKey data metadata publishedAt } total } }

Query Arguments

ArgumentTypeDescription
idIDRecord ID (for single record queries)
modelKeyStringModel key (e.g., "page", "product")
naturalKeyStringNatural key / slug
localeStringLocale for content resolution (e.g., "en-US", "fr-FR")
previewBooleanReturn draft content instead of published
contextsJSONContext dimensions for variant resolution
limitIntMaximum records to return (default: 50)
offsetIntNumber of records to skip for pagination
sortSortInputSort field and direction
filters[FilterInput!]Array of filter conditions

Content Resolution

Use the resolved field to get fully resolved content with variant selection, locale handling, and reference resolution:

query GetResolvedPage { recordByKey(modelKey: "page", naturalKey: "homepage") { id naturalKey resolved(locale: "en-US", contexts: { device: "mobile" }) { content record { id modelKey naturalKey } variant { id variantKey matchedContexts { key value } } version { id versionNumber } experiment { experimentId experimentKey variantKey isControl } resolvedWith { locale contexts } } } }

The resolved field accepts these parameters:

ParameterTypeDescription
localeStringLocale for translated fields (e.g., "fr-FR")
contextsJSONContext dimensions for variant matching
maxDepthIntMaximum depth for nested reference resolution
previewBooleanReturn latest draft instead of published version

If you do not need resolution (no variants, no localization), read data directly for better performance.

System Fields

Every record includes these system fields:

FieldTypeDescription
_id / idIDUnique record identifier
_naturalKey / naturalKeyStringSlug or handle
_modelKey / modelKeyStringModel this record belongs to
_createdAt / createdAtDateTimeWhen the record was created
_updatedAt / updatedAtDateTimeWhen the record was last modified
publishedVersionNumberIntCurrently published version number
publishedAtDateTimeWhen the record was last published
versionNumberIntLatest version number

Version History

For models with versioning enabled, browse version history with recordVersions:

query GetVersions { recordVersions(parentId: "rec_abc123", limit: 10) { items { id versionNumber changeDescription createdAt } } }

Filtering

Use FilterInput to narrow query results. Filters are combined with AND logic.

query FilteredProducts { records( modelKey: "product" filters: [ { field: "data.category", operator: EQ, value: "electronics" } { field: "data.price", operator: GTE, value: 100 } { field: "data.inStock", operator: EQ, value: true } ] sort: { field: "createdAt", direction: DESC } ) { items { id naturalKey data } total } }

Filter Operators

OperatorDescriptionExample Value
EQEquals"active"
NENot equals"cancelled"
LTLess than100
GTGreater than50
LTELess than or equal100
GTEGreater than or equal50
LIKEPattern match (use % as wildcard)"%shirt%"
INValue is in array["electronics", "clothing"]
NOT_INValue is not in array["archived", "deleted"]
IS_NULLField is nullnull
IS_NOT_NULLField is not nullnull

FilterInput Type

input FilterInput { field: String! # Field path (e.g., "data.price", "createdAt") operator: FilterOperator! value: JSON # Value to compare against }

SortInput Type

input SortInput { field: String! # Field path to sort by direction: SortDirection! # ASC or DESC }

Context

Context allows you to deliver personalized content based on dimensions like device, locale, or custom attributes. Pass context to the resolved field to trigger variant matching.

query PersonalizedContent { recordByKey(modelKey: "page", naturalKey: "homepage") { resolved( locale: "en-US" contexts: { device: "mobile", platform: "ios", market: "us", userSegment: "returning" } ) { content variant { variantKey matchedContexts { key value } } } } }

Built-in Context Dimensions

DimensionDescriptionExample Values
localeLanguage/region"en-US", "fr-FR"
deviceDevice type"mobile", "tablet", "desktop"
platformOperating system or platform"ios", "android", "web"

Custom Dimensions

You can define custom context dimensions in your project settings. Common examples include market, userSegment, channel, or experiment. These are used for variant resolution and A/B testing.

Mutations

Create a Record

mutation CreatePage { createRecord(input: { modelKey: "page" naturalKey: "about" data: { title: "About Us" content: "Welcome to our company..." } changeDescription: "Initial creation" }) { record { id modelKey naturalKey versionNumber createdAt } } }

CreateRecordInput fields:

FieldTypeDescription
modelKeyString!Model key
naturalKeyStringNatural key (slug/handle)
dataJSONRecord content data
metadataJSONRecord metadata (not versioned)
changeDescriptionStringDescription of this change
customerIdStringCustomer who owns this record
validateBooleanRun schema validation (default: true)
hooksBooleanTrigger lifecycle hooks (default: true)

Required scope: records:write

Update a Record

For models with versioning, updates create a new version automatically.

mutation UpdatePage { updateRecord(input: { id: "rec_abc123" data: { title: "About Us -- Updated" content: "Updated content..." } changeDescription: "Updated page title" }) { record { id versionNumber updatedAt } matched } }

The matched field indicates whether any preconditions (if specified) were satisfied. If no conditions are provided, matched is always true.

Required scope: records:write

Delete a Record

Permanently deletes a record. This cannot be undone.

mutation DeleteRecord { deleteRecord(id: "rec_abc123") { id modelKey } }

Required scope: records:delete

Publish a Version

Publish a specific version to make it available via live API keys:

mutation PublishPage { publishVersion(versionId: "ver_xyz789") }

Returns true on success.

Required scope: records:publish

Unpublish a Record

Remove a record from the published API:

mutation UnpublishPage { unpublishRecord(id: "rec_abc123") }

Returns true on success.

Required scope: records:publish

Batch Operations

Process multiple create, update, and delete operations in a single request:

mutation BatchOps { batchRecordOperations(input: { operations: [ { type: CREATE create: { modelKey: "product" naturalKey: "blue-widget" data: { name: "Blue Widget", price: 29.99 } } } { type: UPDATE update: { id: "rec_existing123" data: { price: 24.99 } } } { type: DELETE delete: { id: "rec_old456" } } ] }) { success results { type index record { id modelKey naturalKey } matched } } }

Each result includes an index matching the input array position so you can correlate results with operations.

Required scopes: records:write for create/update, records:delete for delete operations.

Atomic Field Operations

Instead of replacing the entire data object, use atomic operations for surgical field updates:

mutation IncrementViewCount { updateRecord(input: { id: "rec_abc123" operations: [ { op: SET, path: "lastViewed", value: "2026-04-01T12:00:00Z" } { op: INCREMENT, path: "viewCount", value: 1 } ] }) { record { data } matched } }

Available operations:

OpDescriptionExample
SETSet a field value{ op: SET, path: "status", value: "active" }
INCREMENTAdd to a numeric field{ op: INCREMENT, path: "viewCount", value: 1 }
MINSet to minimum of current and new value{ op: MIN, path: "lowestPrice", value: 9.99 }
MAXSet to maximum of current and new value{ op: MAX, path: "highScore", value: 500 }
APPENDAppend to an array field{ op: APPEND, path: "tags", value: "featured" }
REMOVERemove from an array field{ op: REMOVE, path: "tags", value: "draft" }

Conditional Updates

Apply updates only when specific conditions are met:

mutation ConditionalUpdate { updateRecord(input: { id: "rec_abc123" data: { status: "confirmed" } conditions: [ { op: EQ, path: "status", value: "pending" } { op: GT, path: "quantity", value: 0 } ] }) { record { data } matched } }

If conditions are not met, matched returns false and the record is not modified.

Condition operators: EQ, NE, GT, GTE, LT, LTE, EXISTS, NOT_EXISTS

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

  1. Register the operation in the dashboard with an endpoint URL
  2. Enable the API touch point on the operation
  3. Create an API key with the operations:execute scope

Execute an Operation

mutation ExecuteOperation { publicExecuteOperation(input: { operationKey: "contact-form-handler" input: { name: "Jane Doe" email: "jane@example.com" message: "I have a question about your enterprise plan..." } }) { success executionId result durationMs error { code message } } }

Input fields:

FieldTypeDescription
operationKeyString!Unique operation identifier
inputJSON!Input data for the operation
contentJSONContent to process (for field transforms)

Check Execution Status

For long-running operations, poll the execution status using the returned executionId:

query GetExecution { operationExecution(id: "exec_abc123") { id operationKey status result error { code message } durationMs createdAt completedAt } }

Execution statuses: PENDING, RUNNING, COMPLETED, FAILED, CANCELLED

Cancel an Execution

mutation CancelExecution { cancelOperationExecution(id: "exec_abc123") { id operationKey status } }

List Executions

query ListExecutions { operationExecutions( operationKey: "contact-form-handler" status: COMPLETED limit: 20 ) { items { id operationKey status durationMs createdAt } total } }

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?.message); 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 semantic search powered by vector embeddings, allowing you to search content by meaning rather than exact keywords.

query SearchProducts { semanticSearch(input: { query: "lightweight shoes for trail running" modelKeys: ["product"] source: RECORD limit: 10 threshold: 0.7 }) { recordId source modelKey naturalKey similarity highlights } }

Input fields:

FieldTypeRequiredDescription
queryStringYesNatural language search query
modelKeys[String]NoFilter by model keys
sourceEmbeddingSourceNoSource type (RECORD)
limitIntNoMax results (default: 10)
thresholdFloatNoMinimum similarity score 0-1 (default: 0.0)

Response fields:

FieldTypeDescription
recordIdIDMatching record ID
modelKeyStringModel key of the match
naturalKeyStringNatural key of the match
similarityFloatSimilarity score from 0 to 1
highlights[String]Relevant text excerpts

Required scope: search:read

Check Embedding Status

Verify whether records have embeddings generated:

query CheckEmbeddings { embeddingStatus( recordIds: ["rec_abc123", "rec_def456"] source: RECORD ) { recordId hasEmbedding model dimensions lastUpdated } }

Generate Embedding

Manually generate or regenerate an embedding for a record:

mutation EmbedRecord { generateEmbedding( recordId: "rec_abc123" modelKey: "product" source: RECORD ) { success embeddingId recordId tokenCount dimensions skipReason } }

If the content has not changed since the last embedding, the operation is automatically skipped and skipReason explains why.

Required scope: records:write

Check AI Status

query CheckAI { isAIEnabled }

Returns true if search and embedding features are enabled for your project.

Customer Authentication

The Customer Auth API provides authentication flows for end-user customers, supporting password, OTP, and OAuth methods.

Login with Password

mutation CustomerLogin { customerLogin(email: "customer@example.com", password: "securepassword") { success accessToken refreshToken user { id email userType } } }

Register a Customer

mutation CustomerRegister { customerRegister(email: "new@example.com", password: "securepassword") { success accessToken refreshToken user { id email } emailVerificationRequired } }

Logout

mutation CustomerLogout { customerLogout { success message } }

OTP Authentication

Request a one-time password:

mutation RequestOTP { customerRequestOTP(email: "customer@example.com") { success expiresAt message } }

Login with the OTP:

mutation LoginWithOTP { customerLoginOTP(email: "customer@example.com", otp: "123456") { success accessToken refreshToken user { id email } } }

Token Refresh

mutation RefreshToken { customerRefreshToken(refreshToken: "eyJhbGci...") { success accessToken refreshToken } }

Get Current User

Requires a valid customer JWT in the Authorization header:

query CurrentUser { currentUser { id email emailVerified status userType createdAt } }

Password Management

Request a password reset:

mutation RequestReset { customerRequestPasswordReset(email: "customer@example.com") { success message } }

Reset with token:

mutation ResetPassword { customerResetPassword(token: "reset_token_here", newPassword: "newsecurepassword") { success message } }

Update password (authenticated):

mutation UpdatePassword { customerUpdatePassword(currentPassword: "oldpassword", newPassword: "newpassword") { success message } }

Email Verification

mutation VerifyEmail { customerVerifyEmail(token: "verification_token") { success user { id emailVerified } message } }

Resend verification:

mutation ResendVerification { customerResendVerificationEmail { success message } }

Auth Providers (OAuth/OIDC)

List available authentication providers:

query ListProviders { authProviders { id key name type enabled isDefault priority } }

Login with a provider:

mutation ProviderLogin { customerLoginWithProvider(input: { providerKey: "google" email: "customer@example.com" returnTo: "https://mysite.com/callback" }) { method redirectUrl state accessToken refreshToken otpSent expiresAt } }

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_uri exactly, or appear in the additional_redirect_uris array.
  • 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" }) { accessToken refreshToken user { id email } isNewCustomer } }

Auth Configuration

Check what authentication methods are available for your project:

query AuthConfig { authConfig { authMethods publicRegistrationEnabled passwordPolicy { minLength requireUppercase requireLowercase requireNumbers requireSpecialChars } } }

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:

TypeDescription
textSingle-line text
textareaMulti-line plain text
contentUnified rich-text + structured-blocks editor (Lexical)
numberNumeric value
booleanTrue/false
dateDate picker (can include time)
selectDropdown selection (option model)
imageImage file
videoVideo file
fileGeneric file
linkURL with text
jsonRaw JSON
listOrdered list of items
referenceRelation 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.io/graphql \ -H "Content-Type: application/json" \ -H "x-api-key: sk_live_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 --json

Models Query

List available models and their configuration:

query GetModels { models(limit: 50) { items { id key name pluralName description category fields config } total } }

The config field includes capability flags:

{ "versioning": true, "publishing": true, "variants": true, "inline": false, "publicApi": true }

Only models with publicApi: true are accessible through the public API.

Type Reference

Record

type Record { id: ID! modelKey: String! naturalKey: String data: JSON metadata: JSON publishedVersionNumber: Int publishedAt: DateTime versionNumber: Int changeDescription: String createdAt: DateTime! updatedAt: DateTime! resolved(locale: String, contexts: JSON, maxDepth: Int, preview: Boolean): ResolvedRecordContent }

ResolvedRecordContent

type ResolvedRecordContent { content: JSON! record: ResolvedRecordInfo! variant: ResolvedVariantInfo version: ResolvedVersionInfo experiment: ExperimentAssignment resolvedWith: ResolutionOutput! }

PublicExecuteOperationResult

type PublicExecuteOperationResult { success: Boolean! executionId: ID! result: JSON error: PublicOperationError durationMs: Int metadata: JSON }

LoginResponse

type LoginResponse { success: Boolean! accessToken: String refreshToken: String user: User }

User

type User { id: ID! email: String! emailVerified: Boolean! status: UserStatus userType: UserType! createdAt: DateTime! updatedAt: DateTime! }
Last updated on