@capacms/sdk 1.0.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +678 -0
- package/bin/capa-codegen.js +57 -0
- package/dist/client.d.ts +413 -0
- package/dist/client.js +288 -0
- package/dist/codegen.d.ts +69 -0
- package/dist/codegen.js +188 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +15 -0
- package/dist/http.d.ts +67 -0
- package/dist/http.js +144 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +21 -0
- package/dist/next/client.d.ts +294 -0
- package/dist/next/client.js +408 -0
- package/dist/next/index.d.ts +3 -0
- package/dist/next/index.js +9 -0
- package/dist/next/select-types.d.ts +39 -0
- package/dist/next/select-types.js +2 -0
- package/dist/nextjs/index.d.ts +53 -0
- package/dist/nextjs/index.js +165 -0
- package/dist/webhook-signature.d.ts +88 -0
- package/dist/webhook-signature.js +161 -0
- package/dist/webhooks.d.ts +244 -0
- package/dist/webhooks.js +130 -0
- package/package.json +69 -0
package/README.md
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
# @capacms/sdk
|
|
2
|
+
|
|
3
|
+
## The `/api/` client (`@capacms/sdk/next`)
|
|
4
|
+
|
|
5
|
+
Install the SDK from the `next` dist tag:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @capacms/sdk@next
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Create one client per key and pin the platform version in code:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createClient, CapaError } from "@capacms/sdk/next";
|
|
15
|
+
|
|
16
|
+
const capa = createClient({
|
|
17
|
+
baseUrl: process.env.CAPA_BASE_URL!,
|
|
18
|
+
apiKey: process.env.CAPA_API_KEY!, // cap_live_... or cap_test_...
|
|
19
|
+
version: "2026-10-01",
|
|
20
|
+
contract: 1,
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The `/api/` client sends `x-api-key`, `Capa-Version`, optional
|
|
25
|
+
`Capa-Contract`, and `Accept: application/json`. It never sends
|
|
26
|
+
`X-Tenant-Key`; the tenant comes from the `cap_` key.
|
|
27
|
+
|
|
28
|
+
### Entries
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const page = await capa.entries.list("articles", {
|
|
32
|
+
select: [
|
|
33
|
+
"title",
|
|
34
|
+
"views",
|
|
35
|
+
{ author: ["name"] },
|
|
36
|
+
{ coauthors: { select: ["name"], limit: 5, sort: "-name" } },
|
|
37
|
+
],
|
|
38
|
+
filter: {
|
|
39
|
+
views: { gte: 10 },
|
|
40
|
+
"author.name": { eq: "Ada Vale" },
|
|
41
|
+
},
|
|
42
|
+
sort: ["-views"],
|
|
43
|
+
limit: 25,
|
|
44
|
+
count: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
for await (const entry of capa.entries.iterate("articles", { select: ["title"] })) {
|
|
48
|
+
console.log(entry.fields.title);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const one = await capa.entries.get("articles", "entry-id", {
|
|
52
|
+
select: ["title", { author: "*" }],
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`select` may be the grammar string from `docs/api/entries.md` or the object
|
|
57
|
+
form above. Lists return `{ data, page, meta, cacheTags }`; singles return
|
|
58
|
+
`{ data, meta, cacheTags }`. `cacheTags` is parsed from the `Surrogate-Key`
|
|
59
|
+
header. `get()` returns `null` for `404 entry_not_found` and throws every other
|
|
60
|
+
error.
|
|
61
|
+
|
|
62
|
+
Filters use the `/api/` operators:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
await capa.entries.list("articles", {
|
|
66
|
+
filter: {
|
|
67
|
+
id: { in: ["a", "b"] },
|
|
68
|
+
views: { gte: 10 },
|
|
69
|
+
tags: { hasAny: ["news", "launch"] },
|
|
70
|
+
},
|
|
71
|
+
where: { or: [{ featured: { eq: true } }, { views: { gt: 100 } }] },
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Unknown filter operators throw a local `TypeError` before any request is sent.
|
|
76
|
+
Per-call `{ signal }` is forwarded to `fetch`.
|
|
77
|
+
|
|
78
|
+
### Errors
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
try {
|
|
82
|
+
await capa.entries.list("articles", { limit: 500 });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof CapaError) {
|
|
85
|
+
console.log(error.status, error.code, error.param, error.hint, error.requestId);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`CapaError` carries `{ status, type, code, message, param, hint, requestId,
|
|
91
|
+
docs }`. A non-JSON response is reported as `code: "unparseable_response"`.
|
|
92
|
+
|
|
93
|
+
### Next.js helpers (`@capacms/sdk/nextjs`)
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { createClient } from "@capacms/sdk/next";
|
|
97
|
+
import { withCache, tagsFor } from "@capacms/sdk/nextjs";
|
|
98
|
+
|
|
99
|
+
const fetchWithCache = withCache(fetch, {
|
|
100
|
+
tags: tagsFor({ model: articleModelId }),
|
|
101
|
+
revalidate: 60,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const capa = createClient({ baseUrl, apiKey, version: "2026-10-01", fetch: fetchWithCache });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`withCache` merges `{ next: { tags, revalidate } }` into every fetch call.
|
|
108
|
+
`tagsFor` builds Capa surrogate keys: `m:`, `e:`, `k:`, and `t:`.
|
|
109
|
+
|
|
110
|
+
Webhook revalidation pairs with the existing signature verifier:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// app/api/capa/route.ts
|
|
114
|
+
import { revalidateTag } from "next/cache";
|
|
115
|
+
import { revalidateFromWebhook } from "@capacms/sdk/nextjs";
|
|
116
|
+
import { verifyWebhookSignature } from "@capacms/sdk";
|
|
117
|
+
|
|
118
|
+
export async function POST(request: Request) {
|
|
119
|
+
const raw = await request.text();
|
|
120
|
+
const ok = await verifyWebhookSignature({
|
|
121
|
+
payload: raw,
|
|
122
|
+
header: request.headers.get("capa-signature") ?? "",
|
|
123
|
+
secret: process.env.CAPA_WEBHOOK_SECRET!,
|
|
124
|
+
});
|
|
125
|
+
if (!ok) return new Response("bad signature", { status: 400 });
|
|
126
|
+
|
|
127
|
+
await revalidateFromWebhook({
|
|
128
|
+
payload: JSON.parse(raw),
|
|
129
|
+
revalidateTag,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return new Response("ok");
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`draftClient` is server-only. Pass Next's draft state in from the caller so the
|
|
137
|
+
SDK never imports `next/*`:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { draftMode } from "next/headers";
|
|
141
|
+
import { draftClient } from "@capacms/sdk/nextjs";
|
|
142
|
+
|
|
143
|
+
const capa = await draftClient({
|
|
144
|
+
production,
|
|
145
|
+
draft,
|
|
146
|
+
isDraft: async () => (await draftMode()).isEnabled,
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Typed select and codegen
|
|
151
|
+
|
|
152
|
+
`Select<T>` is exported from `@capacms/sdk/next`. `capa-codegen` keeps the legacy
|
|
153
|
+
`/v2/schema/types` shape for schemas without relations. When a schema has
|
|
154
|
+
relations, codegen wraps them in branded helpers:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
export type CapaRelation<T> = T & { readonly __capaRelation: "one"; readonly __capaRelationTarget: T };
|
|
158
|
+
export type CapaRelationList<T> = T[] & { readonly __capaRelation: "many"; readonly __capaRelationTarget: T };
|
|
159
|
+
|
|
160
|
+
export interface Article {
|
|
161
|
+
title?: string;
|
|
162
|
+
author?: CapaRelation<Author>;
|
|
163
|
+
coauthors?: CapaRelationList<Author>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type ArticleSelect = import("@capacms/sdk/next").Select<Article>;
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Those brands let TypeScript tell scalar fields from relation fields, so misspelled
|
|
170
|
+
fields and invalid nested selects fail in consumer typechecks.
|
|
171
|
+
|
|
172
|
+
## Pages and preview
|
|
173
|
+
|
|
174
|
+
Capa can tell you which of **your** pages read which entries, and it can open a
|
|
175
|
+
draft in your own site. Both are opt in and both live on `@capacms/sdk/next` and
|
|
176
|
+
`@capacms/sdk/nextjs`.
|
|
177
|
+
|
|
178
|
+
### Tell Capa which page a read is for
|
|
179
|
+
|
|
180
|
+
Set `page` and every read sends a `Capa-Page` header. Capa records it and
|
|
181
|
+
answers exactly as it would have without it: same body, same `ETag`, same cache
|
|
182
|
+
key. Nothing about your site changes except that Capa can now answer "what
|
|
183
|
+
breaks if I unpublish this?".
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { createClient } from "@capacms/sdk/next";
|
|
187
|
+
import { routeOf } from "@capacms/sdk/nextjs";
|
|
188
|
+
|
|
189
|
+
const capa = createClient({ baseUrl, apiKey, version: "2026-10-01" });
|
|
190
|
+
|
|
191
|
+
// In app/blog/[slug]/page.tsx
|
|
192
|
+
const posts = await capa.entries.list("articles", { page: routeOf(import.meta.url) });
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`routeOf` turns a Next route file into the page string: `/blog/[slug]`. It
|
|
196
|
+
drops route groups `(marketing)`, parallel slots `@modal`, the leaf file name
|
|
197
|
+
and the extension. Write the string out by hand if you prefer; `routeOf` exists
|
|
198
|
+
so that moving a folder cannot silently split one page's telemetry in two. Set
|
|
199
|
+
`page` on the config instead when a client serves exactly one page; a value on
|
|
200
|
+
the call wins over one on the config.
|
|
201
|
+
|
|
202
|
+
A malformed value throws a `TypeError`. Capa itself ignores a header it cannot
|
|
203
|
+
store, because a mangled page identity must never take a blog down, so the SDK
|
|
204
|
+
is the place a typo surfaces.
|
|
205
|
+
|
|
206
|
+
### Read the page list
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
const { data: pages } = await capa.pages.list();
|
|
210
|
+
// [{ id: "/blog/[slug]", kind: "both", models: [...], reads30d: 812, ... }]
|
|
211
|
+
|
|
212
|
+
const appearsOn = await capa.pages.list({ entry: "entry-id" });
|
|
213
|
+
// only the pages that read that entry, each with entryReads
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
A page is `declared` when a model carries a route for it, `observed` when a read
|
|
217
|
+
arrived carrying it as `Capa-Page`, and `both` when a correctly wired site has
|
|
218
|
+
done both. `capa.pages.get("/blog/[slug]")` adds the entries the page reads, the
|
|
219
|
+
queries it makes and a per-day read count, and returns `null` for a page Capa
|
|
220
|
+
has never heard of.
|
|
221
|
+
|
|
222
|
+
### Tell Capa which schema you built against
|
|
223
|
+
|
|
224
|
+
Pass `CAPA_SCHEMA_CHECKSUM` from your generated types and every read sends a
|
|
225
|
+
`Capa-Schema` header:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { CAPA_SCHEMA_CHECKSUM } from "./capa-types";
|
|
229
|
+
|
|
230
|
+
const capa = createClient({
|
|
231
|
+
baseUrl,
|
|
232
|
+
apiKey,
|
|
233
|
+
version: "2026-10-01",
|
|
234
|
+
schemaChecksum: CAPA_SCHEMA_CHECKSUM,
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`capa-codegen` writes that constant into the generated file on every run, so it
|
|
239
|
+
is always the schema the committed types describe. It buys one thing nothing
|
|
240
|
+
else can work out: whether your deployed site was built against the models the
|
|
241
|
+
project has now. A site that has never been rebuilt keeps sending perfectly
|
|
242
|
+
valid requests, so without the stamp a stale build is invisible, and with it the
|
|
243
|
+
page detail says "the site was generated from an older schema" and tells you to
|
|
244
|
+
re-run `capa-codegen`.
|
|
245
|
+
|
|
246
|
+
Telemetry only, like `page`: it does not change a response, a cache key or an
|
|
247
|
+
`ETag`, and Capa ignores a value it cannot parse. A bad value throws a
|
|
248
|
+
`TypeError` where the client is built, because a stamp dropped in silence looks
|
|
249
|
+
exactly like a site that is up to date.
|
|
250
|
+
|
|
251
|
+
### Read the suggestions
|
|
252
|
+
|
|
253
|
+
`capa.pages.get(page)` carries an `insights` array: suggestions drawn from that
|
|
254
|
+
page's own reads, computed by Capa so that this SDK, the Capa admin and
|
|
255
|
+
`@capa/mcp` all read one answer.
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
const detail = await capa.pages.get("/blog/[slug]");
|
|
259
|
+
for (const insight of detail?.data.insights ?? []) {
|
|
260
|
+
console.log(insight.severity, insight.title, insight.evidence);
|
|
261
|
+
if (insight.rewrite?.select) console.log("try select=" + insight.rewrite.select);
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
| kind | what it found |
|
|
266
|
+
|---|---|
|
|
267
|
+
| `overfetch` | the page sends no `select` on a wide model or one with relations |
|
|
268
|
+
| `fanout` | the page reads one model an entry at a time instead of filtering |
|
|
269
|
+
| `cache` | most of the page's reads miss or bypass the edge |
|
|
270
|
+
| `drift` | the `Capa-Schema` the page sent is not the project's current one |
|
|
271
|
+
| `unused` | entries no page reads, on `pages.list()` under `meta.insights` |
|
|
272
|
+
|
|
273
|
+
Each one carries `title` (one sentence), `detail` (why it matters), `evidence`
|
|
274
|
+
(every number the rule used, so you can check the claim) and, where there is
|
|
275
|
+
one, `rewrite` with a canonical `select`, a set of query parameters or a whole
|
|
276
|
+
URL you can paste.
|
|
277
|
+
|
|
278
|
+
Every `queries[]` row on the detail also carries `selection`, the parsed
|
|
279
|
+
Selection IR for its `select`, or `selectionError` when the stored `select` no
|
|
280
|
+
longer parses against the model's current schema. That second case is usually
|
|
281
|
+
not a bug: it is a query naming a field the project has since removed.
|
|
282
|
+
|
|
283
|
+
`unused` insights live on the LIST rather than on a page, because "no page reads
|
|
284
|
+
this" is a claim about every page at once. They are in `meta.insights` on
|
|
285
|
+
`capa.pages.list()`.
|
|
286
|
+
|
|
287
|
+
### Open a draft in your own site
|
|
288
|
+
|
|
289
|
+
An editor presses Preview in Capa and gets a link to **your** site carrying a
|
|
290
|
+
signed token. Your site asks Capa whether the token is good, enables draft mode
|
|
291
|
+
and redirects to the page. The token is short lived and names one entry.
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
// app/api/preview/route.ts
|
|
295
|
+
import { draftMode } from "next/headers";
|
|
296
|
+
import { redirect } from "next/navigation";
|
|
297
|
+
import { createClient } from "@capacms/sdk/next";
|
|
298
|
+
import { preview } from "@capacms/sdk/nextjs";
|
|
299
|
+
|
|
300
|
+
export async function GET(request: Request) {
|
|
301
|
+
const token = new URL(request.url).searchParams.get("capa-preview") ?? "";
|
|
302
|
+
const claim = await preview(token, createClient({ baseUrl, apiKey, version }));
|
|
303
|
+
if (!claim) return new Response("Invalid or expired preview link", { status: 401 });
|
|
304
|
+
(await draftMode()).enable();
|
|
305
|
+
redirect(claim.path ?? "/");
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`preview` returns `null` for an invalid or an expired token, because a preview
|
|
310
|
+
route does the same thing for both: do not enable draft mode. Anything else
|
|
311
|
+
throws, so a Capa outage is never mistaken for a stale link. `claim.path` is
|
|
312
|
+
resolved fresh on every call rather than baked into the token, so fixing a route
|
|
313
|
+
or a slug takes effect immediately; it is `null` when the model has no route, and
|
|
314
|
+
your own routing is the fallback.
|
|
315
|
+
|
|
316
|
+
Draft mode still needs a draft key. `draftClient` selects one:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
const capa = await draftClient({
|
|
320
|
+
production: { baseUrl, apiKey: PUBLISHED_KEY, version },
|
|
321
|
+
draft: { baseUrl, apiKey: PREVIEW_KEY, version },
|
|
322
|
+
isDraft: async () => (await draftMode()).isEnabled,
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Set the preview base URL for your project in Capa (Settings), otherwise the
|
|
327
|
+
editor sees the path without a link to open it.
|
|
328
|
+
|
|
329
|
+
## Not yet
|
|
330
|
+
|
|
331
|
+
`--select-from-depth`, `capa convert-url`, `capa persist`, GraphQL, and `/api/`
|
|
332
|
+
writes are not in this release.
|
|
333
|
+
|
|
334
|
+
## The legacy `/v2/api` client
|
|
335
|
+
|
|
336
|
+
A read client for Capa, plus two arrangement writes (`workspaces`,
|
|
337
|
+
`models.setLayout`) and `capa-codegen`, a command that pulls a tenant's
|
|
338
|
+
generated TypeScript so content is typed at the call site.
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
import { createClient } from "@capacms/sdk";
|
|
342
|
+
import type { BlogPost } from "./capa-types"; // written by capa-codegen
|
|
343
|
+
|
|
344
|
+
const capa = createClient({
|
|
345
|
+
baseUrl: process.env.CAPA_BASE_URL!,
|
|
346
|
+
apiKey: process.env.CAPA_API_KEY!,
|
|
347
|
+
tenantId: process.env.CAPA_TENANT_ID!,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const page = await capa.listContent<BlogPost>("blog_post", { limit: 10 });
|
|
351
|
+
const one = await capa.findOne<BlogPost>("blog_post", { handle: "hello" });
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
## Codegen
|
|
355
|
+
|
|
356
|
+
```
|
|
357
|
+
CAPA_BASE_URL=... CAPA_API_KEY=... CAPA_TENANT_ID=... capa-codegen --out src/capa-types.ts
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Exit codes: `0` wrote or already current, `1` failed, `2` `--check` found a diff.
|
|
361
|
+
`--check` is the CI mode — it fails when the committed file is stale.
|
|
362
|
+
|
|
363
|
+
**Commit the output.** Not generated at install (needs credentials during
|
|
364
|
+
`npm install`, which breaks CI images and Docker builds) and not at build time
|
|
365
|
+
(makes every build network-dependent, and a Capa outage a build outage). A
|
|
366
|
+
committed file is also what makes the feature useful: "a model changed, so the
|
|
367
|
+
build fails" only helps if a human can see *what* changed, and a committed file
|
|
368
|
+
turns that into a reviewable diff instead of a wall of `tsc` errors.
|
|
369
|
+
|
|
370
|
+
Re-running when nothing changed costs **one conditional request returning 0
|
|
371
|
+
bytes** — see below.
|
|
372
|
+
|
|
373
|
+
## Three server-side rough edges, handled here
|
|
374
|
+
|
|
375
|
+
The API is mid-port to a byte-parity bar, so changing a customer-facing response
|
|
376
|
+
costs a signed exception. None of these is worth one, because each can be
|
|
377
|
+
handled on this side.
|
|
378
|
+
|
|
379
|
+
**1. `/v2/schema/types` has no ETag,** so it cannot be revalidated. `/v2/schema`
|
|
380
|
+
*does* — it returns a `checksum` and honours `If-None-Match` with a 304, and that
|
|
381
|
+
checksum is `sha256({models, relations})`, exactly what the types are generated
|
|
382
|
+
from. So codegen gates on it and fetches types only when the schema moved.
|
|
383
|
+
|
|
384
|
+
**2. The generator orders models by display name,** while interfaces are *named*
|
|
385
|
+
from the namespace (`blogs_home_section` → `BlogsHomeSection`). Renaming a
|
|
386
|
+
model's display name therefore reorders the whole file without changing a single
|
|
387
|
+
type — a false "your types changed" in the exact mechanism this feature rests
|
|
388
|
+
on. `normalizeTypes` re-sorts by interface name, so the committed file is stable
|
|
389
|
+
under renames.
|
|
390
|
+
|
|
391
|
+
**3. A model field named `id` overwrites the instance id** (#4628). Rows are
|
|
392
|
+
built as `{...instance, ...formatInstanceData(instance)}` (`routes/v2/api.ts`).
|
|
393
|
+
Hoisted fields are spread second, so `id`, `title` and `tags` are overwritten.
|
|
394
|
+
In one ordinary tenant, 20 of 20 models shadow `title` and 7 of 20 shadow `id`.
|
|
395
|
+
|
|
396
|
+
A server-side fix for this shipped briefly as #4628 (`instanceId` appended to
|
|
397
|
+
every public row) and was reverted on 2026-09-20 under the public-surface
|
|
398
|
+
freeze: `/v2/api`, `/v3/api`, the `/v1` public reads and `/files` answer the
|
|
399
|
+
bytes the legacy API answers, and a row key that resolves the shadowing is
|
|
400
|
+
planned for the next API version. **So a row from today's server carries no
|
|
401
|
+
`instanceId`, and `row.id` may be a content value.**
|
|
402
|
+
|
|
403
|
+
The client already handles both: `instanceIdOf` prefers `instanceId` when a row
|
|
404
|
+
has one and falls back to `id` when that is a UUID, so nothing here changes when
|
|
405
|
+
the next version starts sending it.
|
|
406
|
+
|
|
407
|
+
So **read `Page.ids`, not `row.id`**:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
const page = await capa.listContent("shopify_page");
|
|
411
|
+
page.ids[0] // the instance UUID when the row has one to give
|
|
412
|
+
page.data[0].id // the model's OWN id field whenever one exists
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`Page.ids` is `string | null` per row: `null` means the model shadows `id` and
|
|
416
|
+
the server sent nothing else to read it from, which is the state of every server
|
|
417
|
+
today. `getContentById` throws on a non-UUID rather than spending a request on a
|
|
418
|
+
guaranteed 400.
|
|
419
|
+
|
|
420
|
+
`search()` is the same story. `/v2/api/search?extended=true` hoists fields the
|
|
421
|
+
same way, so `SearchHit.id` is read through the same `instanceIdOf` and has the
|
|
422
|
+
same `string | null` type.
|
|
423
|
+
|
|
424
|
+
## Preview is a key, not a flag
|
|
425
|
+
|
|
426
|
+
Capa gates unpublished content on the API key's `environment` column, not on
|
|
427
|
+
anything in the request (`routes/v2/api.ts:203`). There is no `?preview=true`, so
|
|
428
|
+
preview is a second client:
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY });
|
|
432
|
+
const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY });
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
The comparison is exact and case-sensitive against a free-text column, so **any**
|
|
436
|
+
environment that is not literally `production` returns drafts — including a typo
|
|
437
|
+
like `Production`. And a client cannot find out which it holds: `apiKeyEnvironment`
|
|
438
|
+
is consumed internally and returned by no endpoint, so a site handed a `draft`
|
|
439
|
+
key serves unpublished content publicly with no way to detect it.
|
|
440
|
+
|
|
441
|
+
## Not included
|
|
442
|
+
|
|
443
|
+
**Caching.** Every framework does it differently and owning it means
|
|
444
|
+
reimplementing all of them badly. Plain `fetch` returning plain data lets Next's
|
|
445
|
+
fetch cache and React Router loaders work natively. What the host cannot know is
|
|
446
|
+
which surrogate keys a response carries, so those are surfaced as
|
|
447
|
+
`Page.cacheTags` for CDN purging.
|
|
448
|
+
|
|
449
|
+
**`getBySlug`.** Capa has no slug convention — the field is `handle` on
|
|
450
|
+
`shopify_page`, `product_handle` on `judge_me_review`, `bloghandle` on
|
|
451
|
+
`shopify_article`, and nothing marks one as *the* slug. `findOne(ns, {handle})`
|
|
452
|
+
is explicit instead. A slug marker belongs on the model, in Capa.
|
|
453
|
+
|
|
454
|
+
**Content writes.** `/v2/agent/model-instances` exists and an API key reaches
|
|
455
|
+
it, so an SDK that can silently publish a draft is now technically possible.
|
|
456
|
+
That wants a deliberate surface of its own rather than another method quietly
|
|
457
|
+
added to the client, so it is absent rather than half-built. The one exception
|
|
458
|
+
is `scheduledActions`, below, and the exception is narrow on purpose: a schedule
|
|
459
|
+
is a request that leaves a row you can read, show and cancel. Publishing *now*
|
|
460
|
+
leaves the caller holding the only copy of what happened.
|
|
461
|
+
|
|
462
|
+
This section used to say the SDK could not write at all, because `verifyApiKey`
|
|
463
|
+
guarded five route files carrying 15 GETs and no write verb. That stopped being
|
|
464
|
+
true with the agent surface (`#383`) and `tenant_api_keys.permission` becoming a
|
|
465
|
+
real control (`#4646`). Two things write today, and both touch arrangement
|
|
466
|
+
rather than content:
|
|
467
|
+
|
|
468
|
+
```ts
|
|
469
|
+
await capa.workspaces.apply(id, { tree }); // the admin rail (0h, 0t)
|
|
470
|
+
await capa.models.setLayout(id, layout); // the entry editor (0m)
|
|
471
|
+
await capa.models.setLayout(id, null); // back to the linear editor
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Reading has two shapes. `models.getLayout(id)` is the document or null;
|
|
475
|
+
`models.getLayoutInfo(id)` is the same read with the model's `embedByDefault`
|
|
476
|
+
beside it, which is what decides a relation field with no `display` on its
|
|
477
|
+
layout node (section 0m.8, and it applies in linear mode too):
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
const { layout, embedByDefault } = await capa.models.getLayoutInfo(id);
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
A workspace `tree` is a flat `WorkspaceDocNode[]` since 0t: one list of folders
|
|
484
|
+
the customer named, each holding any mix of `{model}`, `{instance}`,
|
|
485
|
+
`{media_folder}` and further folders. The 0h shape, `{ model, content, media }`,
|
|
486
|
+
is still accepted for one release and lands in folders called Models, Content
|
|
487
|
+
and Media.
|
|
488
|
+
|
|
489
|
+
`workspaces.*` needs a key with `write`; `models.setLayout` needs one with
|
|
490
|
+
`agent`, because it writes a MODEL and that is the gate every other model write
|
|
491
|
+
asks for. `models.getLayout` needs only `read`. A refused document comes back as
|
|
492
|
+
`CapaError` carrying the API's own `{ error, path }`, where `path` names the
|
|
493
|
+
node to fix. `apps/api/LAYOUT.md` has the rules and a worked example.
|
|
494
|
+
|
|
495
|
+
## Scheduling a publish
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
const { batchId, resolved, actions, replaced } = await capa.scheduledActions.create({
|
|
499
|
+
action: "publish", // or "unpublish"
|
|
500
|
+
targets: [{ type: "instance", id: instanceId }], // 1 to 500
|
|
501
|
+
wallTime: "2026-10-01T09:00", // local clock, NO offset
|
|
502
|
+
timezone: "America/New_York", // IANA name
|
|
503
|
+
});
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
**Instance targets only, from a key.** A `{ type: "model" }` target needs the
|
|
507
|
+
`model:publish` permission, and no API key bundle carries it in phase 1, so the
|
|
508
|
+
server answers 403 whatever the key. Scheduling a schema publish is an admin
|
|
509
|
+
session job until a bundle grants it.
|
|
510
|
+
|
|
511
|
+
**Send a wall clock and a zone, not an instant.** `wallTime` carrying a `Z` or a
|
|
512
|
+
`+02:00` is refused, here and by the server, because the two together are the
|
|
513
|
+
only way to say "9am local, whatever the offset turns out to be". The server
|
|
514
|
+
converts and reports what it decided in `resolved`:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
resolved.runAt // "2026-10-01T13:00:00.000Z"
|
|
518
|
+
resolved.runAtInTimezone // "2026-10-01T09:00:00-04:00"
|
|
519
|
+
resolved.note // null, or a sentence about daylight saving
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
`resolved.note` is the one field worth showing a person verbatim. A wall time
|
|
523
|
+
that does not exist (the spring gap) resolves forward and a wall time that
|
|
524
|
+
happens twice (the autumn overlap) takes the earlier offset, and the note says
|
|
525
|
+
which happened: `02:30 does not exist on 8 Mar 2026 in America/New_York,
|
|
526
|
+
publishing at 03:30 EDT`.
|
|
527
|
+
|
|
528
|
+
`replaced` holds the ids of actions that were cancelled to make room. A target
|
|
529
|
+
may have one active publish and one active unpublish at a time, so scheduling a
|
|
530
|
+
second publish for the same entry replaces the first rather than queueing it.
|
|
531
|
+
|
|
532
|
+
**`versionId` is optional and you usually want it absent.** With no version
|
|
533
|
+
pinned, the action publishes the latest draft at fire time, which is what an
|
|
534
|
+
editor who fixes a typo on Tuesday expects of a schedule made on Monday.
|
|
535
|
+
|
|
536
|
+
The rest of the resource:
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
await capa.scheduledActions.list({ status: ["pending", "running"], limit: 50 });
|
|
540
|
+
await capa.scheduledActions.list({ targetId: instanceId });
|
|
541
|
+
await capa.scheduledActions.get(id); // null when there is none
|
|
542
|
+
await capa.scheduledActions.reschedule(id, { wallTime, timezone }); // + resolved
|
|
543
|
+
await capa.scheduledActions.cancel(id);
|
|
544
|
+
await capa.scheduledActions.retry(id); // a NEW row, see below
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
`retry` does not reopen the failed action. It creates a new `pending` one with
|
|
548
|
+
`retryOfId` pointing at the original, so what failed stays readable. Expect two
|
|
549
|
+
rows and read the newer one.
|
|
550
|
+
|
|
551
|
+
Statuses are `pending`, `running`, `done`, `failed`, `dead`, `cancelled`. Only
|
|
552
|
+
`pending` can be rescheduled or cancelled: a `running` action is being executed
|
|
553
|
+
right now and `cancel` answers 409 `already_running`. `resultVersionId` on a
|
|
554
|
+
`done` row is the version that went live.
|
|
555
|
+
|
|
556
|
+
Reading needs a `read` key; every write here needs `instance:publish`, which the
|
|
557
|
+
`write`, `delete` and `agent` bundles carry. A refusal is a `CapaError` with the
|
|
558
|
+
API's own `{ error, code }`. The operator's side of all of this, including what
|
|
559
|
+
to do when an action fails at 3am, is `docs/PUBLISHING.md`.
|
|
560
|
+
|
|
561
|
+
## Webhooks
|
|
562
|
+
|
|
563
|
+
Two halves that do not need each other. The verifier is clientless, so a
|
|
564
|
+
receiver imports one function and nothing else. The resource manages endpoints,
|
|
565
|
+
and it is the only thing in this SDK that needs a session token rather than an
|
|
566
|
+
API key.
|
|
567
|
+
|
|
568
|
+
### Verifying a delivery
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
import { verifyWebhookSignature } from "@capacms/sdk";
|
|
572
|
+
|
|
573
|
+
export async function POST(request: Request) {
|
|
574
|
+
const body = await request.text(); // the RAW body, see below
|
|
575
|
+
const ok = await verifyWebhookSignature({
|
|
576
|
+
payload: body,
|
|
577
|
+
header: request.headers.get("capa-signature") ?? "",
|
|
578
|
+
secret: process.env.CAPA_WEBHOOK_SECRET!,
|
|
579
|
+
});
|
|
580
|
+
if (!ok) return new Response("bad signature", { status: 400 });
|
|
581
|
+
|
|
582
|
+
const event = JSON.parse(body);
|
|
583
|
+
// event.id is stable across retries and redeliveries: store it and ignore
|
|
584
|
+
// one you have already handled.
|
|
585
|
+
return new Response("ok");
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
**The payload must be the bytes that arrived.** The signature covers
|
|
590
|
+
`"<t>.<body>"`, and a body that has been through `JSON.parse` and
|
|
591
|
+
`JSON.stringify` again is a different string. Express needs
|
|
592
|
+
`express.raw({ type: "application/json" })`, Fastify needs a raw-body parser on
|
|
593
|
+
the route, Next's app router gives you `await request.text()`. A `Buffer` or
|
|
594
|
+
`Uint8Array` can be passed straight in.
|
|
595
|
+
|
|
596
|
+
`verifyWebhookSignature` is `async` because it uses WebCrypto rather than
|
|
597
|
+
`node:crypto`, which is what lets it run unchanged in Node, Bun, Deno,
|
|
598
|
+
Cloudflare Workers, Vercel's edge runtime and a browser. It returns `false`
|
|
599
|
+
rather than throwing for every bad input: a malformed header, a missing secret,
|
|
600
|
+
a timestamp outside the tolerance (5 minutes by default, `toleranceSeconds` to
|
|
601
|
+
change it), or a body that does not match.
|
|
602
|
+
|
|
603
|
+
During the 24 hours after a rotation Capa sends two `v1=` entries, the new
|
|
604
|
+
secret first. Either one verifies, so a receiver can be updated any time inside
|
|
605
|
+
that window without dropping a request.
|
|
606
|
+
|
|
607
|
+
### Managing endpoints
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
const capa = createClient({
|
|
611
|
+
baseUrl: process.env.CAPA_BASE_URL!,
|
|
612
|
+
apiKey: process.env.CAPA_API_KEY!,
|
|
613
|
+
tenantId: process.env.CAPA_TENANT_ID!,
|
|
614
|
+
accessToken: sessionJwt, // from POST /v2/user/login
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const { secret, ...endpoint } = await capa.webhooks.endpoints.create({
|
|
618
|
+
name: "Site rebuild",
|
|
619
|
+
url: "https://example.com/hooks/capa",
|
|
620
|
+
events: ["instance.published", "instance.unpublished", "instance.deleted"],
|
|
621
|
+
});
|
|
622
|
+
// `secret` is here ONCE. Store it now.
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
`createClient` still needs `apiKey` and `tenantId` to construct at all, so a
|
|
626
|
+
script that only ever calls `webhooks.*` has to pass something non-empty for
|
|
627
|
+
both. Any placeholder will do, because no webhook call reads either one.
|
|
628
|
+
|
|
629
|
+
**The tenant comes from the session, not from `tenantId`.** These routes read
|
|
630
|
+
the current tenant off the logged-in user's token, so the `tenantId` you passed
|
|
631
|
+
to `createClient` is ignored for every `webhooks.*` call: a client built with one
|
|
632
|
+
`tenantId` operates on whatever tenant that user is currently on.
|
|
633
|
+
|
|
634
|
+
**`accessToken`, not `apiKey`.** The webhook routes have no API key mount at
|
|
635
|
+
all (publishing spec D8): a key that can add an endpoint can forward every
|
|
636
|
+
content change in the tenant to a URL of its choosing, and a key is a string in
|
|
637
|
+
a config file nobody rotates. So these routes want a logged-in person, and every
|
|
638
|
+
`webhooks.*` method throws `@capacms/sdk: webhooks need accessToken; API keys
|
|
639
|
+
cannot manage endpoints.` before sending anything when the token is absent.
|
|
640
|
+
|
|
641
|
+
The rest of the resource:
|
|
642
|
+
|
|
643
|
+
```ts
|
|
644
|
+
await capa.webhooks.events(); // the catalogue, grouped
|
|
645
|
+
await capa.webhooks.endpoints.list();
|
|
646
|
+
await capa.webhooks.endpoints.get(id); // null when there is none
|
|
647
|
+
await capa.webhooks.endpoints.update(id, { events }); // headers REPLACE the map
|
|
648
|
+
await capa.webhooks.endpoints.delete(id);
|
|
649
|
+
await capa.webhooks.endpoints.pause(id); // cancels what is queued
|
|
650
|
+
await capa.webhooks.endpoints.resume(id); // enables, and COUNTS the gap
|
|
651
|
+
await capa.webhooks.endpoints.resume(id, { backfill: true, since });
|
|
652
|
+
await capa.webhooks.endpoints.revealSecret(id); // audited, every time
|
|
653
|
+
await capa.webhooks.endpoints.rotateSecret(id); // old secret lives 24h
|
|
654
|
+
await capa.webhooks.endpoints.test(id); // one webhook.test event
|
|
655
|
+
await capa.webhooks.endpoints.deliveries(id, { status: ["failed"], page: 1 });
|
|
656
|
+
await capa.webhooks.endpoints.redeliverFailed(id, { since });
|
|
657
|
+
await capa.webhooks.deliveries.get(deliveryId); // with both bodies
|
|
658
|
+
await capa.webhooks.deliveries.redeliver(deliveryId); // same event id
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
`test` answers `{ eventId }`, and that value is an **opaque request id**: the
|
|
662
|
+
event that actually arrives carries a different `id` (`evt_` plus the outbox row
|
|
663
|
+
id), so there is nothing to correlate on. Read `deliveries(id)` and take the
|
|
664
|
+
newest `webhook.test` row to see what the test did.
|
|
665
|
+
|
|
666
|
+
`resume` without `backfill` is deliberately a two-step: it enables the endpoint
|
|
667
|
+
and tells you how many events it missed, so you can show a person
|
|
668
|
+
`Redeliver everything since 14 Sep, 2:10 PM (312 events)` and let them decide.
|
|
669
|
+
Call it again with `{ backfill: true, since }` if they say yes.
|
|
670
|
+
|
|
671
|
+
Reads need `webhook:read`, writes need the `webhook:*` write actions, and
|
|
672
|
+
`revealSecret` needs `secret:reveal`, which no role bundle holds: it is an owner
|
|
673
|
+
or an explicit override, and every call leaves an audit row whether it was
|
|
674
|
+
allowed or denied. A server without `CAPA_SECRET_KEY` set answers `503` with
|
|
675
|
+
code `webhooks_not_configured` to every write while reads keep working.
|
|
676
|
+
|
|
677
|
+
The operator's side of all of this, including the retry ladder, auto-pause, the
|
|
678
|
+
SSRF rules and how to run a receiver locally, is `docs/WEBHOOKS.md`.
|