Docs

The legacy /v2 client

createClient from @capacms/sdk for /v2/api: reads, scheduling, workspaces, layouts and webhook endpoints.

View as Markdown

@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 exists

Page.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 editor

Workspaces 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 saving

resolved.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 below

retry 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 id

test 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.