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.
| 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. |
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
| Argument | Type | Description |
|---|---|---|
id | ID | Record ID (for single record queries) |
modelKey | String | Model key (e.g., "page", "product") |
naturalKey | String | Natural key / slug |
locale | String | Locale for content resolution (e.g., "en-US", "fr-FR") |
preview | Boolean | Return draft content instead of published |
contexts | JSON | Context dimensions for variant resolution |
limit | Int | Maximum records to return (default: 50) |
offset | Int | Number of records to skip for pagination |
sort | SortInput | Sort 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:
| Parameter | Type | Description |
|---|---|---|
locale | String | Locale for translated fields (e.g., "fr-FR") |
contexts | JSON | Context dimensions for variant matching |
maxDepth | Int | Maximum depth for nested reference resolution |
preview | Boolean | Return 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:
| Field | Type | Description |
|---|---|---|
_id / id | ID | Unique record identifier |
_naturalKey / naturalKey | String | Slug or handle |
_modelKey / modelKey | String | Model this record belongs to |
_createdAt / createdAt | DateTime | When the record was created |
_updatedAt / updatedAt | DateTime | When the record was last modified |
publishedVersionNumber | Int | Currently published version number |
publishedAt | DateTime | When the record was last published |
versionNumber | Int | Latest 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
| Operator | Description | Example Value |
|---|---|---|
EQ | Equals | "active" |
NE | Not equals | "cancelled" |
LT | Less than | 100 |
GT | Greater than | 50 |
LTE | Less than or equal | 100 |
GTE | Greater than or equal | 50 |
LIKE | Pattern match (use % as wildcard) | "%shirt%" |
IN | Value is in array | ["electronics", "clothing"] |
NOT_IN | Value is not in array | ["archived", "deleted"] |
IS_NULL | Field is null | null |
IS_NOT_NULL | Field is not null | null |
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
| Dimension | Description | Example Values |
|---|---|---|
locale | Language/region | "en-US", "fr-FR" |
device | Device type | "mobile", "tablet", "desktop" |
platform | Operating 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:
| Field | Type | Description |
|---|---|---|
modelKey | String! | Model key |
naturalKey | String | Natural key (slug/handle) |
data | JSON | Record content data |
metadata | JSON | Record metadata (not versioned) |
changeDescription | String | Description of this change |
customerId | String | Customer who owns this record |
validate | Boolean | Run schema validation (default: true) |
hooks | Boolean | Trigger 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:
| Op | Description | Example |
|---|---|---|
SET | Set a field value | { op: SET, path: "status", value: "active" } |
INCREMENT | Add to a numeric field | { op: INCREMENT, path: "viewCount", value: 1 } |
MIN | Set to minimum of current and new value | { op: MIN, path: "lowestPrice", value: 9.99 } |
MAX | Set to maximum of current and new value | { op: MAX, path: "highScore", value: 500 } |
APPEND | Append to an array field | { op: APPEND, path: "tags", value: "featured" } |
REMOVE | Remove 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
- 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
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:
| Field | Type | Description |
|---|---|---|
operationKey | String! | Unique operation identifier |
input | JSON! | Input data for the operation |
content | JSON | Content 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.
Semantic Search
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:
| Field | Type | Required | Description |
|---|---|---|---|
query | String | Yes | Natural language search query |
modelKeys | [String] | No | Filter by model keys |
source | EmbeddingSource | No | Source type (RECORD) |
limit | Int | No | Max results (default: 10) |
threshold | Float | No | Minimum similarity score 0-1 (default: 0.0) |
Response fields:
| Field | Type | Description |
|---|---|---|
recordId | ID | Matching record ID |
modelKey | String | Model key of the match |
naturalKey | String | Natural key of the match |
similarity | Float | Similarity 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_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"
}) {
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:
| Type | Description |
|---|---|
text | Single-line text |
textarea | Multi-line plain text |
content | Unified rich-text + structured-blocks editor (Lexical) |
number | Numeric value |
boolean | True/false |
date | Date picker (can include time) |
select | Dropdown selection (option model) |
image | Image file |
video | Video file |
file | Generic file |
link | URL with text |
json | Raw JSON |
list | Ordered list of items |
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.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 --jsonModels 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!
}