Personal API
The optional personal API lets a customer read their own Bookmarks from a separate application. It exposes two versioned GET operations: a bounded collection page and one Bookmark detail. Organization resources, writes, paid copy/export and account-security data are outside this capability.
Enable and create a credential
Complete installation, apply the repository’s database migrations, and set FEATURE_PERSONAL_API=true in the application’s runtime environment. It defaults to false; rebuild/restart the selected application lane after changing configuration. See configuration ownership. Use HTTPS for a deployed application; local development may use a loopback origin.
Sign in and open Personal API access. Complete the page’s fresh account proof, enter a short label, and choose an expiry of 1, 7 or 30 days. The default is 7 days; a credential never lasts longer than 30 days. At most five credentials may be usable at a time. The only supported scope is bookmarks:read.
The secret appears once after successful creation. Save it directly in your consumer’s secret store. A page reload, account change or lost creation response cannot reveal it again. Recover the original operation’s metadata, revoke a credential whose secret you did not receive, and deliberately create another with new proof. Repeating an uncertain request must not create another credential automatically.
Never put a credential in a URL, command-line argument, source file, browser storage or log. Credential lists show safe metadata only. Revocation and expiry are independent from account-wide security invalidation. Disabled/deleted accounts and credentials invalidated by account-security changes cannot read data. Ordinary browser sign-out does not revoke an API credential; use the page’s explicit revoke action.
Turning FEATURE_PERSONAL_API off stops issuance and API reads while preserving credential listing and revocation. It does not erase credentials. An unexpired credential may become usable again after re-enablement unless explicitly revoked or otherwise invalidated.
Use the validated client from Node
The recommended client validates response bodies and HTTP status together before exposing Bookmark data. Its build bundles the generated fetch runtime and Zod validation; the copied client needs no installed runtime dependencies, Next.js or Auth.js. Use the project’s Node 24 environment for generation/build (the generator needs Node 22.18 or newer). The compiled client uses standard fetch and runs on Node 20 or newer.
npm ci
npm run api:check
npm run api:buildapi:build emits a standalone ESM package into .cache/personal-api-client/ and verifies a copied consumer outside the application. Copy that whole directory to your consumer. JavaScript may import its client.js directly. For TypeScript package resolution, install the copied directory locally with npm install ./personal-api-client, then import from "personal-api-client"; its package exports select the included declarations. No npm package is published, and the copied package has no dependencies to download.
This example can run as read-bookmarks.mjs in the repository root. Set PERSONAL_API_ORIGIN to your application origin, such as http://localhost:3101, and supply PERSONAL_API_TOKEN through your consumer’s process environment or secret manager. The client takes the raw secret and adds the Bearer header. The origin must have no path, query, fragment or embedded credentials.
import { createPersonalApiClient } from "./.cache/personal-api-client/client.js";
const client = createPersonalApiClient({
baseUrl: process.env.PERSONAL_API_ORIGIN,
token: process.env.PERSONAL_API_TOKEN,
});
const result = await client.listBookmarks({ limit: 20 });
if (result.kind !== "success") {
console.error(result.kind, result.status,
result.kind === "refused" ? result.code : "");
if (result.kind === "refused" && result.status === 429) {
console.error("Retry-After:", result.headers.retryAfter);
}
process.exitCode = 1;
} else {
console.log(`Received ${result.data.items.length} Bookmarks`);
const first = result.data.items[0];
if (first) {
const detail = await client.getBookmark(first.id);
if (detail.kind !== "success") throw new Error("Bookmark detail unavailable");
console.log(`Read Bookmark ${detail.data.id}`);
}
// Request another page only when needed; send the opaque cursor unchanged.
if (result.data.nextCursor !== null) {
const next = await client.listBookmarks({ limit: 20, cursor: result.data.nextCursor });
if (next.kind !== "success") throw new Error("Next page unavailable");
console.log(`Next page has ${next.data.items.length} Bookmarks`);
}
}The maintained executable version is scripts/personal-api-example.mjs. After supplying the two environment variables, run:
npm run api:exampleThis builds the validated client, then starts a separate Node process. It reads at most two pages and one detail and prints safe counts/identifiers. It does not start the application or create credentials. Revoke the credential in /me/api-access, then run the consumer again: the same secret must return refused with 401 invalid_credential.
| Result kind | Meaning |
|---|---|
success | HTTP 200 and a validated page/detail; only a valid nextCursor: null ends traversal |
refused | A declared HTTP status and matching API error code; act on code |
invalid_response | Missing cursor, invalid UUID/time, malformed JSON/body, undeclared status or status/body mismatch |
transport_unavailable | No usable HTTP exchange, including network loss or local cancellation |
Results retain status and the selected response headers: cacheControl, vary, retryAfter, wwwAuthenticate and allow; absent values are null. They never expose the bearer, Request, raw provider error or server message. Do not log Bookmark contents or your configuration. Use one client per credential/account. Each method sends at most one request and never follows redirects, sends cookies, retries or automatically paginates. Callers may pass { signal } as the second method argument to cancel local transport. Invalid configuration throws the fixed invalid_configuration message.
The generated low-level transport remains under src/clients/personal-api/ for specialized integrations. It returns raw request/response objects and does not enforce the recommended entry’s status/body checks; use src/contracts/personal-api/client.ts or the compiled package for ordinary consumers.
Operations, pagination and failures
For a local Cloudflare application, use a separate checkout with its own physical node_modules, then run npm run cf:build:dev. Set FEATURE_PERSONAL_API=true and the local Auth/application settings in .dev.vars.dev, apply npm run db:push:d1:local, and run npx wrangler dev --env dev --local --port 8787. Point the same validated client at http://localhost:8787. OpenNext must use independent writable dependencies; the named local runner’s shared dependency link is not suitable for this build.
To verify the API without a real account or email provider, run node --conditions=react-server --import tsx tests/.tools/personal-api-worker.ts --port 8793 from that already-built isolated checkout. It starts the actual application, creates and removes disposable local D1 fixtures, and exercises the validated client in another process. It does not test the credential-creation UI or email delivery. The repository’s scripts/.docs/personal-api-worker.md contains the complete isolated preparation and cleanup commands.
| Operation | Input | Success |
|---|---|---|
GET /api/v1/bookmarks | Optional limit (default 20, integer 1–100) and opaque cursor (at most 128 characters) | { items, nextCursor }; null ends traversal |
GET /api/v1/bookmarks/{bookmarkId} | Bookmark UUID, no query parameters | { id, title, url, version, createdAt, updatedAt } |
Each page is ordered by creation time descending, then ID descending. Pages observe current data; they are not a frozen snapshot. Only an actual successful empty read returns items: []. A cursor grants no authority. Missing, deleted, foreign-owner and organization-owned identifiers all return 404 not_found.
Send exactly one Authorization: Bearer ... header. Cookies, request bodies, duplicate or unknown query parameters and combined credentials are refused. Sessions, query tokens and arbitrary owner IDs cannot authenticate. All responses are private and uncached (Cache-Control: private, no-store, Vary: Authorization, Cookie). Reads do not renew a credential, record last use or mutate Bookmarks; the existing admission counter may change.
Node ingress rejects raw duplicate Authorization fields. In the tested local Cloudflare runtime, two nonempty fields remain observable and are refused, but an empty first field followed by a valid field loses the empty field before the application receives the request. That order appears as the valid field alone; the reverse order is refused. The local Worker result does not guarantee detection of every wire-level duplicate or characterize a remote deployment; clients must still send exactly one Bearer field.
| HTTP status | error.code | Consumer action |
|---|---|---|
| 400 | invalid_request | Fix the UUID, query or unsupported input; do not retry unchanged |
| 401 | invalid_credential | Check credential state; revoked/expired/invalidated credentials need replacement |
| 403 | insufficient_scope | This credential cannot read personal Bookmarks |
| 404 | not_found | Treat the detail as unavailable to this credential |
| 405 | method_not_allowed | Use GET; Allow: GET describes the supported method |
| 429 | rate_limited | Respect the Retry-After delay in seconds |
| 503 | api_disabled | The operator must enable the capability |
| 503 | service_unavailable | Authority, admission or storage is unavailable; preserve the failure |
Errors use { error: { code, message } }; branch on the stable code. Messages never echo credentials or internal provider errors. 401 includes WWW-Authenticate: Bearer. The existing admission policy is installation-wide: 100 attempts per 60 seconds when FEATURE_RATE_LIMIT is enabled outside its development bypass. Invalid requests share this limit; it is not a per-credential quota.
Change the protocol and regenerate
The authoritative source is src/contracts/personal-api/openapi.yaml (OpenAPI 3.1.1). The HTTP provider maps domain results to its generated transport declarations; src/clients/personal-api/ is entirely generated by the exactly pinned Hey API tool, including Zod schemas. Redocly validates the specification. The handwritten consumer in src/contracts/personal-api/client.ts maps HTTP status and generated validation into safe results.
npm run api:validate # lint the canonical protocol and resolve references
npm run api:generate # validate, then replace generated client/types
npm run api:check # fail when generated output is stale; leave it unchanged
npm run api:build # bundle standalone Node ESM, emit declarations and verify a copied consumerWhen changing the API, update the canonical source, provider validation/mapping and real consumers together. The generation check runs in the static CI lane. The recommended client validates untrusted responses using generated schemas; server authorization remains with the provider. Adding another domain or a write operation requires its own explicit contract and authorization policy; this initial version does not expose every internal service.