
Using the Sanity HTTP API
The Sanity HTTP API is the set of plain HTTPS endpoints behind every Sanity client library. Every request goes to https://<projectId>.api.sanity.io/v<YYYY-MM-DD>/<path>, or to apicdn.sanity.io for cached reads, and the main endpoints are GET /data/query/<dataset> for GROQ queries, POST /data/mutate/<dataset> and POST /data/actions/<dataset> for writes, GET /data/listen/<dataset> for real-time events, GET /data/doc/<dataset>/<id> for uncached document lookups, and /data/history and /data/export for revisions and bulk exports. Public datasets accept anonymous reads of published content; everything else needs an Authorization: Bearer <token> header.
You'll usually reach for @sanity/client in JavaScript, but the HTTP API is what you use from PHP, Python, Go, a shell script, an edge function without npm packages, or anywhere you want to see exactly what's going over the wire. Knowing the raw API also makes client errors much easier to debug. This guide covers the URL format and versioning, authentication, the query endpoint in depth, and the document, mutation, actions, listen, history, export and asset endpoints, with curl and fetch examples for each.
The URL Format
Every request URL has three parts:
https://abc123xy.api.sanity.io/v2026-10-01/data/query/production?query=*[_type == "post"]
└─ project ID ─┘ └─ version ─┘└──────── path ────────┘
| Part | Value |
|---|---|
| Host | <projectId>.api.sanity.io (live) or <projectId>.apicdn.sanity.io (cached) |
| Version | v plus a date, such as v2026-10-01 |
| Path | The endpoint, usually including the dataset name |
You'll find the project ID in sanity.config.ts, at sanity.io/manage or by running npx sanity debug.
API Versions
The version is a date. Sanity resolves it to the latest API release on or before that date, so pick today's date when you start a project and leave it fixed until you need a newer feature. Version dates matter: from v2025-02-19, queries default to the published perspective, while older versions default to raw and return drafts side by side with published documents for authenticated requests. Use the same version everywhere in a project, as described in Sanity Content Lake explained.
The Live API vs the API CDN
api.sanity.io: uncached and always fresh. Required for writes, listeners, drafts and reading immediately after a write.apicdn.sanity.io: an edge cache for reads. It cachesGETqueries and read-onlyPOSTqueries, and rejects otherPOSTrequests because they could contain mutations. It only serves thepublishedandrawperspectives.
Send public website traffic to the CDN. It's faster and cheaper, and the trade-offs are covered in Sanity CDN vs live API.
Authentication
Anonymous requests can read published documents in public datasets. To read drafts, read private datasets or write anything, add a bearer token:
curl -H "Authorization: Bearer $SANITY_TOKEN" \
"https://abc123xy.api.sanity.io/v2026-10-01/data/query/production?query=count(*)"
There are two kinds of token:
- Personal tokens: created when you run
npx sanity login, with your full permissions. Fine for one-off testing, never for applications. - Robot tokens: created in sanity.io/manage under API, Tokens, with a role such as Viewer or Editor. Use one per application so you can revoke it independently. You can set an expiry date when you create one.
A token with write access must never reach a browser. Call write endpoints from a server, a serverless function or a script. Securing your Sanity project covers token hygiene in more detail.
CORS
Browser requests are only allowed from origins listed in your project's CORS settings. Server-side requests aren't affected. If a fetch from the browser fails with a CORS error, add the origin in sanity.io/manage, ticking Allow credentials only if the browser sends a token.
Querying With GROQ
The query endpoint is the one you'll use most:
GET /v2026-10-01/data/query/<dataset>?query=<GROQ>
POST /v2026-10-01/data/query/<dataset>
GET Requests
Pass the query in the query parameter and each GROQ parameter as $name with a JSON-encoded value. Strings therefore need quotes:
curl -G "https://abc123xy.apicdn.sanity.io/v2026-10-01/data/query/production" \
--data-urlencode 'query=*[_type == "post" && slug.current == $slug][0]{title, publishedAt}' \
--data-urlencode '$slug="hello-world"'
An unquoted string such as $slug=hello-world returns a 400 error, because the value isn't valid JSON. The same request with fetch:
const url = new URL('https://abc123xy.apicdn.sanity.io/v2026-10-01/data/query/production')
url.searchParams.set('query', '*[_type == "post" && slug.current == $slug][0]{title, publishedAt}')
url.searchParams.set('$slug', JSON.stringify('hello-world'))
const res = await fetch(url)
const { result } = await res.json()
URLSearchParams encodes each name and value separately, which is what you want. Running encodeURIComponent over a whole query string would also encode the & separators.
POST Requests
Use POST for long queries or large parameter values. Browsers and proxies limit URL length, and @sanity/client switches to POST automatically once the encoded query reaches 11,264 characters. The body accepts only query, params and returnQuery. Other options go in the URL:
curl -X POST "https://abc123xy.apicdn.sanity.io/v2026-10-01/data/query/production?tag=website.search" \
-H "Content-Type: application/json" \
-d '{
"query": "*[_type == \"post\" && _id in $ids]{_id, title}",
"params": { "ids": ["post-1", "post-2"] }
}'
Read-only POST queries are cached on the CDN just like GET requests.
The Response
{
"query": "*[_type == \"post\"][0]{title}",
"result": { "title": "Hello world" },
"syncTags": ["s1:hil1xQ"],
"ms": 12
}
result: the query output, which can be any JSON value.syncTags: opaque tags used by the Live Content API to know when to refetch. See using live preview with Sanity.ms: server-side processing time.query: the query you sent. PassreturnQuery=falseto leave it out and shrink responses.
Missing documents never produce a 404. GROQ always evaluates to a value, so *[_id == "missing"][0] returns null and a filter with no matches returns []. A 404 from this endpoint means the dataset itself doesn't exist.
Useful Query Parameters
| Parameter | Effect |
|---|---|
perspective | published, drafts, raw, or a comma-separated release stack. Drafts and releases need a token and the live API |
tag | A label that appears in request logs, such as website.homepage |
returnQuery | false omits the query from the response |
explain | Includes the query plan as text, useful for slow queries |
resultSourceMap | Adds Content Source Maps, used by visual editing |
lastLiveEventId | Asks the CDN for a response at least as fresh as a live event |
Query syntax is covered from what is GROQ onwards, and safe parameter handling in GROQ parameters.
Errors
Syntax errors come back as 400 with details about where parsing failed:
{
"error": {
"query": "*[",
"description": "expected ']' following expression",
"start": 1,
"end": 2,
"type": "queryParseError"
}
}
One surprise: an unauthenticated query against a private dataset returns 200 with an empty result, not 401. If results are unexpectedly empty, check the token first.
Fetching Documents by ID
The doc endpoint returns documents by ID without going through the query index, so it always reflects the latest write:
curl -H "Authorization: Bearer $SANITY_TOKEN" \
"https://abc123xy.api.sanity.io/v2026-10-01/data/doc/production/post-1,post-2"
{
"documents": [{ "_id": "post-1", "_type": "post", "title": "Hello world" }],
"omitted": [{ "id": "post-2", "reason": "existence" }]
}
IDs that don't exist appear in omitted with the reason existence, and IDs the token can't read appear with permission. Add includeAllVersions=true to also get each document's draft and release versions. The endpoint bypasses all caching and is less scalable than queries, so use it for reads straight after a write, not for page rendering.
Writing With Mutations and Actions
There are two write endpoints, and both are transactional: every change in a request succeeds, or none do.
POST /v2026-10-01/data/mutate/<dataset>
POST /v2026-10-01/data/actions/<dataset>
The Mutation API offers low-level operations: create, createOrReplace, createIfNotExists, delete and patch:
curl -X POST "https://abc123xy.api.sanity.io/v2026-10-01/data/mutate/production?returnIds=true" \
-H "Authorization: Bearer $SANITY_WRITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"mutations": [
{ "createIfNotExists": { "_id": "settings", "_type": "siteSettings", "title": "My Site" } },
{ "patch": { "id": "post-1", "set": { "featured": true }, "inc": { "views": 1 } } }
]
}'
The response contains a transactionId and a results array listing each affected document and the operation performed on it.
The Actions API works at the level of the Studio's editorial model: create a draft, edit it, publish it, unpublish it or discard it. It's what the Studio itself uses:
curl -X POST "https://abc123xy.api.sanity.io/v2026-10-01/data/actions/production" \
-H "Authorization: Bearer $SANITY_WRITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"actions": [
{
"actionType": "sanity.action.document.publish",
"publishedId": "post-1",
"versionId": "drafts.post-1"
}
]
}'
Useful mutation parameters include returnIds, returnDocuments, dryRun, autoGenerateArrayKeys and visibility (sync, async or deferred). Writes are limited to 25 requests per second per IP across both endpoints, and a mutation body can be at most 4 MB. Patch syntax, transactions, optimistic locking and the JavaScript equivalents are covered in how to use Sanity's Mutation API.
Listening for Changes
The listen endpoint keeps an HTTP connection open and streams Server-Sent Events whenever a document matching a GROQ filter changes:
curl -N -H "Authorization: Bearer $SANITY_TOKEN" \
"https://abc123xy.api.sanity.io/v2026-10-01/data/listen/production?query=*%5B_type%20%3D%3D%20%22post%22%5D&includeResult=true"
The stream opens with a welcome event, then sends a mutation event for each change. Each mutation event includes the documentId, a transition of appear, update or disappear, the applied mutations and, with includeResult=true, the resulting document. Other event types are welcomeback and reset (used when resuming), channelError and disconnect, so handle them rather than assuming every event is a mutation.
Only the filter part of the query is used; projections and slices are ignored. Drafts are included when the token can read them. Options include includeResult, includePreviousRevision, visibility=query (wait until the change is queryable) and enableResume, which lets a reconnecting client pick up missed events using the Last-Event-ID header. Connections are dropped after about four hours, and the number of concurrent listeners per dataset depends on your plan, so reconnect gracefully.
For websites with many visitors, use the Live Content API instead. Its events carry only sync tags rather than document content, and it's built for scale.
Reading History
The history endpoints give you earlier versions of a document and the transactions that changed it:
# A document as it was at a point in time
curl -H "Authorization: Bearer $SANITY_TOKEN" \
"https://abc123xy.api.sanity.io/v2026-10-01/data/history/production/documents/post-1?time=2026-10-01T09:00:00Z"
# The last 20 transactions for a document, as NDJSON
curl -H "Authorization: Bearer $SANITY_TOKEN" \
"https://abc123xy.api.sanity.io/v2026-10-01/data/history/production/transactions/post-1?excludeContent=true&limit=20&reverse=true"
The transactions endpoint requires excludeContent=true. How long history is kept depends on your plan, and restoring revisions is covered in Sanity document history and revisions explained.
Exporting a Dataset
GET /data/export/<dataset> streams every document the token can access as NDJSON, including drafts and asset documents but not the asset binaries:
curl -H "Authorization: Bearer $SANITY_TOKEN" \
"https://abc123xy.api.sanity.io/v2026-10-01/data/export/production" > production.ndjson
The status code is sent before the stream starts, so an error midway leaves you with a 200 and an error object on the last line. Check it. For complete exports with assets, use the CLI or @sanity/export, as described in how to migrate content between Sanity datasets.
Uploading Assets
Send the raw file as the request body to the images or files endpoint:
curl -X POST "https://abc123xy.api.sanity.io/v2026-10-01/assets/images/production?filename=hero.jpg" \
-H "Authorization: Bearer $SANITY_WRITE_TOKEN" \
-H "Content-Type: image/jpeg" \
--data-binary @hero.jpg
The response contains the new sanity.imageAsset document, including its _id, CDN url and metadata. To use it, reference that _id from an image field with a mutation. Non-image files go to /assets/files/<dataset>. Image handling is covered in Sanity images.
A Minimal Client Without Dependencies
If you can't install @sanity/client, a small wrapper covers most needs:
// lib/sanity-http.ts
const PROJECT_ID = 'abc123xy'
const DATASET = 'production'
const API_VERSION = 'v2026-10-01'
export async function sanityQuery<T>(
query: string,
params: Record<string, unknown> = {},
{ cdn = true, token }: { cdn?: boolean; token?: string } = {},
): Promise<T> {
const host = cdn && !token ? 'apicdn' : 'api'
const url = new URL(`https://${PROJECT_ID}.${host}.sanity.io/${API_VERSION}/data/query/${DATASET}`)
url.searchParams.set('query', query)
url.searchParams.set('returnQuery', 'false')
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(`$${key}`, JSON.stringify(value))
}
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const body = await res.json()
if (!res.ok) {
throw new Error(body.error?.description ?? `Sanity query failed: ${res.status}`)
}
return body.result as T
}
const post = await sanityQuery<{ title: string } | null>(
`*[_type == "post" && slug.current == $slug][0]{ title }`,
{ slug: 'hello-world' },
)
For anything bigger, such as retries, listeners, live content or typed queries, the official client is worth the dependency.
Rate Limits and Status Codes
| Status | Meaning | What to do |
|---|---|---|
200 | Success, including empty results | Check result, not the status, for missing data |
400 | Bad query, parameter or body | Read error.description |
401 / 403 | Missing token or insufficient permission | Check the token and its role |
404 | Dataset or endpoint not found | Check the dataset name and API version |
409 | Conflict, such as a failed revision check | Refetch and retry |
429 | Rate limit exceeded | Back off and retry, and batch writes |
Rate limits are applied per client IP per second. The CDN isn't rate limited, but cache misses count toward the live API's limits. Tag your requests with tag so you can see which part of your app is responsible when usage spikes.
FAQ: Sanity HTTP API
Not for reading published documents in a public dataset. You need a bearer token to read drafts, release versions or private datasets, and for every write, listen, history or asset upload request.
Use a recent date, such as the day you start the project, written as v followed by YYYY-MM-DD. Versions from v2025-02-19 default queries to the published perspective. Keep the same version across your project and only change it deliberately.
GROQ parameters in URLs must be JSON-encoded. A string needs double quotes, so send $slug="hello-world" rather than $slug=hello-world. URLSearchParams with JSON.stringify for each value handles this for you.
Yes, for reads from origins listed in your project's CORS settings. Never send a write token from the browser. Put writes behind a server route or function that validates input and holds the token securely.
The query endpoint runs GROQ, can be cached on the CDN and is designed for scale. The doc endpoint fetches documents by ID directly, bypassing the index and caches, so it reflects the latest write but should only be used when you need that freshness.
Yes, if you deploy a GraphQL API from your schema with sanity graphql deploy. It has its own endpoint and reflects the schema at deploy time. GROQ needs no deployment and works with any document shape.
Conclusion
The Sanity HTTP API is small and consistent: a project host, a dated version and a handful of endpoints for querying, fetching by ID, mutating, dispatching actions, listening, reading history, exporting and uploading assets. Reads of published content can be anonymous and cached on the CDN, while everything else uses a bearer token on the live API.
Use it directly when you're working outside JavaScript, building something tiny or debugging what a client library actually sends. Keep the API version fixed, JSON-encode your parameters, send public traffic to apicdn, keep write tokens on the server, and you'll find the raw API every bit as pleasant to work with as the libraries built on top of it.


