For developers
Anything the editor does, the API does.
A REST API on one OpenAPI contract, checked in CI, with the CLI, typed SDKs and an MCP server built on it. Filter on any field, expand relations, keep your schema in a file, react to webhooks and serve images by URL, with keys scoped to each job. Here is the same request five ways.
$ curl -G \ -d "filter[published_at][exists]=true" \ -H "Authorization: Bearer $NOHEAD_API_KEY" \ "https://api.nohead.io/v1/collections/posts/records" { "data": [ { "id": "rec_01M3MSCJ…", "data": { "title": "Design tokens as content", "author": "rec_01M5…" } } ], "meta": { "has_more": true, "next_cursor": "…" } }
import { Nohead } from "@nohead/sdk" const nohead = new Nohead() // NOHEAD_API_KEY const posts = nohead.records.list("posts", { filter: { published_at: { exists: true } }, }) for await (const post of posts) { console.log(post.data.title) }
from nohead import Nohead nohead = Nohead() # NOHEAD_API_KEY posts = nohead.records.list( "posts", filter={"published_at": {"exists": True}}, ) for post in posts: print(post.data["title"])
require "nohead" nohead = Nohead::Client.new # NOHEAD_API_KEY posts = nohead.records.list( "posts", filter: { published_at: { exists: true } } ) posts.each { |post| puts post.data["title"] }
# Exactly one JSON document on stdout, for jq $ nohead records list posts \ --status published --json \ | jq -r '.data[].data.title' Designing a content model that ages well Why every change gets a note Design tokens as content
A contract you can build on. No surprises past the first call.
The same rules on every endpoint, so the second resource you integrate takes a tenth of the time of the first.
- Stable, prefixed IDs
rec_,col_,key_: sortable, unique, and never a database row number. Slugs and field keys are for humans and stay stable when labels change; a renamed key keeps working for six months.- One error shape
{ error: { type, message, request_id, details } }, with the offending field and a machine-readable code. The request ID is in the server logs too.- Cursor pagination
limit,cursorandsorton every list;meta.next_cursorandhas_moretell you when to stop. A cursor is bound to the sort it was issued for.- Filters and expansion
filter[price][lt]=50witheq,ne, ranges,inandexistson any field, dates that understandtoday, andexpand=author,tagsto embed related records and assets without changing the record's shape.- Optimistic concurrency
ETagon reads,If-Matchon writes. A stale write gets a 412 instead of overwriting someone's work.- Idempotent writes
- Send an
Idempotency-Keyand a retry within 24 hours returns the first result instead of repeating the change. The CLI does it for you. - Upserts for syncs
- Create with
"upsert_on": "slug"and the record that already has that slug is updated instead, so a sync can run again without duplicates. Batches change up to 100 records at once. - Rate limits you can see
RateLimit-Limit,-Remainingand-Reseton every response. A 429 carriesRetry-Afterand is neither performed nor metered.- Images by URL
- An image's
image_urltakes its size, crop and format in the URL, with no API call per rendition. - Published-only keys
- A key that can only see published records, for the site in production. Scopes such as
records:readorschema:writekeep every other key as small as its job.
One CLI for people, CI and agents. Scriptable on purpose.
Model a collection in five lines, then change records with a note, a revision check and a dry run. Every command uses the public API, and every change it makes shows up in history as made via the CLI.
# A collection and its fields; each change is kept in its history $ nohead collections create posts $ nohead fields add posts title --type text --required $ nohead fields add posts body --type rich-text # A relation points at another collection; an asset field holds a file $ nohead fields add posts author --type relation --target authors $ nohead fields add posts cover --type asset # Records start unpublished, at revision 1 $ nohead records create posts --set title="Hello, world" Created rec_01M3MSCJ15FNCV670PF2KYKF5M (revision 1, unpublished) # Publish when it's ready $ nohead records publish rec_01M3MSCJ…
# Say why, and refuse to overwrite anything newer than revision 3 $ nohead records update rec_01M3MSCJ… \ --set title="Hello, world!" \ --if-revision 3 --note "Fix typo" Error: Record has changed since revision 3 - Current revision is 4 # Preview a revert before it happens $ nohead records revert rec_01M3MSCJ… 3 --dry-run Dry run: reverting to revision 3 would change: reading_time (changed): 9 → 8 # Deleting is soft; restore within 30 days $ nohead records delete rec_01M3MSCJ… $ nohead records restore rec_01M3MSCJ…
An output contract scripts can depend on
- stdout carries only results. With
--json, exactly one JSON document: the API's own representation. - stderr carries errors and diagnostics, as the API's error envelope when
--jsonis on. - Exit codes: 0 success, 1 API error, 2 usage error, 3 API unreachable, 4 unexpected failure.
- Retries for 429s and transient failures, but never for a write without an idempotency key.
- Pages:
--allfollows every cursor, so a script never writes a pagination loop.
# nohead.schema.yaml, reviewed like any other code collections: posts: fields: title: { type: text, required: true } summary: long_text body: rich_text author: { type: relation, collection: authors } cover: asset tags: { type: text, multiple: true }
$ nohead schema diff + field posts.summary (long_text) ~ field posts.title: required true ! field posts.reading_time: text -> integer (field migration) posts.reading_time migration preview (conditional): 17 records, 16 converted, 1 invalid $ nohead schema push --yes --allow-migrations --clear-invalid Applied 3 changes.
Schema as code. Reviewed, then applied.
Pull the schema into a file, review it like any other change, and let CI apply it.
schema diffshows the plan: additions, changes and, for anything that rewrites values, a preview of how many records convert, are cleared or cannot be converted.schema push --yesapplies additions and edits. It never deletes a collection or a field, and it refuses field migrations unless you pass--allow-migrations.- Tightening a field (making it required, shortening a maximum length) is checked the same way: if records would break it, the push stops before changing anything.
schema diff --exit-codefails when the project has drifted from the file: a one-line CI check.
SDK for your language. Or any HTTP client.
Typed clients on the published contract, smoke-tested against the real API in CI: authentication, records, pagination, idempotent retries, uploads and error handling.
TypeScript
const nohead = new Nohead()
nohead.records.list("posts")Python
nohead = Nohead()
nohead.records.list("posts")Ruby
nohead = Nohead::Client.new
nohead.records.list("posts")You tell us
Need another language? Let us know which.
The contract itself is at /openapi.json; generate a client for anything else from it.
Webhooks, signed and retried. React instead of polling.
Subscribe a URL to the events you care about. Each delivery is signed with the Standard Webhooks scheme, retried with backoff for over a day, and logged with every attempt.
- record.created
- record.updated
- record.published
- record.unpublished
- record.deleted
- record.*
- draft.*
- field.*
- asset.*
Payloads carry the change's actor and via, so an integration that writes back can skip its own changes. Deliveries are at least once; deduplicate on the webhook-id header. Respond within ten seconds; anything else is retried after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h.
import { unwrapWebhook } from "@nohead/sdk/webhooks" import { revalidateTag } from "next/cache" export async function POST(request: Request) { // Checks the signature and the timestamp, or throws const event = await unwrapWebhook( await request.text(), // the raw body, as signed request.headers, { secret: process.env.NOHEAD_WEBHOOK_SECRET! } ) // Skip the changes this integration made itself if (event.actor.id === process.env.NOHEAD_KEY_ID) { return new Response(null, { status: 204 }) } if (event.type === "record.published" && event.data.record) { revalidateTag(event.data.record.collection, "max") } return new Response(null, { status: 204 }) }
import os from flask import Flask, request from nohead.webhooks import unwrap app = Flask(__name__) @app.post("/nohead") def nohead_webhook(): # Checks the signature and the timestamp, or raises event = unwrap( request.get_data(), # the raw body, as signed request.headers, secret=os.environ["NOHEAD_WEBHOOK_SECRET"], ) # Skip the changes this integration made itself if event.actor.id == os.environ["NOHEAD_KEY_ID"]: return "", 204 if event.type == "record.published" and event.record: rebuild(event.record.collection) return "", 204
class WebhooksController < ApplicationController skip_forgery_protection def create # Checks the signature and the timestamp, or raises event = Nohead::Webhooks.unwrap( request.raw_post, # the raw body, as signed request.headers, secret: ENV.fetch("NOHEAD_WEBHOOK_SECRET") ) # Skip the changes this integration made itself return head :no_content if event.actor.id == ENV.fetch("NOHEAD_KEY_ID") if event.type == "record.published" && event.data.record RebuildJob.perform_later(event.data.record.collection) end head :no_content end end
import { createHmac, timingSafeEqual } from "node:crypto" function verify(secret, headers, rawBody) { const id = headers["webhook-id"] const timestamp = headers["webhook-timestamp"] // Refuse a missing timestamp, or one more than five minutes off const age = Math.abs(Date.now() / 1000 - Number(timestamp)) if (!Number.isFinite(age) || age > 300) return false // HMAC-SHA256 of id.timestamp.body, keyed with the secret after whsec_ const key = Buffer.from(secret.slice("whsec_".length), "base64") const signed = `${id}.${timestamp}.${rawBody}` const expected = "v1," + createHmac("sha256", key).update(signed).digest("base64") // Any one of the space-separated signatures may match return headers["webhook-signature"].split(" ").some( (signature) => signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) ) }
Building with agents?
The CLI, the MCP server and /llms.txt give coding agents, chat apps and your own agents the same API, with keys scoped to their job and every change on the record.
Questions, answered.
About the API, search, images, schema as code, webhooks and the SDKs.
Ask us directly/v1 are additive; anything that could break a client is caught by a breaking-change check before it ships. A deprecated part keeps working for at least 6 months (12 for anything the SDKs use): responses that use it carry a Deprecation header, and the owners of keys that call it are told by email before it goes.Idempotency-Key with a write and a retry within 24 hours returns the first result instead of repeating the change; the CLI and the SDKs do this for you. Rate limits are per key, every response carries RateLimit-* headers, and a 429 comes with Retry-After. A request refused by a rate limit is neither performed nor counted, so retrying it is safe.nohead schema pull writes your project's collections and fields to nohead.schema.yaml. Edit it, review it in a pull request, run nohead schema diff to see what would change (including a preview of any field migration), and nohead schema push --yes to apply it, from your laptop or CI. Push never deletes anything, and diff --exit-code makes a good CI check.webhook-id header.@nohead/sdk has loaders for the image components of Next.js, Astro and Nuxt. Image loads aren't API requests, and there are no per-rendition fees. Other files are served as they are, from a URL that downloads them under their filename, and video and audio play in the browser. The images and files guide has the options.nohead.schema.yaml. Then search one collection or the whole project from the API, the CLI (nohead search) or the SDKs, ranked by relevance and current within seconds of a change. For search as visitors type, turn on public search: the browser queries the search engine directly with a read-only token, sees published records only, and doesn't count toward your API requests.Read the contract, then build.
Create a project key with the scopes your job needs, point the CLI or an SDK at it, and everything you do is in history from the first request.