# The legacy /v2 client

Source: https://capacms.com/docs/sdk/legacy-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.

```ts
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`:

```ts
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:

```ts
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:

```ts
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:

```ts
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

```ts
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`:

```ts
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:

```ts
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

```ts
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

```ts
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:

```ts
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.
