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