The legacy /v2 client
createClient from @capacms/sdk for /v2/api: reads, scheduling, workspaces, layouts and webhook endpoints.
@capacms/sdk is the client for sites that read /v2/api today. It takes a
legacy key (pk_, sk_ or unprefixed) and the tenant's id, and refuses a
cap_ key where it is built, since /v2 answers one as an invalid key: a
cap_ key reads /api/, with createClient from @capacms/sdk/next.
Besides reads, it schedules publishes, arranges the admin's workspaces and
entry layouts, and manages webhook endpoints.
import { createClient } from "@capacms/sdk";
import type { BlogPost } from "./capa-types"; // written by capa-codegen
const capa = createClient({
baseUrl: process.env.CAPA_API_URL!,
apiKey: process.env.CAPA_KEY!, // a pk_ key, not a cap_ key
tenantId: process.env.CAPA_TENANT_ID!,
});
const page = await capa.listContent<BlogPost>("blog_post", { limit: 10 });
const one = await capa.findOne<BlogPost>("blog_post", { handle: "hello" });An entry's id: read Page.ids
A /v2/api row puts the model's own fields at its top level, so a model
with a field named id replaces the entry's id in the row, and the same goes
for title and tags. Many models have one: every Shopify model names a
field id. So read Page.ids, not row.id:
const page = await capa.listContent("shopify_page");
page.ids[0] // the entry's UUID when the row has one to give
page.data[0].id // the model's OWN id field whenever one existsPage.ids is string | null per row, in the order of data: null means
the model shadows id and the row carries nothing else to read the entry's
id from. instanceIdOf(row) reads one row the same way, and prefers an
instanceId when a row carries one. getContentById throws for an id that is
not a UUID rather than spending a request on a certain 400. search() reads
its hits the same way, so SearchHit.id is string | null too. /api/ has
no such collision: an entry's system keys sit beside its fields.
Preview is a key, not a flag
Capa shows unpublished content to a key whose environment is not
production, whatever the request says. There is no ?preview=true, so
preview is a second client:
const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY });
const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY });The comparison is exact and case-sensitive, so any environment that is
not literally production returns drafts, a typo like Production
included. And a /v2 client cannot find out which it holds: no /v2
endpoint reports a key's environment, so a site handed a draft key serves
unpublished content publicly with no way to detect it. /api/ reports it:
capa.me() on @capacms/sdk/next answers the key's environment.
What it leaves out
Caching. Every framework caches differently, so the client returns plain
data from plain fetch, and Next's fetch cache and React Router loaders work
with it as they are. What your host cannot know is which surrogate keys a
response carries, so Page.cacheTags holds them for purging a CDN.
getBySlug. Capa has no slug convention: the field is handle on
shopify_page, product_handle on judge_me_review and bloghandle on
shopify_article, and nothing marks one as the slug. findOne(ns, { handle })
says which.
Content writes. The client writes no entry content. It schedules
publishes (scheduledActions, below), a request that leaves a row you can
read, show and cancel, and it writes arrangement, which touches no entry:
await capa.workspaces.apply(id, { tree }); // the admin's left rail
await capa.models.setLayout(id, layout); // the entry editor
await capa.models.setLayout(id, null); // back to the linear editorWorkspaces and entry layouts
Reading a layout has two shapes. models.getLayout(id) is the document or
null; models.getLayoutInfo(id) is the same read with the model's
embedByDefault beside it, which decides how a relation field with no
display renders, in linear mode too:
const { layout, embedByDefault } = await capa.models.getLayoutInfo(id);A workspace tree is a flat WorkspaceDocNode[]: one list of folders you
named, each holding any mix of {model}, {instance}, {media_folder} and
further folders. The older shape, { model, content, media }, is still
accepted and lands in folders called Models, Content and Media.
workspaces.* needs a key with write; models.setLayout needs one with
agent, because it writes a model and that is the permission every model
write asks for. models.getLayout needs only read. A refused document comes
back as CapaError carrying the API's own { error, path }, where path
names the node to fix.
Scheduling a publish
const { batchId, resolved, actions, replaced } = await capa.scheduledActions.create({
action: "publish", // or "unpublish"
targets: [{ type: "instance", id: instanceId }], // 1 to 500
wallTime: "2026-10-01T09:00", // local clock, NO offset
timezone: "America/New_York", // IANA name
});Entry targets only, from a key. A { type: "model" } target needs the
model:publish permission, which no API key carries, so the server answers
403 whatever the key. Scheduling a model publish is done in the Capa admin.
Send a wall clock and a zone, not an instant. wallTime carrying a Z or a
+02:00 is refused, here and by the server, because the two together are the
only way to say "9am local, whatever the offset turns out to be". The server
converts and reports what it decided in resolved:
resolved.runAt // "2026-10-01T13:00:00.000Z"
resolved.runAtInTimezone // "2026-10-01T09:00:00-04:00"
resolved.note // null, or a sentence about daylight savingresolved.note is the one field worth showing a person verbatim. A wall time
that does not exist (the spring gap) resolves forward and a wall time that
happens twice (the autumn overlap) takes the earlier offset, and the note says
which happened: 02:30 does not exist on 8 Mar 2026 in America/New_York, publishing at 03:30 EDT.
replaced holds the ids of actions that were cancelled to make room. A target
may have one active publish and one active unpublish at a time, so scheduling a
second publish for the same entry replaces the first rather than queueing it.
versionId is optional and you usually want it absent. With no version
pinned, the action publishes the latest draft at fire time, which is what an
editor who fixes a typo on Tuesday expects of a schedule made on Monday.
The rest of the resource:
await capa.scheduledActions.list({ status: ["pending", "running"], limit: 50 });
await capa.scheduledActions.list({ targetId: instanceId });
await capa.scheduledActions.get(id); // null when there is none
await capa.scheduledActions.reschedule(id, { wallTime, timezone }); // + resolved
await capa.scheduledActions.cancel(id);
await capa.scheduledActions.retry(id); // a NEW row, see belowretry does not reopen the failed action. It creates a new pending one with
retryOfId pointing at the original, so what failed stays readable. Expect two
rows and read the newer one.
Statuses are pending, running, done, failed, dead, cancelled. Only
pending can be rescheduled or cancelled: a running action is being executed
right now and cancel answers 409 already_running. resultVersionId on a
done row is the version that went live.
Reading needs a read key; every write here needs instance:publish, which the
write, delete and agent keys carry. A refusal is a CapaError with the
API's own { error, code }.
Webhooks
Two halves that do not need each other. The verifier is clientless, so a receiver imports one function and nothing else. The resource manages endpoints, and it is the only thing in this SDK that needs a session token rather than an API key.
Verifying a delivery
import { verifyWebhookSignature } from "@capacms/sdk";
export async function POST(request: Request) {
const body = await request.text(); // the RAW body, see below
const ok = await verifyWebhookSignature({
payload: body,
header: request.headers.get("capa-signature") ?? "",
secret: process.env.CAPA_WEBHOOK_SECRET!,
});
if (!ok) return new Response("bad signature", { status: 400 });
const event = JSON.parse(body);
// event.id is stable across retries and redeliveries: store it and ignore
// one you have already handled.
return new Response("ok");
}The payload must be the bytes that arrived. The signature covers
"<t>.<body>", and a body that has been through JSON.parse and
JSON.stringify again is a different string. Express needs
express.raw({ type: "application/json" }), Fastify needs a raw-body parser on
the route, Next's app router gives you await request.text(). A Buffer or
Uint8Array can be passed straight in.
verifyWebhookSignature is async because it uses WebCrypto rather than
node:crypto, which is what lets it run unchanged in Node, Bun, Deno,
Cloudflare Workers, Vercel's edge runtime and a browser. It returns false
rather than throwing for every bad input: a malformed header, a missing secret,
a timestamp outside the tolerance (5 minutes by default, toleranceSeconds to
change it), or a body that does not match.
During the 24 hours after a rotation Capa sends two v1= entries, the new
secret first. Either one verifies, so a receiver can be updated any time inside
that window without dropping a request.
Managing endpoints
const capa = createClient({
baseUrl: process.env.CAPA_API_URL!,
apiKey: process.env.CAPA_KEY!,
tenantId: process.env.CAPA_TENANT_ID!,
accessToken: sessionJwt, // from POST /v2/user/login
});
const { secret, ...endpoint } = await capa.webhooks.endpoints.create({
name: "Site rebuild",
url: "https://example.com/hooks/capa",
events: ["instance.published", "instance.unpublished", "instance.deleted"],
});
// `secret` is here ONCE. Store it now.createClient still needs apiKey and tenantId to construct at all, so a
script that only ever calls webhooks.* has to pass something non-empty for
both. Any legacy-looking placeholder will do, because no webhook call reads
either one.
The tenant comes from the session, not from tenantId. These routes read
the current tenant off the logged-in user's token, so the tenantId you passed
to createClient is ignored for every webhooks.* call: a client built with one
tenantId operates on whatever tenant that user is currently on.
accessToken, not apiKey. The webhook routes take no API key at all: a
key that can add an endpoint can forward every content change in the project
to a URL of its choosing, and a key is a string in a config file nobody
rotates. So these routes want a logged-in person, and every webhooks.*
method throws @capacms/sdk: webhooks need accessToken; API keys cannot manage endpoints. before sending anything when the token is absent.
The rest of the resource:
await capa.webhooks.events(); // the catalogue, grouped
await capa.webhooks.endpoints.list();
await capa.webhooks.endpoints.get(id); // null when there is none
await capa.webhooks.endpoints.update(id, { events }); // headers REPLACE the map
await capa.webhooks.endpoints.delete(id);
await capa.webhooks.endpoints.pause(id); // cancels what is queued
await capa.webhooks.endpoints.resume(id); // enables, and COUNTS the gap
await capa.webhooks.endpoints.resume(id, { backfill: true, since });
await capa.webhooks.endpoints.revealSecret(id); // audited, every time
await capa.webhooks.endpoints.rotateSecret(id); // old secret lives 24h
await capa.webhooks.endpoints.test(id); // one webhook.test event
await capa.webhooks.endpoints.deliveries(id, { status: ["failed"], page: 1 });
await capa.webhooks.endpoints.redeliverFailed(id, { since });
await capa.webhooks.deliveries.get(deliveryId); // with both bodies
await capa.webhooks.deliveries.redeliver(deliveryId); // same event idtest answers { eventId }, and that value is an opaque request id: the
event that actually arrives carries a different id (evt_ plus another
id), so there is nothing to correlate on. Read deliveries(id) and take the newest
webhook.test row to see what the test did.
resume without backfill is deliberately a two-step: it enables the endpoint
and tells you how many events it missed, so you can show a person
Redeliver everything since 14 Sep, 2:10 PM (312 events) and let them decide.
Call it again with { backfill: true, since } if they say yes.
Reads need webhook:read, writes need the webhook:* write actions, and
revealSecret needs secret:reveal, which no role holds by default: it is an
owner or an explicit grant, and every call leaves an audit row whether it was
allowed or denied. A server without webhook secrets configured answers 503
with code webhooks_not_configured to every write while reads keep working.