# totalum-sdk reference

> Generated by `scripts/gen-docs.mjs` from the types and JSDoc of `totalum-sdk` 1.11.1. Do not edit.

`totalum-sdk` is the package every Totalum v2 app uses for Totalum's built-in features: the project's database
(`totalum-sdk/d1`, used through Drizzle), file storage, e-mail, AI, payments, real-time rooms, scheduled jobs, vector
search, web scraping, a cloud browser, the user's connected accounts and more. Every call goes over HTTPS to the
Totalum API with the project key, so it works the same in the preview, the published app (Cloudflare Workers), the
coding agent's sandbox and a laptop. Never add a third-party SDK or provider key for something listed here.

## Contents

- [Quick start](#quick-start)
- [Install and configure](#install-and-configure)
- [Errors](#errors)
- [Versions and deprecations](#versions-and-deprecations)
- Modules:

| Module | Import | What it is for |
|---|---|---|
| [The `totalum` object](#totalum-sdk) | `totalum-sdk` | The default `totalum` object and `createTotalum()` give you every namespace on one object: `totalum.files`, `totalum.ai`, `totalum.email` and the rest. |
| [Database driver](#totalum-sdkd1) | `totalum-sdk/d1` | The project's SQL database (Cloudflare D1, SQLite), used through Drizzle: `drizzle(totalumD1())`. |
| [Files & Storage](#totalum-sdkfiles) | `totalum-sdk/files` | Store and serve your app's files (uploads, images, generated PDFs). |
| [Payments](#totalum-sdkpayments) | `totalum-sdk/payments` | Take payments and subscriptions with Stripe on the project owner's own Stripe account (Stripe Connect), without handling card data: create a hosted Checkout, send the buyer to its `url`, and read the result back. |
| [Webhooks](#totalum-sdkwebhooks) | `totalum-sdk/webhooks` | Receive Totalum's signed events (payments, e-mail delivery, browser programs, connected-account events, credits exhausted) and check that they really come from Totalum. |
| [Web Scraping & Search](#totalum-sdkweb) | `totalum-sdk/web` | Read the web from your app: one page as Markdown or HTML (`scrape`), a web search (`search`), every URL of a site (`map`), a whole site in the background (`crawl`) and screenshots. |
| [PDF](#totalum-sdkpdf) | `totalum-sdk/pdf` | Turn HTML or a web page into a PDF stored in the project's files: invoices, tickets, reports. |
| [Document Scan / OCR](#totalum-sdkscan) | `totalum-sdk/scan` | Read documents and images: extract the fields you describe (an invoice's number, date and total) as JSON with `document()`, or get the plain text of every page with `ocr()`. |
| [Vector Search](#totalum-sdkvectors) | `totalum-sdk/vectors` | Semantic search over your own content: store texts or images as vectors with metadata (`upsert`), then find the closest ones to a question, a text or an image (`query`). |
| [Connected Accounts](#totalum-sdkconnections) | `totalum-sdk/connections` | Let your app's users connect their own accounts of about 1,500 apps — Google Calendar, Gmail, Google Drive, Outlook, Slack, HubSpot, Notion, GitHub, Stripe, Airtable and more — and act on them from your server: create a calendar event, send an e-mail from their Gmail, read their files, or receive their events. |
| [Speech](#totalum-sdkspeech) | `totalum-sdk/speech` | Speech to text (`transcribe`) and text to speech (`synthesize`): transcribe a recording or a voice note, read a text aloud, store the audio as a project file. |
| [AI](#totalum-sdkai) | `totalum-sdk/ai` | AI from your server without any provider account: chat completions (streamed or not), structured JSON, a label out of a list, embeddings, image generation and editing, video generation and image description. |
| [Email](#totalum-sdkemail) | `totalum-sdk/email` | Send transactional e-mail (sign-up confirmations, receipts, notifications) from the project's own sender, and check whether it was delivered. |
| [Cron Jobs](#totalum-sdkcron) | `totalum-sdk/cron` | Run a route of your own app on a schedule: every night at 8:00, every 15 minutes, the first day of the month. |
| [SEO & IndexNow](#totalum-sdkseo) | `totalum-sdk/seo` | Tell search engines that pages of your custom domain changed (IndexNow), so new and edited pages are indexed in minutes instead of days. |
| [Browser Automation](#totalum-sdkbrowser) | `totalum-sdk/browser` | A real Chrome in the cloud, driven over HTTPS from your server, so it works inside the published app: open pages, click, type, log in, fill forms, download files, take screenshots and PDFs, extract data. |
| [Realtime](#totalum-sdkrealtime) | `totalum-sdk/realtime` | Live updates between the people using your app: chat, notifications, presence ("who is online"), collaborative editing, live dashboards and games. |
| [Logs](#totalum-sdklogs) | `totalum-sdk/logs` | Read your app's own server logs: the requests the published app (`env: 'live'`) or the preview (`'dev'`) handled, with their `console` lines, status codes and errors. |
| [Site Analytics](#totalum-sdkanalytics) | `totalum-sdk/analytics` | Your site's own traffic: page views, visits, unique visitors, errors, bytes and response times, as a time series or a top list (top pages, referrers, countries, devices, browsers). |
| [React Realtime Hooks](#totalum-sdkreact) | `totalum-sdk/react` | The browser side of realtime rooms for React client components: `useRealtimeChannel(room)` connects to a room, keeps the messages received, shows who else is present, and reconnects by itself (every deploy drops sockets). |

- [Types shared by every module](#types)

## Quick start

1. Install the package (it is already installed in every Totalum project):

   ```sh
   npm install --save-exact totalum-sdk
   ```

2. Give the server its project key. Totalum sets `TOTALUM_PROJECT_KEY` (`tlm_pk_…`) on the preview, the published app and
   the sandbox; on your own machine put it in `.env.local`. It is a secret: use it only in server code (route
   handlers, server actions, cron routes, scripts), never in a `NEXT_PUBLIC_` variable or a client component.

3. Call a namespace on the default `totalum` instance:

   ```ts
   // src/app/api/welcome/route.ts
   import { totalum } from 'totalum-sdk';

   export async function POST(request: Request) {
     const { email } = (await request.json()) as { email: string };
     const sent = await totalum.email.send({
       to: email,
       subject: 'Welcome to Acme',
       html: '<p>Thanks for signing up.</p>',
     });
     return Response.json({ id: sent.id, credits: sent.usage.credits });
   }
   ```

   `sent` is:

   ```json
   {
     "success": true,
     "message": "Email sent successfully",
     "messageId": "01a12c0c-9e5f-74ea-a128-a72d3fdd47ae",
     "id": "em_01M4P0S77M6XCM69SYZWP1XME9",
     "status": "queued",
     "usage": { "credits": 0.0515 }
   }
   ```

4. Read the database with Drizzle on `totalumD1()`:

   ```ts
   import { drizzle } from 'drizzle-orm/d1';
   import { totalumD1 } from 'totalum-sdk/d1';
   import { users } from '@/db/schema';

   const db = drizzle(totalumD1());
   const rows = await db.select().from(users).limit(10);
   ```

Paid calls return what they cost in `usage.credits` (Totalum credits). Failures throw `TotalumError`: see
[Errors](#errors).

## Install and configure

```sh
npm install --save-exact totalum-sdk
```

Server-side only (route handlers, server actions, cron handlers, `npx tsx scripts/<name>.ts`). Every namespace reads the
same environment at call time, never at import; an option of the same name wins:

| Variable | Option | Meaning |
|---|---|---|
| `TOTALUM_PROJECT_KEY` | `key` | The project key `tlm_pk_…`. Never `NEXT_PUBLIC_`. Missing → `SDK_NOT_CONFIGURED`. |
| `TOTALUM_SDK_API_URL` | `apiUrl` | Default `https://sdk-api.totalum.app`. |
| `TOTALUM_ENV` | `env` | `dev` or `live` (default), sent as `x-totalum-env`: the database `totalum-sdk/d1` targets over HTTPS, and dev vs live usage. |
| `TOTALUM_FILES_URL` | `filesUrl` | The public files host of `files.url()`. |
| `TOTALUM_AI_BASE_URL` | `aiBaseUrl` | The OpenAI-compatible AI gateway `ai.gatewayConfig()` returns. |
| `TOTALUM_APP_KEY` | `key` | Set by Totalum in the sandbox only: the deployed app's own key, used before `TOTALUM_PROJECT_KEY` by every namespace but the database, so the agent's checks have the app's permissions. |
| `TOTALUM_WEBHOOK_SECRET` | argument | The secret `webhooks.verify` checks `Totalum-Signature` with. Totalum sets it on the preview, the published site and the sandbox, and a rotation reaches them at once: never set it yourself. |

```ts
import { totalum } from 'totalum-sdk';            // or one namespace: import { totalumFiles } from 'totalum-sdk/files';
const file = await totalum.files.upload(blob, { name: 'avatar.png' });
```

## Errors

Every failure throws `TotalumError` (`isTotalumError(e)`) with `errorCode` (frozen), `status`, `details` and
`requestId`; `TotalumD1Error` (`message` shaped like a native D1 error) and `TotalumWebhookError` extend it. A capped
or disabled feature (`FEATURE_LIMIT_REACHED` 429, `FEATURE_DISABLED` 403) refuses only its own namespace; at a zero
balance paid calls answer `INSUFFICIENT_CREDITS` 402. A project makes at most 100 built-in calls per second (AI, e-mail,
file uploads, web, scan, PDF, speech, vectors, browser, realtime, payments): above it the Totalum API answers `RATE_LIMITED` 429 with
`Retry-After`, which the SDK retries twice (with jitter) before throwing — handle it, and never fan out hundreds of calls
at once (batch, queue or `await` them in small groups).

```ts
import { isTotalumError, totalum } from 'totalum-sdk';

try {
  await totalum.files.get('p/my-app/01M4P0PZXJ0DPQM7R20QVZ0EKC.txt');
} catch (e) {
  if (isTotalumError(e) && e.errorCode === 'FILE_NOT_FOUND') return null;
  throw e; // logs as: TotalumError [CODE]: message
}
```

These are the errors every call can throw; each method lists its own below it.

| Code | HTTP | Meaning |
|---|---|---|
| `SDK_NOT_CONFIGURED` | — | No project key: set `TOTALUM_PROJECT_KEY` (server-side) or pass `{ key }`. Thrown before any request is sent. |
| `NETWORK_ERROR` | 0 | The Totalum API could not be reached (DNS, TLS, connection reset). `cause` holds the original error. |
| `RESPONSE_PARSE_ERROR` | any | The answer was not the JSON the SDK expects (a proxy or gateway page in between). |
| `UNAUTHORIZED` | 401 | The key is unknown or revoked. |
| `LEGACY_KEY_NOT_SUPPORTED` | 401 | An account key (`tlm_sk_…`) or a legacy key was used: v2 projects need a project key `tlm_pk_…`. |
| `SANDBOX_KEY_EXPIRED` | 401 | The coding agent's temporary sandbox key expired (only inside Totalum's sandbox). |
| `FORBIDDEN_SCOPE` | 403 | The key lacks the scope this namespace needs (for example `email`, `ai`, `files:write`). |
| `VALIDATION_ERROR` | 400 | The input does not match what the method accepts; `details` names the fields. |
| `PAYLOAD_TOO_LARGE` | 413 | The request body is over 26 MB. |
| `RATE_LIMITED` | 429 | More than the project's built-in calls per second (100). The SDK already waited and retried twice; `details.retryAfterMs` says how long to wait. |
| `RATE_LIMIT_EXCEEDED` | 429 | A per-visitor or per-feature limit that lasts up to a minute; `details` says which and when to retry. |
| `INSUFFICIENT_CREDITS` | 402 | The account has no credits left for a paid call. Nothing was charged. |
| `FEATURE_LIMIT_REACHED` | 429 | The plan's limit for this feature is used up for the period; only this namespace is refused. |
| `FEATURE_DISABLED` | 403 | The project owner turned this feature off; only this namespace is refused. |
| `AI_MEDIA_PAUSED_THIS_HOUR` | 429 | The project used a lot of credits this hour, so image, video and speech AI pause until the hour ends (`details.resetsAt`). Text AI keeps working. |
| `PROJECT_PAUSED_RUNAWAY` | 429 | The project was paused because its usage passed the safety threshold. |
| `PROJECT_PAUSED_BALANCE` | 402 | The project is paused because the account balance is negative. |
| `PROJECT_SUSPENDED` | 402 | The project is suspended because of a negative credit balance. |
| `PROJECT_PAUSED_BY_USER` | 402 | The owner paused the project; it works again once resumed. |
| `PROJECT_HIBERNATED` | 410 | The project is hibernated; the owner reactivates it in Totalum (free, about a minute). |
| `PROJECT_DELETED` | 410 | The project was deleted. |
| `PROVIDER_UNAVAILABLE` | 503 | The service behind this feature is unavailable right now. Retry later. |
| `ENGINE_UNAVAILABLE` | 500 | An unexpected error on Totalum's side; `details.errorId` identifies it for support. |

**Database failures** read `D1_ERROR: …` on every connection, whichever engine holds the project's database:
`Network connection lost.` (a transient failure), `D1 DB is overloaded. …` (too many statements at once: send
fewer, or group writes in `db.batch`), `D1 DB exceeded its CPU time limit and was reset.` (one statement ran too
long: add an index or split it), `Memory limit exceeded before EOF.` (a result too big for the Worker: select fewer
rows or columns, paginate), `statement too long: SQLITE_TOOBIG`. The driver repeats a statement up to twice (after
≈ 0.1 s and ≈ 0.4 s) only when that is safe: a read (`SELECT`, or a batch of them) after a transient failure, and a
write only when the failure says the statement never reached the database. A write that may have been applied, an
overloaded database, a limit and a SQL error are thrown at once — make a write you retry yourself idempotent
(`INSERT … ON CONFLICT DO NOTHING`, a unique key).

**Node-only methods** throw `NODE_ONLY` inside a Worker (the preview and the published app both run on Cloudflare
Workers) and name the HTTPS replacement: `ai.video.wait()`.

All client-side codes: `SDK_NOT_CONFIGURED`, `NOT_SUPPORTED`, `BATCH_FOREIGN_STATEMENT`, `TRANSACTIONS_NOT_SUPPORTED`, `NETWORK_ERROR`, `TIMEOUT`, `RESPONSE_PARSE_ERROR`, `NODE_ONLY`, `WEBHOOK_SECRET_MISSING`, `WEBHOOK_SIGNATURE_INVALID`, `WEBHOOK_TIMESTAMP_EXPIRED`.

All server codes: `SERVICE_UNAUTHORIZED`, `SERVICE_REPLAY`, `ACTOR_MISMATCH`, `PAYLOAD_TOO_LARGE`, `VALIDATION_ERROR`, `PROJECT_NOT_FOUND`, `PROJECT_NOT_READY`, `OPERATION_IN_PROGRESS`, `AGENT_RUNNING`, `PROJECT_MOVED`, `OBJECT_MIGRATING`, `INDEX_SHARD_UNAVAILABLE`, `RATE_LIMIT_EXCEEDED`, `RATE_LIMITED`, `PROJECT_PAUSED_RUNAWAY`, `AI_MEDIA_PAUSED_THIS_HOUR`, `PROJECT_PAUSED_BALANCE`, `PROJECT_SUSPENDED`, `PROJECT_HIBERNATED`, `PROJECT_DELETED`, `PROJECT_PAUSED_BY_USER`, `LIFECYCLE_NOT_APPLICABLE`, `PROJECT_NOT_PAUSED`, `PAUSE_DUMP_EXPIRED`, `PAUSE_VERIFICATION_FAILED`, `DEPLOYMENT_IN_PROGRESS`, `REACTIVATE_IMPORT_MISMATCH`, `RESUME_IMPORT_MISMATCH`, `INSUFFICIENT_CREDITS`, `VCAAS_INSUFFICIENT_CREDITS`, `PROJECT_CREDIT_LIMIT_REACHED`, `FREE_PROMPTS_DISABLED`, `PLAN_REQUIRED`, `FEATURE_LIMIT_REACHED`, `FEATURE_DISABLED`, `VECTORS_LIMIT_REACHED`, `CONNECTION_REQUIRED`, `CONNECTION_ACTION_FAILED`, `TOOLKIT_NOT_AVAILABLE`, `TOOLKIT_NEEDS_OWN_APP`, `AGENT_ALREADY_RUNNING`, `IMPORT_IN_PROGRESS`, `MIGRATION_TOO_LARGE`, `MIGRATION_START_FAILED`, `TOO_MANY_RUN_REQUESTS`, `PROMPT_SECURITY_VIOLATION`, `NO_PROCESS_RUNNING`, `RUN_NOT_FOUND`, `KEY_NOT_FOUND`, `DEPLOYMENT_RUNNING`, `AUTO_EXECUTION_ALREADY_ACTIVE`, `REBUILD_RUNNING`, `SECRET_ALREADY_EXISTS`, `SECRET_LIMIT_EXCEEDED`, `RESERVED_SECRET_NAME`, `DUMP_NOT_FOUND`, `DUMP_INVALID`, `RESTORE_TARGET_NOT_EMPTY`, `ENGINE_NOT_SUPPORTED`, `ENGINE_V2_DISABLED`, `V2_TEMPORARILY_DISABLED`, `ENGINE_REQUEST_NOT_ELIGIBLE`, `ENGINE_MISMATCH`, `INVALID_ENGINE_VERSION`, `PROJECT_ENGINE_MISMATCH`, `ENGINE_UNAVAILABLE`, `PREVIEW_LABEL_TOO_LONG`, `PROJECT_ALREADY_EXISTS`, `CLOUDFLARE_API_ERROR`, `PROVISIONING_BUSY`, `UNAUTHORIZED`, `LEGACY_KEY_NOT_SUPPORTED`, `SANDBOX_KEY_EXPIRED`, `AUTH_RATE_LIMITED`, `FORBIDDEN_SCOPE`, `KEY_ENV_MISMATCH`, `KEY_LIMIT_EXCEEDED`, `LIVE_DB_READ_ONLY`, `LIVE_WRITE_NEEDS_CONFIRMATION`, `DDL_NOT_ALLOWED`, `MIGRATION_IN_PROGRESS`, `MIGRATION_EDITED`, `SCHEMA_OUT_OF_SYNC`, `LIVE_MIGRATION_FAILED`, `DESTRUCTIVE_MIGRATION_NEEDS_CONFIRMATION`, `DESTRUCTIVE_MIGRATION_UNDONE_BY_RESTORE`, `DEV_DATABASE_NOT_FOUND`, `DATABASE_ROLE_NOT_FOUND`, `BOOKMARK_NOT_FOUND`, `BACKUP_NOT_FOUND`, `BACKUP_NOT_DELETABLE`, `BACKUP_RATE_LIMITED`, `BACKUP_TOO_LARGE_FOR_IMPORT`, `PRE_RESTORE_BACKUP_FAILED`, `RESTORE_VERIFY_FAILED`, `RESTORE_BEFORE_LIVE_MIGRATION`, `RUN_NOT_LATEST`, `DEV_DATABASE_EXISTS`, `SQL_ERROR`, `SQL_MULTI_STATEMENT`, `SQL_STATEMENT_TOO_LARGE`, `SQL_TOO_MANY_PARAMS`, `STATEMENT_NOT_ALLOWED`, `VIRTUAL_TABLES_NOT_SUPPORTED`, `USE_STORAGE_FOR_FILES`, `D1_TYPE_ERROR`, `MIGRATION_INVALID`, `SYSTEM_TABLE_READ_ONLY`, `SQL_READ_ONLY`, `RESULT_TOO_LARGE`, `NL_QUERY_UNANSWERABLE`, `QUERY_TIMEOUT`, `DB_OVERLOADED`, `D1_TRANSIENT`, `DB_FULL_NEAR_LIMIT`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENT_REPLAY_UNAVAILABLE`, `TABLE_NOT_FOUND`, `SCHEMA_TOO_LARGE_FOR_COUNTS`, `EXEC_REQUIRES_MIGRATE_MODE`, `MIGRATION_UNKNOWN_IN_DB`, `QUERY_LIMIT_EXCEEDED`, `STORAGE_LIMIT_EXCEEDED`, `METER_UNAVAILABLE`, `COLUMN_NOT_FOUND`, `VALUE_NOT_ALLOWED`, `COLUMN_REQUIRED`, `FILTER_TOO_COMPLEX`, `OFFSET_TOO_LARGE`, `BULK_TOO_LARGE`, `NOT_A_JUNCTION_TABLE`, `AMBIGUOUS_RELATION`, `LIVE_WRITE_CONFIRMATION_REQUIRED`, `RECORD_NOT_FOUND`, `RECORD_KEY_REQUIRED`, `AMBIGUOUS_RECORD_ID`, `DEV_NAME_GUARD`, `PROD_NAME_GUARD`, `INVALID_RUN_REQUEST`, `RUN_ALREADY_ACTIVE`, `RUN_CONFLICT`, `INVALID_CREDENTIALS`, `ADAPTER_UNAVAILABLE`, `DISK_FULL`, `DEPLOY_FIX_ATTEMPTS_EXHAUSTED`, `TOKEN_REVOKED`, `AM_UPDATE_DOWNLOAD_FAILED`, `AM_UPDATE_BAD_SHA`, `AM_UPDATE_BAD_SIGNATURE`, `AM_UPDATE_INCOMPATIBLE_IMAGE`, `AM_UPDATE_START_FAILED`, `AM_VERSION_UNAVAILABLE`, `AM_UPDATE_FAILED`, `RELEASE_CANARY_NOT_PASSED`, `RELEASE_NOT_FOUND`, `RELEASE_ROLLOUT_ACTIVE`, `RELEASE_GUARD_PAUSED`, `RELEASE_BUILD_UNAVAILABLE`, `RELEASE_BELOW_MIN_SAFE_VERSION`, `VISUAL_EDIT_PREVIEW_DEPLOYING`, `VISUAL_EDIT_NOT_PATCHABLE`, `VISUAL_EDIT_BASE_MISMATCH`, `VISUAL_EDIT_TYPECHECK_FAILED`, `INVALID_VISUAL_EDIT`, `IMAGE_VERSION_NOT_FOUND`, `IMAGE_VERSION_NOT_PROMOTABLE`, `REALTIME_TICKET_INVALID`, `REALTIME_TICKET_EXPIRED`, `REALTIME_PERMISSION_DENIED`, `REALTIME_MESSAGE_TOO_LARGE`, `REALTIME_MESSAGE_RATE_EXCEEDED`, `REALTIME_CONNECTION_LIMIT_REACHED`, `REALTIME_ROOM_LIMIT_REACHED`, `CONFIRMATION_REQUIRED`, `MIGRATION_NOT_REVERSIBLE`, `ALREADY_ON_PROVIDER`, `DATABASE_MIGRATING`, `TURSO_STORAGE_INSUFFICIENT`, `LOGWATCH_INACTIVE`, `AGENT_LOG_READS_LIMIT`, `LOGS_UPSTREAM_ERROR`, `ANALYTICS_INVALID_QUERY`, `ANALYTICS_UPSTREAM_ERROR`, `ANALYTICS_WINDOW_OUT_OF_RETENTION`, `INVALID_CIDR`, `RATE_LIMIT_ABOVE_PLAN`, `RATE_LIMIT_DISABLE_NOT_ALLOWED`, `OWNER_ONLY`, `SIGNING_NOT_CONFIGURED`, `SIGNING_ALREADY_CONFIGURED`, `SIGNING_INVALID_KEYSTORE`, `SIGNING_INVALID_ASC_KEY`, `KEYSTORE_DOWNLOAD_EXPIRED`, `KEYSTORE_NOT_DOWNLOADABLE`, `MOBILE_BUILD_RUNNING`, `MOBILE_BUILD_RATE_LIMITED`, `MOBILE_BUILD_QUEUE_TIMEOUT`, `MOBILE_BUILD_NOT_CANCELLABLE`, `MOBILE_BUILD_NOT_FOUND`, `MOBILE_BUILD_NOT_READY`, `MOBILE_REQUIRES_PUBLISHED_PROJECT`, `DOWNLOAD_EXPIRED`, `PROCESS_IN_PROGRESS`, `SANDBOX_NOT_RUNNING`, `MOBILE_LAYER_MISSING`, `MOBILE_BUILD_FAILED`, `MOBILE_ARTIFACT_INVALID`, `SIGNING_FINGERPRINT_MISMATCH`, `AM_RESTARTED`, `MOBILE_BUILD_NOT_SIGNED`, `STORE_NOT_CONFIGURED`, `STORE_CREDENTIALS_INVALID`, `STORE_APP_NOT_FOUND`, `STORE_UPLOAD_RUNNING`, `STORE_UPLOAD_LIMIT_REACHED`, `STORE_UPLOAD_NOT_FOUND`, `STORE_UPLOAD_FAILED`, `PLAY_FIRST_UPLOAD_MANUAL`, `STORE_PROCESSING_TIMEOUT`, `LIMIT_ABOVE_PLAN_CEILING`, `UNKNOWN_FEATURE`, `INVALID_LIMIT`, `FEATURE_NOT_DISABLABLE`, `USE_PUT_LIMITS`, `LIMIT_NOT_USER_EDITABLE`, `NOT_AVAILABLE_FOR_ENGINE`, `INVALID_AMOUNT`, `REFUND_EXCEEDS_CHARGES`, `IDEMPOTENCY_CONFLICT`, `ORGANIZATION_NOT_FOUND`, `ACCOUNT_NOT_FOUND`, `PROVIDER_UNAVAILABLE`, `URL_NOT_ALLOWED`, `JOB_NOT_FOUND`, `CONCURRENCY_QUEUE_TIMEOUT`, `CONCURRENCY_QUEUE_FULL`, `PROVIDER_TIMEOUT`, `FILE_TOO_LARGE`, `UNSUPPORTED_FILE_TYPE`, `FILE_NOT_FOUND`, `FILE_KEY_FORBIDDEN`, `FILE_UPLOAD_ERROR`, `FILE_DOWNLOAD_ERROR`, `FILE_DELETE_ERROR`, `UPLOAD_NOT_COMPLETED`, `MISSING_URL`, `MISSING_INPUT`, `MISSING_EXTRACTION_CONFIG`, `MISSING_QUERY`, `SCRAPE_FAILED`, `EXTRACT_FAILED`, `SCREENSHOT_FAILED`, `SEARCH_FAILED`, `SCRAPE_BLOCKED`, `SESSION_NOT_FOUND`, `SESSION_EXPIRED`, `SESSION_BUSY`, `SESSION_LIMIT_REACHED`, `SESSION_ENDED_INSUFFICIENT_CREDITS`, `ACTION_TIMEOUT`, `ACTION_FAILED`, `UNKNOWN_COMMAND`, `BATCH_TOO_LARGE`, `SKIPPED`, `UPLOAD_TOO_LARGE`, `DOWNLOAD_TOO_LARGE`, `CAPTURE_LIMIT_REACHED`, `CAPTCHA_UNSOLVED`, `BROWSER_CDP_NODE_ONLY`, `DIALOG_NOT_OPEN`, `STEALTH_TIER_UNAVAILABLE`, `DOMAIN_NOT_ALLOWED`, `CONTEXT_SEED_UNSUPPORTED`, `EXTENSION_TOO_LARGE`, `NL_PROVIDER_UNAVAILABLE`, `PROGRAM_TOO_LARGE`, `PROGRAM_UNBOUNDED`, `UNKNOWN_STEP`, `PROGRAM_IN_FLIGHT`, `PROGRAM_TIMEOUT`, `PROGRAM_NOT_FOUND`, `PROGRAM_STEP_FAILED`, `PROGRAM_LIMIT_REACHED`, `CREATE_PDF_FROM_HTML_ERROR`, `GENERATE_PDF_BY_URL_ERROR`, `PDF_TIMEOUT`, `PDF_TOO_LARGE`, `FILENAME_REQUIRED`, `SCANUM_EYE_ARRAY_NOT_SUPPORTED`, `INVALID_EXTRA_FIELD_TYPE`, `SCANUM_EYE_PDF_NOT_SUPPORTED`, `UNSUPPORTED_SCAN_MODEL`, `SCAN_DOCUMENT_ERROR`, `SCAN_INVOICE_ERROR`, `OCR_IMAGE_PROCESSING_ERROR`, `OCR_PDF_PROCESSING_ERROR`, `SCAN_TOO_MANY_PAGES`, `SCAN_PDF_ENCRYPTED`, `SCAN_XFA_UNSUPPORTED`, `AUDIO_FILE_REQUIRED`, `AUDIO_FILE_TOO_LARGE`, `INVALID_AUDIO_FORMAT`, `AUDIO_TRANSCRIPTION_ERROR`, `TTS_TEXT_TOO_LONG`, `TTS_VOICE_UNKNOWN`, `TOO_MANY_AUDIO_TRANSCRIPTIONS`, `EMAIL_SEND_FAILED`, `TOO_MANY_ATTACHMENTS`, `INVALID_ATTACHMENT_URL`, `ATTACHMENT_TOO_LARGE`, `TOO_MANY_RECIPIENTS`, `EMAIL_RECIPIENT_SUPPRESSED`, `EMAIL_DOMAIN_NOT_VERIFIED`, `EMAIL_LOCAL_PART_TAKEN`, `EMAIL_SENDING_PAUSED`, `EMAIL_DOMAINS_REQUIRE_PAID_PLAN`, `SUPPRESSION_PERMANENT`, `DEV_RECIPIENT_NOT_ALLOWED`, `CRON_LIMIT_REACHED`, `INVALID_SCHEDULE`, `CRON_URL_NOT_ALLOWED`, `CRON_JOB_NOT_FOUND`, `CRON_REQUIRES_PUBLISHED_PROJECT`, `LIVE_CRON_READ_ONLY`, `URL_NOT_PROJECT_HOST`, `INDEXNOW_ERROR`, `MCP_VERIFY_FAILED`, `MCP_NAME_RESERVED`, `MCP_LIMIT_REACHED`, `MCP_SERVER_NOT_FOUND`, `PAYMENTS_NOT_ENABLED`, `PAYMENTS_LIVE_ONBOARDING_REQUIRED`, `PAYMENTS_PUBLISH_FIRST`, `PAYMENTS_ACCOUNT_DISCONNECTED`, `CARD_DATA_NOT_ACCEPTED`, `STRIPE_ERROR`, `STRIPE_RATE_LIMITED`, `PAYMENTS_WEBHOOK_SIGNATURE_INVALID`, `AI_MODEL_NOT_ALLOWED`, `AI_MODEL_UNKNOWN`, `AI_INVALID_SCHEMA`, `AI_CONTENT_BLOCKED`, `AI_CONTEXT_TOO_LONG`, `AI_PROVIDER_QUOTA`, `AI_PROVIDER_UNAVAILABLE`, `AI_STREAM_ERROR`, `AI_VIDEO_JOB_NOT_FOUND`, `AI_VIDEO_FAILED`, `MISSING_VIDEO_URL`, `MISSING_PROMPT`, `INVALID_VIDEO_URL`, `INVALID_VIDEO_URL_PROTOCOL`, `INVALID_MIME_TYPE_HINT`, `VIDEO_TOO_LARGE`, `VIDEO_URL_UNREACHABLE`, `VIDEO_URL_NOT_A_VIDEO`, `YOUTUBE_VIDEO_UNAVAILABLE`, `NO_PAYMENT_METHOD`, `DOMAIN_CONTACT_REQUIRED`, `DOMAIN_UNAVAILABLE`, `DOMAIN_PREMIUM_NOT_ALLOWED`, `DOMAIN_PRICE_OVER_CAP`, `TLD_NOT_ALLOWED`, `DOMAIN_LIMIT_REACHED`, `PRICE_CHANGED`, `PAYMENT_FAILED`, `REGISTRATION_FAILED`, `DOMAIN_NOT_RENEWABLE`, `DOMAIN_ALREADY_ATTACHED`, `DNS_RECORD_MANAGED`, `DNS_RECORD_CONFLICT`, `DNS_RECORD_LIMIT`, `DOMAIN_TRANSFER_LOCKED`, `DOMAIN_NOT_FOUND`, `DOMAIN_IN_USE`, `DOMAIN_ALREADY_ADDED`, `PROJECT_HAS_CUSTOM_DOMAIN`, `NO_DEPLOYMENT`, `REDIRECT_LIMIT_REACHED`, `REDIRECT_INVALID`, `APEX_NOT_FLATTENABLE`, `SECOND_FACTOR_NOT_CONFIGURED`, `PASSWORD_REQUIRED`, `PASSWORD_INCORRECT`, `TOO_MANY_ATTEMPTS`, `CONFIRMATION_MISMATCH`, `INVALID_TOKEN`, `MISSING_PERMISSIONS`, `REPO_NOT_FOUND`, `REPO_MISSING_BRANCHES`, `UNKNOWN`, `SANDBOX_NOT_READY`, `SYNC_FAILED`, `TOKEN_EXPIRED`, `GITHUB_NOT_CONNECTED`, `FIGMA_TOKEN_MISSING`, `FIGMA_TOKEN_MALFORMED`, `FIGMA_TOKEN_INVALID`, `FIGMA_TOKEN_FORBIDDEN`, `FIGMA_RATE_LIMITED`, `FIGMA_UNREACHABLE`, `INVALID_IMPORT_CODE`, `IMPORT_SOURCE_GONE`, `PROJECT_NOT_IMPORTABLE`, `PROJECT_IMPORT_LIMIT_REACHED`, `PROJECT_EXPORT_LIMIT_REACHED`, `VERSION_NOT_FOUND`, `INVALID_CODE_EDIT`, `CODE_EDIT_FAILED`, `BADGE_IN_SOURCE`.

## Versions and deprecations

Semantic versioning from `1.0.0`: a minor release only adds, so an app written against `1.a` runs unchanged on every
later `1.b`. A replaced name keeps working for at least one major version: it is marked **Deprecated** below and logs
one `console.warn` naming its replacement the first time it runs — switch to the replacement when you touch that
code. The package's `CHANGELOG.md` lists every change.

## `totalum-sdk`

The default `totalum` object and `createTotalum()` give you every namespace on one object: `totalum.files`,
`totalum.ai`, `totalum.email` and the rest. Import it in server code and call the namespace you need; nothing is
read or sent until the first call. Every subpath (`totalum-sdk/files`, `totalum-sdk/ai`, …) also exports its own
factory (`totalumFiles()`, `totalumAi()`, …) for a smaller bundle, and the root re-exports all of them.

```ts
import { totalum } from 'totalum-sdk';
const page = await totalum.files.list({ limit: 20 });
```

| Export | What it does |
|---|---|
| [`createTotalum`](#createtotalum) | Creates a `totalum` object with your own options — another project key, the `dev` environment, a test API URL. |
| [`totalum`](#totalum) | The default instance, configured from the environment (`TOTALUM_PROJECT_KEY`, `TOTALUM_SDK_API_URL`, `TOTALUM_ENV`): import it and call any namespace. |
| [`Totalum`](#totalum-type) | The type of `totalum` and of `createTotalum()`'s result: one property per namespace. |
| [`TotalumOptions`](#totalumoptions) | The options of `createTotalum()`: every namespace's options in one object (each falls back to its env var). |

### `createTotalum`

```ts
createTotalum(options?: TotalumOptions): Totalum
```

Creates a `totalum` object with your own options — another project key, the `dev` environment, a test API URL.
Every integration namespace is on it, each built on first access with the same options — no I/O and no env read
until a method is called. The database is `totalumD1()` from `totalum-sdk/d1`, not a namespace.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumOptions`](#totalumoptions) | no | Every namespace's options: `key`, `apiUrl`, `env`, `filesUrl`, `aiBaseUrl`; each falls back to its environment variable. |
| `options.aiBaseUrl` | `string` | no | The OpenAI-compatible AI gateway `gatewayConfig()` returns; default `TOTALUM_AI_BASE_URL`, else `https://ai.totalum.app/api/v1`. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |
| `options.filesUrl` | `string` | no | The public files host `files.url()` uses; default `TOTALUM_FILES_URL`, else `https://files.totalum-project.com`. |

**Returns** [`Totalum`](#totalum-type) — An object with every namespace (`files`, `ai`, `email`, …), like `totalum`.

**Example**

```ts
import { createTotalum } from 'totalum-sdk';

const devTotalum = createTotalum({ env: 'dev' });
await devTotalum.email.send({ to: 'ana@example.com', subject: 'Test', html: '<p>Test</p>' });
```

### `totalum`

```ts
const totalum: Totalum
```

The default instance, configured from the environment (`TOTALUM_PROJECT_KEY`, `TOTALUM_SDK_API_URL`, `TOTALUM_ENV`):
import it and call any namespace. Use it in server code only.

**Example**

```ts
import { totalum } from 'totalum-sdk';

const page = await totalum.files.list({ limit: 10 });
```

### `Totalum` type

The type of `totalum` and of `createTotalum()`'s result: one property per namespace.

| Field | Type | Always present | Description |
|---|---|---|---|
| `ai` | [`TotalumAi`](#totalumai) | yes | AI from your server without any provider account: chat completions (streamed or not), structured JSON, a label out of a list, embeddings, image generation and editing, video generation and image description. See [AI](#totalum-sdkai). |
| `analytics` | [`TotalumAnalytics`](#totalumanalytics) | yes | Your site's own traffic: page views, visits, unique visitors, errors, bytes and response times, as a time series or a top list (top pages, referrers, countries, devices, browsers). See [Site Analytics](#totalum-sdkanalytics). |
| `browser` | [`TotalumBrowser`](#totalumbrowser) | yes | A real Chrome in the cloud, driven over HTTPS from your server, so it works inside the published app: open pages, click, type, log in, fill forms, download files, take screenshots and PDFs, extract data. See [Browser Automation](#totalum-sdkbrowser). |
| `connections` | [`TotalumConnections`](#totalumconnections) | yes | Let your app's users connect their own accounts of about 1,500 apps — Google Calendar, Gmail, Google Drive, Outlook, Slack, HubSpot, Notion, GitHub, Stripe, Airtable and more — and act on them from your server: create a calendar event, send an e-mail from their Gmail, read their files, or receive their events. See [Connected Accounts](#totalum-sdkconnections). |
| `cron` | [`TotalumCron`](#totalumcron) | yes | Run a route of your own app on a schedule: every night at 8:00, every 15 minutes, the first day of the month. See [Cron Jobs](#totalum-sdkcron). |
| `email` | [`TotalumEmail`](#totalumemail) | yes | Send transactional e-mail (sign-up confirmations, receipts, notifications) from the project's own sender, and check whether it was delivered. See [Email](#totalum-sdkemail). |
| `files` | [`TotalumFiles`](#totalumfiles) | yes | Store and serve your app's files (uploads, images, generated PDFs). See [Files & Storage](#totalum-sdkfiles). |
| `logs` | [`TotalumLogs`](#totalumlogs) | yes | Read your app's own server logs: the requests the published app (`env: 'live'`) or the preview (`'dev'`) handled, with their `console` lines, status codes and errors. See [Logs](#totalum-sdklogs). |
| `payments` | [`TotalumPayments`](#totalumpayments) | yes | Take payments and subscriptions with Stripe on the project owner's own Stripe account (Stripe Connect), without handling card data: create a hosted Checkout, send the buyer to its `url`, and read the result back. See [Payments](#totalum-sdkpayments). |
| `pdf` | [`TotalumPdf`](#totalumpdf) | yes | Turn HTML or a web page into a PDF stored in the project's files: invoices, tickets, reports. See [PDF](#totalum-sdkpdf). |
| `realtime` | [`TotalumRealtime`](#totalumrealtime) | yes | Live updates between the people using your app: chat, notifications, presence ("who is online"), collaborative editing, live dashboards and games. See [Realtime](#totalum-sdkrealtime). |
| `scan` | [`TotalumScan`](#totalumscan) | yes | Read documents and images: extract the fields you describe (an invoice's number, date and total) as JSON with `document()`, or get the plain text of every page with `ocr()`. See [Document Scan / OCR](#totalum-sdkscan). |
| `seo` | [`TotalumSeo`](#totalumseo) | yes | Tell search engines that pages of your custom domain changed (IndexNow), so new and edited pages are indexed in minutes instead of days. See [SEO & IndexNow](#totalum-sdkseo). |
| `speech` | [`TotalumSpeech`](#totalumspeech) | yes | Speech to text (`transcribe`) and text to speech (`synthesize`): transcribe a recording or a voice note, read a text aloud, store the audio as a project file. See [Speech](#totalum-sdkspeech). |
| `vectors` | [`TotalumVectors`](#totalumvectors) | yes | Semantic search over your own content: store texts or images as vectors with metadata (`upsert`), then find the closest ones to a question, a text or an image (`query`). See [Vector Search](#totalum-sdkvectors). |
| `web` | [`TotalumWeb`](#totalumweb) | yes | Read the web from your app: one page as Markdown or HTML (`scrape`), a web search (`search`), every URL of a site (`map`), a whole site in the background (`crawl`) and screenshots. See [Web Scraping & Search](#totalum-sdkweb). |
| `webhooks` | [`TotalumWebhooks`](#totalumwebhooks) | yes | Receive Totalum's signed events (payments, e-mail delivery, browser programs, connected-account events, credits exhausted) and check that they really come from Totalum. See [Webhooks](#totalum-sdkwebhooks). |

### `TotalumOptions`

The options of `createTotalum()`: every namespace's options in one object (each falls back to its env var).

| Name | Type | Required | Description |
|---|---|---|---|
| `aiBaseUrl` | `string` | no | The OpenAI-compatible AI gateway `gatewayConfig()` returns; default `TOTALUM_AI_BASE_URL`, else `https://ai.totalum.app/api/v1`. |
| `key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |
| `filesUrl` | `string` | no | The public files host `files.url()` uses; default `TOTALUM_FILES_URL`, else `https://files.totalum-project.com`. |

## `totalum-sdk/d1`

The project's SQL database (Cloudflare D1, SQLite), used through Drizzle: `drizzle(totalumD1())`. `totalumD1()`
returns a `D1Database` that finds the database wherever the code runs — the `TOTALUM_DB` binding inside the preview
and the published app, the local database under `next dev`, and an HTTPS connection with the project key in a script
or on a laptop — so the same code works everywhere. The database is the project's own: no other project can reach it.

```ts
import { drizzle } from 'drizzle-orm/d1';
import { totalumD1 } from 'totalum-sdk/d1';

export const db = drizzle(totalumD1());
```

| Method or type | What it does |
|---|---|
| [`totalumD1`](#totalumd1) | The project's own database as a `D1Database`, wherever the code runs: the bound `TOTALUM_DB` inside a Worker (the preview and the published app), Turso when the environment moved there, the local D1 that `initOpenNextCloudflareForDev()` binds under `next dev`, and an HTTPS client to the Totalum API everywhere else (a script, a laptop: with `TOTALUM_PROJECT_KEY`). |
| [`isTotalumD1Error`](#istotalumd1error) | Whether `e` is a database error (a `TotalumD1Error`): a SQL error, a refusal of the driver, or the Totalum API's answer to a statement. |
| [`READ_YOUR_WRITES_COOKIE`](#read_your_writes_cookie) | The name of the cookie `withTotalumSession` sets for 10 s after a write (`__tlm_d1_w`): while the browser holds it, its reads go to the primary database, so a user always sees what they just saved. |
| [`withTotalumSession`](#withtotalumsession) | Wraps one request of the app's Worker entry (the template does it for you) so reads use the nearest read replica and writes stay consistent for the user who made them. |
| [`D1Database`](#d1database) | The project's database, as `totalumD1()` returns it: Cloudflare's `D1Database` interface, so `drizzle(totalumD1())` works, and you can also run SQL by hand with `prepare`/`batch`. |
| [`D1DatabaseSession`](#d1databasesession) | A session for read replicas, from `db.withSession()`: its statements see a consistent view, and its bookmark can continue the view in a later request. |
| [`D1ExecResult`](#d1execresult) | What `db.exec()` returns. |
| [`D1PreparedStatement`](#d1preparedstatement) | A prepared SQL statement, from `db.prepare(sql)`: bind its parameters, then run it with `first`, `all`, `run` or `raw`. |
| [`D1Result`](#d1result) | What `run()`, `all()` and each `batch()` statement return: the rows and the statement's metadata. |
| [`TotalumD1ClientCode`](#totalumd1clientcode) | The codes the database driver itself throws (before or without the Totalum API): no key, an unsupported call, a statement of another instance in a batch, the network, an unreadable answer. |
| [`TotalumD1Error`](#totalumd1error) | The database `TotalumError`: `message` is `D1_ERROR: …`-shaped so it reads like a native failure — byte-identical to the binding's message when the failure is SQLite's. |
| [`TotalumD1ErrorCode`](#totalumd1errorcode) | Deprecated: Since 1.0.0: use `TotalumErrorCode` (the same type). |
| [`TotalumD1Options`](#totalumd1options) | The options of `totalumD1()`. |

### `totalumD1`

```ts
totalumD1(options?: TotalumD1Options): D1Database
```

The project's own database as a `D1Database`, wherever the code runs: the bound `TOTALUM_DB` inside a Worker (the
preview and the published app), Turso when the environment moved there, the local D1 that
`initOpenNextCloudflareForDev()` binds under `next dev`, and an HTTPS client to the Totalum API everywhere else (a
script, a laptop: with `TOTALUM_PROJECT_KEY`). Detection happens on the first statement, never at call time, so
`drizzle(totalumD1())` at module scope is fine. A transient failure of a read is retried for you.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumD1Options`](#totalumd1options) | no | The key, API URL and environment for the HTTPS path, migrate mode, or an explicit binding. |
| `options.mode` | `'query' \| 'migrate'` | no | `'migrate'` (scripts/migrate.ts only) sends `x-totalum-migrate: 1` and enables `exec()`; default `'query'`. |
| `options.destructive` | `boolean` | no | Migrate mode only: sends `x-totalum-migrate-destructive: 1`. |
| `options.binding` | [`D1Database`](#d1database) | no | An explicit binding wins over detection (plain Workers: `env.TOTALUM_DB`; tests: a Miniflare D1). |
| `options.transport` | `'auto' \| 'https'` | no | `'https'` forces the HTTPS path even inside a Worker (the conformance corpus); default `'auto'`. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`D1Database`](#d1database) — The database: pass it to `drizzle()`, or use `prepare`/`batch` directly.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SDK_NOT_CONFIGURED` | — | Thrown by the first statement when no binding is found and `TOTALUM_PROJECT_KEY` is unset. |

**Examples**

```ts
// src/db/index.ts
import { drizzle } from 'drizzle-orm/d1';
import { totalumD1 } from 'totalum-sdk/d1';
import * as schema from './schema';

export const db = drizzle(totalumD1(), { schema });

// anywhere on the server
const latest = await db.select().from(schema.orders).orderBy(desc(schema.orders.createdAt)).limit(5);
```

```ts
// plain SQL, from a script (npx tsx scripts/count-users.ts) — over HTTPS with TOTALUM_PROJECT_KEY
const row = await totalumD1().prepare('SELECT COUNT(*) AS n FROM users').first();
```

Result:

```json
{ "n": 128 }
```

### `isTotalumD1Error`

```ts
isTotalumD1Error(e: unknown): boolean
```

Whether `e` is a database error (a `TotalumD1Error`): a SQL error, a refusal of the driver, or the Totalum API's
answer to a statement.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `e` | `unknown` | yes | Anything caught. |

**Returns** `boolean` — `true` for a `TotalumD1Error` (then `e.errorCode`, `e.message` are typed).

**Example**

```ts
try {
  await db.insert(users).values({ email });
} catch (e) {
  if (isTotalumD1Error(e) && e.message.includes('UNIQUE constraint failed')) return { error: 'Email already used' };
  throw e;
}
```

### `READ_YOUR_WRITES_COOKIE`

```ts
const READ_YOUR_WRITES_COOKIE: '__tlm_d1_w'
```

The name of the cookie `withTotalumSession` sets for 10 s after a write (`__tlm_d1_w`): while the browser holds it,
its reads go to the primary database, so a user always sees what they just saved. Exported for apps that clear or
inspect it.

**Example**

```ts
import { READ_YOUR_WRITES_COOKIE } from 'totalum-sdk/d1';
const fresh = request.headers.get('cookie')?.includes(`${READ_YOUR_WRITES_COOKIE}=`);
```

### `withTotalumSession`

```ts
withTotalumSession(request: Request, env: E, run: (env: E) => Promise<Response>): Promise<Response>
```

Wraps one request of the app's Worker entry (the template does it for you) so reads use the nearest read replica
and writes stay consistent for the user who made them. In detail: a `GET`/`HEAD` reads through a
`first-unconstrained` session — the nearest replica — unless the browser wrote in the last 10 s; any other method
uses the plain binding (writes go to the primary; measured faster than any session) and its response sets the short
cookie that sends that browser's next reads to the primary. A replica read that takes over `REPLICA_READ_TIMEOUT_MS`
is answered by the primary instead, and the rest of that request reads the primary. A mutation's pure read the primary
does not answer in that time is asked again on the primary (one call stalled while the database answered
others; a sign-up waited on its first SELECT until the Worker was cancelled). `run` receives the env to hand to the
app, whose `totalumD1()` reads `TOTALUM_DB` from it on every statement. No D1 binding (Turso, a laptop): `run(env)`
unchanged.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `request` | `Request` | yes | The incoming request. |
| `env` | `E` | yes | The Worker's env (with the `TOTALUM_DB` binding). |
| `run` | `(env: E) => Promise<Response>` | yes | Handles the request with the env to use (its `TOTALUM_DB` may be a session). |

**Returns** `Promise<Response>` — The response, with the short read-your-writes cookie after a write.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SQL_ERROR` | — | Whatever `run` throws is passed on unchanged. |

**Example**

```ts
// worker.ts (the template's entry)
import { withTotalumSession } from 'totalum-sdk/d1';
import handler from './.open-next/worker.js';

export default {
  fetch: (request: Request, env: Env, ctx: ExecutionContext) =>
    withTotalumSession(request, env, (env) => handler.fetch(request, env, ctx)),
};
```

### `D1Database`

The project's database, as `totalumD1()` returns it: Cloudflare's `D1Database` interface, so `drizzle(totalumD1())`
works, and you can also run SQL by hand with `prepare`/`batch`.

### `db.prepare`

```ts
db.prepare(sql: string): D1PreparedStatement
```

Prepares one SQL statement (with `?` placeholders); run it with `first`, `all`, `run` or `raw`. Nothing runs
until then.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `sql` | `string` | yes | The SQL text. Several statements separated by `;` run together, atomically. |

**Returns** [`D1PreparedStatement`](#d1preparedstatement) — The prepared statement.

**Example**

```ts
const db = totalumD1();
const user = await db.prepare('SELECT * FROM users WHERE id = ?').bind(42).first();
```

### `db.batch`

```ts
db.batch(statements: D1PreparedStatement[]): Promise<D1Result[]>
```

Runs several statements in one round trip, as one atomic unit: all apply or none (D1 has no `BEGIN`/`COMMIT`; this
is its transaction). Drizzle's `db.batch([...])` uses it.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `statements` | [`D1PreparedStatement[]`](#d1preparedstatement) | yes | Statements prepared by this database. |

**Returns** `Promise<D1Result[]>` — One result per statement, in order.

Each item:

| Field | Type | Always present | Description |
|---|---|---|---|
| `results` | `T[]` | yes | The rows, as objects keyed by column name (empty for a statement that returns none). |
| `success` | `true` | yes | Always `true`: a failure throws instead. |
| `meta` | `{ duration, size_after, rows_read, rows_written, last_row_id, changed_db, changes, served_by_region?, served_by_primary? }` | yes | Timing, rows read and written, changes and the last inserted rowid. |
| `meta.duration` | `number` | yes | How long the statement took in the database, in ms. |
| `meta.size_after` | `number` | yes | The database's size after the statement, in bytes. |
| `meta.rows_read` | `number` | yes | Rows the statement read (what makes a query slow: add an index when it is high). |
| `meta.rows_written` | `number` | yes | Rows the statement wrote. |
| `meta.last_row_id` | `number` | yes | The rowid of the last inserted row (`INSERT` without `RETURNING`). |
| `meta.changed_db` | `boolean` | yes | Whether the database changed. |
| `meta.changes` | `number` | yes | Rows inserted, updated or deleted. |
| `meta.served_by_region` | `string` | no | The region that answered (with read replicas). |
| `meta.served_by_primary` | `boolean` | no | Whether the primary database answered (`false` for a read replica). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SQL_ERROR` | — | A statement failed; none was applied. |
| `BATCH_FOREIGN_STATEMENT` | — | A statement prepared by another `totalumD1()` instance. |

**Example**

```ts
const [debit, credit] = await db.batch([
  db.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?').bind(50, 1),
  db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?').bind(50, 2),
]);
```

Result:

```ts
[
  { "success": true, "meta": { "changes": 1, … }, "results": [] },
  { "success": true, "meta": { "changes": 1, … }, "results": [] }
]
```

### `db.exec`

```ts
db.exec(sql: string): Promise<D1ExecResult>
```

Runs a SQL script (several statements, no parameters): only in migrate mode, `totalumD1({ mode: 'migrate' })` from
`scripts/migrate.ts`. For data, use `prepare` or `batch`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `sql` | `string` | yes | The script. |

**Returns** `Promise<D1ExecResult>` — How many statements ran and how long they took.

| Field | Type | Always present | Description |
|---|---|---|---|
| `count` | `number` | yes | How many statements ran. |
| `duration` | `number` | yes | Their total time in ms. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `EXEC_REQUIRES_MIGRATE_MODE` | — | Called outside migrate mode. |
| `SQL_ERROR` | — | A statement failed. |

**Example**

```ts
const db = totalumD1({ mode: 'migrate' });
await db.exec('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL);');
```

Result:

```json
{ "count": 1, "duration": 0.42 }
```

### `db.dump`

```ts
db.dump(): Promise<ArrayBuffer>
```

Not available: always throws `NOT_SUPPORTED`. Database backups are in the project's settings in Totalum.

**Returns** `Promise<ArrayBuffer>` — Never resolves.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `NOT_SUPPORTED` | — | Always. |

**Example**

```ts
await db.dump(); // throws TotalumD1Error NOT_SUPPORTED
```

### `db.withSession`

```ts
db.withSession(constraintOrBookmark?: string): D1DatabaseSession
```

A session for read replicas: `'first-unconstrained'` reads from the nearest replica, `'first-primary'` starts at
the primary, a bookmark continues a previous session. The template already wraps each request with
`withTotalumSession`, so app code rarely needs this. Over HTTPS and on Turso the session is the database itself.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `constraintOrBookmark` | `string` | no | `'first-unconstrained'`, `'first-primary'` or a bookmark from `getBookmark()`. |

**Returns** [`D1DatabaseSession`](#d1databasesession) — The session.

**Example**

```ts
const session = db.withSession('first-unconstrained');
const { results } = await session.prepare('SELECT * FROM products LIMIT 20').all();
```

### `D1DatabaseSession`

A session for read replicas, from `db.withSession()`: its statements see a consistent view, and its bookmark can
continue the view in a later request.

### `dbSession.prepare`

```ts
dbSession.prepare(sql: string): D1PreparedStatement
```

Prepares a statement in this session.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `sql` | `string` | yes | One SQL statement with `?` placeholders. |

**Returns** [`D1PreparedStatement`](#d1preparedstatement) — The prepared statement.

**Example**

```ts
const session = db.withSession('first-unconstrained');
const posts = await session.prepare('SELECT * FROM posts ORDER BY id DESC LIMIT 10').all();
```

### `dbSession.batch`

```ts
dbSession.batch(statements: D1PreparedStatement[]): Promise<D1Result[]>
```

Runs several statements in this session, as one atomic batch.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `statements` | [`D1PreparedStatement[]`](#d1preparedstatement) | yes | Statements prepared by this session. |

**Returns** `Promise<D1Result[]>` — One result per statement.

Each item:

| Field | Type | Always present | Description |
|---|---|---|---|
| `results` | `T[]` | yes | The rows, as objects keyed by column name (empty for a statement that returns none). |
| `success` | `true` | yes | Always `true`: a failure throws instead. |
| `meta` | `{ duration, size_after, rows_read, rows_written, last_row_id, changed_db, changes, served_by_region?, served_by_primary? }` | yes | Timing, rows read and written, changes and the last inserted rowid. |
| `meta.duration` | `number` | yes | How long the statement took in the database, in ms. |
| `meta.size_after` | `number` | yes | The database's size after the statement, in bytes. |
| `meta.rows_read` | `number` | yes | Rows the statement read (what makes a query slow: add an index when it is high). |
| `meta.rows_written` | `number` | yes | Rows the statement wrote. |
| `meta.last_row_id` | `number` | yes | The rowid of the last inserted row (`INSERT` without `RETURNING`). |
| `meta.changed_db` | `boolean` | yes | Whether the database changed. |
| `meta.changes` | `number` | yes | Rows inserted, updated or deleted. |
| `meta.served_by_region` | `string` | no | The region that answered (with read replicas). |
| `meta.served_by_primary` | `boolean` | no | Whether the primary database answered (`false` for a read replica). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SQL_ERROR` | — | A statement failed; none was applied. |

**Example**

```ts
const [users, posts] = await session.batch([session.prepare('SELECT * FROM users'), session.prepare('SELECT * FROM posts')]);
```

### `dbSession.getBookmark`

```ts
dbSession.getBookmark(): string | null
```

The session's bookmark after its last statement: pass it to `withSession(bookmark)` in a later request to read at
least that state. `null` before the first statement, and always over HTTPS and on Turso (one database, no
replicas).

**Returns** `string \| null` — The bookmark, or `null`.

**Example**

```ts
const bookmark = session.getBookmark();
```

Result:

```json
"0000002a-00000003-00004f5e-7a4ce4bd3c0e9b0e1f2c3d4e5f60718"
```

### `D1ExecResult`

What `db.exec()` returns.

| Field | Type | Always present | Description |
|---|---|---|---|
| `count` | `number` | yes | How many statements ran. |
| `duration` | `number` | yes | Their total time in ms. |

### `D1PreparedStatement`

A prepared SQL statement, from `db.prepare(sql)`: bind its parameters, then run it with `first`, `all`, `run` or
`raw`. Nothing runs until one of those is awaited.

### `stmt.bind`

```ts
stmt.bind(...values: unknown[]): D1PreparedStatement
```

Binds the values of the statement's `?` placeholders, in order, and returns a new statement (the original is
unchanged). Values: strings, numbers, booleans (stored as 1/0), `null`, `ArrayBuffer`/`Uint8Array` (BLOB).

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `values` | `unknown[]` | yes | One value per `?`, in order. |

**Returns** [`D1PreparedStatement`](#d1preparedstatement) — A statement with those parameters.

**Example**

```ts
const stmt = db.prepare('SELECT * FROM users WHERE email = ? AND active = ?').bind('ana@example.com', 1);
```

### `stmt.first`

```ts
stmt.first(colName?: string): Promise<T | null>
```

Runs the statement and returns the first row (or one column of it), or `null` when there is none.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `colName` | `string` | no | Return only this column's value. |

**Returns** `Promise<T \| null>` — The first row, that column's value, or `null`.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SQL_ERROR` | — | SQLite refused the statement (`message` is SQLite's, as `D1_ERROR: …`). |

**Example**

```ts
const user = await db.prepare('SELECT id, name FROM users WHERE email = ?').bind('ana@example.com').first();
const count = await db.prepare('SELECT COUNT(*) AS n FROM users').first<number>('n');
```

Result:

```ts
user = { id: 42, name: 'Ana' }   count = 128
```

### `stmt.run`

```ts
stmt.run(): Promise<D1Result>
```

Runs a statement that changes data (`INSERT`, `UPDATE`, `DELETE`, DDL) and returns its metadata (`changes`,
`last_row_id`) — and its rows, with `RETURNING`.

**Returns** `Promise<D1Result>` — The result: `results` (rows of a `RETURNING`) and `meta`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `results` | `T[]` | yes | The rows, as objects keyed by column name (empty for a statement that returns none). |
| `success` | `true` | yes | Always `true`: a failure throws instead. |
| `meta` | `{ duration, size_after, rows_read, rows_written, last_row_id, changed_db, changes, served_by_region?, served_by_primary? }` | yes | Timing, rows read and written, changes and the last inserted rowid. |
| `meta.duration` | `number` | yes | How long the statement took in the database, in ms. |
| `meta.size_after` | `number` | yes | The database's size after the statement, in bytes. |
| `meta.rows_read` | `number` | yes | Rows the statement read (what makes a query slow: add an index when it is high). |
| `meta.rows_written` | `number` | yes | Rows the statement wrote. |
| `meta.last_row_id` | `number` | yes | The rowid of the last inserted row (`INSERT` without `RETURNING`). |
| `meta.changed_db` | `boolean` | yes | Whether the database changed. |
| `meta.changes` | `number` | yes | Rows inserted, updated or deleted. |
| `meta.served_by_region` | `string` | no | The region that answered (with read replicas). |
| `meta.served_by_primary` | `boolean` | no | Whether the primary database answered (`false` for a read replica). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SQL_ERROR` | — | SQLite refused the statement (a constraint, a syntax error). |

**Example**

```ts
const r = await db.prepare('INSERT INTO users (email, name) VALUES (?, ?)').bind('ana@example.com', 'Ana').run();
```

Result:

```json
{
  "success": true,
  "meta": { "duration": 0.31, "size_after": 12288, "rows_read": 1, "rows_written": 1, "last_row_id": 42, "changed_db": true, "changes": 1 },
  "results": []
}
```

### `stmt.all`

```ts
stmt.all(): Promise<D1Result>
```

Runs a query and returns every row.

**Returns** `Promise<D1Result>` — The result: `results` (the rows) and `meta`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `results` | `T[]` | yes | The rows, as objects keyed by column name (empty for a statement that returns none). |
| `success` | `true` | yes | Always `true`: a failure throws instead. |
| `meta` | `{ duration, size_after, rows_read, rows_written, last_row_id, changed_db, changes, served_by_region?, served_by_primary? }` | yes | Timing, rows read and written, changes and the last inserted rowid. |
| `meta.duration` | `number` | yes | How long the statement took in the database, in ms. |
| `meta.size_after` | `number` | yes | The database's size after the statement, in bytes. |
| `meta.rows_read` | `number` | yes | Rows the statement read (what makes a query slow: add an index when it is high). |
| `meta.rows_written` | `number` | yes | Rows the statement wrote. |
| `meta.last_row_id` | `number` | yes | The rowid of the last inserted row (`INSERT` without `RETURNING`). |
| `meta.changed_db` | `boolean` | yes | Whether the database changed. |
| `meta.changes` | `number` | yes | Rows inserted, updated or deleted. |
| `meta.served_by_region` | `string` | no | The region that answered (with read replicas). |
| `meta.served_by_primary` | `boolean` | no | Whether the primary database answered (`false` for a read replica). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SQL_ERROR` | — | SQLite refused the statement. |

**Example**

```ts
const { results } = await db.prepare("SELECT 'Ana' AS name, 42 AS age").all();
```

Result:

```json
{
  "success": true,
  "meta": { "served_by_region": "WEUR", "served_by_primary": true, "duration": 0.1934, "changes": 0, "last_row_id": 0, "changed_db": false, "size_after": 12288, "rows_read": 0, "rows_written": 0 },
  "results": [{ "name": "Ana", "age": 42 }]
}
```

### `stmt.raw`

```ts
stmt.raw(options: { columnNames }): Promise<[string[], ...T[]]>
stmt.raw(options?: { columnNames? }): Promise<T[]>
```

Runs a query and returns the rows as arrays of values (no column names), or with `columnNames: true` the names
first.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | `{ columnNames? }` | no | `{ columnNames: true }` puts the column names first. |
| `options.columnNames` | `boolean` | no | `true`: the first array is the column names. |

**Returns** `Promise<T[]>` — The rows as arrays.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SQL_ERROR` | — | SQLite refused the statement. |

**Example**

```ts
const rows = await db.prepare('SELECT id, name FROM users LIMIT 2').raw({ columnNames: true });
```

Result:

```json
[["id", "name"], [41, "Bob"], [42, "Ana"]]
```

### `D1Result`

What `run()`, `all()` and each `batch()` statement return: the rows and the statement's metadata.

| Field | Type | Always present | Description |
|---|---|---|---|
| `results` | `T[]` | yes | The rows, as objects keyed by column name (empty for a statement that returns none). |
| `success` | `true` | yes | Always `true`: a failure throws instead. |
| `meta` | `{ duration, size_after, rows_read, rows_written, last_row_id, changed_db, changes, served_by_region?, served_by_primary? }` | yes | Timing, rows read and written, changes and the last inserted rowid. |
| `meta.duration` | `number` | yes | How long the statement took in the database, in ms. |
| `meta.size_after` | `number` | yes | The database's size after the statement, in bytes. |
| `meta.rows_read` | `number` | yes | Rows the statement read (what makes a query slow: add an index when it is high). |
| `meta.rows_written` | `number` | yes | Rows the statement wrote. |
| `meta.last_row_id` | `number` | yes | The rowid of the last inserted row (`INSERT` without `RETURNING`). |
| `meta.changed_db` | `boolean` | yes | Whether the database changed. |
| `meta.changes` | `number` | yes | Rows inserted, updated or deleted. |
| `meta.served_by_region` | `string` | no | The region that answered (with read replicas). |
| `meta.served_by_primary` | `boolean` | no | Whether the primary database answered (`false` for a read replica). |

### `TotalumD1ClientCode`

The codes the database driver itself throws (before or without the Totalum API): no key, an unsupported call, a
statement of another instance in a batch, the network, an unreadable answer.

| Value | Meaning |
|---|---|
| `'SDK_NOT_CONFIGURED'` | No binding was found and `TOTALUM_PROJECT_KEY` is unset. |
| `'NOT_SUPPORTED'` | The call is not available (`dump()`). |
| `'BATCH_FOREIGN_STATEMENT'` | `batch()` got a statement prepared by another `totalumD1()` instance. |
| `'NETWORK_ERROR'` | The Totalum API could not be reached (HTTPS path). |
| `'RESPONSE_PARSE_ERROR'` | The answer was not the expected JSON (HTTPS path). |

### `TotalumD1Error`

The database `TotalumError`: `message` is `D1_ERROR: …`-shaped so it reads like a native failure —
byte-identical to the binding's message when the failure is SQLite's.
| Member | Type | Description |
|---|---|---|
| `name` | `string` | `'TotalumD1Error'`, or `'Error'` for SQLite's own failures (as the native binding throws them). |

| Method | Description |
|---|---|
| `TotalumD1Error.client(errorCode, message)` → [`TotalumD1Error`](#totalumd1error) | A client-side refusal (`D1_ERROR: <code>: <message>`). |
| `TotalumD1Error.engine(sqliteMessage, options?)` → [`TotalumD1Error`](#totalumd1error) | SQLite's own failure, as the binding throws it: `name` `Error`, `message` `D1_ERROR: <sqlite>`, `cause` an `Error` with SQLite's words — so `String(err)`, `err.message` and `String(err.cause)` read the same on every path. Still a `TotalumD1Error` (`errorCode` `SQL_ERROR`) for code that checks. |
| `TotalumD1Error.fromWire(errors, status, requestId)` → [`TotalumD1Error`](#totalumd1error) | The Totalum API error envelope → typed error; `SQL_ERROR` is rebuilt as the binding's own error from `sqliteMessage`. |

### `TotalumD1ErrorCode`

**Deprecated.** Since 1.0.0: use `TotalumErrorCode` (the same type).

Deprecated: Since 1.0.0: use `TotalumErrorCode` (the same type).

```ts
type TotalumD1ErrorCode = 'SERVICE_UNAUTHORIZED' | 'SERVICE_REPLAY' | 'ACTOR_MISMATCH' | 'PAYLOAD_TOO_LARGE' | 'VALIDATION_ERROR' | 'PROJECT_NOT_FOUND' | 'PROJECT_NOT_READY' | 'OPERATION_IN_PROGRESS' | … // 421 values
```

### `TotalumD1Options`

The options of `totalumD1()`. `key`, `apiUrl` and `env` (HTTPS path only) resolve exactly as in every other namespace.

| Name | Type | Required | Description |
|---|---|---|---|
| `mode` | `'query' \| 'migrate'` | no | `'migrate'` (scripts/migrate.ts only) sends `x-totalum-migrate: 1` and enables `exec()`; default `'query'`. |
| `destructive` | `boolean` | no | Migrate mode only: sends `x-totalum-migrate-destructive: 1`. |
| `binding` | [`D1Database`](#d1database) | no | An explicit binding wins over detection (plain Workers: `env.TOTALUM_DB`; tests: a Miniflare D1). |
| `transport` | `'auto' \| 'https'` | no | `'https'` forces the HTTPS path even inside a Worker (the conformance corpus); default `'auto'`. |
| `key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

## `totalum-sdk/files`

Store and serve your app's files (uploads, images, generated PDFs). Each upload returns a file descriptor: save it
as-is in the row's file column and show `fileUrl` (public files) or a signed URL (private files). Files up to 25 MB
go through `upload()`; larger ones (up to your plan's limit) go straight from the browser with `getUploadUrl()` and
`complete()`. Storage is billed in credits per GB-month, and each stored file per write.

```ts
import { totalum } from 'totalum-sdk';            // totalum.files, or: import { totalumFiles } from 'totalum-sdk/files'
const file = await totalum.files.upload(formFile, { folder: 'avatars' });
```

| Method or type | What it does |
|---|---|
| [`totalumFiles`](#totalumfiles) | Creates the `files` namespace (the same object as `totalum.files`) with its own options. |
| [`files.upload`](#filesupload) | Stores a file of up to 25 MB and returns its descriptor. |
| [`files.getUploadUrl`](#filesgetuploadurl) | Starts a direct upload for a large file (over 25 MB, or any file you want the browser to send straight to storage): your server asks for an upload URL, the browser `PUT`s the bytes to it with the returned `headers`, then your server calls `complete(key)`. |
| [`files.complete`](#filescomplete) | Finishes a direct upload started with `getUploadUrl`: checks the bytes arrived with the declared size and type and returns the file's descriptor. |
| [`files.list`](#fileslist) | One page of the project's files, newest first, with the project's total storage. |
| [`files.get`](#filesget) | The descriptor of one file: check that a stored key still exists, or read its name, size and type. |
| [`files.getSignedUrl`](#filesgetsignedurl) | A temporary link to one file — the way to show or download a private file in the browser. |
| [`files.getDownloadUrl`](#filesgetdownloadurl) | Deprecated: Since 1.0.0: use `getSignedUrl` (the same call). |
| [`files.presign`](#filespresign) | Signed links for many files in one call — for a gallery or a table of private files. |
| [`files.delete`](#filesdelete) | Deletes one file, or up to 1,000 at once. |
| [`files.stream`](#filesstream) | The file's bytes as a `Response` (private files included), to read on the server or to return from a route that serves the file after your own permission check. |
| [`files.url`](#filesurl) | The URL of a file, computed locally (no request): the public URL of a public `p/…` key, or for a private `pv/…` key the Totalum API URL that serves it only with the project key (a server-side fetch). |
| [`FileDescriptor`](#filedescriptor) | A stored file, as every files call returns it. |
| [`FilesListQuery`](#fileslistquery) | The filters of `files.list()`. |
| [`FilesPage`](#filespage) | One page of `files.list()`. |
| [`FileUploadUrlInput`](#fileuploadurlinput) | What `files.getUploadUrl()` needs to know about the file before it is uploaded. |
| [`FileUploadUrlOutput`](#fileuploadurloutput) | Where to upload the bytes of a direct upload, from `files.getUploadUrl()`. |
| [`TotalumFilesOptions`](#totalumfilesoptions) | The options of `totalumFiles()`: the client options plus the public files host `files.url()` builds URLs on. |
| [`UploadOptions`](#uploadoptions) | How `files.upload()` stores a file: its name, type, visibility and folder. |

### `totalumFiles`

```ts
totalumFiles(options?: TotalumFilesOptions): TotalumFiles
```

Creates the `files` namespace (the same object as `totalum.files`) with its own options. Every call returns the file
descriptor `{key, fileName, fileSize, contentType, fileUrl, visibility, createdAt}` meant to be stored as-is in the
row's file column; failures throw `TotalumError` with the Totalum API's frozen code.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumFilesOptions`](#totalumfilesoptions) | no | The project key, API URL, environment and public files host; each falls back to its environment variable. |
| `options.filesUrl` | `string` | no | The public files host `files.url()` uses; default `TOTALUM_FILES_URL`, else `https://files.totalum-project.com`. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumFiles`](#totalumfiles) — The `files` namespace: the methods below.

**Example**

```ts
import { totalumFiles } from 'totalum-sdk/files';

const files = totalumFiles({ env: 'dev' });
const page = await files.list({ limit: 10 });
```

The namespace's type is exported as `TotalumFiles` (`import type { TotalumFiles } from 'totalum-sdk/files'`).

### `files.upload`

```ts
files.upload(input: string | Blob | ArrayBuffer | Uint8Array, opts?: UploadOptions): Promise<FileDescriptor>
```

Stores a file of up to 25 MB and returns its descriptor. Call it from a route handler or server action with the
`File` from a form, a `Blob`, bytes or a string; larger files go through `getUploadUrl` + `complete`. Public files
(the default) get a permanent, unguessable `fileUrl`; private ones only signed URLs. Each upload gets a new key:
uploading the same name again never overwrites a file.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `string \| Blob \| ArrayBuffer \| Uint8Array` | yes | The content: a `File`/`Blob` (its name and type are used), an `ArrayBuffer`/`Uint8Array`, or a string (stored as UTF-8 text). |
| `opts` | [`UploadOptions`](#uploadoptions) | no | The name, type, visibility and folder. |
| `opts.name` | `string` | no | The file name users see and downloads get (`invoice-12.pdf`). Default: the `File`'s own name, else `file`. |
| `opts.contentType` | `string` | no | The MIME type (`image/png`, `application/pdf`). Default: the `Blob`'s type, else `application/octet-stream` — pass it for a string or bytes so browsers display the file. HTML and executables are refused. |
| `opts.private` | `boolean` | no | `true` stores the file under the private prefix: no public URL, read it through `getSignedUrl`. |
| `opts.folder` | `string` | no | A folder name to group files by (`avatars`, `invoices/2026`); `files.list({ folder })` filters on it. |

**Returns** `Promise<FileDescriptor>` — The stored file's descriptor: save it as-is in the row's file column.

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The file's permanent id: `p/<project>/<id>.<ext>` for a public file, `pv/<project>/<id>.<ext>` for a private one. Pass it to every other files method. |
| `fileName` | `string` | yes | The file name users see and downloads get. |
| `fileSize` | `number` | yes | Size in bytes. |
| `contentType` | `string` | yes | The MIME type the file is served with. |
| `fileUrl` | `string \| null` | yes | The public, permanent, unguessable URL of a public file; `null` for a private file (use `getSignedUrl`). |
| `visibility` | `'public-by-link' \| 'private'` | yes | `public-by-link`: anyone with `fileUrl` can read it. `private`: only through signed URLs or `stream()`. |
| `createdAt` | `string` | yes | When it was stored. |
| `etag` | `string` | no | The storage ETag (changes if the bytes change). |
| `storageClass` | `'Standard' \| 'InfrequentAccess'` | no | The storage tier the file is kept in. |
| `name` | `string` | no | Same as `fileName` (kept for apps written against the first file API). |
| `url` | `string \| null` | no | Same as `fileUrl` (kept for apps written against the first file API). |
| `size` | `number` | no | Same as `fileSize` (kept for apps written against the first file API). |
| `id` | `string` | no | Same as `key` (kept for apps written against the first file API). |
| `fileNameId` | `string` | no | Same as `key` (kept for apps written against the first file API). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `FILE_TOO_LARGE` | 413 | Over 25 MB (use `getUploadUrl`), or over the plan's largest file; `details.maxBytes`. |
| `UNSUPPORTED_FILE_TYPE` | 415 | HTML and executable files are never stored. |
| `STORAGE_LIMIT_EXCEEDED` | 429 | The plan's storage is full; `details.capBytes`. |
| `RATE_LIMIT_EXCEEDED` | 429 | The plan's uploads per minute or per month are used up; `details.period`. |
| `FILE_UPLOAD_ERROR` | 502 | The file could not be stored; retry. |

**Examples**

```ts
// src/app/api/avatar/route.ts
import { totalum } from 'totalum-sdk';

export async function POST(request: Request) {
  const form = await request.formData();
  const file = form.get('file') as File;
  const stored = await totalum.files.upload(file, { folder: 'avatars' });
  // save `stored` in the user's avatar column, then show stored.fileUrl
  return Response.json(stored);
}
```

Result:

```json
{
  "key": "p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.png",
  "fileName": "ana.png",
  "fileSize": 48213,
  "contentType": "image/png",
  "fileUrl": "https://files.totalum-project.com/p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.png",
  "visibility": "public-by-link",
  "createdAt": "2026-10-11T17:38:33.872Z",
  "etag": "\"8e72fb50886ffd6a1cdef31c54e3596b\"",
  "name": "ana.png",
  "url": "https://files.totalum-project.com/p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.png",
  "size": 48213,
  "id": "p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.png",
  "fileNameId": "p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.png"
}
```

```ts
// a private file: no fileUrl, read it with getSignedUrl
const contract = await totalum.files.upload(pdfBytes, {
  name: 'contract-ana.pdf',
  contentType: 'application/pdf',
  private: true,
});
```

Result:

```ts
{ "key": "pv/acme-crm/01M4P0Q0X79QHFPBKBK2XHFTDG.pdf", "fileName": "contract-ana.pdf", "fileUrl": null, "visibility": "private", … }
```

### `files.getUploadUrl`

```ts
files.getUploadUrl(input: FileUploadUrlInput): Promise<FileUploadUrlOutput>
```

Starts a direct upload for a large file (over 25 MB, or any file you want the browser to send straight to
storage): your server asks for an upload URL, the browser `PUT`s the bytes to it with the returned `headers`,
then your server calls `complete(key)`. The URL lasts 15 minutes and only accepts exactly this type and size.
The largest file is 50 MB on the Free plan, 500 MB on Starter, Builder and Professional, and 1 GB on Enterprise.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`FileUploadUrlInput`](#fileuploadurlinput) | yes | The file's name, type and exact size in bytes, plus its folder and visibility. |
| `input.name` | `string` | yes | The file name users will see. Allowed: at least 1 character. |
| `input.contentType` | `string` | yes | The exact MIME type the browser will send in `content-type`. Allowed: at least 1 character. |
| `input.size` | `number` | yes | The exact size in bytes the browser will send. Allowed: > 0. |
| `input.folder` | `string` | no | A folder name to group files by. |
| `input.private` | `boolean` | no | `true` stores it as a private file (no public URL). |

**Returns** `Promise<FileUploadUrlOutput>` — Where and how to `PUT` the bytes, and the key to pass to `complete()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `uploadUrl` | `string` | yes | The URL to `PUT` the bytes to, valid until `expiresAt`. |
| `key` | `string` | yes | The key the file will have; pass it to `complete()` after the upload. |
| `headers` | `Record<string, string>` | yes | The headers the `PUT` must send exactly (`content-type`, `content-length`). |
| `expiresAt` | `string` | yes | When `uploadUrl` stops working (15 minutes). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `FILE_TOO_LARGE` | 413 | Over the plan's largest file; `details.maxBytes`. |
| `UNSUPPORTED_FILE_TYPE` | 415 | HTML and executable files are never stored. |
| `STORAGE_LIMIT_EXCEEDED` | 429 | The plan's storage is full. |

**Example**

```ts
// server: hand the browser an upload URL
const upload = await totalum.files.getUploadUrl({ name: 'demo.mp4', contentType: 'video/mp4', size: 73400320 });
// browser: await fetch(upload.uploadUrl, { method: 'PUT', headers: upload.headers, body: file });
// server, afterwards:
const stored = await totalum.files.complete(upload.key);
```

Result:

```json
{
  "uploadUrl": "https://2e99a1160c18f1f4e0d589ff6105397e.r2.cloudflarestorage.com/project-files/p/acme-crm/01M4P0Q21EK7JVGY3MXK224DAH.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&…",
  "key": "p/acme-crm/01M4P0Q21EK7JVGY3MXK224DAH.mp4",
  "headers": { "content-type": "video/mp4", "content-length": "73400320" },
  "expiresAt": "2026-10-11T17:53:35.830Z"
}
```

### `files.complete`

```ts
files.complete(key: string): Promise<FileDescriptor>
```

Finishes a direct upload started with `getUploadUrl`: checks the bytes arrived with the declared size and type and
returns the file's descriptor. Calling it again for a finished file returns the same descriptor.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `key` | `string` | yes | The `key` `getUploadUrl` returned. |

**Returns** `Promise<FileDescriptor>` — The stored file's descriptor, the same shape `upload()` returns.

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The file's permanent id: `p/<project>/<id>.<ext>` for a public file, `pv/<project>/<id>.<ext>` for a private one. Pass it to every other files method. |
| `fileName` | `string` | yes | The file name users see and downloads get. |
| `fileSize` | `number` | yes | Size in bytes. |
| `contentType` | `string` | yes | The MIME type the file is served with. |
| `fileUrl` | `string \| null` | yes | The public, permanent, unguessable URL of a public file; `null` for a private file (use `getSignedUrl`). |
| `visibility` | `'public-by-link' \| 'private'` | yes | `public-by-link`: anyone with `fileUrl` can read it. `private`: only through signed URLs or `stream()`. |
| `createdAt` | `string` | yes | When it was stored. |
| `etag` | `string` | no | The storage ETag (changes if the bytes change). |
| `storageClass` | `'Standard' \| 'InfrequentAccess'` | no | The storage tier the file is kept in. |
| `name` | `string` | no | Same as `fileName` (kept for apps written against the first file API). |
| `url` | `string \| null` | no | Same as `fileUrl` (kept for apps written against the first file API). |
| `size` | `number` | no | Same as `fileSize` (kept for apps written against the first file API). |
| `id` | `string` | no | Same as `key` (kept for apps written against the first file API). |
| `fileNameId` | `string` | no | Same as `key` (kept for apps written against the first file API). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `UPLOAD_NOT_COMPLETED` | 409 | Nothing was uploaded to the URL yet, or the bytes did not match the declared size or type (the object is then deleted: start again). |
| `FILE_NOT_FOUND` | 404 | No upload was started for this key. |
| `FILE_KEY_FORBIDDEN` | 403 | The key belongs to another project. |

**Example**

```ts
const stored = await totalum.files.complete('p/acme-crm/01M4P0Q21EK7JVGY3MXK224DAH.mp4');
```

Result:

```ts
{
  "key": "p/acme-crm/01M4P0Q21EK7JVGY3MXK224DAH.mp4",
  "fileName": "demo.mp4",
  "fileSize": 73400320,
  "contentType": "video/mp4",
  "fileUrl": "https://files.totalum-project.com/p/acme-crm/01M4P0Q21EK7JVGY3MXK224DAH.mp4",
  "visibility": "public-by-link",
  "createdAt": "2026-10-11T17:38:35.830Z",
  …
}
```

### `files.list`

```ts
files.list(q?: { name?, folder?, type?, createdFrom?, createdTo?, sizeFrom?, sizeTo?, cursor?, limit? }): Promise<FilesPage>
```

One page of the project's files, newest first, with the project's total storage. Filter by folder, name, type,
creation date and size; fetch the next page with `cursor` set to the previous page's `nextCursor`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `q` | `{ name?, folder?, type?, createdFrom?, createdTo?, sizeFrom?, sizeTo?, cursor?, limit? }` | no | The filters and paging; every field is optional. |
| `q.name` | `string \| number` | no | Only files whose name contains this text. |
| `q.folder` | `string \| number` | no | Only files in this folder (as given to `upload`). |
| `q.type` | `string \| number` | no | Only files whose content type starts with this (`image/`, `application/pdf`). |
| `q.createdFrom` | `string \| number` | no | Only files created at or after this ISO date-time. |
| `q.createdTo` | `string \| number` | no | Only files created at or before this ISO date-time. |
| `q.sizeFrom` | `string \| number` | no | Only files of at least this many bytes. |
| `q.sizeTo` | `string \| number` | no | Only files of at most this many bytes. |
| `q.cursor` | `string \| number` | no | The previous page's `nextCursor`, for the next page. |
| `q.limit` | `string \| number` | no | Files per page, 1–100. Default 100. |

**Returns** `Promise<FilesPage>` — The page of files and the project's totals.

| Field | Type | Always present | Description |
|---|---|---|---|
| `items` | [`FileDescriptor[]`](#filedescriptor) | yes | The files of this page, newest first. |
| `nextCursor` | `string \| null` | yes | Pass it as `cursor` for the next page; `null` on the last page. |
| `totalBytes` | `number` | yes | Bytes stored by the whole project (all files, not only this page). |
| `fileCount` | `number` | yes | Files stored by the whole project. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | A filter has the wrong format (a date that is not ISO, a limit over 100). |

**Example**

```ts
const page = await totalum.files.list({ folder: 'invoices', limit: 2 });
```

Result:

```ts
{
  "items": [
    {
      "key": "p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.pdf",
      "fileName": "invoice-12.pdf",
      "fileSize": 18233,
      "contentType": "application/pdf",
      "fileUrl": "https://files.totalum-project.com/p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.pdf",
      "visibility": "public-by-link",
      "createdAt": "2026-10-11T17:38:33.872Z",
      …
    }
  ],
  "nextCursor": null,
  "totalBytes": 2445409,
  "fileCount": 11
}
```

### `files.get`

```ts
files.get(key: string): Promise<FileDescriptor>
```

The descriptor of one file: check that a stored key still exists, or read its name, size and type.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `key` | `string` | yes | The file's `key`. |

**Returns** `Promise<FileDescriptor>` — The file's descriptor.

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The file's permanent id: `p/<project>/<id>.<ext>` for a public file, `pv/<project>/<id>.<ext>` for a private one. Pass it to every other files method. |
| `fileName` | `string` | yes | The file name users see and downloads get. |
| `fileSize` | `number` | yes | Size in bytes. |
| `contentType` | `string` | yes | The MIME type the file is served with. |
| `fileUrl` | `string \| null` | yes | The public, permanent, unguessable URL of a public file; `null` for a private file (use `getSignedUrl`). |
| `visibility` | `'public-by-link' \| 'private'` | yes | `public-by-link`: anyone with `fileUrl` can read it. `private`: only through signed URLs or `stream()`. |
| `createdAt` | `string` | yes | When it was stored. |
| `etag` | `string` | no | The storage ETag (changes if the bytes change). |
| `storageClass` | `'Standard' \| 'InfrequentAccess'` | no | The storage tier the file is kept in. |
| `name` | `string` | no | Same as `fileName` (kept for apps written against the first file API). |
| `url` | `string \| null` | no | Same as `fileUrl` (kept for apps written against the first file API). |
| `size` | `number` | no | Same as `fileSize` (kept for apps written against the first file API). |
| `id` | `string` | no | Same as `key` (kept for apps written against the first file API). |
| `fileNameId` | `string` | no | Same as `key` (kept for apps written against the first file API). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `FILE_NOT_FOUND` | 404 | No such file (or it was deleted). |
| `FILE_KEY_FORBIDDEN` | 403 | The key belongs to another project. |

**Example**

```ts
const file = await totalum.files.get('p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.txt');
```

Result:

```ts
{
  "key": "p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.txt",
  "fileName": "hello.txt",
  "fileSize": 19,
  "contentType": "text/plain",
  "fileUrl": "https://files.totalum-project.com/p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.txt",
  "visibility": "public-by-link",
  "createdAt": "2026-10-11T17:38:33.872Z",
  …
}
```

### `files.getSignedUrl`

```ts
files.getSignedUrl(key: string, opts?: { expiresInSeconds?, download? }): Promise<string>
```

A temporary link to one file — the way to show or download a private file in the browser. The link works for 15
minutes by default and at most 7 days; anyone holding it can read the file until then, so make it when the page
needs it rather than storing it.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `key` | `string` | yes | The file's `key` (private `pv/…` or public `p/…`). |
| `opts` | `{ expiresInSeconds?, download? }` | no | How long the link works and whether the browser downloads the file. |
| `opts.expiresInSeconds` | `number` | no | Seconds the link works, 1–604800 (7 days). Default 900 (15 minutes). |
| `opts.download` | `boolean` | no | `true` makes the browser download the file under its name instead of showing it. |

**Returns** `Promise<string>` — The signed `https://` URL.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `FILE_NOT_FOUND` | 404 | No such file. |
| `FILE_KEY_FORBIDDEN` | 403 | The key belongs to another project. |
| `VALIDATION_ERROR` | 400 | `expiresInSeconds` is out of range. |

**Example**

```ts
const url = await totalum.files.getSignedUrl(contract.key, { expiresInSeconds: 3600, download: true });
```

Result:

```json
"https://2e99a1160c18f1f4e0d589ff6105397e.r2.cloudflarestorage.com/project-files/pv/acme-crm/01M4P0Q0X79QHFPBKBK2XHFTDG.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&…"
```

### `files.getDownloadUrl`

**Deprecated.** Since 1.0.0: use `getSignedUrl` (the same call).

```ts
files.getDownloadUrl(key: string, opts?: { expiresInSeconds?, download? }): Promise<string>
```

### `files.presign`

```ts
files.presign(keys: string[], opts?: { expiresInSeconds?, download?, ttl? }): Promise<Record<string, string>>
```

Signed links for many files in one call — for a gallery or a table of private files.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `keys` | `string[]` | yes | The files' keys. |
| `opts` | `{ expiresInSeconds?, download?, ttl? }` | no | How long the links work and whether browsers download the files. |
| `opts.expiresInSeconds` | `number` | no | Seconds the links work, at most 604800 (7 days). Default 900 (15 minutes). |
| `opts.download` | `boolean` | no | `true` makes browsers download the files instead of showing them. |
| `opts.ttl` | `number` | no | **Deprecated.** Since 1.0.0: use `expiresInSeconds`. |

**Returns** `Promise<Record<string, string>>` — An object from each key to its signed URL.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `FILE_NOT_FOUND` | 404 | One of the keys does not exist; `details.key` names it. |
| `FILE_KEY_FORBIDDEN` | 403 | A key belongs to another project. |

**Example**

```ts
const urls = await totalum.files.presign(photos.map((p) => p.key), { expiresInSeconds: 600 });
```

Result:

```json
{
  "pv/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.jpg": "https://2e99a1160c18f1f4e0d589ff6105397e.r2.cloudflarestorage.com/project-files/pv/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&…",
  "pv/acme-crm/01M4P0Q0X79QHFPBKBK2XHFTDG.jpg": "https://2e99a1160c18f1f4e0d589ff6105397e.r2.cloudflarestorage.com/project-files/pv/acme-crm/01M4P0Q0X79QHFPBKBK2XHFTDG.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&…"
}
```

### `files.delete`

```ts
files.delete(keys: string | string[]): Promise<void>
```

Deletes one file, or up to 1,000 at once. The public URL stops working within the hour (edge caches expire).
Deleting a list skips keys that are already gone; deleting a single key that does not exist throws.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `keys` | `string \| string[]` | yes | One key, or an array of up to 1,000 keys. |

**Returns** `Promise<void>` — Nothing once the files are deleted.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `FILE_NOT_FOUND` | 404 | The single key does not exist. |
| `FILE_KEY_FORBIDDEN` | 403 | A key belongs to another project. |
| `VALIDATION_ERROR` | 400 | More than 1,000 keys, or an empty list. |

**Example**

```ts
await totalum.files.delete(user.avatar.key);
await totalum.files.delete(oldInvoices.map((f) => f.key));
```

### `files.stream`

```ts
files.stream(key: string, opts?: { range? }): Promise<Response>
```

The file's bytes as a `Response` (private files included), to read on the server or to return from a route that
serves the file after your own permission check. Supports HTTP ranges for partial reads (video seeking).

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `key` | `string` | yes | The file's `key`. |
| `opts` | `{ range? }` | no | A byte range to read. |
| `opts.range` | `string` | no | An HTTP `Range` header value, such as `bytes=0-1023`; the answer is then `206` with those bytes. |

**Returns** `Promise<Response>` — The fetch `Response` with the file's bytes, `content-type` and length.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `FILE_NOT_FOUND` | 404 | No such file. |
| `FILE_KEY_FORBIDDEN` | 403 | The key belongs to another project. |

**Examples**

```ts
// src/app/api/contracts/[id]/route.ts — serve a private file to its owner only
const contract = await getContractForCurrentUser(params.id); // your own permission check
const res = await totalum.files.stream(contract.fileKey);
return new Response(res.body, { headers: { 'content-type': res.headers.get('content-type') ?? 'application/pdf' } });
```

```ts
const res = await totalum.files.stream('p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.txt', { range: 'bytes=0-4' });
console.log(res.status, await res.text());
```

Result:

```ts
206 Hello
```

### `files.url`

```ts
files.url(key: string): string
```

The URL of a file, computed locally (no request): the public URL of a public `p/…` key, or for a private `pv/…`
key the Totalum API URL that serves it only with the project key (a server-side fetch). To show a private file in
a browser use `getSignedUrl` instead.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `key` | `string` | yes | The file's `key`. |

**Returns** `string` — The file's URL.

**Example**

```ts
totalum.files.url('p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.png');
```

Result:

```json
"https://files.totalum-project.com/p/acme-crm/01M4P0PZXJ0DPQM7R20QVZ0EKC.png"
```

### `FileDescriptor`

A stored file, as every files call returns it. Save it as-is in the row's file column (a JSON column) and render `fileUrl` (public) or a signed URL (private).

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The file's permanent id: `p/<project>/<id>.<ext>` for a public file, `pv/<project>/<id>.<ext>` for a private one. Pass it to every other files method. |
| `fileName` | `string` | yes | The file name users see and downloads get. |
| `fileSize` | `number` | yes | Size in bytes. |
| `contentType` | `string` | yes | The MIME type the file is served with. |
| `fileUrl` | `string \| null` | yes | The public, permanent, unguessable URL of a public file; `null` for a private file (use `getSignedUrl`). |
| `visibility` | `'public-by-link' \| 'private'` | yes | `public-by-link`: anyone with `fileUrl` can read it. `private`: only through signed URLs or `stream()`. |
| `createdAt` | `string` | yes | When it was stored. |
| `etag` | `string` | no | The storage ETag (changes if the bytes change). |
| `storageClass` | `'Standard' \| 'InfrequentAccess'` | no | The storage tier the file is kept in. |
| `name` | `string` | no | Same as `fileName` (kept for apps written against the first file API). |
| `url` | `string \| null` | no | Same as `fileUrl` (kept for apps written against the first file API). |
| `size` | `number` | no | Same as `fileSize` (kept for apps written against the first file API). |
| `id` | `string` | no | Same as `key` (kept for apps written against the first file API). |
| `fileNameId` | `string` | no | Same as `key` (kept for apps written against the first file API). |

### `FilesListQuery`

The filters of `files.list()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `folder` | `string` | no | Only files in this folder. |
| `type` | `string` | no | Only files whose content type starts with this. |
| `name` | `string` | no | Only files whose name contains this text. |
| `createdFrom` | `string` | no | Only files created at or after this time. Allowed: ISO 8601 date-time. |
| `createdTo` | `string` | no | Only files created at or before this time. Allowed: ISO 8601 date-time. |
| `sizeFrom` | `number` | no | Only files of at least this many bytes. Allowed: ≥ 0. |
| `sizeTo` | `number` | no | Only files of at most this many bytes. Allowed: ≥ 0. |
| `cursor` | `string` | no | The previous page's `nextCursor`. |
| `limit` | `number` | no | Files per page. Default 100. Allowed: ≥ 1, ≤ 100. |

### `FilesPage`

One page of `files.list()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `items` | [`FileDescriptor[]`](#filedescriptor) | yes | The files of this page, newest first. |
| `nextCursor` | `string \| null` | yes | Pass it as `cursor` for the next page; `null` on the last page. |
| `totalBytes` | `number` | yes | Bytes stored by the whole project (all files, not only this page). |
| `fileCount` | `number` | yes | Files stored by the whole project. |

### `FileUploadUrlInput`

What `files.getUploadUrl()` needs to know about the file before it is uploaded.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | `string` | yes | The file name users will see. Allowed: at least 1 character. |
| `contentType` | `string` | yes | The exact MIME type the browser will send in `content-type`. Allowed: at least 1 character. |
| `size` | `number` | yes | The exact size in bytes the browser will send. Allowed: > 0. |
| `folder` | `string` | no | A folder name to group files by. |
| `private` | `boolean` | no | `true` stores it as a private file (no public URL). |

### `FileUploadUrlOutput`

Where to upload the bytes of a direct upload, from `files.getUploadUrl()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `uploadUrl` | `string` | yes | The URL to `PUT` the bytes to, valid until `expiresAt`. |
| `key` | `string` | yes | The key the file will have; pass it to `complete()` after the upload. |
| `headers` | `Record<string, string>` | yes | The headers the `PUT` must send exactly (`content-type`, `content-length`). |
| `expiresAt` | `string` | yes | When `uploadUrl` stops working (15 minutes). |

### `TotalumFilesOptions`

The options of `totalumFiles()`: the client options plus the public files host `files.url()` builds URLs on.

| Name | Type | Required | Description |
|---|---|---|---|
| `filesUrl` | `string` | no | The public files host `files.url()` uses; default `TOTALUM_FILES_URL`, else `https://files.totalum-project.com`. |
| `key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

### `UploadOptions`

How `files.upload()` stores a file: its name, type, visibility and folder.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | `string` | no | The file name users see and downloads get (`invoice-12.pdf`). Default: the `File`'s own name, else `file`. |
| `contentType` | `string` | no | The MIME type (`image/png`, `application/pdf`). Default: the `Blob`'s type, else `application/octet-stream` — pass it for a string or bytes so browsers display the file. HTML and executables are refused. |
| `private` | `boolean` | no | `true` stores the file under the private prefix: no public URL, read it through `getSignedUrl`. |
| `folder` | `string` | no | A folder name to group files by (`avatars`, `invoices/2026`); `files.list({ folder })` filters on it. |

## `totalum-sdk/payments`

Take payments and subscriptions with Stripe on the project owner's own Stripe account (Stripe Connect), without
handling card data: create a hosted Checkout, send the buyer to its `url`, and read the result back. The owner
connects Stripe once with `onboardingLink()`. The preview and the sandbox use Stripe test mode; the published site
uses live mode once the owner switched it on. Payment events reach the app's own `POST /api/webhooks/stripe` with no
setup, signed by Totalum (verify them with `totalum.webhooks.verify`).

```ts
const { url } = await totalum.payments.createCheckout({
  mode: 'payment',
  lineItems: [{ name: 'Pro plan', unitAmount: 1900, currency: 'eur', quantity: 1 }],
  successUrl: 'https://acme.totalum.app/thanks?session_id={CHECKOUT_SESSION_ID}',
  cancelUrl: 'https://acme.totalum.app/pricing',
});
```

| Method or type | What it does |
|---|---|
| [`totalumPayments`](#totalumpayments) | Creates the `payments` namespace (the same object as `totalum.payments`, scope `payments`): Stripe Connect charges on the project's own connected account. |
| [`payments.getStatus`](#paymentsgetstatus) | Whether the project's Stripe account can take payments right now: use it to show "Connect Stripe" to the owner or to hide a Buy button while payments are off. |
| [`payments.onboardingLink`](#paymentsonboardinglink) | A single-use link to Stripe's hosted onboarding, where the project owner connects (or updates) the Stripe account the app's payments go to. |
| [`payments.createCheckout`](#paymentscreatecheckout) | Creates a Stripe Checkout page for a one-off payment (`payment`), a subscription (`subscription`) or saving a card for later (`setup`), and returns its URL: redirect the buyer there. |
| [`payments.getCheckout`](#paymentsgetcheckout) | Reads a Checkout Session back, to confirm a payment on the page Stripe returns the buyer to (`successUrl` with `?session_id={CHECKOUT_SESSION_ID}`): when `paymentStatus` is `'paid'`, mark the order paid — no webhook needed. |
| [`payments.customerPortalLink`](#paymentscustomerportallink) | A link to Stripe's hosted customer portal, where a subscriber updates their card, downloads invoices or cancels their subscription. |
| [`payments.subscriptions.get`](#paymentssubscriptionsget) | Reads a subscription: give access while `status` is `'active'` or `'trialing'`; `currentPeriodEnd` is the next renewal (or the end of access when `cancelAtPeriodEnd` is true). |
| [`payments.subscriptions.update`](#paymentssubscriptionsupdate) | Cancels a subscription at the end of the paid period (`cancelAtPeriodEnd: true`, the usual "Cancel" button: the member keeps access until then), or undoes that (`false`). |
| [`payments.subscriptions.cancel`](#paymentssubscriptionscancel) | Cancels a subscription immediately: no more charges, `status: 'canceled'` at once, access ends now. |
| [`payments.refunds.create`](#paymentsrefundscreate) | Refunds a payment, in full or in part. |
| [`CheckoutInput`](#checkoutinput) | What a Checkout page sells and where it sends the buyer, for `payments.createCheckout()`. |
| [`CheckoutOutput`](#checkoutoutput) | The Checkout page created by `payments.createCheckout()`. |
| [`CheckoutSession`](#checkoutsession) | A Checkout Session read back with `payments.getCheckout()`. |
| [`CustomerPortalLinkInput`](#customerportallinkinput) | The input of `payments.customerPortalLink()`. |
| [`CustomerPortalLinkOutput`](#customerportallinkoutput) | The answer of `payments.customerPortalLink()`. |
| [`OnboardingLinkInput`](#onboardinglinkinput) | The input of `payments.onboardingLink()`. |
| [`OnboardingLinkOutput`](#onboardinglinkoutput) | The answer of `payments.onboardingLink()`. |
| [`PaymentsStatus`](#paymentsstatus) | The state of the project's Stripe account, from `payments.getStatus()`. |
| [`RefundInput`](#refundinput) | The input of `payments.refunds.create()`. |
| [`RefundOutput`](#refundoutput) | A refund, from `payments.refunds.create()`. |
| [`Subscription`](#subscription) | A Stripe subscription, from `payments.subscriptions.*`. |
| [`SubscriptionUpdateInput`](#subscriptionupdateinput) | The change `payments.subscriptions.update()` makes. |

### `totalumPayments`

```ts
totalumPayments(options?: TotalumClientOptions): TotalumPayments
```

Creates the `payments` namespace (the same object as `totalum.payments`, scope `payments`): Stripe Connect charges
on the project's own connected account. Card data never passes through here — send buyers to the hosted Checkout
`url`; card fields are refused with `CARD_DATA_NOT_ACCEPTED`. Stripe test mode on the preview and in the sandbox;
live mode only on the published site, once the owner onboarded and switched live on. Payment events
(`payments.<Stripe type>`) reach the app's own `POST /api/webhooks/stripe` with no setup — test mode's on the
preview, live mode's on the published site — signed by Totalum: verify them with `totalum.webhooks.verify`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumPayments`](#totalumpayments) — The `payments` namespace: the methods below.

**Example**

```ts
import { totalumPayments } from 'totalum-sdk/payments';

const payments = totalumPayments();
const status = await payments.getStatus();
```

The namespace's type is exported as `TotalumPayments` (`import type { TotalumPayments } from 'totalum-sdk/payments'`).

### `payments.getStatus`

```ts
payments.getStatus(): Promise<PaymentsStatus>
```

Whether the project's Stripe account can take payments right now: use it to show "Connect Stripe" to the owner or
to hide a Buy button while payments are off. Free.

**Returns** `Promise<PaymentsStatus>` — The state of the connected Stripe account in the current mode (test on the preview, live on the
published site).

| Field | Type | Always present | Description |
|---|---|---|---|
| `enabled` | `boolean` | yes | Whether payments can be used in the current mode. |
| `mode` | `'connect' \| 'own_key'` | yes | `connect`: payments run on the owner's Stripe account connected through Totalum. `own_key`: the app uses its own Stripe keys. |
| `accountId` | `string \| null` | yes | The connected Stripe account id (`acct_…`), or `null` before the first connection. |
| `livemode` | `boolean` | yes | Whether this is the live (real money) account; `false` on the preview and in the sandbox. |
| `chargesEnabled` | `boolean` | yes | Whether the account can take payments now. |
| `payoutsEnabled` | `boolean` | yes | Whether Stripe can pay out to the owner's bank account. |
| `detailsSubmitted` | `boolean` | yes | Whether the owner finished Stripe's onboarding form. |
| `requirements` | `{ currentlyDue, pastDue, eventuallyDue }` | yes | What Stripe still needs from the owner. |
| `requirements.currentlyDue` | `string[]` | yes | Information Stripe needs now to keep the account working. |
| `requirements.pastDue` | `string[]` | yes | Information that is overdue: charges or payouts may be paused until it is given. |
| `requirements.eventuallyDue` | `string[]` | yes | Information Stripe will need later. |
| `liveEnabled` | `boolean` | yes | Whether the owner switched live payments on for the published site. |
| `disconnectedAt` | `string \| null` | no | When the owner disconnected the Stripe account, or `null`. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `PAYMENTS_NOT_ENABLED` | 403 | Payments are not configured for this environment. |

**Example**

```ts
const status = await totalum.payments.getStatus();
if (!status.chargesEnabled) return <p>Payments are not set up yet.</p>;
```

Result:

```json
{
  "enabled": true,
  "mode": "connect",
  "accountId": "acct_1QZ3kPQm4n8Xa2Lb",
  "livemode": true,
  "chargesEnabled": true,
  "payoutsEnabled": true,
  "detailsSubmitted": true,
  "requirements": { "currentlyDue": [], "pastDue": [], "eventuallyDue": [] },
  "liveEnabled": true,
  "disconnectedAt": null
}
```

### `payments.onboardingLink`

```ts
payments.onboardingLink(input: OnboardingLinkInput): Promise<OnboardingLinkOutput>
```

A single-use link to Stripe's hosted onboarding, where the project owner connects (or updates) the Stripe account
the app's payments go to. Only for the owner: the coding agent's sandbox key is refused. The live account can
only be connected once the project is published.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`OnboardingLinkInput`](#onboardinglinkinput) | yes | Where Stripe sends the owner back, and the account's country. |
| `input.returnUrl` | `string` | yes | Where Stripe sends the owner after onboarding. Allowed: a URL. |
| `input.refreshUrl` | `string` | yes | Where Stripe sends the owner if the link expired or was already used; make a new link there. Allowed: a URL. |
| `input.type` | `'account_onboarding' \| 'account_update'` | no | `account_onboarding` (default) to connect the account, `account_update` to change its details. |
| `input.country` | `string` | no | The two-letter country of the owner's business (`ES`, `US`); required the first time the live account is created. |

**Returns** `Promise<OnboardingLinkOutput>` — The onboarding URL to redirect the owner to, and when it expires.

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | `string` | yes | The Stripe onboarding page to redirect the owner to (single use). |
| `expiresAt` | `string` | yes | When the link stops working. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `PAYMENTS_PUBLISH_FIRST` | 409 | The project is not published yet: publish it, then connect Stripe. |
| `MISSING_INPUT` | 400 | `country` is needed to create the live Stripe account. |
| `FORBIDDEN_SCOPE` | 403 | Called with the agent's sandbox key: onboarding links are for the project owner. |
| `STRIPE_ERROR` | 400 | Stripe refused the request; `details.stripeCode` says why. |

**Example**

```ts
const { url } = await totalum.payments.onboardingLink({
  returnUrl: 'https://acme.totalum.app/admin/payments?connected=1',
  refreshUrl: 'https://acme.totalum.app/admin/payments',
  country: 'ES',
});
```

Result:

```json
{ "url": "https://connect.stripe.com/setup/e/acct_1QZ3kPQm4n8Xa2Lb/Xq3VbD9yPpLk", "expiresAt": "2026-10-11T18:05:00.000Z" }
```

### `payments.createCheckout`

```ts
payments.createCheckout(input: CheckoutInput): Promise<CheckoutOutput>
```

Creates a Stripe Checkout page for a one-off payment (`payment`), a subscription (`subscription`) or saving a card
for later (`setup`), and returns its URL: redirect the buyer there. Put your own ids (order, user) in `metadata`
to find them again on the return page or in the webhook. A subscription needs a recurring price: an inline item
with `recurring: { interval: 'month' }`, or a `priceId`; its `metadata` is also set on the subscription.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`CheckoutInput`](#checkoutinput) | yes | What is sold, the mode, and where Stripe sends the buyer afterwards. |
| `input.mode` | `'payment' \| 'subscription' \| 'setup'` | yes | `payment`: a one-off payment. `subscription`: a recurring payment (needs a recurring price). `setup`: save a card without charging. |
| `input.successUrl` | `string` | yes | Where Stripe sends the buyer after paying. Add `?session_id={CHECKOUT_SESSION_ID}` to read the session back with `getCheckout()`. Allowed: a URL. |
| `input.cancelUrl` | `string` | yes | Where Stripe sends the buyer if they go back without paying. Allowed: a URL. |
| `input.lineItems` | `({ priceId, quantity } \| { name, unitAmount, currency, quantity, recurring? })[]` | no | What is sold: each item is either a Stripe `priceId` with a quantity, or an inline item (`name`, `unitAmount`, `currency`, `quantity`, optional `recurring`). Required for `payment` and `subscription`. Allowed: at least 1 item. |
| `input.lineItems.priceId` | `string` | no | A Price id (`price_…`) of the connected Stripe account, instead of an inline item. Allowed: at least 1 character. |
| `input.lineItems.quantity` | `number` | yes | How many units. Allowed: > 0. |
| `input.lineItems.name` | `string` | no | The product name shown on the Checkout page (inline item). Allowed: at least 1 character. |
| `input.lineItems.unitAmount` | `number` | no | The price of one unit in the smallest currency unit: cents for EUR/USD (`1900` = 19.00). Allowed: > 0. |
| `input.lineItems.currency` | `string` | no | Three-letter ISO currency code, lower case (`eur`, `usd`). Allowed: exactly 3 characters. |
| `input.lineItems.recurring` | `{ interval, intervalCount? }` | no | Makes the inline item a subscription price: how often it renews. |
| `input.lineItems.recurring.interval` | `'day' \| 'month' \| 'week' \| 'year'` | yes | The billing period: `day`, `week`, `month` or `year`. |
| `input.lineItems.recurring.intervalCount` | `number` | no | How many periods between charges (`3` with `month` = quarterly). Default 1. Allowed: > 0. |
| `input.currency` | `string` | no | The currency of a `setup` session (three-letter code, lower case). Allowed: exactly 3 characters. |
| `input.customerEmail` | `string` | no | Pre-fills the buyer's e-mail on the Checkout page. Allowed: an e-mail address. |
| `input.customerId` | `string` | no | An existing Stripe customer (`cus_…`) to charge, so their saved cards appear. |
| `input.metadata` | `Record<string, string>` | no | Your own key/value strings (order id, user id). Returned by `getCheckout()`, in the webhook events and, for a subscription, set on the subscription. |
| `input.allowPromotionCodes` | `boolean` | no | `true` shows a promotion-code field. |
| `input.automaticTax` | `boolean` | no | `true` lets Stripe Tax compute tax (the owner must have Stripe Tax set up). |
| `input.trialDays` | `number` | no | Free trial days before the first charge of a subscription. Allowed: > 0. |
| `input.clientReferenceId` | `string` | no | One more id of yours, returned by `getCheckout()`. |

**Returns** `Promise<CheckoutOutput>` — The Checkout Session id and the URL to redirect the buyer to.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The Checkout Session id (`cs_test_…` or `cs_live_…`). |
| `url` | `string` | yes | The Stripe Checkout page: redirect the buyer here. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `CARD_DATA_NOT_ACCEPTED` | 400 | The request contained card fields: card data is only ever typed on Stripe's page. |
| `PAYMENTS_LIVE_ONBOARDING_REQUIRED` | 403 | Live mode, but the owner has not finished Stripe onboarding. |
| `PAYMENTS_ACCOUNT_DISCONNECTED` | 403 | The owner disconnected the Stripe account: they must connect it again. |
| `PAYMENTS_NOT_ENABLED` | 403 | Payments are not configured for this environment. |
| `STRIPE_ERROR` | 400 | Stripe refused the session (an unknown `priceId`, a bad currency); `details.stripeCode`. |
| `STRIPE_RATE_LIMITED` | 429 | Stripe's own rate limit; retry shortly. |

**Examples**

```ts
// src/app/api/checkout/route.ts
const { url } = await totalum.payments.createCheckout({
  mode: 'payment',
  lineItems: [{ name: 'Concert ticket', unitAmount: 2500, currency: 'eur', quantity: 2 }],
  successUrl: 'https://acme.totalum.app/orders/ord_42?session_id={CHECKOUT_SESSION_ID}',
  cancelUrl: 'https://acme.totalum.app/cart',
  customerEmail: 'ana@example.com',
  metadata: { orderId: 'ord_42' },
});
return Response.redirect(url, 303);
```

Result:

```json
{ "id": "cs_test_a1B2c3D4e5F6g7H8i9J0", "url": "https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4e5F6g7H8i9J0#fid…" }
```

```ts
// a monthly subscription with a 14-day trial
const { url } = await totalum.payments.createCheckout({
  mode: 'subscription',
  lineItems: [{ name: 'Pro', unitAmount: 1900, currency: 'eur', quantity: 1, recurring: { interval: 'month' } }],
  trialDays: 14,
  successUrl: 'https://acme.totalum.app/billing?session_id={CHECKOUT_SESSION_ID}',
  cancelUrl: 'https://acme.totalum.app/pricing',
  metadata: { userId: user.id },
});
```

### `payments.getCheckout`

```ts
payments.getCheckout(sessionId: string): Promise<CheckoutSession>
```

Reads a Checkout Session back, to confirm a payment on the page Stripe returns the buyer to (`successUrl` with
`?session_id={CHECKOUT_SESSION_ID}`): when `paymentStatus` is `'paid'`, mark the order paid — no webhook needed. A
subscription checkout also gives `subscriptionId` and `customerId`: store both on the member.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `sessionId` | `string` | yes | The `session_id` Stripe added to `successUrl` (`cs_test_…` or `cs_live_…`). |

**Returns** `Promise<CheckoutSession>` — The session's status, amount, your `metadata` and the ids it created.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The Checkout Session id. |
| `status` | `'expired' \| 'open' \| 'complete'` | yes | `open`: not finished. `complete`: the buyer finished. `expired`: abandoned (sessions expire after 24 h). |
| `paymentStatus` | `'paid' \| 'unpaid' \| 'no_payment_required'` | yes | `paid`: the money was taken (mark the order paid). `unpaid`: not yet (some payment methods confirm later). `no_payment_required`: nothing to pay (a `setup` session or a 100 % discount). |
| `amountTotal` | `number \| null` | yes | The total charged, in the smallest currency unit (`5000` = 50.00). |
| `currency` | `string \| null` | yes | The currency, lower case. |
| `metadata` | `Record<string, string>` | yes | The `metadata` you passed to `createCheckout()`. |
| `paymentIntentId` | `string \| null` | yes | The payment (`pi_…`) of a `payment` session; pass it to `refunds.create()`. |
| `customerEmail` | `string \| null` | yes | The e-mail the buyer typed. |
| `clientReferenceId` | `string \| null` | yes | The `clientReferenceId` you passed. |
| `customerId` | `string \| null` | yes | The Stripe customer (`cus_…`) of a subscription or saved card; store it for `customerPortalLink()`. |
| `subscriptionId` | `string \| null` | yes | The subscription (`sub_…`) a `subscription` session created; store it to check access later. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | Not a Checkout Session id. |
| `STRIPE_ERROR` | 404 | No such session on this project's Stripe account. |

**Example**

```ts
// src/app/orders/[id]/page.tsx
const session = await totalum.payments.getCheckout(searchParams.session_id);
if (session.paymentStatus === 'paid') await markOrderPaid(session.metadata.orderId);
```

Result:

```json
{
  "id": "cs_test_a1B2c3D4e5F6g7H8i9J0",
  "status": "complete",
  "paymentStatus": "paid",
  "amountTotal": 5000,
  "currency": "eur",
  "metadata": { "orderId": "ord_42" },
  "paymentIntentId": "pi_3QZ4mNPQm4n8Xa2L0x1y2z3a",
  "customerEmail": "ana@example.com",
  "clientReferenceId": null,
  "customerId": null,
  "subscriptionId": null
}
```

### `payments.customerPortalLink`

```ts
payments.customerPortalLink(input: CustomerPortalLinkInput): Promise<CustomerPortalLinkOutput>
```

A link to Stripe's hosted customer portal, where a subscriber updates their card, downloads invoices or cancels
their subscription. Show it as "Manage billing".

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`CustomerPortalLinkInput`](#customerportallinkinput) | yes | The Stripe customer and where to send them back. |
| `input.customerId` | `string` | yes | The Stripe customer (`cus_…`), from `getCheckout()`. Allowed: at least 1 character. |
| `input.returnUrl` | `string` | yes | Where the portal's back link goes. Allowed: a URL. |

**Returns** `Promise<CustomerPortalLinkOutput>` — The portal URL to redirect the customer to.

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | `string` | yes | The portal page to redirect the customer to. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `STRIPE_ERROR` | 404 | No such customer on this project's Stripe account. |
| `PAYMENTS_LIVE_ONBOARDING_REQUIRED` | 403 | Live mode, but the owner has not finished Stripe onboarding. |

**Example**

```ts
const { url } = await totalum.payments.customerPortalLink({
  customerId: member.stripeCustomerId,
  returnUrl: 'https://acme.totalum.app/account',
});
```

Result:

```json
{ "url": "https://billing.stripe.com/p/session/test_YWNjdF8xUVozaw…" }
```

### `payments.subscriptions.get`

```ts
payments.subscriptions.get(subscriptionId: string): Promise<Subscription>
```

Reads a subscription: give access while `status` is `'active'` or `'trialing'`; `currentPeriodEnd` is the next
renewal (or the end of access when `cancelAtPeriodEnd` is true).

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `subscriptionId` | `string` | yes | The subscription id (`sub_…`) from `getCheckout()` or a payment webhook. |

**Returns** `Promise<Subscription>` — The subscription's status, renewal date and metadata.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The subscription id (`sub_…`). |
| `status` | `'unpaid' \| 'active' \| 'paused' \| 'incomplete' \| 'incomplete_expired' \| 'trialing' \| 'past_due' \| 'canceled'` | yes | Give access while `active` or `trialing`. `past_due`/`unpaid`: a renewal failed. `canceled`: ended. `incomplete`/`incomplete_expired`: the first payment did not go through. `paused`: a trial ended without a payment method. |
| `customerId` | `string` | yes | The Stripe customer (`cus_…`). |
| `cancelAtPeriodEnd` | `boolean` | yes | `true` when it ends at `currentPeriodEnd` instead of renewing. |
| `currentPeriodEnd` | `string \| null` | yes | When the paid period ends: the next renewal, or the end of access when `cancelAtPeriodEnd`. |
| `canceledAt` | `string \| null` | yes | When it was canceled, or `null`. |
| `metadata` | `Record<string, string>` | yes | The `metadata` given to the Checkout that created it. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | Not a subscription id. |
| `STRIPE_ERROR` | 404 | No such subscription on this project's Stripe account. |

**Example**

```ts
const sub = await totalum.payments.subscriptions.get(member.subscriptionId);
const active = sub.status === 'active' || sub.status === 'trialing';
```

Result:

```json
{
  "id": "sub_1QZ5aBPQm4n8Xa2LcD3eF4gH",
  "status": "active",
  "customerId": "cus_R2x9YpL0aBcDeF",
  "cancelAtPeriodEnd": false,
  "currentPeriodEnd": "2026-11-11T17:40:00.000Z",
  "canceledAt": null,
  "metadata": { "userId": "user_42" }
}
```

### `payments.subscriptions.update`

```ts
payments.subscriptions.update(subscriptionId: string, input: SubscriptionUpdateInput): Promise<Subscription>
```

Cancels a subscription at the end of the paid period (`cancelAtPeriodEnd: true`, the usual "Cancel" button: the
member keeps access until then), or undoes that (`false`).

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `subscriptionId` | `string` | yes | The subscription id (`sub_…`). |
| `input` | [`SubscriptionUpdateInput`](#subscriptionupdateinput) | yes | Whether it ends at the end of the period. |
| `input.cancelAtPeriodEnd` | `boolean` | yes | `true`: end the subscription when the paid period ends. `false`: keep renewing (undo a cancellation). |

**Returns** `Promise<Subscription>` — The updated subscription.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The subscription id (`sub_…`). |
| `status` | `'unpaid' \| 'active' \| 'paused' \| 'incomplete' \| 'incomplete_expired' \| 'trialing' \| 'past_due' \| 'canceled'` | yes | Give access while `active` or `trialing`. `past_due`/`unpaid`: a renewal failed. `canceled`: ended. `incomplete`/`incomplete_expired`: the first payment did not go through. `paused`: a trial ended without a payment method. |
| `customerId` | `string` | yes | The Stripe customer (`cus_…`). |
| `cancelAtPeriodEnd` | `boolean` | yes | `true` when it ends at `currentPeriodEnd` instead of renewing. |
| `currentPeriodEnd` | `string \| null` | yes | When the paid period ends: the next renewal, or the end of access when `cancelAtPeriodEnd`. |
| `canceledAt` | `string \| null` | yes | When it was canceled, or `null`. |
| `metadata` | `Record<string, string>` | yes | The `metadata` given to the Checkout that created it. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | Not a subscription id. |
| `STRIPE_ERROR` | 404 | No such subscription on this project's Stripe account. |

**Example**

```ts
const sub = await totalum.payments.subscriptions.update(member.subscriptionId, { cancelAtPeriodEnd: true });
```

Result:

```json
{
  "id": "sub_1QZ5aBPQm4n8Xa2LcD3eF4gH",
  "status": "active",
  "customerId": "cus_R2x9YpL0aBcDeF",
  "cancelAtPeriodEnd": true,
  "currentPeriodEnd": "2026-11-11T17:40:00.000Z",
  "canceledAt": null,
  "metadata": { "userId": "user_42" }
}
```

### `payments.subscriptions.cancel`

```ts
payments.subscriptions.cancel(subscriptionId: string): Promise<Subscription>
```

Cancels a subscription immediately: no more charges, `status: 'canceled'` at once, access ends now. To let the
member keep the period they paid for, use `update(id, { cancelAtPeriodEnd: true })` instead.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `subscriptionId` | `string` | yes | The subscription id (`sub_…`). |

**Returns** `Promise<Subscription>` — The canceled subscription.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The subscription id (`sub_…`). |
| `status` | `'unpaid' \| 'active' \| 'paused' \| 'incomplete' \| 'incomplete_expired' \| 'trialing' \| 'past_due' \| 'canceled'` | yes | Give access while `active` or `trialing`. `past_due`/`unpaid`: a renewal failed. `canceled`: ended. `incomplete`/`incomplete_expired`: the first payment did not go through. `paused`: a trial ended without a payment method. |
| `customerId` | `string` | yes | The Stripe customer (`cus_…`). |
| `cancelAtPeriodEnd` | `boolean` | yes | `true` when it ends at `currentPeriodEnd` instead of renewing. |
| `currentPeriodEnd` | `string \| null` | yes | When the paid period ends: the next renewal, or the end of access when `cancelAtPeriodEnd`. |
| `canceledAt` | `string \| null` | yes | When it was canceled, or `null`. |
| `metadata` | `Record<string, string>` | yes | The `metadata` given to the Checkout that created it. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | Not a subscription id. |
| `STRIPE_ERROR` | 404 | No such subscription on this project's Stripe account. |

**Example**

```ts
const sub = await totalum.payments.subscriptions.cancel('sub_1QZ5aBPQm4n8Xa2LcD3eF4gH');
```

Result:

```json
{
  "id": "sub_1QZ5aBPQm4n8Xa2LcD3eF4gH",
  "status": "canceled",
  "customerId": "cus_R2x9YpL0aBcDeF",
  "cancelAtPeriodEnd": false,
  "currentPeriodEnd": "2026-11-11T17:40:00.000Z",
  "canceledAt": "2026-10-11T18:02:11.000Z",
  "metadata": { "userId": "user_42" }
}
```

### `payments.refunds.create`

```ts
payments.refunds.create(input: RefundInput): Promise<RefundOutput>
```

Refunds a payment, in full or in part. The buyer gets the money back on their card in a few days.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`RefundInput`](#refundinput) | yes | The payment to refund and, for a partial refund, how much. |
| `input.paymentIntentId` | `string` | yes | The payment to refund (`pi_…`), from `getCheckout()` or a payment webhook. Allowed: at least 1 character. |
| `input.amount` | `number` | no | For a partial refund, the amount in the smallest currency unit. Default: everything not yet refunded. Allowed: > 0. |
| `input.reason` | `'duplicate' \| 'fraudulent' \| 'requested_by_customer'` | no | Why, as Stripe records it: `duplicate`, `fraudulent` or `requested_by_customer`. |

**Returns** `Promise<RefundOutput>` — The refund's id, status and amount.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The refund id (`re_…`). |
| `status` | `string` | yes | Stripe's status: `succeeded`, `pending`, `requires_action`, `failed` or `canceled`. |
| `amount` | `number` | yes | The refunded amount in the smallest currency unit. |
| `currency` | `string` | yes | The currency, lower case. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `STRIPE_ERROR` | 400 | Stripe refused it (already refunded, an amount above what is left); `details.stripeCode`. |
| `PAYMENTS_LIVE_ONBOARDING_REQUIRED` | 403 | Live mode, but the owner has not finished Stripe onboarding. |

**Example**

```ts
const refund = await totalum.payments.refunds.create({
  paymentIntentId: 'pi_3QZ4mNPQm4n8Xa2L0x1y2z3a',
  amount: 2500,
  reason: 'requested_by_customer',
});
```

Result:

```json
{ "id": "re_3QZ6bCPQm4n8Xa2L1k2l3m4n", "status": "succeeded", "amount": 2500, "currency": "eur" }
```

### `CheckoutInput`

What a Checkout page sells and where it sends the buyer, for `payments.createCheckout()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `mode` | `'payment' \| 'subscription' \| 'setup'` | yes | `payment`: a one-off payment. `subscription`: a recurring payment (needs a recurring price). `setup`: save a card without charging. |
| `successUrl` | `string` | yes | Where Stripe sends the buyer after paying. Add `?session_id={CHECKOUT_SESSION_ID}` to read the session back with `getCheckout()`. Allowed: a URL. |
| `cancelUrl` | `string` | yes | Where Stripe sends the buyer if they go back without paying. Allowed: a URL. |
| `lineItems` | `({ priceId, quantity } \| { name, unitAmount, currency, quantity, recurring? })[]` | no | What is sold: each item is either a Stripe `priceId` with a quantity, or an inline item (`name`, `unitAmount`, `currency`, `quantity`, optional `recurring`). Required for `payment` and `subscription`. Allowed: at least 1 item. |
| `lineItems.priceId` | `string` | no | A Price id (`price_…`) of the connected Stripe account, instead of an inline item. Allowed: at least 1 character. |
| `lineItems.quantity` | `number` | yes | How many units. Allowed: > 0. |
| `lineItems.name` | `string` | no | The product name shown on the Checkout page (inline item). Allowed: at least 1 character. |
| `lineItems.unitAmount` | `number` | no | The price of one unit in the smallest currency unit: cents for EUR/USD (`1900` = 19.00). Allowed: > 0. |
| `lineItems.currency` | `string` | no | Three-letter ISO currency code, lower case (`eur`, `usd`). Allowed: exactly 3 characters. |
| `lineItems.recurring` | `{ interval, intervalCount? }` | no | Makes the inline item a subscription price: how often it renews. |
| `lineItems.recurring.interval` | `'day' \| 'month' \| 'week' \| 'year'` | yes | The billing period: `day`, `week`, `month` or `year`. |
| `lineItems.recurring.intervalCount` | `number` | no | How many periods between charges (`3` with `month` = quarterly). Default 1. Allowed: > 0. |
| `currency` | `string` | no | The currency of a `setup` session (three-letter code, lower case). Allowed: exactly 3 characters. |
| `customerEmail` | `string` | no | Pre-fills the buyer's e-mail on the Checkout page. Allowed: an e-mail address. |
| `customerId` | `string` | no | An existing Stripe customer (`cus_…`) to charge, so their saved cards appear. |
| `metadata` | `Record<string, string>` | no | Your own key/value strings (order id, user id). Returned by `getCheckout()`, in the webhook events and, for a subscription, set on the subscription. |
| `allowPromotionCodes` | `boolean` | no | `true` shows a promotion-code field. |
| `automaticTax` | `boolean` | no | `true` lets Stripe Tax compute tax (the owner must have Stripe Tax set up). |
| `trialDays` | `number` | no | Free trial days before the first charge of a subscription. Allowed: > 0. |
| `clientReferenceId` | `string` | no | One more id of yours, returned by `getCheckout()`. |

### `CheckoutOutput`

The Checkout page created by `payments.createCheckout()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The Checkout Session id (`cs_test_…` or `cs_live_…`). |
| `url` | `string` | yes | The Stripe Checkout page: redirect the buyer here. |

### `CheckoutSession`

A Checkout Session read back with `payments.getCheckout()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The Checkout Session id. |
| `status` | `'expired' \| 'open' \| 'complete'` | yes | `open`: not finished. `complete`: the buyer finished. `expired`: abandoned (sessions expire after 24 h). |
| `paymentStatus` | `'paid' \| 'unpaid' \| 'no_payment_required'` | yes | `paid`: the money was taken (mark the order paid). `unpaid`: not yet (some payment methods confirm later). `no_payment_required`: nothing to pay (a `setup` session or a 100 % discount). |
| `amountTotal` | `number \| null` | yes | The total charged, in the smallest currency unit (`5000` = 50.00). |
| `currency` | `string \| null` | yes | The currency, lower case. |
| `metadata` | `Record<string, string>` | yes | The `metadata` you passed to `createCheckout()`. |
| `paymentIntentId` | `string \| null` | yes | The payment (`pi_…`) of a `payment` session; pass it to `refunds.create()`. |
| `customerEmail` | `string \| null` | yes | The e-mail the buyer typed. |
| `clientReferenceId` | `string \| null` | yes | The `clientReferenceId` you passed. |
| `customerId` | `string \| null` | yes | The Stripe customer (`cus_…`) of a subscription or saved card; store it for `customerPortalLink()`. |
| `subscriptionId` | `string \| null` | yes | The subscription (`sub_…`) a `subscription` session created; store it to check access later. |

### `CustomerPortalLinkInput`

The input of `payments.customerPortalLink()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `customerId` | `string` | yes | The Stripe customer (`cus_…`), from `getCheckout()`. Allowed: at least 1 character. |
| `returnUrl` | `string` | yes | Where the portal's back link goes. Allowed: a URL. |

### `CustomerPortalLinkOutput`

The answer of `payments.customerPortalLink()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | `string` | yes | The portal page to redirect the customer to. |

### `OnboardingLinkInput`

The input of `payments.onboardingLink()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `returnUrl` | `string` | yes | Where Stripe sends the owner after onboarding. Allowed: a URL. |
| `refreshUrl` | `string` | yes | Where Stripe sends the owner if the link expired or was already used; make a new link there. Allowed: a URL. |
| `type` | `'account_onboarding' \| 'account_update'` | no | `account_onboarding` (default) to connect the account, `account_update` to change its details. |
| `country` | `string` | no | The two-letter country of the owner's business (`ES`, `US`); required the first time the live account is created. |

### `OnboardingLinkOutput`

The answer of `payments.onboardingLink()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | `string` | yes | The Stripe onboarding page to redirect the owner to (single use). |
| `expiresAt` | `string` | yes | When the link stops working. |

### `PaymentsStatus`

The state of the project's Stripe account, from `payments.getStatus()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `enabled` | `boolean` | yes | Whether payments can be used in the current mode. |
| `mode` | `'connect' \| 'own_key'` | yes | `connect`: payments run on the owner's Stripe account connected through Totalum. `own_key`: the app uses its own Stripe keys. |
| `accountId` | `string \| null` | yes | The connected Stripe account id (`acct_…`), or `null` before the first connection. |
| `livemode` | `boolean` | yes | Whether this is the live (real money) account; `false` on the preview and in the sandbox. |
| `chargesEnabled` | `boolean` | yes | Whether the account can take payments now. |
| `payoutsEnabled` | `boolean` | yes | Whether Stripe can pay out to the owner's bank account. |
| `detailsSubmitted` | `boolean` | yes | Whether the owner finished Stripe's onboarding form. |
| `requirements` | `{ currentlyDue, pastDue, eventuallyDue }` | yes | What Stripe still needs from the owner. |
| `requirements.currentlyDue` | `string[]` | yes | Information Stripe needs now to keep the account working. |
| `requirements.pastDue` | `string[]` | yes | Information that is overdue: charges or payouts may be paused until it is given. |
| `requirements.eventuallyDue` | `string[]` | yes | Information Stripe will need later. |
| `liveEnabled` | `boolean` | yes | Whether the owner switched live payments on for the published site. |
| `disconnectedAt` | `string \| null` | no | When the owner disconnected the Stripe account, or `null`. |

### `RefundInput`

The input of `payments.refunds.create()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `paymentIntentId` | `string` | yes | The payment to refund (`pi_…`), from `getCheckout()` or a payment webhook. Allowed: at least 1 character. |
| `amount` | `number` | no | For a partial refund, the amount in the smallest currency unit. Default: everything not yet refunded. Allowed: > 0. |
| `reason` | `'duplicate' \| 'fraudulent' \| 'requested_by_customer'` | no | Why, as Stripe records it: `duplicate`, `fraudulent` or `requested_by_customer`. |

### `RefundOutput`

A refund, from `payments.refunds.create()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The refund id (`re_…`). |
| `status` | `string` | yes | Stripe's status: `succeeded`, `pending`, `requires_action`, `failed` or `canceled`. |
| `amount` | `number` | yes | The refunded amount in the smallest currency unit. |
| `currency` | `string` | yes | The currency, lower case. |

### `Subscription`

A Stripe subscription, from `payments.subscriptions.*`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The subscription id (`sub_…`). |
| `status` | `'unpaid' \| 'active' \| 'paused' \| 'incomplete' \| 'incomplete_expired' \| 'trialing' \| 'past_due' \| 'canceled'` | yes | Give access while `active` or `trialing`. `past_due`/`unpaid`: a renewal failed. `canceled`: ended. `incomplete`/`incomplete_expired`: the first payment did not go through. `paused`: a trial ended without a payment method. |
| `customerId` | `string` | yes | The Stripe customer (`cus_…`). |
| `cancelAtPeriodEnd` | `boolean` | yes | `true` when it ends at `currentPeriodEnd` instead of renewing. |
| `currentPeriodEnd` | `string \| null` | yes | When the paid period ends: the next renewal, or the end of access when `cancelAtPeriodEnd`. |
| `canceledAt` | `string \| null` | yes | When it was canceled, or `null`. |
| `metadata` | `Record<string, string>` | yes | The `metadata` given to the Checkout that created it. |

### `SubscriptionUpdateInput`

The change `payments.subscriptions.update()` makes.

| Name | Type | Required | Description |
|---|---|---|---|
| `cancelAtPeriodEnd` | `boolean` | yes | `true`: end the subscription when the paid period ends. `false`: keep renewing (undo a cancellation). |

## `totalum-sdk/webhooks`

Receive Totalum's signed events (payments, e-mail delivery, browser programs, connected-account events, credits
exhausted) and check that they really come from Totalum. `verifyWebhook()` checks the `Totalum-Signature` header
with `TOTALUM_WEBHOOK_SECRET`, which Totalum sets for you; `totalum.webhooks` reads and changes the optional extra URL
events are also sent to, and rotates the secret. Free.

```ts
import { verifyWebhook } from 'totalum-sdk/webhooks';
const event = await verifyWebhook(await request.text(), request.headers);
```

| Method or type | What it does |
|---|---|
| [`verifyWebhook`](#verifywebhook) | Checks that a webhook delivery really comes from Totalum and returns its event. |
| [`totalumWebhooks`](#totalumwebhooks) | Creates the `webhooks` namespace (the same object as `totalum.webhooks`; a key with scope `*`): where the project receives its signed events (payments, e-mail, browser programs, `project.credits.exhausted`) and the secret they are signed with. |
| [`webhooks.verify`](#webhooksverify) | The same function as `verifyWebhook`: checks `Totalum-Signature` and returns the parsed event. |
| [`webhooks.get`](#webhooksget) | The project's extra webhook URL and the first characters of its signing secret (the secret itself is only shown by `rotateSecret`). |
| [`webhooks.setUrl`](#webhooksseturl) | Sets the extra `https://` URL the project's events are also delivered to (for example a separate backend or an automation tool). |
| [`webhooks.rotateSecret`](#webhooksrotatesecret) | Replaces the signing secret, for example after it leaked. |
| [`TotalumWebhookError`](#totalumwebhookerror) | What `verifyWebhook` (and `totalum.webhooks.verify`, `connections.verifyEvent`) throws when a delivery cannot be trusted: answer it with 401 and do nothing else. |
| [`WebhookConfig`](#webhookconfig) | The project's extra webhook address, from `webhooks.get()` and `webhooks.setUrl()`. |
| [`WebhookEvent`](#webhookevent) | A signed event Totalum delivers to the app, returned by `verifyWebhook()`. |
| [`WebhookSecretOutput`](#webhooksecretoutput) | The answer of `webhooks.rotateSecret()`. |

### `verifyWebhook`

```ts
verifyWebhook(rawBody: string, headers: Headers | Record<string, string>, secret?: string, now?: number): Promise<WebhookEvent>
```

Checks that a webhook delivery really comes from Totalum and returns its event. It verifies the
`Totalum-Signature: t=…,v1=…` header — HMAC-SHA256 keyed with the secret over `${t}.${rawBody}` — and refuses a
timestamp more than 300 s away. Pass the body exactly as received (`await request.text()`, never re-serialised
JSON). `secret` defaults to `TOTALUM_WEBHOOK_SECRET`, which Totalum sets on the preview, the published site and the
sandbox (a rotation reaches them at once): never ask the owner to set it. Totalum retries a delivery that does not
get a 2xx, so handle each event `id` once.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `rawBody` | `string` | yes | The request body as text, untouched. |
| `headers` | `Headers \| Record<string, string>` | yes | The request's headers (a `Headers` or a plain object). |
| `secret` | `string` | no | The signing secret. Default: `TOTALUM_WEBHOOK_SECRET`. |
| `now` | `number` | no | The current time in milliseconds, for tests. Default: `Date.now()`. |

**Returns** `Promise<WebhookEvent>` — The event: its `type`, `id` and `data`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The event id; the same event can be delivered more than once (retries), so handle each id once. |
| `type` | `string` | yes | What happened: `payments.<Stripe event type>` (`payments.checkout.session.completed`, `payments.invoice.paid`, …), `email.delivered`, `email.bounced`, `email.complained`, `email.failed`, `email.delivery_delayed`, `browser.program.completed`, `connections.event`, `project.credits.exhausted`, `project.payments.disconnected`. |
| `createdAt` | `string` | yes | When it happened. |
| `projectId` | `string` | yes | Your project id. |
| `env` | `'dev' \| 'live'` | yes | `dev` for the preview (Stripe test mode), `live` for the published site. |
| `data` | `Record<string, unknown>` | yes | The event's content: for a payment event `{ object }` (the Stripe object), for an e-mail event `{ emailId, to, providerId, at }`. |
| `livemode` | `boolean` | no | For payment events: whether it is a real-money (live) Stripe event. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `WEBHOOK_SECRET_MISSING` | — | No secret: `TOTALUM_WEBHOOK_SECRET` is unset and none was passed. |
| `WEBHOOK_SIGNATURE_INVALID` | 401 | The header is missing or no signature matches: not from Totalum, or the body was changed. |
| `WEBHOOK_TIMESTAMP_EXPIRED` | 401 | The signature is older (or newer) than 300 s: a replayed delivery. |

**Example**

```ts
// src/app/api/webhooks/stripe/route.ts
import { isTotalumError } from 'totalum-sdk';
import { verifyWebhook } from 'totalum-sdk/webhooks';

export async function POST(request: Request) {
  let event;
  try {
    event = await verifyWebhook(await request.text(), request.headers);
  } catch (e) {
    if (isTotalumError(e)) return new Response('invalid signature', { status: 401 });
    throw e;
  }
  if (event.type === 'payments.checkout.session.completed') {
    const session = event.data.object as { metadata: { orderId: string } };
    await markOrderPaid(session.metadata.orderId);
  }
  return new Response('ok');
}
```

Result:

```json
{
  "id": "evt_1QZ7cDPQm4n8Xa2LoP5qR6sT",
  "type": "payments.checkout.session.completed",
  "createdAt": "2026-10-11T18:01:12.000Z",
  "projectId": "acme-crm",
  "env": "live",
  "livemode": true,
  "data": { "object": { "id": "cs_live_a1B2c3D4e5F6g7H8", "payment_status": "paid", "metadata": { "orderId": "ord_42" } } }
}
```

### `totalumWebhooks`

```ts
totalumWebhooks(options?: TotalumClientOptions): TotalumWebhooks
```

Creates the `webhooks` namespace (the same object as `totalum.webhooks`; a key with scope `*`): where the project
receives its signed events (payments, e-mail, browser programs, `project.credits.exhausted`) and the secret they are
signed with. Payment events also reach the app's own `/api/webhooks/stripe` by themselves; this URL is an optional
extra address.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumWebhooks`](#totalumwebhooks) — The `webhooks` namespace: the methods below.

**Example**

```ts
import { totalumWebhooks } from 'totalum-sdk/webhooks';

const webhooks = totalumWebhooks();
const config = await webhooks.get();
```

The namespace's type is exported as `TotalumWebhooks` (`import type { TotalumWebhooks } from 'totalum-sdk/webhooks'`).

### `webhooks.verify`

```ts
webhooks.verify(rawBody: string, headers: Headers | Record<string, string>, secret?: string, now?: number): Promise<WebhookEvent>
```

The same function as `verifyWebhook`: checks `Totalum-Signature` and returns the parsed event.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `rawBody` | `string` | yes | The request body as text, untouched. |
| `headers` | `Headers \| Record<string, string>` | yes | The request's headers. |
| `secret` | `string` | no | The signing secret. Default: `TOTALUM_WEBHOOK_SECRET`. |
| `now` | `number` | no | The current time in milliseconds, for tests. Default: `Date.now()`. |

**Returns** `Promise<WebhookEvent>` — The verified event.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The event id; the same event can be delivered more than once (retries), so handle each id once. |
| `type` | `string` | yes | What happened: `payments.<Stripe event type>` (`payments.checkout.session.completed`, `payments.invoice.paid`, …), `email.delivered`, `email.bounced`, `email.complained`, `email.failed`, `email.delivery_delayed`, `browser.program.completed`, `connections.event`, `project.credits.exhausted`, `project.payments.disconnected`. |
| `createdAt` | `string` | yes | When it happened. |
| `projectId` | `string` | yes | Your project id. |
| `env` | `'dev' \| 'live'` | yes | `dev` for the preview (Stripe test mode), `live` for the published site. |
| `data` | `Record<string, unknown>` | yes | The event's content: for a payment event `{ object }` (the Stripe object), for an e-mail event `{ emailId, to, providerId, at }`. |
| `livemode` | `boolean` | no | For payment events: whether it is a real-money (live) Stripe event. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `WEBHOOK_SECRET_MISSING` | — | No secret is set or passed. |
| `WEBHOOK_SIGNATURE_INVALID` | 401 | No signature matches. |
| `WEBHOOK_TIMESTAMP_EXPIRED` | 401 | The signature is more than 300 s old. |

**Example**

```ts
const event = await totalum.webhooks.verify(await request.text(), request.headers);
```

Result:

```json
{
  "id": "msg_2nQx8kZf0aYc3Vb1",
  "type": "email.delivered",
  "createdAt": "2026-10-11T18:01:12.000Z",
  "projectId": "acme-crm",
  "env": "live",
  "data": { "emailId": "em_01M4P0S77M6XCM69SYZWP1XME9", "to": ["ana@example.com"], "providerId": "01a12c0c-9e5f-74ea-a128-a72d3fdd47ae", "at": "2026-10-11T18:01:12.000Z" }
}
```

### `webhooks.get`

```ts
webhooks.get(): Promise<WebhookConfig>
```

The project's extra webhook URL and the first characters of its signing secret (the secret itself is only shown
by `rotateSecret`). Free.

**Returns** `Promise<WebhookConfig>` — The configured URL (or `null`), whether delivery is on, and the secret's prefix.

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | `string \| null` | yes | The `https://` URL events are also sent to, or `null`. |
| `enabled` | `boolean` | yes | Whether events are being delivered there (a URL and a secret are both set). |
| `secretPrefix` | `string \| null` | yes | The first 10 characters of the signing secret (`whsec_…`), to tell secrets apart; `null` before the first one. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `FORBIDDEN_SCOPE` | 403 | The key does not have scope `*`. |

**Example**

```ts
const config = await totalum.webhooks.get();
```

Result:

```json
{ "url": "https://hooks.acme.com/totalum", "enabled": true, "secretPrefix": "whsec_4f9a" }
```

### `webhooks.setUrl`

```ts
webhooks.setUrl(url: string): Promise<WebhookConfig>
```

Sets the extra `https://` URL the project's events are also delivered to (for example a separate backend or an
automation tool). Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `url` | `string` | yes | The `https://` URL that receives the events. |

**Returns** `Promise<WebhookConfig>` — The new configuration.

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | `string \| null` | yes | The `https://` URL events are also sent to, or `null`. |
| `enabled` | `boolean` | yes | Whether events are being delivered there (a URL and a secret are both set). |
| `secretPrefix` | `string \| null` | yes | The first 10 characters of the signing secret (`whsec_…`), to tell secrets apart; `null` before the first one. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | Not an `https://` URL. |
| `FORBIDDEN_SCOPE` | 403 | The key does not have scope `*`. |

**Example**

```ts
const config = await totalum.webhooks.setUrl('https://hooks.acme.com/totalum');
```

Result:

```json
{ "url": "https://hooks.acme.com/totalum", "enabled": true, "secretPrefix": "whsec_4f9a" }
```

### `webhooks.rotateSecret`

```ts
webhooks.rotateSecret(): Promise<WebhookSecretOutput>
```

Replaces the signing secret, for example after it leaked. The new secret is shown once; for 24 hours every delivery
is signed with both, so receivers keep working while they update. Totalum sets the new one as
`TOTALUM_WEBHOOK_SECRET` on the preview, the published site and the sandbox by itself.

**Returns** `Promise<WebhookSecretOutput>` — The new secret (store it only if another system verifies the events).

| Field | Type | Always present | Description |
|---|---|---|---|
| `secret` | `string` | yes | The new signing secret (`whsec_…`), shown only this once. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `FORBIDDEN_SCOPE` | 403 | The key does not have scope `*`. |

**Example**

```ts
const { secret } = await totalum.webhooks.rotateSecret();
```

Result:

```json
{ "secret": "whsec_7b1e0c9f…" }
```

### `TotalumWebhookError`

What `verifyWebhook` (and `totalum.webhooks.verify`, `connections.verifyEvent`) throws when a delivery cannot be
trusted: answer it with 401 and do nothing else. `errorCode` says why.
| Member | Type | Description |
|---|---|---|
| `name` | `string` | Always `'TotalumWebhookError'`. |
| `errorCode` | `'WEBHOOK_SECRET_MISSING' \| 'WEBHOOK_SIGNATURE_INVALID' \| 'WEBHOOK_TIMESTAMP_EXPIRED'` | Why the delivery was refused: no secret, a signature that does not match, or a timestamp outside ±300 s. |

### `WebhookConfig`

The project's extra webhook address, from `webhooks.get()` and `webhooks.setUrl()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | `string \| null` | yes | The `https://` URL events are also sent to, or `null`. |
| `enabled` | `boolean` | yes | Whether events are being delivered there (a URL and a secret are both set). |
| `secretPrefix` | `string \| null` | yes | The first 10 characters of the signing secret (`whsec_…`), to tell secrets apart; `null` before the first one. |

### `WebhookEvent`

A signed event Totalum delivers to the app, returned by `verifyWebhook()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The event id; the same event can be delivered more than once (retries), so handle each id once. |
| `type` | `string` | yes | What happened: `payments.<Stripe event type>` (`payments.checkout.session.completed`, `payments.invoice.paid`, …), `email.delivered`, `email.bounced`, `email.complained`, `email.failed`, `email.delivery_delayed`, `browser.program.completed`, `connections.event`, `project.credits.exhausted`, `project.payments.disconnected`. |
| `createdAt` | `string` | yes | When it happened. |
| `projectId` | `string` | yes | Your project id. |
| `env` | `'dev' \| 'live'` | yes | `dev` for the preview (Stripe test mode), `live` for the published site. |
| `data` | `Record<string, unknown>` | yes | The event's content: for a payment event `{ object }` (the Stripe object), for an e-mail event `{ emailId, to, providerId, at }`. |
| `livemode` | `boolean` | no | For payment events: whether it is a real-money (live) Stripe event. |

### `WebhookSecretOutput`

The answer of `webhooks.rotateSecret()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `secret` | `string` | yes | The new signing secret (`whsec_…`), shown only this once. |

## `totalum-sdk/web`

Read the web from your app: one page as Markdown or HTML (`scrape`), a web search (`search`), every URL of a site
(`map`), a whole site in the background (`crawl`) and screenshots. Use it to read pages; when you need to click,
type or log in, use the cloud browser instead. Every call is billed in credits and returns its price in
`usage.credits`.

```ts
const page = await totalum.web.scrape('https://example.com', { formats: ['markdown'] });
```

| Method or type | What it does |
|---|---|
| [`totalumWeb`](#totalumweb) | Creates the `web` namespace (the same object as `totalum.web`, scope `web`): scrape, search, map, crawl and screenshots, billed in credits — every result carries `usage.credits`. |
| [`web.scrape`](#webscrape) | Reads one web page and returns its content as clean Markdown (the default), HTML, its links, a screenshot, a summary or JSON extracted with your schema. |
| [`web.search`](#websearch) | Searches the web and returns the results (title, URL, snippet); with `scrapeOptions`, each result's page content too. |
| [`web.map`](#webmap) | Lists the URLs of a site (from its sitemap and links) without reading the pages — to choose what to scrape, or to build a site index. |
| [`web.crawl`](#webcrawl) | Starts reading a whole site (or part of it) in the background and returns a job id at once. |
| [`web.crawlStatus`](#webcrawlstatus) | Reads a crawl job: its status, how many pages are done and their content. |
| [`web.cancelCrawl`](#webcancelcrawl) | Stops a running crawl. |
| [`web.screenshot`](#webscreenshot) | Takes a screenshot of a web page (`url`) or of HTML you pass (`html`) and stores it as a public project file: link previews, social images, visual checks. |
| [`JobCreated`](#jobcreated) | A background job that was started. |
| [`ScreenshotInput`](#screenshotinput) | The options of `web.screenshot()`. |
| [`ScreenshotOutput`](#screenshotoutput) | A screenshot stored as a public project file. |
| [`WebJob`](#webjob) | A crawl job, from `web.crawlStatus()` and `web.cancelCrawl()`. |
| [`WebScrapeOutput`](#webscrapeoutput) | One page read by `web.scrape()`. |
| [`WebSearchOutput`](#websearchoutput) | The results of `web.search()`, by source. |

### `totalumWeb`

```ts
totalumWeb(options?: TotalumClientOptions): TotalumWeb
```

Creates the `web` namespace (the same object as `totalum.web`, scope `web`): scrape, search, map, crawl and
screenshots, billed in credits — every result carries `usage.credits`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumWeb`](#totalumweb) — The `web` namespace: the methods below.

**Example**

```ts
import { totalumWeb } from 'totalum-sdk/web';

const web = totalumWeb();
const page = await web.scrape('https://example.com');
```

The namespace's type is exported as `TotalumWeb` (`import type { TotalumWeb } from 'totalum-sdk/web'`).

### `web.scrape`

```ts
web.scrape(url: string, opts?: { formats?, onlyMainContent?, waitFor?, timeout?, proxy?, location?, actions?, schema?, prompt?, cacheMaxAgeMs? }): Promise<WebScrapeOutput>
```

Reads one web page and returns its content as clean Markdown (the default), HTML, its links, a screenshot, a
summary or JSON extracted with your schema. Use it to import an article, read a product page or feed a page to
AI. Pages that need a click or a login need the cloud browser instead.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `url` | `string` | yes | The page's `https://` URL. |
| `opts` | `{ formats?, onlyMainContent?, waitFor?, timeout?, proxy?, location?, actions?, schema?, prompt?, cacheMaxAgeMs? }` | no | What to return and how to load the page. |
| `opts.formats` | `('screenshot' \| 'markdown' \| 'html' \| 'rawHtml' \| 'links' \| 'json' \| 'summary')[]` | no | What to return: `markdown` (default, clean text), `html` (cleaned HTML), `rawHtml` (the page as served), `links`, `screenshot` (a URL of a page screenshot), `json` (data extracted with `schema`/`prompt`), `summary`. |
| `opts.onlyMainContent` | `boolean` | no | `true` (default) drops headers, footers and navigation; `false` keeps the whole page. |
| `opts.waitFor` | `number` | no | Milliseconds to wait after the page loads, for content that appears late. Allowed: ≥ 0. |
| `opts.timeout` | `number` | no | Milliseconds to wait for the page; capped at 9 s. Allowed: > 0, ≤ 55000. |
| `opts.proxy` | `'basic' \| 'stealth' \| 'auto'` | no | `basic` (default), `stealth` for sites that block scrapers (costs more), or `auto` (stealth only when needed). |
| `opts.location` | `{ country?, languages? }` | no | Where the request appears to come from. |
| `opts.location.country` | `string` | no | Two-letter country code (`ES`, `US`). |
| `opts.location.languages` | `string[]` | no | Preferred languages (`["es-ES"]`). |
| `opts.actions` | `(Record<string, unknown>)[]` | no | Browser steps to run before reading (click, type, wait, scroll), each `{ type, … }`; up to 20. Allowed: at most 20 items. |
| `opts.schema` | `Record<string, unknown>` | no | With `formats: ["json"]`: the JSON Schema of the data to extract. |
| `opts.prompt` | `string` | no | With `formats: ["json"]`: what to extract, in words (with or without `schema`). |
| `opts.cacheMaxAgeMs` | `number` | no | Accept a cached copy of the page up to this old, in milliseconds (faster, same price). Allowed: ≥ 0. |

**Returns** `Promise<WebScrapeOutput>` — The page in the formats asked for, its metadata, and the credits it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | `string` | yes | The page's final URL (after redirects). |
| `statusCode` | `number` | yes | The HTTP status the page answered with. |
| `metadata` | `{ sourceURL, title?, description?, language? }` | yes | The page's title, description and language. |
| `metadata.sourceURL` | `string` | yes | The URL that was asked for. |
| `metadata.title` | `string` | no | The page `<title>`. |
| `metadata.description` | `string` | no | The page's meta description. |
| `metadata.language` | `string` | no | The page language (`en`). |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `markdown` | `string` | no | The page as Markdown (with `formats` including `markdown`, the default). |
| `html` | `string` | no | The cleaned HTML (with `html`). |
| `rawHtml` | `string` | no | The HTML exactly as served (with `rawHtml`). |
| `links` | `string[]` | no | Every link on the page (with `links`). |
| `screenshotUrl` | `string` | no | A URL of a screenshot of the page (with `screenshot`). |
| `json` | `unknown` | no | The data extracted with your `schema`/`prompt` (with `json`). |
| `summary` | `string` | no | A short summary of the page (with `summary`). |
| `contentUrl` | `string` | no | Reserved for content too large to return inline; not returned today. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `URL_NOT_ALLOWED` | 400 | Not a public `http(s)` URL (a private address, `localhost`, a Totalum host). |
| `SCRAPE_BLOCKED` | 502 | The site refused the request; try `proxy: 'stealth'`. |
| `SCRAPE_FAILED` | 502 | The page could not be read. |
| `PROVIDER_TIMEOUT` | 504 | The page took too long; raise `timeout` or retry. |
| `PROVIDER_UNAVAILABLE` | 503 | The scraping service is unavailable; retry later. |

**Examples**

```ts
const page = await totalum.web.scrape('https://example.com', { formats: ['markdown'] });
```

Result:

```json
{
  "url": "https://example.com/",
  "statusCode": 200,
  "metadata": { "sourceURL": "https://example.com/", "title": "Example Domain", "language": "en" },
  "markdown": "# Example Domain\n\nThis domain is for use in documentation examples without needing permission. …",
  "usage": { "credits": 0.0343 }
}
```

```ts
// structured data with a JSON schema
const { json } = await totalum.web.scrape('https://shop.example.com/p/red-shoes', {
  formats: ['json'],
  schema: { type: 'object', properties: { name: { type: 'string' }, price: { type: 'number' } } },
});
```

Result:

```json
{ "name": "Red running shoes", "price": 79.9 }
```

### `web.search`

```ts
web.search(query: string, opts?: { limit?, location?, sources?, timeRange?, scrapeOptions? }): Promise<{ web, news?, images?, scraped?, usage }>
```

Searches the web and returns the results (title, URL, snippet); with `scrapeOptions`, each result's page content
too. Use it for research features, lead finding or to ground an AI answer in current pages.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `query` | `string` | yes | What to search for, as you would type it in a search engine. |
| `opts` | `{ limit?, location?, sources?, timeRange?, scrapeOptions? }` | no | How many results, which sources, where and how recent. |
| `opts.limit` | `number` | no | Results per source. Default 5. Allowed: > 0, ≤ 100. |
| `opts.location` | `{ country?, city? }` | no | Search as if from this place. |
| `opts.location.country` | `string` | no | Two-letter country code. |
| `opts.location.city` | `string` | no | A city name. |
| `opts.sources` | `('web' \| 'news' \| 'images')[]` | no | Which result types: `web` (default), `news`, `images`. |
| `opts.timeRange` | `'day' \| 'month' \| 'week' \| 'year'` | no | Only results from the last `day`, `week`, `month` or `year`. |
| `opts.scrapeOptions` | `{ formats?, onlyMainContent?, waitFor?, timeout?, proxy?, location?, actions?, schema?, prompt?, cacheMaxAgeMs? }` | no | Also read each result page with these scrape options (see `web.scrape`); the page content comes back in `scraped`. Costs more. |
| `opts.scrapeOptions.formats` | `('screenshot' \| 'markdown' \| 'html' \| 'rawHtml' \| 'links' \| 'json' \| 'summary')[]` | no | What to return: `markdown` (default, clean text), `html` (cleaned HTML), `rawHtml` (the page as served), `links`, `screenshot` (a URL of a page screenshot), `json` (data extracted with `schema`/`prompt`), `summary`. |
| `opts.scrapeOptions.onlyMainContent` | `boolean` | no | `true` (default) drops headers, footers and navigation; `false` keeps the whole page. |
| `opts.scrapeOptions.waitFor` | `number` | no | Milliseconds to wait after the page loads, for content that appears late. Allowed: ≥ 0. |
| `opts.scrapeOptions.timeout` | `number` | no | Milliseconds to wait for the page; capped at 9 s. Allowed: > 0, ≤ 55000. |
| `opts.scrapeOptions.proxy` | `'basic' \| 'stealth' \| 'auto'` | no | `basic` (default), `stealth` for sites that block scrapers (costs more), or `auto` (stealth only when needed). |
| `opts.scrapeOptions.location` | `{ country?, languages? }` | no | Where the request appears to come from. |
| `opts.scrapeOptions.location.country` | `string` | no | Two-letter country code (`ES`, `US`). |
| `opts.scrapeOptions.location.languages` | `string[]` | no | Preferred languages (`["es-ES"]`). |
| `opts.scrapeOptions.actions` | `(Record<string, unknown>)[]` | no | Browser steps to run before reading (click, type, wait, scroll), each `{ type, … }`; up to 20. Allowed: at most 20 items. |
| `opts.scrapeOptions.schema` | `Record<string, unknown>` | no | With `formats: ["json"]`: the JSON Schema of the data to extract. |
| `opts.scrapeOptions.prompt` | `string` | no | With `formats: ["json"]`: what to extract, in words (with or without `schema`). |
| `opts.scrapeOptions.cacheMaxAgeMs` | `number` | no | Accept a cached copy of the page up to this old, in milliseconds (faster, same price). Allowed: ≥ 0. |

**Returns** `Promise<{ web, news?, images?, scraped?, usage }>` — The results by source and the credits the search cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `web` | `({ title, url, position, snippet? })[]` | yes | Web results, best first. |
| `web.title` | `string` | yes | The result's title. |
| `web.url` | `string` | yes | The result's URL. |
| `web.position` | `number` | yes | The rank, from 1. |
| `web.snippet` | `string` | no | A short extract of the page. |
| `news` | `({ title, url, position, snippet? })[]` | no | News results (with `sources` including `news`). |
| `news.title` | `string` | yes | The article's title. |
| `news.url` | `string` | yes | The article's URL. |
| `news.position` | `number` | yes | The rank, from 1. |
| `news.snippet` | `string` | no | A short extract. |
| `images` | `(Record<string, unknown>)[]` | no | Image results (with `sources` including `images`), as the search returns them. |
| `scraped` | `({ url, statusCode, metadata, markdown?, html?, rawHtml?, links?, screenshotUrl?, json?, summary?, contentUrl? })[]` | no | With `scrapeOptions`: each web result's page, read like `web.scrape()` (same fields). |
| `scraped.url` | `string` | yes | The page's final URL (after redirects). |
| `scraped.statusCode` | `number` | yes | The HTTP status the page answered with. |
| `scraped.metadata` | `{ sourceURL, title?, description?, language? }` | yes | The page's title, description and language. |
| `scraped.metadata.sourceURL` | `string` | yes | The URL that was asked for. |
| `scraped.metadata.title` | `string` | no | The page `<title>`. |
| `scraped.metadata.description` | `string` | no | The page's meta description. |
| `scraped.metadata.language` | `string` | no | The page language (`en`). |
| `scraped.markdown` | `string` | no | The page as Markdown (with `formats` including `markdown`, the default). |
| `scraped.html` | `string` | no | The cleaned HTML (with `html`). |
| `scraped.rawHtml` | `string` | no | The HTML exactly as served (with `rawHtml`). |
| `scraped.links` | `string[]` | no | Every link on the page (with `links`). |
| `scraped.screenshotUrl` | `string` | no | A URL of a screenshot of the page (with `screenshot`). |
| `scraped.json` | `unknown` | no | The data extracted with your `schema`/`prompt` (with `json`). |
| `scraped.summary` | `string` | no | A short summary of the page (with `summary`). |
| `scraped.contentUrl` | `string` | no | Reserved for content too large to return inline; not returned today. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SEARCH_FAILED` | 502 | The search could not be run. |
| `PROVIDER_TIMEOUT` | 504 | The search took too long; retry. |
| `PROVIDER_UNAVAILABLE` | 503 | The search service is unavailable; retry later. |

**Example**

```ts
const results = await totalum.web.search('totalum app builder', { limit: 2 });
```

Result:

```json
{
  "web": [
    {
      "title": "Totalum - The most powerful AI app builder for humans and for agents",
      "url": "https://www.totalum.app/en",
      "snippet": "Create any web or app. The first AI app builder you can use via API and MCP…",
      "position": 1
    },
    {
      "title": "Totalum Blog: AI app builder, AI coding agents, MCP guides",
      "url": "https://www.totalum.app/blog",
      "snippet": "An AI app builder that generates real code lets you export and own the source…",
      "position": 2
    }
  ],
  "usage": { "credits": 0.0685 }
}
```

### `web.map`

```ts
web.map(url: string, opts?: { limit?, search? }): Promise<{ links, usage }>
```

Lists the URLs of a site (from its sitemap and links) without reading the pages — to choose what to scrape, or
to build a site index.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `url` | `string` | yes | The site's `https://` URL. |
| `opts` | `{ limit?, search? }` | no | A search filter and the maximum number of links. |
| `opts.limit` | `number` | no | At most this many URLs. Allowed: > 0, ≤ 5000. |
| `opts.search` | `string` | no | Only URLs related to this word. |

**Returns** `Promise<{ links, usage }>` — The URLs found and the credits the call cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `links` | `string[]` | yes | The URLs found. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `URL_NOT_ALLOWED` | 400 | Not a public `http(s)` URL. |
| `SCRAPE_FAILED` | 502 | The site could not be mapped. |
| `PROVIDER_UNAVAILABLE` | 503 | The scraping service is unavailable; retry later. |

**Example**

```ts
const { links } = await totalum.web.map('https://docs.example.com', { search: 'pricing', limit: 5 });
```

Result:

```json
{
  "links": ["https://docs.example.com/pricing", "https://docs.example.com/pricing/teams"],
  "usage": { "credits": 0.0343 }
}
```

### `web.crawl`

```ts
web.crawl(url: string, opts?: { limit?, scrapeOptions?, maxDepth?, includePaths?, excludePaths?, webhook? }): Promise<JobCreated>
```

Starts reading a whole site (or part of it) in the background and returns a job id at once. Store the id, then
call `crawlStatus(jobId)` (from a later request or a cron job) until `status` is no longer `running`; the job
is charged by the pages read, settled on that read. The plan caps the pages per crawl: 20 on Free, 500 on
Starter, 2,000 on Builder, 10,000 on Professional and 50,000 on Enterprise.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `url` | `string` | yes | The `https://` URL to start from. |
| `opts` | `{ limit?, scrapeOptions?, maxDepth?, includePaths?, excludePaths?, webhook? }` | no | How many pages, how deep, which paths, and how to read each page. |
| `opts.limit` | `number` | no | At most this many pages. Default: 100, or the plan's maximum if lower. Allowed: > 0. |
| `opts.scrapeOptions` | `{ formats?, onlyMainContent?, waitFor?, timeout?, proxy?, location?, actions?, schema?, prompt?, cacheMaxAgeMs? }` | no | How each page is read (see `web.scrape`). |
| `opts.scrapeOptions.formats` | `('screenshot' \| 'markdown' \| 'html' \| 'rawHtml' \| 'links' \| 'json' \| 'summary')[]` | no | What to return: `markdown` (default, clean text), `html` (cleaned HTML), `rawHtml` (the page as served), `links`, `screenshot` (a URL of a page screenshot), `json` (data extracted with `schema`/`prompt`), `summary`. |
| `opts.scrapeOptions.onlyMainContent` | `boolean` | no | `true` (default) drops headers, footers and navigation; `false` keeps the whole page. |
| `opts.scrapeOptions.waitFor` | `number` | no | Milliseconds to wait after the page loads, for content that appears late. Allowed: ≥ 0. |
| `opts.scrapeOptions.timeout` | `number` | no | Milliseconds to wait for the page; capped at 9 s. Allowed: > 0, ≤ 55000. |
| `opts.scrapeOptions.proxy` | `'basic' \| 'stealth' \| 'auto'` | no | `basic` (default), `stealth` for sites that block scrapers (costs more), or `auto` (stealth only when needed). |
| `opts.scrapeOptions.location` | `{ country?, languages? }` | no | Where the request appears to come from. |
| `opts.scrapeOptions.location.country` | `string` | no | Two-letter country code (`ES`, `US`). |
| `opts.scrapeOptions.location.languages` | `string[]` | no | Preferred languages (`["es-ES"]`). |
| `opts.scrapeOptions.actions` | `(Record<string, unknown>)[]` | no | Browser steps to run before reading (click, type, wait, scroll), each `{ type, … }`; up to 20. Allowed: at most 20 items. |
| `opts.scrapeOptions.schema` | `Record<string, unknown>` | no | With `formats: ["json"]`: the JSON Schema of the data to extract. |
| `opts.scrapeOptions.prompt` | `string` | no | With `formats: ["json"]`: what to extract, in words (with or without `schema`). |
| `opts.scrapeOptions.cacheMaxAgeMs` | `number` | no | Accept a cached copy of the page up to this old, in milliseconds (faster, same price). Allowed: ≥ 0. |
| `opts.maxDepth` | `number` | no | How many links away from `url` to follow. Allowed: ≥ 0. |
| `opts.includePaths` | `string[]` | no | Only URLs whose path matches one of these regular expressions (`/blog/.*`). |
| `opts.excludePaths` | `string[]` | no | Skip URLs whose path matches one of these regular expressions. |
| `opts.webhook` | `boolean` | no | Accepted for compatibility but not used: poll `crawlStatus()` instead. |

**Returns** `Promise<JobCreated>` — The job id and the credits reserved for it (the real charge is settled when it ends).

| Field | Type | Always present | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The job id: store it and pass it to the status method. |
| `estimatedCredits` | `number` | no | The credits reserved for the job; the real charge is settled when it ends. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `limit` is above the plan's pages per crawl; `details.maxPages`. |
| `URL_NOT_ALLOWED` | 400 | Not a public `http(s)` URL. |
| `SCRAPE_FAILED` | 502 | The crawl could not be started. |
| `PROVIDER_UNAVAILABLE` | 503 | The scraping service is unavailable; retry later. |

**Example**

```ts
const { jobId } = await totalum.web.crawl('https://docs.example.com', { limit: 50, includePaths: ['/guides/.*'] });
// save jobId, then poll crawlStatus(jobId) later
```

Result:

```json
{ "jobId": "01M4P1C8Z3X7Y2W5V9T6S4R0QA", "estimatedCredits": 1.716 }
```

### `web.crawlStatus`

```ts
web.crawlStatus(jobId: string): Promise<WebJob>
```

Reads a crawl job: its status, how many pages are done and their content. Poll it every few seconds or from a cron
job until `status` is `completed`, `failed`, `cancelled` or `expired`. Reading a finished job settles its charge.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The `jobId` from `crawl()`. |

**Returns** `Promise<WebJob>` — The job's progress and the pages read so far.

| Field | Type | Always present | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The job id. |
| `status` | `'expired' \| 'completed' \| 'queued' \| 'running' \| 'failed' \| 'cancelled'` | yes | `running` while pages are read; then `completed`, `failed`, `cancelled` or `expired`. `queued` before it starts. |
| `total` | `number` | yes | Pages found so far. |
| `completed` | `number` | yes | Pages read so far. |
| `creditsUsed` | `number` | yes | The crawl's usage units so far (one per page, more with `stealth` or `json`); the charge in Totalum credits is settled from it when the job ends. |
| `data` | `({ url, statusCode, metadata, markdown?, html?, rawHtml?, links?, screenshotUrl?, json?, summary?, contentUrl? })[]` | yes | The pages read so far, each like a `web.scrape()` result. |
| `data.url` | `string` | yes | The page's final URL (after redirects). |
| `data.statusCode` | `number` | yes | The HTTP status the page answered with. |
| `data.metadata` | `{ sourceURL, title?, description?, language? }` | yes | The page's title, description and language. |
| `data.metadata.sourceURL` | `string` | yes | The URL that was asked for. |
| `data.metadata.title` | `string` | no | The page `<title>`. |
| `data.metadata.description` | `string` | no | The page's meta description. |
| `data.metadata.language` | `string` | no | The page language (`en`). |
| `data.markdown` | `string` | no | The page as Markdown (with `formats` including `markdown`, the default). |
| `data.html` | `string` | no | The cleaned HTML (with `html`). |
| `data.rawHtml` | `string` | no | The HTML exactly as served (with `rawHtml`). |
| `data.links` | `string[]` | no | Every link on the page (with `links`). |
| `data.screenshotUrl` | `string` | no | A URL of a screenshot of the page (with `screenshot`). |
| `data.json` | `unknown` | no | The data extracted with your `schema`/`prompt` (with `json`). |
| `data.summary` | `string` | no | A short summary of the page (with `summary`). |
| `data.contentUrl` | `string` | no | Reserved for content too large to return inline; not returned today. |
| `next` | `string` | no | Reserved for paging very large jobs; not returned today. |
| `resultUrl` | `string` | no | Reserved for very large results; not returned today. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `JOB_NOT_FOUND` | 404 | No such job for this project (job ids are kept 7 days). |
| `SCRAPE_FAILED` | 502 | The job could not be read; retry. |

**Example**

```ts
const job = await totalum.web.crawlStatus('01M4P1C8Z3X7Y2W5V9T6S4R0QA');
if (job.status === 'completed') for (const page of job.data) await savePage(page.url, page.markdown);
```

Result:

```json
{
  "jobId": "01M4P1C8Z3X7Y2W5V9T6S4R0QA",
  "status": "completed",
  "total": 2,
  "completed": 2,
  "creditsUsed": 2,
  "data": [
    { "url": "https://docs.example.com/guides/start", "statusCode": 200, "metadata": { "sourceURL": "https://docs.example.com/guides/start", "title": "Getting started" }, "markdown": "# Getting started\n…" },
    { "url": "https://docs.example.com/guides/deploy", "statusCode": 200, "metadata": { "sourceURL": "https://docs.example.com/guides/deploy", "title": "Deploy" }, "markdown": "# Deploy\n…" }
  ]
}
```

### `web.cancelCrawl`

```ts
web.cancelCrawl(jobId: string): Promise<WebJob>
```

Stops a running crawl. Pages already read are kept in the answer and charged.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The `jobId` from `crawl()`. |

**Returns** `Promise<WebJob>` — The job, now `cancelled`, with the pages read before it stopped.

| Field | Type | Always present | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The job id. |
| `status` | `'expired' \| 'completed' \| 'queued' \| 'running' \| 'failed' \| 'cancelled'` | yes | `running` while pages are read; then `completed`, `failed`, `cancelled` or `expired`. `queued` before it starts. |
| `total` | `number` | yes | Pages found so far. |
| `completed` | `number` | yes | Pages read so far. |
| `creditsUsed` | `number` | yes | The crawl's usage units so far (one per page, more with `stealth` or `json`); the charge in Totalum credits is settled from it when the job ends. |
| `data` | `({ url, statusCode, metadata, markdown?, html?, rawHtml?, links?, screenshotUrl?, json?, summary?, contentUrl? })[]` | yes | The pages read so far, each like a `web.scrape()` result. |
| `data.url` | `string` | yes | The page's final URL (after redirects). |
| `data.statusCode` | `number` | yes | The HTTP status the page answered with. |
| `data.metadata` | `{ sourceURL, title?, description?, language? }` | yes | The page's title, description and language. |
| `data.metadata.sourceURL` | `string` | yes | The URL that was asked for. |
| `data.metadata.title` | `string` | no | The page `<title>`. |
| `data.metadata.description` | `string` | no | The page's meta description. |
| `data.metadata.language` | `string` | no | The page language (`en`). |
| `data.markdown` | `string` | no | The page as Markdown (with `formats` including `markdown`, the default). |
| `data.html` | `string` | no | The cleaned HTML (with `html`). |
| `data.rawHtml` | `string` | no | The HTML exactly as served (with `rawHtml`). |
| `data.links` | `string[]` | no | Every link on the page (with `links`). |
| `data.screenshotUrl` | `string` | no | A URL of a screenshot of the page (with `screenshot`). |
| `data.json` | `unknown` | no | The data extracted with your `schema`/`prompt` (with `json`). |
| `data.summary` | `string` | no | A short summary of the page (with `summary`). |
| `data.contentUrl` | `string` | no | Reserved for content too large to return inline; not returned today. |
| `next` | `string` | no | Reserved for paging very large jobs; not returned today. |
| `resultUrl` | `string` | no | Reserved for very large results; not returned today. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `JOB_NOT_FOUND` | 404 | No such job for this project. |

**Example**

```ts
const job = await totalum.web.cancelCrawl('01M4P1C8Z3X7Y2W5V9T6S4R0QA');
```

Result:

```ts
{ "jobId": "01M4P1C8Z3X7Y2W5V9T6S4R0QA", "status": "cancelled", "total": 50, "completed": 12, "creditsUsed": 12, "data": […] }
```

### `web.screenshot`

```ts
web.screenshot(input: ScreenshotInput): Promise<{ key, url, format, base64?, usage }>
```

Takes a screenshot of a web page (`url`) or of HTML you pass (`html`) and stores it as a public project file:
link previews, social images, visual checks.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`ScreenshotInput`](#screenshotinput) | yes | What to capture and how. |
| `input.url` | `string` | no | The page to capture (or pass `html`). Allowed: a URL. |
| `input.html` | `string` | no | HTML to render and capture instead of a URL. |
| `input.fullPage` | `boolean` | no | `true` captures the whole scrolling page; default only the viewport. |
| `input.viewport` | `{ width, height }` | no | The browser window size. |
| `input.viewport.width` | `number` | yes | Width in pixels. Allowed: > 0. |
| `input.viewport.height` | `number` | yes | Height in pixels. Allowed: > 0. |
| `input.format` | `'png' \| 'jpeg' \| 'webp'` | no | `png` (default), `jpeg` or `webp`. |
| `input.waitForSelector` | `string` | no | Wait until this CSS selector is on the page before capturing. |
| `input.inline` | `boolean` | no | `true` also returns the image as base64 (images up to 2 MB). |

**Returns** `Promise<{ key, url, format, base64?, usage }>` — The stored image's `key` and `url`, its format, and the credits it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `url` | `string` | yes | The image's public URL. |
| `format` | `'png' \| 'jpeg' \| 'webp'` | yes | The image format. |
| `base64` | `string` | no | The image bytes in base64, with `inline: true` (up to 2 MB). |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `URL_NOT_ALLOWED` | 400 | Not a public `http(s)` URL. |
| `SCREENSHOT_FAILED` | 502 | The page could not be captured. |
| `VALIDATION_ERROR` | 400 | Neither `url` nor `html` was given. |

**Example**

```ts
const shot = await totalum.web.screenshot({
  html: '<div style="font:48px sans-serif;padding:40px">Launch day 🚀</div>',
  viewport: { width: 1200, height: 630 },
});
```

Result:

```json
{
  "key": "p/acme-crm/01M4P0SFVZXNRS871ZSKZNWHY0.png",
  "url": "https://files.totalum-project.com/p/acme-crm/01M4P0SFVZXNRS871ZSKZNWHY0.png",
  "format": "png",
  "usage": { "credits": 0.0002 }
}
```

### `JobCreated`

A background job that was started.

| Field | Type | Always present | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The job id: store it and pass it to the status method. |
| `estimatedCredits` | `number` | no | The credits reserved for the job; the real charge is settled when it ends. |

### `ScreenshotInput`

The options of `web.screenshot()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `url` | `string` | no | The page to capture (or pass `html`). Allowed: a URL. |
| `html` | `string` | no | HTML to render and capture instead of a URL. |
| `fullPage` | `boolean` | no | `true` captures the whole scrolling page; default only the viewport. |
| `viewport` | `{ width, height }` | no | The browser window size. |
| `viewport.width` | `number` | yes | Width in pixels. Allowed: > 0. |
| `viewport.height` | `number` | yes | Height in pixels. Allowed: > 0. |
| `format` | `'png' \| 'jpeg' \| 'webp'` | no | `png` (default), `jpeg` or `webp`. |
| `waitForSelector` | `string` | no | Wait until this CSS selector is on the page before capturing. |
| `inline` | `boolean` | no | `true` also returns the image as base64 (images up to 2 MB). |

### `ScreenshotOutput`

A screenshot stored as a public project file.

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `url` | `string` | yes | The image's public URL. |
| `format` | `'png' \| 'jpeg' \| 'webp'` | yes | The image format. |
| `base64` | `string` | no | The image bytes in base64, with `inline: true` (up to 2 MB). |

### `WebJob`

A crawl job, from `web.crawlStatus()` and `web.cancelCrawl()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The job id. |
| `status` | `'expired' \| 'completed' \| 'queued' \| 'running' \| 'failed' \| 'cancelled'` | yes | `running` while pages are read; then `completed`, `failed`, `cancelled` or `expired`. `queued` before it starts. |
| `total` | `number` | yes | Pages found so far. |
| `completed` | `number` | yes | Pages read so far. |
| `creditsUsed` | `number` | yes | The crawl's usage units so far (one per page, more with `stealth` or `json`); the charge in Totalum credits is settled from it when the job ends. |
| `data` | `({ url, statusCode, metadata, markdown?, html?, rawHtml?, links?, screenshotUrl?, json?, summary?, contentUrl? })[]` | yes | The pages read so far, each like a `web.scrape()` result. |
| `data.url` | `string` | yes | The page's final URL (after redirects). |
| `data.statusCode` | `number` | yes | The HTTP status the page answered with. |
| `data.metadata` | `{ sourceURL, title?, description?, language? }` | yes | The page's title, description and language. |
| `data.metadata.sourceURL` | `string` | yes | The URL that was asked for. |
| `data.metadata.title` | `string` | no | The page `<title>`. |
| `data.metadata.description` | `string` | no | The page's meta description. |
| `data.metadata.language` | `string` | no | The page language (`en`). |
| `data.markdown` | `string` | no | The page as Markdown (with `formats` including `markdown`, the default). |
| `data.html` | `string` | no | The cleaned HTML (with `html`). |
| `data.rawHtml` | `string` | no | The HTML exactly as served (with `rawHtml`). |
| `data.links` | `string[]` | no | Every link on the page (with `links`). |
| `data.screenshotUrl` | `string` | no | A URL of a screenshot of the page (with `screenshot`). |
| `data.json` | `unknown` | no | The data extracted with your `schema`/`prompt` (with `json`). |
| `data.summary` | `string` | no | A short summary of the page (with `summary`). |
| `data.contentUrl` | `string` | no | Reserved for content too large to return inline; not returned today. |
| `next` | `string` | no | Reserved for paging very large jobs; not returned today. |
| `resultUrl` | `string` | no | Reserved for very large results; not returned today. |

### `WebScrapeOutput`

One page read by `web.scrape()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | `string` | yes | The page's final URL (after redirects). |
| `statusCode` | `number` | yes | The HTTP status the page answered with. |
| `metadata` | `{ sourceURL, title?, description?, language? }` | yes | The page's title, description and language. |
| `metadata.sourceURL` | `string` | yes | The URL that was asked for. |
| `metadata.title` | `string` | no | The page `<title>`. |
| `metadata.description` | `string` | no | The page's meta description. |
| `metadata.language` | `string` | no | The page language (`en`). |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `markdown` | `string` | no | The page as Markdown (with `formats` including `markdown`, the default). |
| `html` | `string` | no | The cleaned HTML (with `html`). |
| `rawHtml` | `string` | no | The HTML exactly as served (with `rawHtml`). |
| `links` | `string[]` | no | Every link on the page (with `links`). |
| `screenshotUrl` | `string` | no | A URL of a screenshot of the page (with `screenshot`). |
| `json` | `unknown` | no | The data extracted with your `schema`/`prompt` (with `json`). |
| `summary` | `string` | no | A short summary of the page (with `summary`). |
| `contentUrl` | `string` | no | Reserved for content too large to return inline; not returned today. |

### `WebSearchOutput`

The results of `web.search()`, by source.

| Field | Type | Always present | Description |
|---|---|---|---|
| `web` | `({ title, url, position, snippet? })[]` | yes | Web results, best first. |
| `web.title` | `string` | yes | The result's title. |
| `web.url` | `string` | yes | The result's URL. |
| `web.position` | `number` | yes | The rank, from 1. |
| `web.snippet` | `string` | no | A short extract of the page. |
| `news` | `({ title, url, position, snippet? })[]` | no | News results (with `sources` including `news`). |
| `news.title` | `string` | yes | The article's title. |
| `news.url` | `string` | yes | The article's URL. |
| `news.position` | `number` | yes | The rank, from 1. |
| `news.snippet` | `string` | no | A short extract. |
| `images` | `(Record<string, unknown>)[]` | no | Image results (with `sources` including `images`), as the search returns them. |
| `scraped` | `({ url, statusCode, metadata, markdown?, html?, rawHtml?, links?, screenshotUrl?, json?, summary?, contentUrl? })[]` | no | With `scrapeOptions`: each web result's page, read like `web.scrape()` (same fields). |
| `scraped.url` | `string` | yes | The page's final URL (after redirects). |
| `scraped.statusCode` | `number` | yes | The HTTP status the page answered with. |
| `scraped.metadata` | `{ sourceURL, title?, description?, language? }` | yes | The page's title, description and language. |
| `scraped.metadata.sourceURL` | `string` | yes | The URL that was asked for. |
| `scraped.metadata.title` | `string` | no | The page `<title>`. |
| `scraped.metadata.description` | `string` | no | The page's meta description. |
| `scraped.metadata.language` | `string` | no | The page language (`en`). |
| `scraped.markdown` | `string` | no | The page as Markdown (with `formats` including `markdown`, the default). |
| `scraped.html` | `string` | no | The cleaned HTML (with `html`). |
| `scraped.rawHtml` | `string` | no | The HTML exactly as served (with `rawHtml`). |
| `scraped.links` | `string[]` | no | Every link on the page (with `links`). |
| `scraped.screenshotUrl` | `string` | no | A URL of a screenshot of the page (with `screenshot`). |
| `scraped.json` | `unknown` | no | The data extracted with your `schema`/`prompt` (with `json`). |
| `scraped.summary` | `string` | no | A short summary of the page (with `summary`). |
| `scraped.contentUrl` | `string` | no | Reserved for content too large to return inline; not returned today. |

## `totalum-sdk/pdf`

Turn HTML or a web page into a PDF stored in the project's files: invoices, tickets, reports. Each call returns the
stored file's `key` and `url`, plus what it cost in `usage.credits`.

```ts
const pdf = await totalum.pdf.fromHtml({ html: '<h1>Invoice 12</h1>', name: 'invoice-12' });
```

| Method or type | What it does |
|---|---|
| [`totalumPdf`](#totalumpdf) | Creates the `pdf` namespace (the same object as `totalum.pdf`, scope `files:write`): each call renders a PDF and stores it as a new public file of the project. |
| [`pdf.fromHtml`](#pdffromhtml) | Renders HTML (with its CSS) to a PDF in a real browser and stores it as a project file: invoices, tickets, certificates, reports. |
| [`pdf.fromUrl`](#pdffromurl) | Prints a web page to a PDF and stores it as a project file — archive a page, or render one of your own pages (a printable report route) as a PDF. |
| [`PdfFromHtmlInput`](#pdffromhtmlinput) | The input of `pdf.fromHtml()`. |
| [`PdfFromUrlInput`](#pdffromurlinput) | The input of `pdf.fromUrl()`. |
| [`PdfOutput`](#pdfoutput) | A PDF stored as a public project file, from `totalum.pdf`. |

### `totalumPdf`

```ts
totalumPdf(options?: TotalumClientOptions): TotalumPdf
```

Creates the `pdf` namespace (the same object as `totalum.pdf`, scope `files:write`): each call renders a PDF and
stores it as a new public file of the project.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumPdf`](#totalumpdf) — The `pdf` namespace: the methods below.

**Example**

```ts
import { totalumPdf } from 'totalum-sdk/pdf';

const pdf = totalumPdf();
const invoice = await pdf.fromHtml({ html: '<h1>Invoice 12</h1>', name: 'invoice-12' });
```

The namespace's type is exported as `TotalumPdf` (`import type { TotalumPdf } from 'totalum-sdk/pdf'`).

### `pdf.fromHtml`

```ts
pdf.fromHtml(input: PdfFromHtmlInput): Promise<{ key, fileName, url, usage }>
```

Renders HTML (with its CSS) to a PDF in a real browser and stores it as a project file: invoices, tickets,
certificates, reports. Use inline `<style>` and absolute image URLs. Every call stores a new file, even with the
same `name`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`PdfFromHtmlInput`](#pdffromhtmlinput) | yes | The HTML, the file name and the page options. |
| `input.name` | `string` | yes | The file name; `.pdf` is added when missing. Allowed: at least 1 character. |
| `input.html` | `string` | no | The HTML to render (or pass `base64HtmlString`). At most 10 MB. |
| `input.base64HtmlString` | `string` | no | The same HTML, base64-encoded, instead of `html`. |
| `input.options` | `{ format?, landscape?, margin?, printBackground?, headerTemplate?, footerTemplate?, displayHeaderFooter? }` | no | Paper and layout options. |
| `input.options.format` | `string` | no | The paper size: `A4` (default), `Letter`, `Legal`, `A3`, `A5`, `Tabloid`. |
| `input.options.landscape` | `boolean` | no | `true` for landscape pages. |
| `input.options.margin` | `Record<string, string>` | no | Page margins as CSS lengths: `{ top, right, bottom, left }` (`"20mm"`). |
| `input.options.printBackground` | `boolean` | no | `true` prints background colours and images. |
| `input.options.headerTemplate` | `string` | no | HTML of the header on every page (with `displayHeaderFooter`); `<span class="pageNumber"></span>` and `<span class="totalPages"></span>` are filled in. |
| `input.options.footerTemplate` | `string` | no | HTML of the footer on every page (with `displayHeaderFooter`). |
| `input.options.displayHeaderFooter` | `boolean` | no | `true` prints the header and footer templates. |
| `input.waitForSelector` | `string` | no | Wait until this CSS selector is on the page before printing. |

**Returns** `Promise<{ key, fileName, url, usage }>` — The stored PDF's `key`, `url` and `fileName`, and the credits it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `fileName` | `string` | yes | The file name (`invoice-12.pdf`). |
| `url` | `string` | yes | The PDF's public URL. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `PDF_TOO_LARGE` | 413 | The HTML is over 10 MB. |
| `CREATE_PDF_FROM_HTML_ERROR` | 502 | The HTML could not be rendered. |
| `VALIDATION_ERROR` | 400 | Neither `html` nor `base64HtmlString` was given, or `name` is empty. |

**Example**

```ts
const pdf = await totalum.pdf.fromHtml({
  html: '<style>body{font-family:sans-serif}</style><h1>Invoice 12</h1><p>Total: 49.00 €</p>',
  name: 'invoice-12',
  options: { format: 'A4', printBackground: true, margin: { top: '20mm', bottom: '20mm' } },
});
```

Result:

```json
{
  "url": "https://files.totalum-project.com/p/acme-crm/01M4P0S5Q26K57B47C6E9A65Y5.pdf",
  "fileName": "invoice-12.pdf",
  "key": "p/acme-crm/01M4P0S5Q26K57B47C6E9A65Y5.pdf",
  "usage": { "credits": 0.0004 }
}
```

### `pdf.fromUrl`

```ts
pdf.fromUrl(input: PdfFromUrlInput): Promise<{ key, fileName, url, usage }>
```

Prints a web page to a PDF and stores it as a project file — archive a page, or render one of your own pages
(a printable report route) as a PDF.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`PdfFromUrlInput`](#pdffromurlinput) | yes | The page URL, the file name, page options, and cookies or headers to send. |
| `input.url` | `string` | yes | The page to print. Allowed: a URL. |
| `input.name` | `string` | yes | The file name; `.pdf` is added when missing. Allowed: at least 1 character. |
| `input.options` | `{ format?, landscape?, margin?, printBackground?, headerTemplate?, footerTemplate?, displayHeaderFooter? }` | no | Paper and layout options (as in `pdf.fromHtml`). |
| `input.options.format` | `string` | no | The paper size: `A4` (default), `Letter`, `Legal`, `A3`, `A5`, `Tabloid`. |
| `input.options.landscape` | `boolean` | no | `true` for landscape pages. |
| `input.options.margin` | `Record<string, string>` | no | Page margins as CSS lengths: `{ top, right, bottom, left }` (`"20mm"`). |
| `input.options.printBackground` | `boolean` | no | `true` prints background colours and images. |
| `input.options.headerTemplate` | `string` | no | HTML of the header on every page (with `displayHeaderFooter`); `<span class="pageNumber"></span>` and `<span class="totalPages"></span>` are filled in. |
| `input.options.footerTemplate` | `string` | no | HTML of the footer on every page (with `displayHeaderFooter`). |
| `input.options.displayHeaderFooter` | `boolean` | no | `true` prints the header and footer templates. |
| `input.cookies` | `(Record<string, unknown>)[]` | no | Cookies to send with the page request, `{ name, value, domain }` each — for a page behind a login. |
| `input.headers` | `Record<string, string>` | no | Extra HTTP headers to send with the page request. |

**Returns** `Promise<{ key, fileName, url, usage }>` — The stored PDF's `key`, `url` and `fileName`, and the credits it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `fileName` | `string` | yes | The file name (`invoice-12.pdf`). |
| `url` | `string` | yes | The PDF's public URL. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `URL_NOT_ALLOWED` | 400 | Not a public `http(s)` URL. |
| `GENERATE_PDF_BY_URL_ERROR` | 502 | The page could not be rendered. |

**Example**

```ts
const pdf = await totalum.pdf.fromUrl({
  url: 'https://acme.totalum.app/reports/2026-09/print',
  name: 'report-2026-09',
  options: { format: 'A4', landscape: true },
});
```

Result:

```json
{
  "url": "https://files.totalum-project.com/p/acme-crm/01M4P1D2E3F4G5H6J7K8M9N0PQ.pdf",
  "fileName": "report-2026-09.pdf",
  "key": "p/acme-crm/01M4P1D2E3F4G5H6J7K8M9N0PQ.pdf",
  "usage": { "credits": 0.0006 }
}
```

### `PdfFromHtmlInput`

The input of `pdf.fromHtml()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | `string` | yes | The file name; `.pdf` is added when missing. Allowed: at least 1 character. |
| `html` | `string` | no | The HTML to render (or pass `base64HtmlString`). At most 10 MB. |
| `base64HtmlString` | `string` | no | The same HTML, base64-encoded, instead of `html`. |
| `options` | `{ format?, landscape?, margin?, printBackground?, headerTemplate?, footerTemplate?, displayHeaderFooter? }` | no | Paper and layout options. |
| `options.format` | `string` | no | The paper size: `A4` (default), `Letter`, `Legal`, `A3`, `A5`, `Tabloid`. |
| `options.landscape` | `boolean` | no | `true` for landscape pages. |
| `options.margin` | `Record<string, string>` | no | Page margins as CSS lengths: `{ top, right, bottom, left }` (`"20mm"`). |
| `options.printBackground` | `boolean` | no | `true` prints background colours and images. |
| `options.headerTemplate` | `string` | no | HTML of the header on every page (with `displayHeaderFooter`); `<span class="pageNumber"></span>` and `<span class="totalPages"></span>` are filled in. |
| `options.footerTemplate` | `string` | no | HTML of the footer on every page (with `displayHeaderFooter`). |
| `options.displayHeaderFooter` | `boolean` | no | `true` prints the header and footer templates. |
| `waitForSelector` | `string` | no | Wait until this CSS selector is on the page before printing. |

### `PdfFromUrlInput`

The input of `pdf.fromUrl()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `url` | `string` | yes | The page to print. Allowed: a URL. |
| `name` | `string` | yes | The file name; `.pdf` is added when missing. Allowed: at least 1 character. |
| `options` | `{ format?, landscape?, margin?, printBackground?, headerTemplate?, footerTemplate?, displayHeaderFooter? }` | no | Paper and layout options (as in `pdf.fromHtml`). |
| `options.format` | `string` | no | The paper size: `A4` (default), `Letter`, `Legal`, `A3`, `A5`, `Tabloid`. |
| `options.landscape` | `boolean` | no | `true` for landscape pages. |
| `options.margin` | `Record<string, string>` | no | Page margins as CSS lengths: `{ top, right, bottom, left }` (`"20mm"`). |
| `options.printBackground` | `boolean` | no | `true` prints background colours and images. |
| `options.headerTemplate` | `string` | no | HTML of the header on every page (with `displayHeaderFooter`); `<span class="pageNumber"></span>` and `<span class="totalPages"></span>` are filled in. |
| `options.footerTemplate` | `string` | no | HTML of the footer on every page (with `displayHeaderFooter`). |
| `options.displayHeaderFooter` | `boolean` | no | `true` prints the header and footer templates. |
| `cookies` | `(Record<string, unknown>)[]` | no | Cookies to send with the page request, `{ name, value, domain }` each — for a page behind a login. |
| `headers` | `Record<string, string>` | no | Extra HTTP headers to send with the page request. |

### `PdfOutput`

A PDF stored as a public project file, from `totalum.pdf`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | `string` | yes | The PDF's public URL. |
| `fileName` | `string` | yes | The file name (`invoice-12.pdf`). |
| `key` | `string` | yes | The stored file's key (see `totalum.files`). |

## `totalum-sdk/scan`

Read documents and images: extract the fields you describe (an invoice's number, date and total) as JSON with
`document()`, or get the plain text of every page with `ocr()`. The document can be a project file, a URL or a
file name of the project. Billed in credits.

```ts
const { data } = await totalum.scan.document({
  key: invoice.key,
  properties: { total: { type: 'number', description: 'the invoice total' } },
});
```

| Method or type | What it does |
|---|---|
| [`totalumScan`](#totalumscan) | Creates the `scan` namespace (the same object as `totalum.scan`, scope `scan`): documents and images read by a vision model. |
| [`scan.document`](#scandocument) | Reads a document or image (a PDF, a photo, a scan) and returns the fields you describe — an invoice's number, date and total, an ID card's name — as JSON. |
| [`scan.ocr`](#scanocr) | The text of a document or image (OCR), as Markdown per page, keeping reading order and tables. |
| [`ScanDocumentInput`](#scandocumentinput) | The input of `scan.document()`: one document and the fields to extract. |
| [`ScanDocumentResult`](#scandocumentresult) | What `scan.document()` returns: the extracted fields (one set per page with `processEveryPdfPageAsDifferentScan`), details of the scan, and what it cost. |
| [`ScanOcrInput`](#scanocrinput) | The input of `scan.ocr()`: one document. |
| [`ScanOcrOutput`](#scanocroutput) | The text of a document, from `scan.ocr()`. |

### `totalumScan`

```ts
totalumScan(options?: TotalumClientOptions): TotalumScan
```

Creates the `scan` namespace (the same object as `totalum.scan`, scope `scan`): documents and images read by a
vision model.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumScan`](#totalumscan) — The `scan` namespace: the methods below.

**Example**

```ts
import { totalumScan } from 'totalum-sdk/scan';

const scan = totalumScan();
const { text } = await scan.ocr({ key: 'p/acme-crm/01M4P0SFVZXNRS871ZSKZNWHY0.png' });
```

The namespace's type is exported as `TotalumScan` (`import type { TotalumScan } from 'totalum-sdk/scan'`).

### `scan.document`

```ts
scan.document(input: ScanDocumentInput): Promise<ScanDocumentResult>
```

Reads a document or image (a PDF, a photo, a scan) and returns the fields you describe — an invoice's number,
date and total, an ID card's name — as JSON. Describe each field with a JSON Schema type and a `description`;
fields not found are `null`. Pass exactly one of `key`, `fileName` or `url`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`ScanDocumentInput`](#scandocumentinput) | yes | The document and the fields to extract. |
| `input.properties` | `Record<string, Record<string, unknown>>` | yes | The fields to extract, as JSON Schema properties: `{ total: { type: "number", description: "the invoice total" } }`. A good `description` gives better results. |
| `input.options` | `{ model?, removeFileAfterScan?, returnOcrFullResult?, scanDescription?, processEveryPdfPageAsDifferentScan?, maxPages?, pdfPages?, minimumConfidence? }` | no | How the document is read. |
| `input.options.model` | `'scanum' \| 'scanum-pro' \| 'scanum-eye' \| 'scanum-eye-pro'` | no | `scanum` / `scanum-eye` (default: the fast vision model) or `scanum-pro` / `scanum-eye-pro` (the smarter model, for hard documents). |
| `input.options.removeFileAfterScan` | `boolean` | no | Accepted for compatibility; has no effect (delete the file with `files.delete`). |
| `input.options.returnOcrFullResult` | `boolean` | no | Accepted for compatibility; has no effect (use `scan.ocr` for the text). |
| `input.options.scanDescription` | `string` | no | What the document is, in words (`a Spanish electricity bill`): helps the model. |
| `input.options.processEveryPdfPageAsDifferentScan` | `boolean` | no | `true` extracts the fields once per page (`data` is then an array): a PDF of many receipts. |
| `input.options.maxPages` | `number` | no | Accepted for compatibility; has no effect. Allowed: > 0, ≤ 200. |
| `input.options.pdfPages` | `number[]` | no | Accepted for compatibility; has no effect. |
| `input.options.minimumConfidence` | `number` | no | Accepted for compatibility; has no effect. Allowed: ≥ 0, ≤ 1. |
| `input.fileName` | `string` | no | Same as `key` (the name of the first scan API). |
| `input.key` | `string` | no | The document as a project file key (a PDF or an image). |
| `input.url` | `string` | no | The document as a URL (a public `https://` URL, or a project file URL). Allowed: a URL. |

**Returns** `Promise<ScanDocumentResult>` — The extracted fields, details of the scan, and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `data` | `Record<string, unknown> \| (Record<string, unknown>)[]` | yes | The fields you described in `properties`, with the values read from the document (`null` when absent); an array of them, one per page, with `processEveryPdfPageAsDifferentScan`. |
| `metadata` | `{ usageUnits, exactUsageUnits, pages, provider, credits }` | yes | Details of the scan, kept in the shape of the first scan API. |
| `metadata.usageUnits` | `number` | yes | Pages read. |
| `metadata.exactUsageUnits` | `number` | yes | Same as `usageUnits`. |
| `metadata.pages` | `number` | yes | Pages in the document. |
| `metadata.provider` | `string` | yes | Always `'totalum'`. |
| `metadata.credits` | `number` | yes | Same as `usage.credits`. |
| `usage` | `{ credits }` | yes | What the scan cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `UNSUPPORTED_FILE_TYPE` | 400 | The file is not a PDF or an image. |
| `FILE_NOT_FOUND` | 404 | No file with this key. |
| `FILE_KEY_FORBIDDEN` | 403 | The key belongs to another project. |
| `FILE_TOO_LARGE` | 413 | The file is over the plan's largest file. |
| `URL_NOT_ALLOWED` | 400 | The `url` is not a public `http(s)` URL. |
| `SCAN_DOCUMENT_ERROR` | 502 | The document could not be read; retry, or try `model: 'scanum-pro'`. |

**Example**

```ts
const scan = await totalum.scan.document({
  key: invoice.key,
  properties: {
    invoiceNumber: { type: 'string', description: 'the invoice number' },
    issueDate: { type: 'string', description: 'the issue date as YYYY-MM-DD' },
    total: { type: 'number', description: 'the total amount including tax' },
  },
});
```

Result:

```json
{
  "data": { "invoiceNumber": "F-2026-0012", "issueDate": "2026-09-30", "total": 1210 },
  "metadata": { "usageUnits": 1, "exactUsageUnits": 1, "pages": 1, "provider": "totalum", "credits": 0.0039 },
  "usage": { "credits": 0.0039 }
}
```

### `scan.ocr`

```ts
scan.ocr(input: ScanOcrInput): Promise<{ pages, text, fullDetails?, usage }>
```

The text of a document or image (OCR), as Markdown per page, keeping reading order and tables. Use it to index a
PDF for search, or to read a photo of a sign. Long PDFs are read in parallel chunks. Pass exactly one of `key`,
`fileName` or `url`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`ScanOcrInput`](#scanocrinput) | yes | The document to read. |
| `input.pages` | `number[]` | no | Accepted for compatibility; has no effect (every page is read). |
| `input.fileName` | `string` | no | Same as `key` (the name of the first scan API). |
| `input.key` | `string` | no | The document as a project file key (a PDF or an image). |
| `input.url` | `string` | no | The document as a URL (a public `https://` URL, or a project file URL). Allowed: a URL. |

**Returns** `Promise<{ pages, text, fullDetails?, usage }>` — The whole text, the text of each page, and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `pages` | `({ markdown, width, height, words? })[]` | yes | One entry per page. |
| `pages.markdown` | `string` | yes | The page's text as Markdown (tables kept). |
| `pages.width` | `number` | yes | Not measured today: always 0. |
| `pages.height` | `number` | yes | Not measured today: always 0. |
| `pages.words` | `({ text, bbox })[]` | no | Not returned today. |
| `pages.words.text` | `string` | yes | Not returned today. |
| `pages.words.bbox` | `number[]` | yes | Not returned today. |
| `text` | `string` | yes | The whole text, pages separated by blank lines. |
| `fullDetails` | `unknown` | no | Not returned today. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `UNSUPPORTED_FILE_TYPE` | 400 | The file is not a PDF or an image. |
| `FILE_NOT_FOUND` | 404 | No file with this key. |
| `FILE_KEY_FORBIDDEN` | 403 | The key belongs to another project. |
| `FILE_TOO_LARGE` | 413 | The file is over the plan's largest file. |
| `OCR_PDF_PROCESSING_ERROR` | 502 | A PDF could not be read. |
| `OCR_IMAGE_PROCESSING_ERROR` | 502 | An image could not be read. |

**Example**

```ts
const { text, pages } = await totalum.scan.ocr({ key: 'p/acme-crm/01M4P0SFVZXNRS871ZSKZNWHY0.png' });
```

Result:

```json
{
  "text": "TOTALUM 42",
  "pages": [{ "markdown": "TOTALUM 42", "width": 0, "height": 0 }],
  "usage": { "credits": 0.021 }
}
```

### `ScanDocumentInput`

The input of `scan.document()`: one document and the fields to extract.

| Name | Type | Required | Description |
|---|---|---|---|
| `properties` | `Record<string, Record<string, unknown>>` | yes | The fields to extract, as JSON Schema properties: `{ total: { type: "number", description: "the invoice total" } }`. A good `description` gives better results. |
| `options` | `{ model?, removeFileAfterScan?, returnOcrFullResult?, scanDescription?, processEveryPdfPageAsDifferentScan?, maxPages?, pdfPages?, minimumConfidence? }` | no | How the document is read. |
| `options.model` | `'scanum' \| 'scanum-pro' \| 'scanum-eye' \| 'scanum-eye-pro'` | no | `scanum` / `scanum-eye` (default: the fast vision model) or `scanum-pro` / `scanum-eye-pro` (the smarter model, for hard documents). |
| `options.removeFileAfterScan` | `boolean` | no | Accepted for compatibility; has no effect (delete the file with `files.delete`). |
| `options.returnOcrFullResult` | `boolean` | no | Accepted for compatibility; has no effect (use `scan.ocr` for the text). |
| `options.scanDescription` | `string` | no | What the document is, in words (`a Spanish electricity bill`): helps the model. |
| `options.processEveryPdfPageAsDifferentScan` | `boolean` | no | `true` extracts the fields once per page (`data` is then an array): a PDF of many receipts. |
| `options.maxPages` | `number` | no | Accepted for compatibility; has no effect. Allowed: > 0, ≤ 200. |
| `options.pdfPages` | `number[]` | no | Accepted for compatibility; has no effect. |
| `options.minimumConfidence` | `number` | no | Accepted for compatibility; has no effect. Allowed: ≥ 0, ≤ 1. |
| `fileName` | `string` | no | Same as `key` (the name of the first scan API). |
| `key` | `string` | no | The document as a project file key (a PDF or an image). |
| `url` | `string` | no | The document as a URL (a public `https://` URL, or a project file URL). Allowed: a URL. |

### `ScanDocumentResult`

What `scan.document()` returns: the extracted fields (one set per page with `processEveryPdfPageAsDifferentScan`),
details of the scan, and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `data` | `Record<string, unknown> \| (Record<string, unknown>)[]` | yes | The fields you described in `properties`, with the values read from the document (`null` when absent); an array of them, one per page, with `processEveryPdfPageAsDifferentScan`. |
| `metadata` | `{ usageUnits, exactUsageUnits, pages, provider, credits }` | yes | Details of the scan, kept in the shape of the first scan API. |
| `metadata.usageUnits` | `number` | yes | Pages read. |
| `metadata.exactUsageUnits` | `number` | yes | Same as `usageUnits`. |
| `metadata.pages` | `number` | yes | Pages in the document. |
| `metadata.provider` | `string` | yes | Always `'totalum'`. |
| `metadata.credits` | `number` | yes | Same as `usage.credits`. |
| `usage` | `{ credits }` | yes | What the scan cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

### `ScanOcrInput`

The input of `scan.ocr()`: one document.

| Name | Type | Required | Description |
|---|---|---|---|
| `pages` | `number[]` | no | Accepted for compatibility; has no effect (every page is read). |
| `fileName` | `string` | no | Same as `key` (the name of the first scan API). |
| `key` | `string` | no | The document as a project file key (a PDF or an image). |
| `url` | `string` | no | The document as a URL (a public `https://` URL, or a project file URL). Allowed: a URL. |

### `ScanOcrOutput`

The text of a document, from `scan.ocr()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `text` | `string` | yes | The whole text, pages separated by blank lines. |
| `pages` | `({ markdown, width, height, words? })[]` | yes | One entry per page. |
| `pages.markdown` | `string` | yes | The page's text as Markdown (tables kept). |
| `pages.width` | `number` | yes | Not measured today: always 0. |
| `pages.height` | `number` | yes | Not measured today: always 0. |
| `pages.words` | `({ text, bbox })[]` | no | Not returned today. |
| `pages.words.text` | `string` | yes | Not returned today. |
| `pages.words.bbox` | `number[]` | yes | Not returned today. |
| `fullDetails` | `unknown` | no | Not returned today. |

## `totalum-sdk/vectors`

Semantic search over your own content: store texts or images as vectors with metadata (`upsert`), then find the
closest ones to a question, a text or an image (`query`). Totalum embeds the text and images for you and keeps each
project environment's vectors private. Use it for "search by meaning", recommendations and retrieval for an AI
answer. Server-side code only. Writes and queries are billed in credits; `count()` is free.

```ts
await totalum.vectors.upsert([{ id: 'faq-1', text: 'Refunds are possible for 30 days.', metadata: { source: 'faq' } }]);
const { matches } = await totalum.vectors.query({ text: 'Can I get my money back?', topK: 3 });
```

| Method or type | What it does |
|---|---|
| [`totalumVectors`](#totalumvectors) | Creates the `vectors` namespace (the same object as `totalum.vectors`; scope `vectors`; server-side code only: it needs the project key). |
| [`vectors.upsert`](#vectorsupsert) | Stores up to 100 items (at most 8 of them images) for semantic search: give each an `id` of yours (the row id), its content as `text`, `image` or ready-made `values`, and metadata to filter on. |
| [`vectors.query`](#vectorsquery) | Finds the stored items closest in meaning to a text, an image or a vector — "search by meaning". |
| [`vectors.get`](#vectorsget) | Reads stored items back by id, with their numbers and metadata. |
| [`vectors.delete`](#vectorsdelete) | Deletes items by id — when the row they describe is deleted. |
| [`vectors.count`](#vectorscount) | How many items this environment stores, in total and per space. |
| [`VectorFilter`](#vectorfilter) | A metadata filter for `vectors.query()`: an object from a metadata key (`source`, `type`, `tag`, `group` — strings — or `page` — a number) to a value it must equal, or to an operator object: `$eq`, `$ne` (equal, not equal), `$in`, `$nin` (one of, none of a list), `$lt`, `$lte`, `$gt`, `$gte` (ranges). |
| [`VectorMatch`](#vectormatch) | One result of `vectors.query()`. |
| [`VectorMetadata`](#vectormetadata) | The metadata stored with an item: an object from string keys (not starting with `_`) to a string, a number, a boolean or a list of strings; at most 9 KB as JSON. |
| [`VectorRecord`](#vectorrecord) | A stored item, from `vectors.get()`. |
| [`VectorSpace`](#vectorspace) | Which embedding space an item lives in. |
| [`VectorsQueryInput`](#vectorsqueryinput) | What `vectors.query()` searches for: exactly one of `text`, `image` or `values`. |
| [`VectorUpsertItem`](#vectorupsertitem) | One item to store with `vectors.upsert()`: an id, exactly one content (`text`, `image` or `values`) and optional metadata. |

### `totalumVectors`

```ts
totalumVectors(options?: TotalumClientOptions): TotalumVectors
```

Creates the `vectors` namespace (the same object as `totalum.vectors`; scope `vectors`; server-side code only: it
needs the project key). Every
project environment has its own private vector store: ids, namespaces and indexes are Totalum's, never yours to
name, and `x-totalum-env` (the `env` option) keeps dev and live apart. Vectors are 1,024 numbers; `text` is
embedded with the `embed` model and `image` (a project file key, a files URL, an https URL or a data URL) with the
`embed-image` model, whose space also embeds text: search images by words with `space: 'image'`. Filter on the
metadata keys `source`, `type`, `tag`, `group` (strings) and `page` (number).

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumVectors`](#totalumvectors) — The `vectors` namespace: the methods below.

**Example**

```ts
import { totalumVectors } from 'totalum-sdk/vectors';

const vectors = totalumVectors();
const { count } = await vectors.count();
```

The namespace's type is exported as `TotalumVectors` (`import type { TotalumVectors } from 'totalum-sdk/vectors'`).

### `vectors.upsert`

```ts
vectors.upsert(vectors: VectorUpsertItem[]): Promise<{ upserted, count, usage }>
```

Stores up to 100 items (at most 8 of them images) for semantic search: give each an `id` of yours (the row id),
its content as `text`, `image` or ready-made `values`, and metadata to filter on. Writing an existing id replaces
it. The answer (and `count()`) means stored; `query` and `get` see the write later — often seconds, sometimes 10+
minutes (the index applies writes asynchronously). Never wait or poll for a write to become searchable.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `vectors` | [`VectorUpsertItem[]`](#vectorupsertitem) | yes | The items to store; each has exactly one of `text`, `image` or `values`. |

**Returns** `Promise<{ upserted, count, usage }>` — How many were written, how many the environment now holds, and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `upserted` | `number` | yes | How many items were written. |
| `count` | `number` | yes | How many items the environment holds now. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | More than 100 items or 8 images, duplicate ids, an item with no or several contents, a metadata key starting with `_`, or metadata over 9 KB. |
| `VECTORS_LIMIT_REACHED` | 403 | The plan's stored vectors per environment are used up; delete some or upgrade. |
| `FILE_TOO_LARGE` | 413 | An image is over 20 MB. |
| `AI_PROVIDER_UNAVAILABLE` | 502 | The text or image could not be embedded; retry. |
| `PROVIDER_UNAVAILABLE` | 503 | The vector store is unavailable (or being set up: retry in a minute). |

**Example**

```ts
await totalum.vectors.upsert([
  { id: 'faq-12', text: 'Refunds are possible for 30 days after purchase.', metadata: { source: 'faq', tag: 'billing' } },
  { id: 'product-88', image: product.photo.key, metadata: { type: 'product' } },
]);
```

Result:

```json
{ "upserted": 2, "count": 2, "usage": { "credits": 0.0021 } }
```

### `vectors.query`

```ts
vectors.query(input: VectorsQueryInput): Promise<{ matches, usage }>
```

Finds the stored items closest in meaning to a text, an image or a vector — "search by meaning". Results are best
first with a similarity `score`; filter them on metadata. Pass exactly one of `text`, `image` or `values`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`VectorsQueryInput`](#vectorsqueryinput) | yes | What to search for, how many results, and the metadata filter. |
| `input.text` | `string` | no | Find items close in meaning to this text. Allowed: at least 1 character, at most 20000 characters. |
| `input.values` | `number[]` | no | Find items close to this vector of 1,024 numbers. Allowed: at least 1024 items, at most 1024 items. |
| `input.image` | `string` | no | Find images close to this image (a project file key, a files URL, an `https://` URL or a `data:` URL). Allowed: at least 1 character. |
| `input.space` | `'text' \| 'image'` | no | `text` (default) searches texts; `image` searches images — with `text`, the words are matched against the images. |
| `input.topK` | `number` | no | How many results. Default 5. Allowed: ≥ 1, ≤ 50. |
| `input.filter` | [`VectorFilter`](#vectorfilter) | no | Only items whose metadata matches: `{ source: "faq" }`, `{ page: { $gte: 3 } }`, `{ tag: { $in: ["a", "b"] } }`. Keys: `source`, `type`, `tag`, `group`, `page`. Operators: `$eq`, `$ne`, `$in`, `$nin`, `$lt`, `$lte`, `$gt`, `$gte`. At most 2 KB. |
| `input.includeValues` | `boolean` | no | `true` also returns each match's 1,024 numbers. |

**Returns** `Promise<{ matches, usage }>` — The closest items (best first) and what the query cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `matches` | [`VectorMatch[]`](#vectormatch) | yes | The closest items, best first. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | None or several of `text`/`image`/`values`, `topK` over 50, a filter over 2 KB or on another metadata key. |
| `FILE_TOO_LARGE` | 413 | The image is over 20 MB. |
| `AI_PROVIDER_UNAVAILABLE` | 502 | The query could not be embedded; retry. |
| `PROVIDER_UNAVAILABLE` | 503 | The vector store is unavailable; retry later. |

**Examples**

```ts
const { matches } = await totalum.vectors.query({
  text: 'Can I get my money back?',
  topK: 3,
  filter: { source: 'faq' },
});
```

Result:

```json
{
  "matches": [
    { "id": "faq-12", "score": 0.83, "space": "text", "metadata": { "source": "faq", "tag": "billing" } },
    { "id": "faq-07", "score": 0.61, "space": "text", "metadata": { "source": "faq", "tag": "orders" } }
  ],
  "usage": { "credits": 0.0006 }
}
```

```ts
// images that look like a description (image search by words)
const { matches } = await totalum.vectors.query({ text: 'red running shoes', space: 'image', topK: 8 });
```

### `vectors.get`

```ts
vectors.get(ids: string[]): Promise<VectorRecord[]>
```

Reads stored items back by id, with their numbers and metadata. Unknown ids — and ids written in the last seconds
or minutes — are left out of the answer. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `ids` | `string[]` | yes | Up to 100 ids. |

**Returns** `Promise<VectorRecord[]>` — The items found (possibly fewer than asked).

Each item:

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The item's id. |
| `space` | [`VectorSpace`](#vectorspace) | yes | Its space. |
| `values` | `number[]` | yes | Its 1,024 numbers. |
| `metadata` | [`VectorMetadata`](#vectormetadata) | no | Its metadata. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | No ids, or more than 100. |

**Example**

```ts
const [doc] = await totalum.vectors.get(['faq-12']);
```

Result:

```ts
[
  { "id": "faq-12", "space": "text", "values": [0.0138, -0.0121, 0.0125, …], "metadata": { "source": "faq", "tag": "billing" } }
]
```

### `vectors.delete`

```ts
vectors.delete(ids: string[]): Promise<{ count, deleted, usage }>
```

Deletes items by id — when the row they describe is deleted. Ids that do not exist are ignored.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `ids` | `string[]` | yes | Up to 1,000 ids. |

**Returns** `Promise<{ count, deleted, usage }>` — How many existed and were deleted, how many the environment still holds, and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `count` | `number` | yes | How many items the environment still holds. |
| `deleted` | `number` | yes | How many of the ids existed and were deleted. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | No ids, or more than 1,000. |

**Example**

```ts
const out = await totalum.vectors.delete(['faq-12', 'faq-13']);
```

Result:

```json
{ "deleted": 1, "count": 41, "usage": { "credits": 0 } }
```

### `vectors.count`

```ts
vectors.count(): Promise<{ count, text, image }>
```

How many items this environment stores, in total and per space. Free.

**Returns** `Promise<{ count, text, image }>` — The totals.

| Field | Type | Always present | Description |
|---|---|---|---|
| `count` | `number` | yes | Items stored in this environment. |
| `text` | `number` | yes | Of them, in the `text` space. |
| `image` | `number` | yes | Of them, in the `image` space. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `PROVIDER_UNAVAILABLE` | 503 | The vector store is unavailable; retry later. |

**Example**

```ts
const counts = await totalum.vectors.count();
```

Result:

```json
{ "count": 42, "text": 40, "image": 2 }
```

### `VectorFilter`

A metadata filter for `vectors.query()`: an object from a metadata key (`source`, `type`, `tag`, `group` — strings — or `page` — a number) to a value it must equal, or to an operator object: `$eq`, `$ne` (equal, not equal), `$in`, `$nin` (one of, none of a list), `$lt`, `$lte`, `$gt`, `$gte` (ranges). Example: `{ source: "faq", page: { $gte: 3 } }`. Strings are compared on their first 64 bytes.

### `VectorMatch`

One result of `vectors.query()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The item's id. |
| `score` | `number` | yes | How similar it is, higher is closer (cosine similarity, up to 1). |
| `space` | [`VectorSpace`](#vectorspace) | yes | The space it was found in. |
| `metadata` | [`VectorMetadata`](#vectormetadata) | no | The item's metadata. |
| `values` | `number[]` | no | The item's 1,024 numbers (with `includeValues: true`). |

### `VectorMetadata`

The metadata stored with an item: an object from string keys (not starting with `_`) to a string, a number, a boolean or a list of strings; at most 9 KB as JSON.

```ts
type VectorMetadata = Record<string, string | number | boolean | string[]>
```

### `VectorRecord`

A stored item, from `vectors.get()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The item's id. |
| `space` | [`VectorSpace`](#vectorspace) | yes | Its space. |
| `values` | `number[]` | yes | Its 1,024 numbers. |
| `metadata` | [`VectorMetadata`](#vectormetadata) | no | Its metadata. |

### `VectorSpace`

Which embedding space an item lives in.

| Value | Meaning |
|---|---|
| `'text'` | Texts, embedded with the `embed` model. |
| `'image'` | Images, embedded with the `embed-image` model; this space also embeds text, so words can find images. |

### `VectorsQueryInput`

What `vectors.query()` searches for: exactly one of `text`, `image` or `values`.

| Name | Type | Required | Description |
|---|---|---|---|
| `text` | `string` | no | Find items close in meaning to this text. Allowed: at least 1 character, at most 20000 characters. |
| `values` | `number[]` | no | Find items close to this vector of 1,024 numbers. Allowed: at least 1024 items, at most 1024 items. |
| `image` | `string` | no | Find images close to this image (a project file key, a files URL, an `https://` URL or a `data:` URL). Allowed: at least 1 character. |
| `space` | `'text' \| 'image'` | no | `text` (default) searches texts; `image` searches images — with `text`, the words are matched against the images. |
| `topK` | `number` | no | How many results. Default 5. Allowed: ≥ 1, ≤ 50. |
| `filter` | [`VectorFilter`](#vectorfilter) | no | Only items whose metadata matches: `{ source: "faq" }`, `{ page: { $gte: 3 } }`, `{ tag: { $in: ["a", "b"] } }`. Keys: `source`, `type`, `tag`, `group`, `page`. Operators: `$eq`, `$ne`, `$in`, `$nin`, `$lt`, `$lte`, `$gt`, `$gte`. At most 2 KB. |
| `includeValues` | `boolean` | no | `true` also returns each match's 1,024 numbers. |

### `VectorUpsertItem`

One item to store with `vectors.upsert()`: an id, exactly one content (`text`, `image` or `values`) and optional metadata.

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | yes | Your id for the item (usually the row id). Writing an existing id replaces it. Printable ASCII, no spaces. Allowed: at least 1 character, at most 48 characters. |
| `values` | `number[]` | no | Ready-made embedding: exactly 1,024 numbers. Allowed: at least 1024 items, at most 1024 items. |
| `text` | `string` | no | Text to embed with the `embed` model (the `text` space). Allowed: at least 1 character, at most 20000 characters. |
| `image` | `string` | no | An image to embed with the `embed-image` model (the `image` space): a project file key, a files URL, an `https://` URL or a `data:image/…` URL. Allowed: at least 1 character. |
| `space` | `'text' \| 'image'` | no | Only with `values`: the space they were made in, `text` (default) or `image`. |
| `metadata` | [`VectorMetadata`](#vectormetadata) | no | Key/value data kept with the item (strings, numbers, booleans or lists of strings; at most 9 KB; keys must not start with `_`). Queries can filter on `source`, `type`, `tag`, `group` and `page`. |

## `totalum-sdk/connections`

Let your app's users connect their own accounts of about 1,500 apps — Google Calendar, Gmail, Google Drive,
Outlook, Slack, HubSpot, Notion, GitHub, Stripe, Airtable and more — and act on them from your server: create a
calendar event, send an e-mail from their Gmail, read their files, or receive their events. They sign in on a hosted
page, you never handle OAuth apps or tokens, and each user only ever sees their own connections. `userId` is always
your signed-in user's id, taken from the session on the server. Actions are billed in credits; listing is free.

```ts
const url = await totalum.connections.connectUrl({ userId: user.id, toolkit: 'googlecalendar', returnUrl });
```

| Method or type | What it does |
|---|---|
| [`totalumConnections`](#totalumconnections) | Creates the `connections` namespace (the same object as `totalum.connections`; scope `connections`; server-side code only: it needs the project key). |
| [`connections.connectUrl`](#connectionsconnecturl) | The URL to send the user to so they connect `toolkit` (e.g. |
| [`connections.list`](#connectionslist) | The user's connected accounts (newest first), optionally of one app: show them on a settings page with a "Disconnect" button. |
| [`connections.isConnected`](#connectionsisconnected) | Whether the user has an active connection of an app: show "Connect" or the feature that needs it. |
| [`connections.disconnect`](#connectionsdisconnect) | Disconnects one of the user's connected accounts (the provider's access is revoked). |
| [`connections.execute`](#connectionsexecute) | Runs `action` (e.g. |
| [`connections.actions`](#connectionsactions) | The actions an app offers (slug, name, description), optionally filtered by words — to find the slug to pass to `execute()`. |
| [`connections.action`](#connectionsaction) | One action with its `inputSchema`: the JSON Schema of what `execute()` takes as `input`. |
| [`connections.toolkits`](#connectionstoolkits) | Searches the catalogue of apps (`search` matches names and descriptions, `category` e.g. |
| [`connections.toolkit`](#connectionstoolkit) | One app, exactly: how it connects and, for `credentials`, the fields the user types on the hosted page. |
| [`connections.triggers`](#connectionstriggers) | The events (triggers) an app can send, such as `GMAIL_NEW_GMAIL_MESSAGE` — to find the slug to pass to `subscribe()`. |
| [`connections.trigger`](#connectionstrigger) | One trigger with `configSchema` (what `subscribe`'s `config` takes) and `payloadSchema` (the event's shape). |
| [`connections.subscribe`](#connectionssubscribe) | Watches `trigger` on the user's active connection of its toolkit: each event is POSTed to `deliverTo` (a path of your own app, default `/api/webhooks/connections`) on the preview (dev users) or the published site (live users), signed with your project's webhook secret — read it with `verifyEvent`. |
| [`connections.subscriptions`](#connectionssubscriptions) | The user's event subscriptions. |
| [`connections.unsubscribe`](#connectionsunsubscribe) | Stops one of the user's subscriptions: no more events are delivered. |
| [`connections.verifyEvent`](#connectionsverifyevent) | Verifies one delivered event (the raw body text and the request headers of your `deliverTo` route) with `TOTALUM_WEBHOOK_SECRET` and returns it typed: `data.userId` is your user, `data.payload` the event. |
| [`Connection`](#connection) | One of a user's connected accounts. |
| [`ConnectionAction`](#connectionaction) | An action an app offers. |
| [`ConnectionEvent`](#connectionevent) | One event from a user's connected account, delivered to your `deliverTo` route and returned by `connections.verifyEvent()`. |
| [`ConnectionStatus`](#connectionstatus) | The state of a connected account. |
| [`ConnectionSubscription`](#connectionsubscription) | A subscription to a trigger on a user's connected account. |
| [`ConnectionToolkit`](#connectiontoolkit) | An app of the catalogue. |
| [`ConnectionToolkitDetail`](#connectiontoolkitdetail) | One app, from `connections.toolkit(slug)`: the catalogue fields plus the credential fields. |
| [`ConnectionTrigger`](#connectiontrigger) | An event an app can send. |

### `totalumConnections`

```ts
totalumConnections(options?: TotalumClientOptions): TotalumConnections
```

Creates the `connections` namespace (the same object as `totalum.connections`; scope `connections`; server-side code
only: it needs the project key). Your app's users connect their own accounts of ~1,500 apps — Google Calendar, Gmail, Google Drive, Outlook,
OneDrive, Slack, Dropbox, Notion, HubSpot, GitHub, Stripe, Airtable, … — on a hosted page (they sign in at the
provider, or type their own API key there), with no OAuth app of yours; then your server runs actions on them
(`GOOGLECALENDAR_CREATE_EVENT`, `GMAIL_SEND_EMAIL`, …) and receives their events (`subscribe`). The tokens never
reach your app. `userId` is always **your signed-in user's id** (the one in your database), taken from the session
on the server — never from the request: each user, project and environment (dev/live) only ever sees its own
connections.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumConnections`](#totalumconnections) — The `connections` namespace: the methods below.

**Example**

```ts
import { totalumConnections } from 'totalum-sdk/connections';

const connections = totalumConnections();
const list = await connections.list({ userId: user.id });
```

The namespace's type is exported as `TotalumConnections` (`import type { TotalumConnections } from 'totalum-sdk/connections'`).

### `connections.connectUrl`

```ts
connections.connectUrl(input: { userId, toolkit, returnUrl }): Promise<string>
```

The URL to send the user to so they connect `toolkit` (e.g. `'googlecalendar'`); valid about 10 minutes, so
make it when the user clicks "Connect" and redirect at once. They sign in at the provider (or type their own key
on the hosted page) and come back to `returnUrl` with `?connection=success|failed&toolkit=<toolkit>` added. A new
connection through Totalum's sign-in is billed once in credits when it becomes active.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ userId, toolkit, returnUrl }` | yes | Your user, the app to connect and where to come back. |
| `input.userId` | `string` | yes | Your signed-in user's id (from your session, never from the request). Allowed: at least 1 character, at most 200 characters. |
| `input.toolkit` | `string` | yes | The app to connect, by slug (`googlecalendar`, `gmail`, `slack`); find slugs with `toolkits({ search })`. |
| `input.returnUrl` | `string` | yes | Where the user comes back after signing in; `https://` (or `http://localhost`). `?connection=success\|failed&toolkit=…` is added. Allowed: at most 2000 characters, a URL. |

**Returns** `Promise<string>` — The hosted sign-in URL to redirect the user to.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `returnUrl` is not `https://` (only `localhost` may be `http`). |
| `RECORD_NOT_FOUND` | 404 | No app with this slug: look it up with `toolkits({ search })`. |
| `TOOLKIT_NOT_AVAILABLE` | 400 | This app cannot be connected. |
| `TOOLKIT_NEEDS_OWN_APP` | 400 | This app only signs in through an OAuth app registered by the developer, which is not available. |

**Example**

```ts
// src/app/api/connect/google-calendar/route.ts
const user = await requireUser(); // your own session
const url = await totalum.connections.connectUrl({
  userId: user.id,
  toolkit: 'googlecalendar',
  returnUrl: 'https://acme.totalum.app/settings/integrations',
});
return Response.redirect(url, 302);
```

Result:

```json
"https://connect.<sign-in host>/link/lk_Iem_dXdIv3-i"
```

### `connections.list`

```ts
connections.list(input: { userId, toolkit? }): Promise<Connection[]>
```

The user's connected accounts (newest first), optionally of one app: show them on a settings page with a
"Disconnect" button. `status` is `active` when usable. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ userId, toolkit? }` | yes | Your user, and optionally one app. |
| `input.userId` | `string` | yes | Your signed-in user's id, from the session. |
| `input.toolkit` | `string` | no | Only connections of this app (`googlecalendar`). |

**Returns** `Promise<Connection[]>` — The connections.

Each item:

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The connection id: pass it to `disconnect()`, or as `connectionId`. |
| `toolkit` | `string` | yes | The app (`googlecalendar`). |
| `status` | [`ConnectionStatus`](#connectionstatus) | yes | Whether it can be used: see `ConnectionStatus`. |
| `createdAt` | `string` | yes | When the user connected it. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `userId` is empty or over 200 characters. |

**Example**

```ts
const list = await totalum.connections.list({ userId: 'user_42' });
```

Result:

```json
[
  { "id": "ca_8Qw2nZ4kLp", "toolkit": "googlecalendar", "status": "active", "createdAt": "2026-10-02T09:14:00.000Z" },
  { "id": "ca_3Hy7tR1mXc", "toolkit": "gmail", "status": "expired", "createdAt": "2026-08-21T16:40:00.000Z" }
]
```

### `connections.isConnected`

```ts
connections.isConnected(input: { userId, toolkit }): Promise<boolean>
```

Whether the user has an active connection of an app: show "Connect" or the feature that needs it. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ userId, toolkit }` | yes | Your user and the app. |
| `input.userId` | `string` | yes | Your signed-in user's id, from the session. |
| `input.toolkit` | `string` | yes | The app's slug (`googlecalendar`). |

**Returns** `Promise<boolean>` — `true` when the user has an active connection of `toolkit`.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `userId` is empty or over 200 characters. |

**Example**

```ts
const connected = await totalum.connections.isConnected({ userId: 'user_42', toolkit: 'googlecalendar' });
```

Result:

```json
false
```

### `connections.disconnect`

```ts
connections.disconnect(input: { userId, id }): Promise<{ deleted }>
```

Disconnects one of the user's connected accounts (the provider's access is revoked). Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ userId, id }` | yes | Your user and the connection. |
| `input.userId` | `string` | yes | Your signed-in user's id, from the session. |
| `input.id` | `string` | yes | The connection's `id`, from `list()`. |

**Returns** `Promise<{ deleted }>` — `{ deleted: true }`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `deleted` | `true` | yes | Always `true`. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `RECORD_NOT_FOUND` | 404 | The user has no connection with this id. |

**Example**

```ts
await totalum.connections.disconnect({ userId: 'user_42', id: 'ca_8Qw2nZ4kLp' });
```

Result:

```json
{ "deleted": true }
```

### `connections.execute`

```ts
connections.execute(input: { userId, action, input?, connectionId? }): Promise<{ data, usage }>
```

Runs `action` (e.g. `GOOGLECALENDAR_CREATE_EVENT`) with the user's active connection of its toolkit and
returns the provider's answer in `data`. Read the action's `inputSchema` with `action(slug)` to build `input`.
Billed in credits per action.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ userId, action, input?, connectionId? }` | yes | Your user, the action and its input. |
| `input.userId` | `string` | yes | Your signed-in user's id. Allowed: at least 1 character, at most 200 characters. |
| `input.action` | `string` | yes | The action's slug (`GOOGLECALENDAR_CREATE_EVENT`); find it with `actions({ toolkit, search })`. |
| `input.input` | `Record<string, unknown>` | no | The action's arguments, as its `inputSchema` describes (read it with `action(slug)`). |
| `input.connectionId` | `string` | no | Which of the user's connections to use, when they have several of the same app. Default: their newest active one. Allowed: at most 64 characters. |

**Returns** `Promise<{ data, usage }>` — The provider's answer and what the action cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `data` | `unknown` | yes | The provider's answer, as the action's documentation describes (the created event, the sent message, …). |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `CONNECTION_REQUIRED` | 409 | The user has not connected this app yet: send them to `connectUrl()`; `details.action` names the action. |
| `CONNECTION_ACTION_FAILED` | 422 | The provider refused the action (a bad input, a missing permission); the message says why. |
| `RECORD_NOT_FOUND` | 404 | No action with this slug. |
| `PROVIDER_TIMEOUT` | 504 | The provider did not answer in time. |

**Example**

```ts
const { data } = await totalum.connections.execute({
  userId: 'user_42',
  action: 'GOOGLECALENDAR_CREATE_EVENT',
  input: {
    summary: 'Demo with Acme',
    start_datetime: '2026-10-14T10:00:00',
    timezone: 'Europe/Madrid',
    event_duration_minutes: 30,
    attendees: ['ana@example.com'],
  },
});
```

Result:

```ts
{
  "data": { "id": "5r1q8m2v6k0c4n9b", "status": "confirmed", "htmlLink": "https://www.google.com/calendar/event?eid=NXIxcThtMnY2azBj…", … },
  "usage": { "credits": 0.0028 }
}
```

### `connections.actions`

```ts
connections.actions(input: { toolkit, search? }): Promise<ConnectionAction[]>
```

The actions an app offers (slug, name, description), optionally filtered by words — to find the slug to pass to
`execute()`. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ toolkit, search? }` | yes | The app and an optional search. |
| `input.toolkit` | `string` | yes | The app's slug (`googlecalendar`). |
| `input.search` | `string` | no | Only actions matching these words (`create event`). |

**Returns** `Promise<ConnectionAction[]>` — The matching actions, without their input schemas (read one with `action(slug)`).

Each item:

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | `string` | yes | The action's id, to pass to `execute()`. |
| `toolkit` | `string` | yes | The app it belongs to. |
| `name` | `string` | yes | A short human name. |
| `description` | `string` | yes | What it does and how to call it. |
| `inputSchema` | `Record<string, unknown>` | no | The JSON Schema of `execute()`'s `input` (only from `action(slug)`). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `RECORD_NOT_FOUND` | 404 | No app with this slug. |

**Example**

```ts
const actions = await totalum.connections.actions({ toolkit: 'googlecalendar', search: 'create event' });
```

Result:

```json
[
  { "slug": "GOOGLECALENDAR_CREATE_EVENT", "toolkit": "googlecalendar", "name": "Create Event", "description": "Creates a timed event using start_datetime and either end_datetime or duration fields, or an all-day event…" },
  { "slug": "GOOGLECALENDAR_QUICK_ADD", "toolkit": "googlecalendar", "name": "Quick Add Event", "description": "Creates an event from a text string such as 'Lunch with Ana tomorrow at 1pm'…" }
]
```

### `connections.action`

```ts
connections.action(slug: string): Promise<ConnectionAction>
```

One action with its `inputSchema`: the JSON Schema of what `execute()` takes as `input`. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `slug` | `string` | yes | The action's slug (`GOOGLECALENDAR_CREATE_EVENT`). |

**Returns** `Promise<ConnectionAction>` — The action and its input schema.

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | `string` | yes | The action's id, to pass to `execute()`. |
| `toolkit` | `string` | yes | The app it belongs to. |
| `name` | `string` | yes | A short human name. |
| `description` | `string` | yes | What it does and how to call it. |
| `inputSchema` | `Record<string, unknown>` | no | The JSON Schema of `execute()`'s `input` (only from `action(slug)`). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `RECORD_NOT_FOUND` | 404 | No action with this slug. |

**Example**

```ts
const action = await totalum.connections.action('GOOGLECALENDAR_CREATE_EVENT');
```

Result:

```ts
{
  "slug": "GOOGLECALENDAR_CREATE_EVENT",
  "toolkit": "googlecalendar",
  "name": "Create Event",
  "description": "Creates a timed event using start_datetime and either end_datetime or duration fields…",
  "inputSchema": {
    "type": "object",
    "properties": {
      "summary": { "type": "string", "description": "Summary (title) of the event." },
      "start_datetime": { "type": "string", "description": "Event start time…" },
      …
    }
  }
}
```

### `connections.toolkits`

```ts
connections.toolkits(input?: { search?, category?, cursor?, limit? }): Promise<{ toolkits, nextCursor, total }>
```

Searches the catalogue of apps (`search` matches names and descriptions, `category` e.g. `crm`); `connect`
says how a user connects it: `oauth` (signs in at the provider), `credentials` (types their own key on the
hosted page), `none` (no connection: run its actions directly), `unavailable` (cannot be connected). Pages
with `cursor` = the previous `nextCursor`. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ search?, category?, cursor?, limit? }` | no | The search, category and paging; all optional. |
| `input.search` | `string` | no | Words to match in app names and descriptions (`calendar`). |
| `input.category` | `string` | no | Only apps of this category (`crm`, `email`, `calendar`). |
| `input.cursor` | `string` | no | The previous page's `nextCursor`. |
| `input.limit` | `number` | no | Apps per page. |

**Returns** `Promise<{ toolkits, nextCursor, total }>` — One page of apps, the cursor of the next and the total.

| Field | Type | Always present | Description |
|---|---|---|---|
| `toolkits` | [`ConnectionToolkit[]`](#connectiontoolkit) | yes | The apps of this page. |
| `nextCursor` | `string \| null` | yes | Pass it as `cursor` for the next page; `null` on the last. |
| `total` | `number` | yes | How many apps match in total. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `PROVIDER_UNAVAILABLE` | 503 | The catalogue is not available right now; retry later. |

**Example**

```ts
const page = await totalum.connections.toolkits({ search: 'calendar', limit: 2 });
```

Result:

```json
{
  "toolkits": [
    { "slug": "googlecalendar", "name": "Google Calendar", "description": "Google Calendar is a time management tool…", "categories": ["scheduling-&-booking"], "authSchemes": ["OAUTH2"], "connect": "oauth", "actionsCount": 47, "triggersCount": 7 },
    { "slug": "outlook", "name": "Outlook", "description": "Outlook is Microsoft's email and calendaring platform…", "categories": ["email", "calendar"], "authSchemes": ["OAUTH2", "S2S_OAUTH2"], "connect": "oauth", "actionsCount": 297, "triggersCount": 5 }
  ],
  "nextCursor": "Mi0y",
  "total": 18
}
```

### `connections.toolkit`

```ts
connections.toolkit(slug: string): Promise<ConnectionToolkitDetail>
```

One app, exactly: how it connects and, for `credentials`, the fields the user types on the hosted page. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `slug` | `string` | yes | The app's slug (`googlecalendar`). |

**Returns** `Promise<ConnectionToolkitDetail>` — The app's details.

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | `string` | yes | The app's id (`googlecalendar`). |
| `name` | `string` | yes | Its name. |
| `description` | `string` | yes | What it is. |
| `categories` | `string[]` | yes | Its categories (`email`, `calendar`, `crm`). |
| `authSchemes` | `string[]` | yes | How the provider authenticates (`OAUTH2`, `API_KEY`). |
| `connect` | `'none' \| 'credentials' \| 'oauth' \| 'unavailable'` | yes | How your user connects it: `oauth` (signs in at the provider), `credentials` (types their own key on the hosted page), `none` (no connection needed: run its actions directly), `unavailable` (cannot be connected). |
| `actionsCount` | `number` | yes | How many actions it offers. |
| `triggersCount` | `number` | yes | How many event triggers it offers. |
| `credentialFields` | `({ name, label, description })[]` | yes | For `connect: "credentials"`: the fields the user types on the hosted page. |
| `credentialFields.name` | `string` | yes | The field's id. |
| `credentialFields.label` | `string` | yes | Its label. |
| `credentialFields.description` | `string` | yes | What to type there. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `RECORD_NOT_FOUND` | 404 | No app with this slug. |

**Example**

```ts
const app = await totalum.connections.toolkit('googlecalendar');
```

Result:

```json
{
  "slug": "googlecalendar",
  "name": "Google Calendar",
  "description": "Google Calendar is a time management tool providing scheduling features…",
  "categories": ["scheduling-&-booking"],
  "authSchemes": ["OAUTH2"],
  "connect": "oauth",
  "actionsCount": 47,
  "triggersCount": 7,
  "credentialFields": []
}
```

### `connections.triggers`

```ts
connections.triggers(input: { toolkit }): Promise<ConnectionTrigger[]>
```

The events (triggers) an app can send, such as `GMAIL_NEW_GMAIL_MESSAGE` — to find the slug to pass to
`subscribe()`. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ toolkit }` | yes | The app. |
| `input.toolkit` | `string` | yes | The app's slug (`gmail`). |

**Returns** `Promise<ConnectionTrigger[]>` — The app's triggers, without their schemas (read one with `trigger(slug)`).

Each item:

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | `string` | yes | The trigger's id, to pass to `subscribe()`. |
| `toolkit` | `string` | yes | The app it belongs to. |
| `name` | `string` | yes | A short human name. |
| `description` | `string` | yes | When it fires. |
| `type` | `string` | yes | `poll` (checked every few minutes) or `webhook` (pushed by the provider at once). |
| `instructions` | `string` | no | Setup notes from the provider. |
| `configSchema` | `Record<string, unknown>` | no | The JSON Schema of `subscribe()`'s `config` (only from `trigger(slug)`). |
| `payloadSchema` | `Record<string, unknown>` | no | The JSON Schema of each event's `payload` (only from `trigger(slug)`). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `RECORD_NOT_FOUND` | 404 | No app with this slug. |

**Example**

```ts
const triggers = await totalum.connections.triggers({ toolkit: 'gmail' });
```

Result:

```json
[
  { "slug": "GMAIL_EMAIL_SENT_TRIGGER", "toolkit": "gmail", "name": "Email Sent", "description": "Triggers when a Gmail message is sent by the authenticated user…", "type": "poll" },
  { "slug": "GMAIL_NEW_GMAIL_MESSAGE", "toolkit": "gmail", "name": "New Gmail Message Received Trigger", "description": "Triggers when a new message is received in Gmail.", "type": "poll" }
]
```

### `connections.trigger`

```ts
connections.trigger(slug: string): Promise<ConnectionTrigger>
```

One trigger with `configSchema` (what `subscribe`'s `config` takes) and `payloadSchema` (the event's shape).
Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `slug` | `string` | yes | The trigger's slug (`GMAIL_NEW_GMAIL_MESSAGE`). |

**Returns** `Promise<ConnectionTrigger>` — The trigger and its schemas.

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | `string` | yes | The trigger's id, to pass to `subscribe()`. |
| `toolkit` | `string` | yes | The app it belongs to. |
| `name` | `string` | yes | A short human name. |
| `description` | `string` | yes | When it fires. |
| `type` | `string` | yes | `poll` (checked every few minutes) or `webhook` (pushed by the provider at once). |
| `instructions` | `string` | no | Setup notes from the provider. |
| `configSchema` | `Record<string, unknown>` | no | The JSON Schema of `subscribe()`'s `config` (only from `trigger(slug)`). |
| `payloadSchema` | `Record<string, unknown>` | no | The JSON Schema of each event's `payload` (only from `trigger(slug)`). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `RECORD_NOT_FOUND` | 404 | No trigger with this slug. |

**Example**

```ts
const trigger = await totalum.connections.trigger('GMAIL_NEW_GMAIL_MESSAGE');
```

Result:

```ts
{
  "slug": "GMAIL_NEW_GMAIL_MESSAGE",
  "toolkit": "gmail",
  "name": "New Gmail Message Received Trigger",
  "description": "Triggers when a new message is received in Gmail.",
  "type": "poll",
  "instructions": "Provide the user ID (usually 'me')…",
  "configSchema": {
    "properties": {
      "interval": { "type": "number", "default": 2, "description": "Periodic Interval to Check for Updates & Send a Trigger in Minutes" },
      "labelIds": { "type": "string", "default": "INBOX", "description": "Filter messages by a single label ID…" }
    }
  },
  "payloadSchema": { "properties": { "message_id": { "type": "string" }, "sender": { "type": "string" }, "subject": { "type": "string" }, … } }
}
```

### `connections.subscribe`

```ts
connections.subscribe(input: { userId, trigger, config?, connectionId?, deliverTo? }): Promise<ConnectionSubscription>
```

Watches `trigger` on the user's active connection of its toolkit: each event is POSTed to `deliverTo` (a path
of your own app, default `/api/webhooks/connections`) on the preview (dev users) or the published site (live
users), signed with your project's webhook secret — read it with `verifyEvent`. Each delivered event is billed in
credits.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ userId, trigger, config?, connectionId?, deliverTo? }` | yes | Your user, the trigger, its configuration and where to deliver. |
| `input.userId` | `string` | yes | Your signed-in user's id. Allowed: at least 1 character, at most 200 characters. |
| `input.trigger` | `string` | yes | The trigger's slug (`GMAIL_NEW_GMAIL_MESSAGE`); find it with `triggers({ toolkit })`. |
| `input.config` | `Record<string, unknown>` | no | The trigger's settings, as its `configSchema` describes (read it with `trigger(slug)`). |
| `input.connectionId` | `string` | no | Which of the user's connections to watch, when they have several of the same app. Allowed: at most 64 characters. |
| `input.deliverTo` | `string` | no | The path of your app the events are POSTed to. Default `/api/webhooks/connections`. |

**Returns** `Promise<ConnectionSubscription>` — The subscription; keep its `id` to `unsubscribe()` later.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The subscription id, to pass to `unsubscribe()`. |
| `trigger` | `string` | yes | The trigger watched. |
| `connectionId` | `string` | yes | The connected account watched. |
| `config` | `Record<string, unknown>` | yes | The trigger's settings. |
| `enabled` | `boolean` | yes | Whether events are being delivered. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `CONNECTION_REQUIRED` | 409 | The user has not connected this app yet: send them to `connectUrl()`. |
| `RECORD_NOT_FOUND` | 404 | No trigger with this slug. |
| `VALIDATION_ERROR` | 400 | `config` does not match the trigger's `configSchema`, or `deliverTo` is not a path. |

**Example**

```ts
const sub = await totalum.connections.subscribe({
  userId: 'user_42',
  trigger: 'GMAIL_NEW_GMAIL_MESSAGE',
  config: { labelIds: 'INBOX', interval: 1 },
});
```

Result:

```json
{ "id": "ti_Zp4Lm8Qw2nXc", "trigger": "GMAIL_NEW_GMAIL_MESSAGE", "connectionId": "ca_3Hy7tR1mXc", "config": { "labelIds": "INBOX", "interval": 1 }, "enabled": true }
```

### `connections.subscriptions`

```ts
connections.subscriptions(input: { userId }): Promise<ConnectionSubscription[]>
```

The user's event subscriptions. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ userId }` | yes | Your user. |
| `input.userId` | `string` | yes | Your signed-in user's id, from the session. |

**Returns** `Promise<ConnectionSubscription[]>` — The subscriptions.

Each item:

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The subscription id, to pass to `unsubscribe()`. |
| `trigger` | `string` | yes | The trigger watched. |
| `connectionId` | `string` | yes | The connected account watched. |
| `config` | `Record<string, unknown>` | yes | The trigger's settings. |
| `enabled` | `boolean` | yes | Whether events are being delivered. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `userId` is empty or over 200 characters. |

**Example**

```ts
const subs = await totalum.connections.subscriptions({ userId: 'user_42' });
```

Result:

```json
[
  { "id": "ti_Zp4Lm8Qw2nXc", "trigger": "GMAIL_NEW_GMAIL_MESSAGE", "connectionId": "ca_3Hy7tR1mXc", "config": { "labelIds": "INBOX", "interval": 1 }, "enabled": true }
]
```

### `connections.unsubscribe`

```ts
connections.unsubscribe(input: { userId, id }): Promise<{ deleted }>
```

Stops one of the user's subscriptions: no more events are delivered. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `{ userId, id }` | yes | Your user and the subscription. |
| `input.userId` | `string` | yes | Your signed-in user's id, from the session. |
| `input.id` | `string` | yes | The subscription's `id`, from `subscribe()` or `subscriptions()`. |

**Returns** `Promise<{ deleted }>` — `{ deleted: true }`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `deleted` | `true` | yes | Always `true`. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `RECORD_NOT_FOUND` | 404 | The user has no subscription with this id. |

**Example**

```ts
await totalum.connections.unsubscribe({ userId: 'user_42', id: 'ti_Zp4Lm8Qw2nXc' });
```

Result:

```json
{ "deleted": true }
```

### `connections.verifyEvent`

```ts
connections.verifyEvent(rawBody: string, headers: Headers | Record<string, string>, secret?: string): Promise<ConnectionEvent>
```

Verifies one delivered event (the raw body text and the request headers of your `deliverTo` route) with
`TOTALUM_WEBHOOK_SECRET` and returns it typed: `data.userId` is your user, `data.payload` the event. Answer 2xx
quickly; a failed delivery is retried.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `rawBody` | `string` | yes | The request body as text, untouched (`await request.text()`). |
| `headers` | `Headers \| Record<string, string>` | yes | The request's headers. |
| `secret` | `string` | no | The signing secret. Default: `TOTALUM_WEBHOOK_SECRET`. |

**Returns** `Promise<ConnectionEvent>` — The verified event.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The event id; handle each id once (a failed delivery is retried). |
| `createdAt` | `string` | yes | When it was delivered. |
| `projectId` | `string` | yes | Your project id. |
| `env` | `'dev' \| 'live'` | yes | `dev` for the preview's users, `live` for the published site's. |
| `type` | `'connections.event'` | yes | Always `connections.event`. |
| `data` | `{ subscriptionId, trigger, userId, connectionId, payload }` | yes | The event. |
| `data.subscriptionId` | `string` | yes | The subscription that produced it. |
| `data.trigger` | `string` | yes | The trigger slug (`GMAIL_NEW_GMAIL_MESSAGE`). |
| `data.userId` | `string` | yes | Your user's id, as given to `subscribe()`. |
| `data.connectionId` | `string` | yes | The connected account it came from. |
| `data.payload` | `Record<string, unknown>` | yes | The event itself, as the trigger's `payloadSchema` describes (a new e-mail's sender, subject, …). |
| `livemode` | `boolean` | no | Not set for connection events. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `WEBHOOK_SIGNATURE_INVALID` | 401 | Not signed by Totalum (or the body was changed): answer 401. |
| `WEBHOOK_TIMESTAMP_EXPIRED` | 401 | A replayed delivery: answer 401. |
| `WEBHOOK_SECRET_MISSING` | — | No secret is set or passed. |

**Example**

```ts
// src/app/api/webhooks/connections/route.ts
export async function POST(request: Request) {
  const event = await totalum.connections.verifyEvent(await request.text(), request.headers);
  if (event.data.trigger === 'GMAIL_NEW_GMAIL_MESSAGE') await saveInboxItem(event.data.userId, event.data.payload);
  return new Response('ok');
}
```

Result:

```ts
{
  "id": "evt_01M4P2A9K3J7H5G1F8D6S4A2Q0",
  "type": "connections.event",
  "createdAt": "2026-10-11T18:20:31.000Z",
  "projectId": "acme-crm",
  "env": "live",
  "data": {
    "subscriptionId": "ti_Zp4Lm8Qw2nXc",
    "trigger": "GMAIL_NEW_GMAIL_MESSAGE",
    "userId": "user_42",
    "connectionId": "ca_3Hy7tR1mXc",
    "payload": { "message_id": "19a2c4e6f8b0d1e3", "sender": "Ana <ana@example.com>", "subject": "Quote for October", … }
  }
}
```

### `Connection`

One of a user's connected accounts.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The connection id: pass it to `disconnect()`, or as `connectionId`. |
| `toolkit` | `string` | yes | The app (`googlecalendar`). |
| `status` | [`ConnectionStatus`](#connectionstatus) | yes | Whether it can be used: see `ConnectionStatus`. |
| `createdAt` | `string` | yes | When the user connected it. |

### `ConnectionAction`

An action an app offers.

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | `string` | yes | The action's id, to pass to `execute()`. |
| `toolkit` | `string` | yes | The app it belongs to. |
| `name` | `string` | yes | A short human name. |
| `description` | `string` | yes | What it does and how to call it. |
| `inputSchema` | `Record<string, unknown>` | no | The JSON Schema of `execute()`'s `input` (only from `action(slug)`). |

### `ConnectionEvent`

One event from a user's connected account, delivered to your `deliverTo` route and returned by `connections.verifyEvent()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The event id; handle each id once (a failed delivery is retried). |
| `createdAt` | `string` | yes | When it was delivered. |
| `projectId` | `string` | yes | Your project id. |
| `env` | `'dev' \| 'live'` | yes | `dev` for the preview's users, `live` for the published site's. |
| `type` | `'connections.event'` | yes | Always `connections.event`. |
| `data` | `{ subscriptionId, trigger, userId, connectionId, payload }` | yes | The event. |
| `data.subscriptionId` | `string` | yes | The subscription that produced it. |
| `data.trigger` | `string` | yes | The trigger slug (`GMAIL_NEW_GMAIL_MESSAGE`). |
| `data.userId` | `string` | yes | Your user's id, as given to `subscribe()`. |
| `data.connectionId` | `string` | yes | The connected account it came from. |
| `data.payload` | `Record<string, unknown>` | yes | The event itself, as the trigger's `payloadSchema` describes (a new e-mail's sender, subject, …). |
| `livemode` | `boolean` | no | Not set for connection events. |

### `ConnectionStatus`

The state of a connected account.

| Value | Meaning |
|---|---|
| `'expired'` | The provider's access expired or was revoked: ask the user to connect again. |
| `'active'` | Connected and usable. |
| `'failed'` | The connection attempt failed: ask the user to connect again. |
| `'disabled'` | Turned off: ask the user to connect again. |
| `'pending'` | The user started connecting but has not finished. |

### `ConnectionSubscription`

A subscription to a trigger on a user's connected account.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The subscription id, to pass to `unsubscribe()`. |
| `trigger` | `string` | yes | The trigger watched. |
| `connectionId` | `string` | yes | The connected account watched. |
| `config` | `Record<string, unknown>` | yes | The trigger's settings. |
| `enabled` | `boolean` | yes | Whether events are being delivered. |

### `ConnectionToolkit`

An app of the catalogue.

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | `string` | yes | The app's id (`googlecalendar`). |
| `name` | `string` | yes | Its name. |
| `description` | `string` | yes | What it is. |
| `categories` | `string[]` | yes | Its categories (`email`, `calendar`, `crm`). |
| `authSchemes` | `string[]` | yes | How the provider authenticates (`OAUTH2`, `API_KEY`). |
| `connect` | `'none' \| 'credentials' \| 'oauth' \| 'unavailable'` | yes | How your user connects it: `oauth` (signs in at the provider), `credentials` (types their own key on the hosted page), `none` (no connection needed: run its actions directly), `unavailable` (cannot be connected). |
| `actionsCount` | `number` | yes | How many actions it offers. |
| `triggersCount` | `number` | yes | How many event triggers it offers. |

### `ConnectionToolkitDetail`

One app, from `connections.toolkit(slug)`: the catalogue fields plus the credential fields.

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | `string` | yes | The app's id (`googlecalendar`). |
| `name` | `string` | yes | Its name. |
| `description` | `string` | yes | What it is. |
| `categories` | `string[]` | yes | Its categories (`email`, `calendar`, `crm`). |
| `authSchemes` | `string[]` | yes | How the provider authenticates (`OAUTH2`, `API_KEY`). |
| `connect` | `'none' \| 'credentials' \| 'oauth' \| 'unavailable'` | yes | How your user connects it: `oauth` (signs in at the provider), `credentials` (types their own key on the hosted page), `none` (no connection needed: run its actions directly), `unavailable` (cannot be connected). |
| `actionsCount` | `number` | yes | How many actions it offers. |
| `triggersCount` | `number` | yes | How many event triggers it offers. |
| `credentialFields` | `({ name, label, description })[]` | yes | For `connect: "credentials"`: the fields the user types on the hosted page. |
| `credentialFields.name` | `string` | yes | The field's id. |
| `credentialFields.label` | `string` | yes | Its label. |
| `credentialFields.description` | `string` | yes | What to type there. |

### `ConnectionTrigger`

An event an app can send.

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | `string` | yes | The trigger's id, to pass to `subscribe()`. |
| `toolkit` | `string` | yes | The app it belongs to. |
| `name` | `string` | yes | A short human name. |
| `description` | `string` | yes | When it fires. |
| `type` | `string` | yes | `poll` (checked every few minutes) or `webhook` (pushed by the provider at once). |
| `instructions` | `string` | no | Setup notes from the provider. |
| `configSchema` | `Record<string, unknown>` | no | The JSON Schema of `subscribe()`'s `config` (only from `trigger(slug)`). |
| `payloadSchema` | `Record<string, unknown>` | no | The JSON Schema of each event's `payload` (only from `trigger(slug)`). |

## `totalum-sdk/speech`

Speech to text (`transcribe`) and text to speech (`synthesize`): transcribe a recording or a voice note, read a
text aloud, store the audio as a project file. Billed in credits per minute of audio or per character.

```ts
const { text } = await totalum.speech.transcribe({ url: recording.fileUrl });
```

| Method or type | What it does |
|---|---|
| [`totalumSpeech`](#totalumspeech) | Creates the `speech` namespace (the same object as `totalum.speech`, scope `speech`): speech to text and text to speech through Totalum. |
| [`speech.transcribe`](#speechtranscribe) | Speech to text: transcribes a recording, a voice note or a video's audio, from a `url` (a public one, or a file this project stored) or from `audioBase64` plus a `filename` with its extension. |
| [`speech.synthesize`](#speechsynthesize) | Reads a text aloud (text to speech): with `store: true` the audio is saved as a public project file and you get its `key` and `url`; otherwise you get the audio bytes to return from a route or store yourself. |
| [`SpeakInput`](#speakinput) | The input of `speech.synthesize()`. |
| [`SpeechAudio`](#speechaudio) | The audio bytes of `synthesize` without `store`, and what the call cost (`usage.credits`, the call's hold). |
| [`StoredMedia`](#storedmedia) | A generated file stored in the project's files. |
| [`TranscribeInput`](#transcribeinput) | The input of `speech.transcribe()`: a `url`, or `audioBase64` with a `filename`. |
| [`TranscribeOutput`](#transcribeoutput) | A transcription, from `speech.transcribe()`. |

### `totalumSpeech`

```ts
totalumSpeech(options?: TotalumClientOptions): TotalumSpeech
```

Creates the `speech` namespace (the same object as `totalum.speech`, scope `speech`): speech to text and text to
speech through Totalum. The AI gateway (`ai.gatewayConfig()`) also offers `/audio/speech` and
`/audio/transcriptions` for OpenAI-compatible clients; these methods keep working.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumSpeech`](#totalumspeech) — The `speech` namespace: the methods below.

**Example**

```ts
import { totalumSpeech } from 'totalum-sdk/speech';

const speech = totalumSpeech();
const { text } = await speech.transcribe({ url: 'https://example.com/voice-note.mp3' });
```

The namespace's type is exported as `TotalumSpeech` (`import type { TotalumSpeech } from 'totalum-sdk/speech'`).

### `speech.transcribe`

```ts
speech.transcribe(input: TranscribeInput): Promise<TranscribeOutput>
```

Speech to text: transcribes a recording, a voice note or a video's audio, from a `url` (a public one, or a file
this project stored) or from `audioBase64` plus a `filename` with its extension. Formats: mp3, mp4, m4a, wav,
webm, ogg, opus, flac, aac; at most 25 MB. Billed in credits per second of audio.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`TranscribeInput`](#transcribeinput) | yes | The audio and the language. |
| `input.audioBase64` | `string` | no | The audio bytes in base64, instead of `url`. |
| `input.filename` | `string` | no | With `audioBase64`: a file name whose extension says the format (`note.m4a`). |
| `input.url` | `string` | no | The audio's URL: a public `https://` URL, or a file this project stored. The extension says the format. Allowed: a URL. |
| `input.language` | `string` | no | The spoken language (`en`, `es`); default: detected. |
| `input.model` | `'fast' \| 'accurate'` | no | `fast` (default) or `accurate` (better on hard audio, costs more). |
| `input.diarize` | `boolean` | no | Accepted for compatibility; has no effect (speakers are not separated). |
| `input.timestamps` | `'word' \| 'segment'` | no | Accepted for compatibility; has no effect (no segments or words are returned). |
| `input.keyterms` | `string[]` | no | Accepted for compatibility; has no effect. |

**Returns** `Promise<TranscribeOutput>` — The text, the audio's duration and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `text` | `string` | yes | The transcribed text. |
| `durationSec` | `number` | yes | The audio's billed duration in seconds. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `language` | `string` | no | Not returned today. |
| `segments` | `({ start, end, text })[]` | no | Not returned today. |
| `segments.start` | `number` | yes | Not returned today. |
| `segments.end` | `number` | yes | Not returned today. |
| `segments.text` | `string` | yes | Not returned today. |
| `words` | `({ word, start, end })[]` | no | Not returned today. |
| `words.word` | `string` | yes | Not returned today. |
| `words.start` | `number` | yes | Not returned today. |
| `words.end` | `number` | yes | Not returned today. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AUDIO_FILE_REQUIRED` | 400 | No audio was given, or the URL could not be read. |
| `INVALID_AUDIO_FORMAT` | 400 | The file extension is not a supported audio format. |
| `AUDIO_FILE_TOO_LARGE` | 413 | The audio is over 25 MB. |
| `AI_PROVIDER_UNAVAILABLE` | 502 | The audio could not be transcribed; retry. |

**Example**

```ts
const result = await totalum.speech.transcribe({ url: voiceNote.fileUrl, language: 'en' });
```

Result:

```json
{ "text": "Hello there.", "durationSec": 10, "usage": { "credits": 0.0064 } }
```

### `speech.synthesize`

```ts
speech.synthesize(input: { text, voice?, language?, model?, format?, speed?, store }): Promise<StoredMedia>
speech.synthesize(input: SpeakInput): Promise<StoredMedia | SpeechAudio>
```

Reads a text aloud (text to speech): with `store: true` the audio is saved as a public project file and you get
its `key` and `url`; otherwise you get the audio bytes to return from a route or store yourself. Billed in credits
per character.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`SpeakInput`](#speakinput) | yes | The text, the voice and the audio format. |
| `input.text` | `string` | yes | The text to read aloud. Allowed: at least 1 character, at most 20000 characters. |
| `input.voice` | `string` | no | The voice name. Default: the `tts` preset's voice. |
| `input.language` | `string` | no | The text's language (`en`, `es`). |
| `input.model` | `'standard' \| 'premium'` | no | `standard` (default) or `premium` (a more natural voice, costs more). |
| `input.format` | `'mp3' \| 'wav' \| 'opus'` | no | `mp3` (default), `wav` or `opus`. |
| `input.speed` | `number` | no | Speaking speed; 1 is normal. Allowed: > 0. |
| `input.store` | `boolean` | no | `true` saves the audio as a public project file and returns its `key` and `url` instead of the bytes. |

**Returns** `Promise<StoredMedia \| SpeechAudio>` — The stored file `{ key, url, usage }` with `store: true`; else the audio bytes `{ audio, contentType, usage }`.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | `text` is empty or over 20,000 characters. |
| `AI_PROVIDER_UNAVAILABLE` | 502 | The voice could not be generated; retry. |

**Examples**

```ts
const audio = await totalum.speech.synthesize({ text: 'Your order has shipped.', store: true });
```

Result:

```json
{
  "key": "p/acme-crm/01M4P0SZ4SKYEPG76P3DATMX1C.mp3",
  "url": "https://files.totalum-project.com/p/acme-crm/01M4P0SZ4SKYEPG76P3DATMX1C.mp3",
  "usage": { "credits": 0.01 }
}
```

```ts
// the bytes, returned from a route
const out = await totalum.speech.synthesize({ text: 'Hi Ana', format: 'mp3' });
if ('audio' in out) return new Response(out.audio, { headers: { 'content-type': out.contentType } });
```

Result:

```ts
{ audio: ArrayBuffer(15360), contentType: 'audio/mpeg', usage: { credits: 0.01 } }
```

### `SpeakInput`

The input of `speech.synthesize()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `text` | `string` | yes | The text to read aloud. Allowed: at least 1 character, at most 20000 characters. |
| `voice` | `string` | no | The voice name. Default: the `tts` preset's voice. |
| `language` | `string` | no | The text's language (`en`, `es`). |
| `model` | `'standard' \| 'premium'` | no | `standard` (default) or `premium` (a more natural voice, costs more). |
| `format` | `'mp3' \| 'wav' \| 'opus'` | no | `mp3` (default), `wav` or `opus`. |
| `speed` | `number` | no | Speaking speed; 1 is normal. Allowed: > 0. |
| `store` | `boolean` | no | `true` saves the audio as a public project file and returns its `key` and `url` instead of the bytes. |

### `SpeechAudio`

The audio bytes of `synthesize` without `store`, and what the call cost (`usage.credits`, the call's hold).

| Field | Type | Always present | Description |
|---|---|---|---|
| `audio` | `ArrayBuffer` | yes | The audio file's bytes: return them from a route, or upload them with `files.upload`. |
| `contentType` | `string` | yes | The audio's MIME type: `audio/mpeg` (mp3), `audio/wav` or `audio/ogg` (opus). |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `credits` | `number` | yes | **Deprecated.** Since 1.0.0: use `usage.credits` (the same number). |

### `StoredMedia`

A generated file stored in the project's files.

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `url` | `string` | yes | Its public URL. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

### `TranscribeInput`

The input of `speech.transcribe()`: a `url`, or `audioBase64` with a `filename`.

| Name | Type | Required | Description |
|---|---|---|---|
| `audioBase64` | `string` | no | The audio bytes in base64, instead of `url`. |
| `filename` | `string` | no | With `audioBase64`: a file name whose extension says the format (`note.m4a`). |
| `url` | `string` | no | The audio's URL: a public `https://` URL, or a file this project stored. The extension says the format. Allowed: a URL. |
| `language` | `string` | no | The spoken language (`en`, `es`); default: detected. |
| `model` | `'fast' \| 'accurate'` | no | `fast` (default) or `accurate` (better on hard audio, costs more). |
| `diarize` | `boolean` | no | Accepted for compatibility; has no effect (speakers are not separated). |
| `timestamps` | `'word' \| 'segment'` | no | Accepted for compatibility; has no effect (no segments or words are returned). |
| `keyterms` | `string[]` | no | Accepted for compatibility; has no effect. |

### `TranscribeOutput`

A transcription, from `speech.transcribe()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `text` | `string` | yes | The transcribed text. |
| `durationSec` | `number` | yes | The audio's billed duration in seconds. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `language` | `string` | no | Not returned today. |
| `segments` | `({ start, end, text })[]` | no | Not returned today. |
| `segments.start` | `number` | yes | Not returned today. |
| `segments.end` | `number` | yes | Not returned today. |
| `segments.text` | `string` | yes | Not returned today. |
| `words` | `({ word, start, end })[]` | no | Not returned today. |
| `words.word` | `string` | yes | Not returned today. |
| `words.start` | `number` | yes | Not returned today. |
| `words.end` | `number` | yes | Not returned today. |

## `totalum-sdk/ai`

AI from your server without any provider account: chat completions (streamed or not), structured JSON, a label
out of a list, embeddings, image generation and editing, video generation and image description. `model` is a preset
— `fast`, `smart` (the default), `reasoning`, `vision`, `image`, `video`, … each following the newest model of its
family — or a pinned model id from `models.list()`. Every call is billed in Totalum credits and returns them in
`usage.credits`. For a library that wants an OpenAI-compatible client (the Vercel AI SDK, `openai`), use
`gatewayConfig()`.

```ts
const { text } = await totalum.ai.chat({ model: 'fast', messages: [{ role: 'user', content: 'Say hi' }] });
```

| Method or type | What it does |
|---|---|
| [`totalumAi`](#totalumai) | Creates the `ai` namespace (the same object as `totalum.ai`, scope `ai`): a `model` is a preset (`fast`, `smart`, `reasoning`, `vision`, `image`, `image-edit`, `video`, `tts`, `stt`, `embed` — each follows the newest model of its family, resolved by Totalum) or a pinned model id; every call returns the Totalum credits it was charged (`usage.credits`, never tokens). |
| [`ai.chat`](#aichat) | Asks a model and waits for the whole answer: summaries, replies, rewriting, extraction, tool calling. |
| [`ai.generateObject`](#aigenerateobject) | Asks a model for a JSON object that matches your JSON Schema — extract fields from text, classify with details, produce data for your UI. |
| [`ai.classify`](#aiclassify) | Picks one label out of your list for an input — route a support ticket, tag a review, detect a language. |
| [`ai.embed`](#aiembed) | Turns texts into embedding vectors (lists of numbers whose distance measures meaning). |
| [`ai.image.generate`](#aiimagegenerate) | Generates images from a prompt — illustrations, product shots, avatars, hero images — and stores each one as a public project file. |
| [`ai.image.edit`](#aiimageedit) | Edits images with a prompt — remove a background, change a colour, put a product in a new scene — and stores the result as a public project file. |
| [`ai.video.generate`](#aivideogenerate) | Starts generating a video clip (up to 30 s) from a prompt, optionally from a first/last frame or reference images. |
| [`ai.video.get`](#aivideoget) | Reads a video job: its status and, once `completed`, the stored clip (`video.key`, `video.url`) and its final cost. |
| [`ai.video.wait`](#aivideowait) | ⚠ NODE-ONLY: polls `get(jobId)` until the job leaves `queued`/`running` (default every 5 s, at most 45 min — the longest clip, 30 s at 1080p, renders in about 22 min). |
| [`ai.vision.describe`](#aivisiondescribe) | Describes an image in words, or answers a question about it — alt text, moderation, reading a chart, checking a photo. |
| [`ai.speech.speak`](#aispeechspeak) | Deprecated: Since 1.0.0: use `totalum.speech.synthesize` (the same call). |
| [`ai.speech.transcribe`](#aispeechtranscribe) | Deprecated: Since 1.0.0: use `totalum.speech.transcribe` (the same call). |
| [`ai.models.list`](#aimodelslist) | Each preset's current model and the catalogue of models this project may call (their ids, names, inputs and context sizes) — to let an admin pick a model, or to pin one. |
| [`ai.gatewayConfig`](#aigatewayconfig) | The URL and key of Totalum's OpenAI-compatible AI gateway, for any library that takes an OpenAI client: the Vercel AI SDK, `openai`, LangChain. |
| [`AiChatInput`](#aichatinput) | A chat request: the model, the conversation and the generation options, in the OpenAI format. |
| [`AiEmbedInput`](#aiembedinput) | The input of `ai.embed()`. |
| [`AiEmbedOutput`](#aiembedoutput) | The answer of `ai.embed()`. |
| [`AiImageEditInput`](#aiimageeditinput) | The input of `ai.image.edit()`. |
| [`AiImageInput`](#aiimageinput) | The input of `ai.image.generate()`. |
| [`AiImageOutput`](#aiimageoutput) | Generated images, stored as public project files. |
| [`AiModelsOutput`](#aimodelsoutput) | The models this project can use, from `ai.models.list()`. |
| [`AiPresetName`](#aipresetname) | A model preset: a name that always points to Totalum's current choice for a job, so apps get newer models without code changes. |
| [`AiTextOutput`](#aitextoutput) | A text answer, from `ai.vision.describe()`. |
| [`AiVideoCreated`](#aivideocreated) | A started video job, from `ai.video.generate()`. |
| [`AiVideoInput`](#aivideoinput) | The input of `ai.video.generate()`. |
| [`AiVideoJob`](#aivideojob) | A video job, from `ai.video.get()` and `ai.video.wait()`. |
| [`AiVisionDescribeInput`](#aivisiondescribeinput) | The input of `ai.vision.describe()`. |
| [`ChatResult`](#chatresult) | `ai.chat()`: the first choice read out, the raw completion kept. |
| [`ChatStream`](#chatstream) | `ai.chat.stream()`: the OpenAI-format SSE (`raw`), its text deltas, and the result once it ends. |
| [`ChatUsage`](#chatusage) | What a call was charged, in Totalum credits — the only usage Totalum shows (no tokens). |
| [`TotalumAiOptions`](#totalumaioptions) | The options of `totalumAi()`: the client options plus the AI gateway URL `gatewayConfig()` returns. |

### `totalumAi`

```ts
totalumAi(options?: TotalumAiOptions): TotalumAi
```

Creates the `ai` namespace (the same object as `totalum.ai`, scope `ai`): a `model` is a preset (`fast`,
`smart`, `reasoning`, `vision`, `image`, `image-edit`, `video`, `tts`, `stt`, `embed` — each follows the newest model
of its family, resolved by Totalum) or a pinned model id; every call returns the Totalum credits it was charged
(`usage.credits`, never tokens). There is no `ai.agent()`: an agent loop runs on an OpenAI-compatible library (the
template's `src/agents/` uses the Vercel AI SDK) pointed at `gatewayConfig()`, which reaches the same models with the
same key, billed in credits. Vector search is `totalum.vectors`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumAiOptions`](#totalumaioptions) | no | The project key, API URL, environment and AI gateway URL; each falls back to its environment variable. |
| `options.aiBaseUrl` | `string` | no | The OpenAI-compatible AI gateway `gatewayConfig()` returns; default `TOTALUM_AI_BASE_URL`, else `https://ai.totalum.app/api/v1`. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumAi`](#totalumai) — The `ai` namespace: the methods below.

**Example**

```ts
import { totalumAi } from 'totalum-sdk/ai';

const ai = totalumAi();
const { text } = await ai.chat({ messages: [{ role: 'user', content: 'Hello' }] });
```

The namespace's type is exported as `TotalumAi` (`import type { TotalumAi } from 'totalum-sdk/ai'`).

### `ai.chat`

```ts
ai.chat(req: { model?, messages, tools?, toolChoice?, parallelToolCalls?, responseFormat?, temperature?, maxTokens?, reasoning?, serverTools?, maxToolCalls?, cache?, zdr?, fastMode?, user? }): Promise<ChatResult>
```

Asks a model and waits for the whole answer: summaries, replies, rewriting, extraction, tool calling. `model` is a
preset (`fast`, `smart` — the default — or `reasoning`) or a pinned model id from `models.list()`; `messages` is
the conversation in the OpenAI format. Billed in credits by the model's use. To show the answer while it is
written, use `chat.stream`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `req` | `{ model?, messages, tools?, toolChoice?, parallelToolCalls?, responseFormat?, temperature?, maxTokens?, reasoning?, serverTools?, maxToolCalls?, cache?, zdr?, fastMode?, user? }` | yes | The model, the conversation and the generation options. |
| `req.model` | `string` | no | A preset (`fast`, `smart`, `reasoning`) or a model id from `models.list()`. Default `smart` (with automatic fallbacks). Allowed: at least 1 character. |
| `req.messages` | `({  })[]` | yes | The conversation, oldest first: `{ role, content }` each. `role` is `system`, `user`, `assistant`, `tool` or `developer`; `content` is a string, or a list of parts (`{ type: "text", text }`, `{ type: "image_url", image_url: { url } }`) to send images. Allowed: at least 1 item. |
| `req.tools` | `(Record<string, unknown>)[]` | no | Functions the model may ask you to call, in the OpenAI format: `{ type: "function", function: { name, description, parameters } }`. |
| `req.toolChoice` | `unknown` | no | `auto`, `none`, `required`, or `{ type: "function", function: { name } }` to force one tool. |
| `req.parallelToolCalls` | `boolean` | no | Whether the model may ask for several tool calls at once. |
| `req.responseFormat` | `Record<string, unknown>` | no | Force JSON: `{ type: "json_object" }` or `{ type: "json_schema", json_schema: { name, schema, strict } }` (or use `generateObject`). |
| `req.temperature` | `number` | no | Randomness, 0–2: lower is more predictable. |
| `req.maxTokens` | `number` | no | The longest answer, in tokens. Default 4096. Allowed: > 0, ≤ 32768. |
| `req.reasoning` | `{ effort }` | no | How much the model thinks before answering, for models that support it. |
| `req.reasoning.effort` | `string` | yes | `low`, `medium` or `high`. |
| `req.serverTools` | `('web_search' \| 'image_generation' \| 'web_fetch' \| 'datetime')[]` | no | Accepted but not used today. |
| `req.maxToolCalls` | `number` | no | Accepted but not used today. Allowed: > 0, ≤ 30. |
| `req.cache` | `{ ttlSec }` | no | Accepted but not used today. |
| `req.cache.ttlSec` | `number` | yes | Accepted but not used today. Allowed: ≥ 1, ≤ 86400. |
| `req.zdr` | `boolean` | no | `true` only uses providers that keep no copy of the prompt (zero data retention). |
| `req.fastMode` | `boolean` | no | Accepted but not used today. |
| `req.user` | `string` | no | An id of your end user, passed to the provider for abuse monitoring. |

**Returns** `Promise<ChatResult>` — The answer's text, any tool calls, why it stopped, and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The completion's id. |
| `model` | `string` | yes | The model that answered (a preset resolves to the model it currently points to). |
| `text` | `string` | yes | The answer's text (empty when the model only called tools). |
| `toolCalls` | `({ id, type, function })[]` | yes | The tools the model asks you to call (with `tools`); empty otherwise. |
| `toolCalls.id` | `string` | yes | The call's id: send the tool's answer back as a `tool` message with this `tool_call_id`. |
| `toolCalls.type` | `string` | yes | Always `function`. |
| `toolCalls.function` | `{ name, arguments }` | yes | The function the model wants to call. |
| `toolCalls.function.name` | `string` | yes | The tool's name, as you declared it in `tools`. |
| `toolCalls.function.arguments` | `string` | yes | Its arguments as a JSON string: `JSON.parse` it. |
| `finishReason` | `string \| null` | yes | Why it stopped: `stop` (finished), `length` (hit `maxTokens`), `tool_calls`, `content_filter`, or `null`. |
| `usage` | [`ChatUsage`](#chatusage) | yes | What the call cost. |
| `raw` | `unknown` | yes | The whole OpenAI-format completion, as received. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AI_MODEL_UNKNOWN` | 400 | No such preset or model id. |
| `AI_MODEL_NOT_ALLOWED` | 403 | The model is not available on this plan; `details` says why. |
| `AI_PROVIDER_UNAVAILABLE` | 502 | The model did not answer; retry, or use another model. |

**Examples**

```ts
const reply = await totalum.ai.chat({
  model: 'fast',
  messages: [
    { role: 'system', content: 'Answer in one word.' },
    { role: 'user', content: 'What is the capital of France?' },
  ],
});
```

Result:

```ts
{
  "id": "gen-1791740364-UbQ0IKv75XZajyP5We6y",
  "model": "openai/gpt-6-luna",
  "text": "Paris",
  "toolCalls": [],
  "finishReason": "stop",
  "usage": { "credits": 0.0007, "inputTokens": 0, "outputTokens": 0 },
  "raw": { "id": "gen-1791740364-UbQ0IKv75XZajyP5We6y", "object": "chat.completion", "choices": [ … ], … }
}
```

```ts
// tool calling: the model asks for getWeather, you run it and send the answer back
const first = await totalum.ai.chat({
  messages: [{ role: 'user', content: 'Weather in Madrid?' }],
  tools: [{ type: 'function', function: { name: 'getWeather', parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] } } }],
});
```

Result:

```ts
{ "text": "", "toolCalls": [{ "id": "call_8f2k", "type": "function", "function": { "name": "getWeather", "arguments": "{\"city\":\"Madrid\"}" } }], "finishReason": "tool_calls", … }
```

### `ai.generateObject`

```ts
ai.generateObject(req: { schema, prompt?, messages?, model?, healing? }): Promise<{ object, usage }>
```

Asks a model for a JSON object that matches your JSON Schema — extract fields from text, classify with details,
produce data for your UI. The output is checked against the schema (and repaired when slightly off). Pass
`prompt` or `messages`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `req` | `{ schema, prompt?, messages?, model?, healing? }` | yes | The schema, the prompt (or messages) and the model. |
| `req.schema` | `Record<string, unknown>` | yes | The JSON Schema of the object (at most 32 KB); use `additionalProperties: false` and `required`. |
| `req.prompt` | `string` | no | The instruction and the input, as one text (or pass `messages`). |
| `req.messages` | `({  })[]` | no | The conversation, instead of `prompt`. |
| `req.model` | `string` | no | A preset or model id. Default `smart`. |
| `req.healing` | `boolean` | no | `false` turns off the automatic repair of almost-valid JSON. Default `true`. |

**Returns** `Promise<{ object, usage }>` — The object and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `object` | `unknown` | yes | The JSON object, matching `schema`. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AI_INVALID_SCHEMA` | 502 | The model did not answer JSON for the schema; `details.usage` says what was charged. |
| `AI_MODEL_UNKNOWN` | 400 | No such preset or model id. |
| `AI_PROVIDER_UNAVAILABLE` | 502 | The model did not answer; retry. |

**Example**

```ts
const { object } = await totalum.ai.generateObject({
  model: 'fast',
  prompt: 'Ana is 31 and lives in Madrid.',
  schema: {
    type: 'object',
    properties: { name: { type: 'string' }, age: { type: 'number' }, city: { type: 'string' } },
    required: ['name', 'age', 'city'],
    additionalProperties: false,
  },
});
```

Result:

```json
{ "object": { "name": "Ana", "age": 31, "city": "Madrid" }, "usage": { "credits": 0.0014 } }
```

### `ai.classify`

```ts
ai.classify(req: { input, options, model?, instructions? }): Promise<{ label }>
```

Picks one label out of your list for an input — route a support ticket, tag a review, detect a language. Sugar
over `generateObject`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `req` | `{ input, options, model?, instructions? }` | yes | The input, the labels and optional instructions. |
| `req.input` | `string` | yes | The text to classify. |
| `req.options` | `string[]` | yes | The labels to choose from. |
| `req.model` | `string` | no | A preset or model id. Default `smart`. |
| `req.instructions` | `string` | no | How to choose, in words. Default: "Classify the input into exactly one label." |

**Returns** `Promise<{ label }>` — `{ label }`: one of `options` (or `''` if the model gave none).

| Field | Type | Always present | Description |
|---|---|---|---|
| `label` | `string` | yes | The chosen label: one of `options`, or `''` when the model gave none. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AI_INVALID_SCHEMA` | 502 | The model did not answer a label. |
| `AI_MODEL_UNKNOWN` | 400 | No such preset or model id. |

**Example**

```ts
const { label } = await totalum.ai.classify({
  input: 'My card was charged twice',
  options: ['billing', 'bug', 'other'],
  model: 'fast',
});
```

Result:

```json
{ "label": "billing" }
```

### `ai.embed`

```ts
ai.embed(req: AiEmbedInput): Promise<AiEmbedOutput>
```

Turns texts into embedding vectors (lists of numbers whose distance measures meaning). To store and search them,
`totalum.vectors` does the embedding for you; use this for your own similarity maths.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `req` | [`AiEmbedInput`](#aiembedinput) | yes | The texts and the model. |
| `req.input` | `string \| string[]` | yes | One text, or a list of texts. Allowed: at least 1 character, at least 1 item, at most 256 items. |
| `req.model` | `string` | no | A preset or model id. Default `embed`. Allowed: at least 1 character. |

**Returns** `Promise<AiEmbedOutput>` — One vector per input, in order, and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `embeddings` | `(number[])[]` | yes | One vector (list of numbers) per input text, in order. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AI_MODEL_UNKNOWN` | 400 | No such preset or model id. |
| `AI_PROVIDER_UNAVAILABLE` | 502 | The model did not answer; retry. |

**Example**

```ts
const { embeddings } = await totalum.ai.embed({ input: ['red shoes', 'blue sandals'] });
```

Result:

```ts
{ "embeddings": [[0.0138, -0.0121, 0.0125, …], [0.0042, -0.0337, 0.0198, …]], "usage": { "credits": 0.0001 } }
```

### `ai.image.generate`

```ts
ai.image.generate(req: AiImageInput): Promise<AiImageOutput>
```

Generates images from a prompt — illustrations, product shots, avatars, hero images — and stores each one as a
public project file. Generate once and save the `key`: never generate on every page view.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `req` | [`AiImageInput`](#aiimageinput) | yes | The prompt, the model, the size and how many. |
| `req.prompt` | `string` | yes | What to draw, in words: subject, style, light, composition. Allowed: at least 1 character. |
| `req.model` | `string` | no | A preset or model id. Default `image`. Allowed: at least 1 character. |
| `req.aspectRatio` | `string` | no | The shape: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, … (what the model supports). |
| `req.resolution` | `'1K' \| '2K' \| '4K' \| '512'` | no | The size class: `512`, `1K`, `2K` or `4K` (for models that support it). |
| `req.quality` | `string` | no | A quality level the model supports (`low`, `medium`, `high`). |
| `req.outputFormat` | `'png' \| 'jpeg' \| 'webp'` | no | `png`, `jpeg` or `webp`. |
| `req.background` | `string` | no | `transparent` for a transparent background (models that support it), or `opaque`. |
| `req.n` | `number` | no | How many images. Default 1. Allowed: ≥ 1, ≤ 4. |
| `req.references` | `string[]` | no | Reference images for style or content: project file keys, files URLs or `https://` URLs. Allowed: at most 14 items. |
| `req.fileName` | `string` | no | The base name of the stored files. |
| `req.store` | `boolean` | no | Accepted for compatibility: images are always stored as project files. |

**Returns** `Promise<AiImageOutput>` — The stored images and what they cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `images` | `({ key, url, mediaType, width?, height? })[]` | yes | The stored images. |
| `images.key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `images.url` | `string` | yes | The image's public URL. |
| `images.mediaType` | `string` | yes | The image's MIME type. |
| `images.width` | `number` | no | Width in pixels, when known. |
| `images.height` | `number` | no | Height in pixels, when known. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `fileName` | `string` | no | The first image's file name (kept for apps written against the first AI API). |
| `imageUrl` | `string` | no | The first image's URL (kept for apps written against the first AI API). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AI_MODEL_UNKNOWN` | 400 | No such preset or model id. |
| `AI_MODEL_NOT_ALLOWED` | 403 | The model is not available on this plan. |
| `AI_PROVIDER_UNAVAILABLE` | 502 | The model returned no image; retry or change the prompt. |

**Example**

```ts
const { images } = await totalum.ai.image.generate({
  prompt: 'A watercolor fox reading a book, warm light',
  aspectRatio: '16:9',
});
```

Result:

```json
{
  "images": [
    { "key": "p/acme-crm/01M4P1J2K3L4M5N6P7Q8R9S0TV.png", "url": "https://files.totalum-project.com/p/acme-crm/01M4P1J2K3L4M5N6P7Q8R9S0TV.png", "mediaType": "image/png", "width": 1344, "height": 768 }
  ],
  "usage": { "credits": 0.2741 },
  "fileName": "01M4P1J2K3L4M5N6P7Q8R9S0TV.png",
  "imageUrl": "https://files.totalum-project.com/p/acme-crm/01M4P1J2K3L4M5N6P7Q8R9S0TV.png"
}
```

### `ai.image.edit`

```ts
ai.image.edit(req: AiImageEditInput): Promise<AiImageOutput>
```

Edits images with a prompt — remove a background, change a colour, put a product in a new scene — and stores
the result as a public project file.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `req` | [`AiImageEditInput`](#aiimageeditinput) | yes | The instruction and the images to edit. |
| `req.prompt` | `string` | yes | The edit, in words. Allowed: at least 1 character. |
| `req.images` | `string[]` | yes | The images to edit (project file keys, files URLs or `https://` URLs). Allowed: at least 1 item, at most 16 items. |
| `req.mask` | `string` | no | Accepted but not used today. |
| `req.fileName` | `string` | no | The base name of the stored files. |
| `req.size` | `string` | no | Accepted but not used today. |
| `req.quality` | `string` | no | A quality level the model supports. |
| `req.outputFormat` | `'png' \| 'jpeg' \| 'webp'` | no | `png`, `jpeg` or `webp`. |
| `req.background` | `string` | no | `transparent` or `opaque`, for models that support it. |
| `req.inputFidelity` | `string` | no | Accepted but not used today. |

**Returns** `Promise<AiImageOutput>` — The stored result image(s) and what they cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `images` | `({ key, url, mediaType, width?, height? })[]` | yes | The stored images. |
| `images.key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `images.url` | `string` | yes | The image's public URL. |
| `images.mediaType` | `string` | yes | The image's MIME type. |
| `images.width` | `number` | no | Width in pixels, when known. |
| `images.height` | `number` | no | Height in pixels, when known. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `fileName` | `string` | no | The first image's file name (kept for apps written against the first AI API). |
| `imageUrl` | `string` | no | The first image's URL (kept for apps written against the first AI API). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AI_MODEL_UNKNOWN` | 400 | No such preset or model id. |
| `FILE_NOT_FOUND` | 404 | An input image key does not exist. |
| `AI_PROVIDER_UNAVAILABLE` | 502 | The model returned no image; retry. |

**Example**

```ts
const { images } = await totalum.ai.image.edit({
  prompt: 'Put this sneaker on a white marble pedestal, studio light',
  images: [product.photo.url],
});
```

Result:

```json
{
  "images": [{ "key": "p/acme-crm/01M4P1K3L4M5N6P7Q8R9S0T1UW.png", "url": "https://files.totalum-project.com/p/acme-crm/01M4P1K3L4M5N6P7Q8R9S0T1UW.png", "mediaType": "image/png" }],
  "usage": { "credits": 0.3012 },
  "fileName": "01M4P1K3L4M5N6P7Q8R9S0T1UW.png",
  "imageUrl": "https://files.totalum-project.com/p/acme-crm/01M4P1K3L4M5N6P7Q8R9S0T1UW.png"
}
```

### `ai.video.generate`

```ts
ai.video.generate(req: AiVideoInput): Promise<AiVideoCreated>
```

Starts generating a video clip (up to 30 s) from a prompt, optionally from a first/last frame or reference
images. It takes minutes: store the `jobId` and read `get(jobId)` later (a page refresh, a cron job); the clip
is stored as a project file when it completes.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `req` | [`AiVideoInput`](#aivideoinput) | yes | The prompt, the duration and the look. |
| `req.prompt` | `string` | yes | The scene, in words: subject, action, camera, style. Allowed: at least 1 character. |
| `req.durationSec` | `number` | yes | The clip's length in seconds (the `video` preset renders 2–30 s). Allowed: > 0, ≤ 30. |
| `req.model` | `string` | no | A preset or model id. Default `video`. Allowed: at least 1 character. |
| `req.resolution` | `string` | no | The size the model supports (`720p`, `1080p`). |
| `req.aspectRatio` | `string` | no | `16:9`, `9:16`, `1:1`, … (what the model supports). |
| `req.firstFrame` | `string` | no | An image the clip starts from (a project file key or a URL). |
| `req.lastFrame` | `string` | no | An image the clip ends on. |
| `req.references` | `string[]` | no | Reference images for style or subject. |
| `req.generateAudio` | `boolean` | no | `true` adds a soundtrack (models that support it). |
| `req.seed` | `number` | no | A number that makes the result reproducible. |

**Returns** `Promise<AiVideoCreated>` — The job id and the credits reserved for it (settled when it ends).

| Field | Type | Always present | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The job id: store it and read `ai.video.get(jobId)` later. |
| `status` | `'queued'` | yes | Always `queued`. |
| `estimatedCredits` | `number` | yes | The credits reserved for the job; the real charge is settled when it ends. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AI_MODEL_UNKNOWN` | 400 | No such preset or model id. |
| `AI_VIDEO_FAILED` | 502 | The video could not be started. |
| `AI_MEDIA_PAUSED_THIS_HOUR` | 429 | Image, video and speech AI are paused until the hour ends. |

**Example**

```ts
const job = await totalum.ai.video.generate({
  prompt: 'A paper boat sailing down a rainy street, cinematic',
  durationSec: 5,
  aspectRatio: '16:9',
});
// save job.jobId on the row, then read ai.video.get(jobId) later
```

Result:

```json
{ "jobId": "vid_8m2k4n6p8q0r", "status": "queued", "estimatedCredits": 21.5 }
```

### `ai.video.get`

```ts
ai.video.get(jobId: string): Promise<AiVideoJob>
```

Reads a video job: its status and, once `completed`, the stored clip (`video.key`, `video.url`) and its final cost.
Call it when the user opens the page or from a cron job until the status is no longer `queued`/`running`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The `jobId` from `video.generate()`. |

**Returns** `Promise<AiVideoJob>` — The job's status, and the clip when done.

| Field | Type | Always present | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The job id. |
| `status` | `'expired' \| 'completed' \| 'queued' \| 'running' \| 'failed' \| 'cancelled'` | yes | `queued` and `running` while it renders; then `completed`, `failed`, `cancelled` or `expired`. |
| `progress` | `number` | no | How far it is, 0–1, when the model reports it. |
| `video` | `{ key, url }` | no | The stored clip, once `completed`. |
| `video.key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `video.url` | `string` | yes | The clip's public URL (MP4). |
| `usage` | `{ credits }` | no | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `error` | `{ code, message }` | no | Why it failed, when `failed` or `expired`. |
| `error.code` | `string` | yes | The error code (`AI_VIDEO_FAILED`). |
| `error.message` | `string` | yes | What happened. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AI_VIDEO_JOB_NOT_FOUND` | 404 | No such job in this project. |
| `AI_PROVIDER_UNAVAILABLE` | 503 | The video service could not be reached; retry. |

**Example**

```ts
const job = await totalum.ai.video.get('vid_8m2k4n6p8q0r');
```

Result:

```json
{
  "jobId": "vid_8m2k4n6p8q0r",
  "status": "completed",
  "video": { "key": "p/acme-crm/01M4P1M5N6P7Q8R9S0T1U2V3WX.mp4", "url": "https://files.totalum-project.com/p/acme-crm/01M4P1M5N6P7Q8R9S0T1U2V3WX.mp4" },
  "usage": { "credits": 19.8 }
}
```

### `ai.video.wait`

```ts
ai.video.wait(jobId: string, opts?: { intervalMs?, timeoutMs? }): Promise<AiVideoJob>
```

⚠ NODE-ONLY: polls `get(jobId)` until the job leaves `queued`/`running` (default every 5 s, at most 45 min —
the longest clip, 30 s at 1080p, renders in about 22 min). For scripts (`npx tsx scripts/…`); in a Worker (the
preview and the published app) it throws `NODE_ONLY` — store the `jobId` and read `get(jobId)` later instead.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The `jobId` from `video.generate()`. |
| `opts` | `{ intervalMs?, timeoutMs? }` | no | How often and how long to poll. |
| `opts.intervalMs` | `number` | no | Milliseconds between polls. Default 5000. |
| `opts.timeoutMs` | `number` | no | Stop polling after this long and return the job as it is. Default 2700000 (45 min). |

**Returns** `Promise<AiVideoJob>` — The job once it finished (or when the timeout passed).

| Field | Type | Always present | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The job id. |
| `status` | `'expired' \| 'completed' \| 'queued' \| 'running' \| 'failed' \| 'cancelled'` | yes | `queued` and `running` while it renders; then `completed`, `failed`, `cancelled` or `expired`. |
| `progress` | `number` | no | How far it is, 0–1, when the model reports it. |
| `video` | `{ key, url }` | no | The stored clip, once `completed`. |
| `video.key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `video.url` | `string` | yes | The clip's public URL (MP4). |
| `usage` | `{ credits }` | no | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `error` | `{ code, message }` | no | Why it failed, when `failed` or `expired`. |
| `error.code` | `string` | yes | The error code (`AI_VIDEO_FAILED`). |
| `error.message` | `string` | yes | What happened. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `NODE_ONLY` | — | Called inside a Worker; `details.replacement` says what to do instead. |
| `AI_VIDEO_JOB_NOT_FOUND` | 404 | No such job in this project. |

**Example**

```ts
// scripts/make-intro-video.ts, run with: npx tsx scripts/make-intro-video.ts
const { jobId } = await totalum.ai.video.generate({ prompt: 'Logo reveal on black', durationSec: 5 });
const job = await totalum.ai.video.wait(jobId);
console.log(job.status, job.video?.url);
```

Result:

```ts
completed https://files.totalum-project.com/p/acme-crm/01M4P1M5N6P7Q8R9S0T1U2V3WX.mp4
```

### `ai.vision.describe`

```ts
ai.vision.describe(req: AiVisionDescribeInput): Promise<AiTextOutput>
```

Describes an image in words, or answers a question about it — alt text, moderation, reading a chart, checking a
photo. The image can be a project file key, a files URL, an `https://` URL or a data URL.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `req` | [`AiVisionDescribeInput`](#aivisiondescribeinput) | yes | The image, the question and the model. |
| `req.image` | `string` | yes | The image: a project file key, a files URL, an `https://` URL, a `data:` URL or bare base64. Allowed: at least 1 character. |
| `req.prompt` | `string` | no | The question or instruction. Default: "Describe this image." |
| `req.model` | `string` | no | A preset or model id. Default `vision`. Allowed: at least 1 character. |

**Returns** `Promise<AiTextOutput>` — The answer's text and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `text` | `string` | yes | The answer. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `FILE_NOT_FOUND` | 404 | The image key does not exist. |
| `AI_PROVIDER_UNAVAILABLE` | 502 | The model could not read the image (for example the URL refused the download); retry with a project file. |

**Example**

```ts
const { text } = await totalum.ai.vision.describe({
  image: 'p/acme-crm/01M4P1J2K3L4M5N6P7Q8R9S0TV.png',
  prompt: 'Write alt text for this image in one sentence.',
});
```

Result:

```json
{ "text": "A watercolor fox sits under a lamp reading an open book.", "usage": { "credits": 0.0019 } }
```

### `ai.speech.speak`

**Deprecated.** Since 1.0.0: use `totalum.speech.synthesize` (the same call).

```ts
ai.speech.speak(input: { text, voice?, language?, model?, format?, speed?, store }): Promise<StoredMedia>
ai.speech.speak(input: SpeakInput): Promise<StoredMedia | SpeechAudio>
```

### `ai.speech.transcribe`

**Deprecated.** Since 1.0.0: use `totalum.speech.transcribe` (the same call).

```ts
ai.speech.transcribe(input: TranscribeInput): Promise<TranscribeOutput>
```

### `ai.models.list`

```ts
ai.models.list(): Promise<AiModelsOutput>
```

Each preset's current model and the catalogue of models this project may call (their ids, names, inputs and
context sizes) — to let an admin pick a model, or to pin one. Free.

**Returns** `Promise<AiModelsOutput>` — The presets and the model catalogue.

| Field | Type | Always present | Description |
|---|---|---|---|
| `presets` | `{ image?, fast?, reasoning?, video?, smart?, vision?, image-edit?, tts?, stt?, embed?, embed-image? }` | yes | Each preset and the model id it points to today. |
| `presets.image` | `string` | no | The `image` preset's model. |
| `presets.fast` | `string` | no | The `fast` preset's model. |
| `presets.reasoning` | `string` | no | The `reasoning` preset's model. |
| `presets.video` | `string` | no | The `video` preset's model. |
| `presets.smart` | `string` | no | The `smart` preset's model. |
| `presets.vision` | `string` | no | The `vision` preset's model. |
| `presets.image-edit` | `string` | no | The `image-edit` preset's model. |
| `presets.tts` | `string` | no | The `tts` preset's model. |
| `presets.stt` | `string` | no | The `stt` preset's model. |
| `presets.embed` | `string` | no | The `embed` preset's model. |
| `presets.embed-image` | `string` | no | The `embed-image` preset's model. |
| `models` | `({ slug, name, inputModalities, pricing, contextLength })[]` | yes | Every model the plan may call. |
| `models.slug` | `string` | yes | The model id to pass as `model`. |
| `models.name` | `string` | yes | Its display name. |
| `models.inputModalities` | `string[]` | yes | What it accepts (`text`, `image`, `file`, `audio`, `video`). |
| `models.pricing` | `Record<string, unknown>` | yes | Always `{}`: prices are in credits, per call (`usage.credits`). |
| `models.contextLength` | `number` | yes | The most tokens it reads at once. |
| `denylist` | `string[]` | no | Model ids this project may not use. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AI_PROVIDER_UNAVAILABLE` | 503 | The model catalogue is unavailable; retry. |

**Example**

```ts
const { presets, models } = await totalum.ai.models.list();
```

Result:

```ts
{
  "presets": {
    "fast": "openai/gpt-6-luna",
    "smart": "openai/gpt-6.1-sol",
    "reasoning": "anthropic/claude-opus-5.5",
    "vision": "openai/gpt-6-luna",
    "image": "microsoft/mai-image-2.6-flash",
    "image-edit": "microsoft/mai-image-2.6-flash",
    "video": "alibaba/wan-3.0",
    "tts": "microsoft/mai-voice-2.1-flash",
    "stt": "openai/whisper-large-v3-turbo",
    "embed": "openai/text-embedding-3-small",
    "embed-image": "google/gemini-embedding-2"
  },
  "models": [
    { "slug": "anthropic/claude-haiku-5.5", "name": "Anthropic: Claude Haiku 5.5", "inputModalities": ["text", "image", "file"], "pricing": {}, "contextLength": 1000000 },
    …
  ]
}
```

### `ai.gatewayConfig`

```ts
ai.gatewayConfig(): { baseURL, apiKey }
```

The URL and key of Totalum's OpenAI-compatible AI gateway, for any library that takes an OpenAI client: the
Vercel AI SDK, `openai`, LangChain. Same models, same key, billed in credits. Server-side only: the key is the
project key. No request is made.

**Returns** `{ baseURL, apiKey }` — `{ baseURL, apiKey }` to pass to the client.

| Field | Type | Always present | Description |
|---|---|---|---|
| `baseURL` | `string` | yes | The gateway's OpenAI-compatible base URL: `TOTALUM_AI_BASE_URL`, else `https://ai.totalum.app/api/v1`. |
| `apiKey` | `string` | yes | The project key, used as the API key. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SDK_NOT_CONFIGURED` | — | No project key is set. |

**Examples**

```ts
import OpenAI from 'openai';

const openai = new OpenAI(totalum.ai.gatewayConfig());
const r = await openai.chat.completions.create({ model: 'fast', messages: [{ role: 'user', content: 'Hi' }] });
```

```ts
import { createOpenAI } from '@ai-sdk/openai';
import { generateText } from 'ai';

const gateway = createOpenAI(totalum.ai.gatewayConfig());
const { text } = await generateText({ model: gateway('smart'), prompt: 'Write a haiku about Mondays' });
```

Result:

```json
{ "baseURL": "https://ai.totalum.app/api/v1", "apiKey": "tlm_pk_…" }
```

### `AiChatInput`

A chat request: the model, the conversation and the generation options, in the OpenAI format.

| Name | Type | Required | Description |
|---|---|---|---|
| `messages` | `({  })[]` | yes | The conversation, oldest first: `{ role, content }` each. `role` is `system`, `user`, `assistant`, `tool` or `developer`; `content` is a string, or a list of parts (`{ type: "text", text }`, `{ type: "image_url", image_url: { url } }`) to send images. Allowed: at least 1 item. |
| `model` | `string` | no | A preset (`fast`, `smart`, `reasoning`) or a model id from `models.list()`. Default `smart` (with automatic fallbacks). Allowed: at least 1 character. |
| `tools` | `(Record<string, unknown>)[]` | no | Functions the model may ask you to call, in the OpenAI format: `{ type: "function", function: { name, description, parameters } }`. |
| `toolChoice` | `unknown` | no | `auto`, `none`, `required`, or `{ type: "function", function: { name } }` to force one tool. |
| `parallelToolCalls` | `boolean` | no | Whether the model may ask for several tool calls at once. |
| `responseFormat` | `Record<string, unknown>` | no | Force JSON: `{ type: "json_object" }` or `{ type: "json_schema", json_schema: { name, schema, strict } }` (or use `generateObject`). |
| `temperature` | `number` | no | Randomness, 0–2: lower is more predictable. |
| `maxTokens` | `number` | no | The longest answer, in tokens. Default 4096. Allowed: > 0, ≤ 32768. |
| `reasoning` | `{ effort }` | no | How much the model thinks before answering, for models that support it. |
| `reasoning.effort` | `string` | yes | `low`, `medium` or `high`. |
| `serverTools` | `('web_search' \| 'image_generation' \| 'web_fetch' \| 'datetime')[]` | no | Accepted but not used today. |
| `maxToolCalls` | `number` | no | Accepted but not used today. Allowed: > 0, ≤ 30. |
| `stream` | `boolean` | no | Set by the SDK (`chat` vs `chat.stream`); do not pass it. |
| `cache` | `{ ttlSec }` | no | Accepted but not used today. |
| `cache.ttlSec` | `number` | yes | Accepted but not used today. Allowed: ≥ 1, ≤ 86400. |
| `zdr` | `boolean` | no | `true` only uses providers that keep no copy of the prompt (zero data retention). |
| `fastMode` | `boolean` | no | Accepted but not used today. |
| `user` | `string` | no | An id of your end user, passed to the provider for abuse monitoring. |

### `AiEmbedInput`

The input of `ai.embed()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | `string \| string[]` | yes | One text, or a list of texts. Allowed: at least 1 character, at least 1 item, at most 256 items. |
| `model` | `string` | no | A preset or model id. Default `embed`. Allowed: at least 1 character. |

### `AiEmbedOutput`

The answer of `ai.embed()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `embeddings` | `(number[])[]` | yes | One vector (list of numbers) per input text, in order. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

### `AiImageEditInput`

The input of `ai.image.edit()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `prompt` | `string` | yes | The edit, in words. Allowed: at least 1 character. |
| `images` | `string[]` | yes | The images to edit (project file keys, files URLs or `https://` URLs). Allowed: at least 1 item, at most 16 items. |
| `mask` | `string` | no | Accepted but not used today. |
| `fileName` | `string` | no | The base name of the stored files. |
| `size` | `string` | no | Accepted but not used today. |
| `quality` | `string` | no | A quality level the model supports. |
| `outputFormat` | `'png' \| 'jpeg' \| 'webp'` | no | `png`, `jpeg` or `webp`. |
| `background` | `string` | no | `transparent` or `opaque`, for models that support it. |
| `inputFidelity` | `string` | no | Accepted but not used today. |

### `AiImageInput`

The input of `ai.image.generate()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `prompt` | `string` | yes | What to draw, in words: subject, style, light, composition. Allowed: at least 1 character. |
| `model` | `string` | no | A preset or model id. Default `image`. Allowed: at least 1 character. |
| `aspectRatio` | `string` | no | The shape: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, … (what the model supports). |
| `resolution` | `'1K' \| '2K' \| '4K' \| '512'` | no | The size class: `512`, `1K`, `2K` or `4K` (for models that support it). |
| `quality` | `string` | no | A quality level the model supports (`low`, `medium`, `high`). |
| `outputFormat` | `'png' \| 'jpeg' \| 'webp'` | no | `png`, `jpeg` or `webp`. |
| `background` | `string` | no | `transparent` for a transparent background (models that support it), or `opaque`. |
| `n` | `number` | no | How many images. Default 1. Allowed: ≥ 1, ≤ 4. |
| `references` | `string[]` | no | Reference images for style or content: project file keys, files URLs or `https://` URLs. Allowed: at most 14 items. |
| `fileName` | `string` | no | The base name of the stored files. |
| `store` | `boolean` | no | Accepted for compatibility: images are always stored as project files. |

### `AiImageOutput`

Generated images, stored as public project files.

| Field | Type | Always present | Description |
|---|---|---|---|
| `images` | `({ key, url, mediaType, width?, height? })[]` | yes | The stored images. |
| `images.key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `images.url` | `string` | yes | The image's public URL. |
| `images.mediaType` | `string` | yes | The image's MIME type. |
| `images.width` | `number` | no | Width in pixels, when known. |
| `images.height` | `number` | no | Height in pixels, when known. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `fileName` | `string` | no | The first image's file name (kept for apps written against the first AI API). |
| `imageUrl` | `string` | no | The first image's URL (kept for apps written against the first AI API). |

### `AiModelsOutput`

The models this project can use, from `ai.models.list()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `presets` | `{ image?, fast?, reasoning?, video?, smart?, vision?, image-edit?, tts?, stt?, embed?, embed-image? }` | yes | Each preset and the model id it points to today. |
| `presets.image` | `string` | no | The `image` preset's model. |
| `presets.fast` | `string` | no | The `fast` preset's model. |
| `presets.reasoning` | `string` | no | The `reasoning` preset's model. |
| `presets.video` | `string` | no | The `video` preset's model. |
| `presets.smart` | `string` | no | The `smart` preset's model. |
| `presets.vision` | `string` | no | The `vision` preset's model. |
| `presets.image-edit` | `string` | no | The `image-edit` preset's model. |
| `presets.tts` | `string` | no | The `tts` preset's model. |
| `presets.stt` | `string` | no | The `stt` preset's model. |
| `presets.embed` | `string` | no | The `embed` preset's model. |
| `presets.embed-image` | `string` | no | The `embed-image` preset's model. |
| `models` | `({ slug, name, inputModalities, pricing, contextLength })[]` | yes | Every model the plan may call. |
| `models.slug` | `string` | yes | The model id to pass as `model`. |
| `models.name` | `string` | yes | Its display name. |
| `models.inputModalities` | `string[]` | yes | What it accepts (`text`, `image`, `file`, `audio`, `video`). |
| `models.pricing` | `Record<string, unknown>` | yes | Always `{}`: prices are in credits, per call (`usage.credits`). |
| `models.contextLength` | `number` | yes | The most tokens it reads at once. |
| `denylist` | `string[]` | no | Model ids this project may not use. |

### `AiPresetName`

A model preset: a name that always points to Totalum's current choice for a job, so apps get newer models without code changes. `models.list()` shows where each points today.

| Value | Meaning |
|---|---|
| `'image'` | Image generation. |
| `'fast'` | Quick, low-cost text model: classification, short answers, extraction. |
| `'reasoning'` | The strongest text model, for hard problems (slower, costs more). |
| `'video'` | Video generation. |
| `'smart'` | The default text model: good quality for most tasks. |
| `'vision'` | A model that reads images (used by `vision.describe` and `scan`). |
| `'image-edit'` | Image editing. |
| `'tts'` | Text to speech. |
| `'stt'` | Speech to text. |
| `'embed'` | Text embeddings (used by `embed` and `vectors` for text). |
| `'embed-image'` | Text and image embeddings in one space (used by `vectors` for images). |

### `AiTextOutput`

A text answer, from `ai.vision.describe()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `text` | `string` | yes | The answer. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

### `AiVideoCreated`

A started video job, from `ai.video.generate()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The job id: store it and read `ai.video.get(jobId)` later. |
| `status` | `'queued'` | yes | Always `queued`. |
| `estimatedCredits` | `number` | yes | The credits reserved for the job; the real charge is settled when it ends. |

### `AiVideoInput`

The input of `ai.video.generate()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `prompt` | `string` | yes | The scene, in words: subject, action, camera, style. Allowed: at least 1 character. |
| `durationSec` | `number` | yes | The clip's length in seconds (the `video` preset renders 2–30 s). Allowed: > 0, ≤ 30. |
| `model` | `string` | no | A preset or model id. Default `video`. Allowed: at least 1 character. |
| `resolution` | `string` | no | The size the model supports (`720p`, `1080p`). |
| `aspectRatio` | `string` | no | `16:9`, `9:16`, `1:1`, … (what the model supports). |
| `firstFrame` | `string` | no | An image the clip starts from (a project file key or a URL). |
| `lastFrame` | `string` | no | An image the clip ends on. |
| `references` | `string[]` | no | Reference images for style or subject. |
| `generateAudio` | `boolean` | no | `true` adds a soundtrack (models that support it). |
| `seed` | `number` | no | A number that makes the result reproducible. |

### `AiVideoJob`

A video job, from `ai.video.get()` and `ai.video.wait()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `jobId` | `string` | yes | The job id. |
| `status` | `'expired' \| 'completed' \| 'queued' \| 'running' \| 'failed' \| 'cancelled'` | yes | `queued` and `running` while it renders; then `completed`, `failed`, `cancelled` or `expired`. |
| `progress` | `number` | no | How far it is, 0–1, when the model reports it. |
| `video` | `{ key, url }` | no | The stored clip, once `completed`. |
| `video.key` | `string` | yes | The stored file's key (see `totalum.files`). |
| `video.url` | `string` | yes | The clip's public URL (MP4). |
| `usage` | `{ credits }` | no | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |
| `error` | `{ code, message }` | no | Why it failed, when `failed` or `expired`. |
| `error.code` | `string` | yes | The error code (`AI_VIDEO_FAILED`). |
| `error.message` | `string` | yes | What happened. |

### `AiVisionDescribeInput`

The input of `ai.vision.describe()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `image` | `string` | yes | The image: a project file key, a files URL, an `https://` URL, a `data:` URL or bare base64. Allowed: at least 1 character. |
| `prompt` | `string` | no | The question or instruction. Default: "Describe this image." |
| `model` | `string` | no | A preset or model id. Default `vision`. Allowed: at least 1 character. |

### `ChatResult`

`ai.chat()`: the first choice read out, the raw completion kept.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The completion's id. |
| `model` | `string` | yes | The model that answered (a preset resolves to the model it currently points to). |
| `text` | `string` | yes | The answer's text (empty when the model only called tools). |
| `toolCalls` | `({ id, type, function })[]` | yes | The tools the model asks you to call (with `tools`); empty otherwise. |
| `toolCalls.id` | `string` | yes | The call's id: send the tool's answer back as a `tool` message with this `tool_call_id`. |
| `toolCalls.type` | `string` | yes | Always `function`. |
| `toolCalls.function` | `{ name, arguments }` | yes | The function the model wants to call. |
| `toolCalls.function.name` | `string` | yes | The tool's name, as you declared it in `tools`. |
| `toolCalls.function.arguments` | `string` | yes | Its arguments as a JSON string: `JSON.parse` it. |
| `finishReason` | `string \| null` | yes | Why it stopped: `stop` (finished), `length` (hit `maxTokens`), `tool_calls`, `content_filter`, or `null`. |
| `usage` | [`ChatUsage`](#chatusage) | yes | What the call cost. |
| `raw` | `unknown` | yes | The whole OpenAI-format completion, as received. |

### `ChatStream`

`ai.chat.stream()`: the OpenAI-format SSE (`raw`), its text deltas, and the result once it ends.

| Field | Type | Always present | Description |
|---|---|---|---|
| `raw` | `ReadableStream<Uint8Array>` | yes | The OpenAI-format server-sent events, as received: return them from a route (`new Response(stream.raw)`) to stream to the browser. Read either `raw` or `text()`, not both. |

### `stream.text`

```ts
stream.text(): AsyncIterable<string>
```

The answer's text pieces as they arrive (one pass): `for await (const piece of stream.text())`.

**Returns** `AsyncIterable<string>` — An async iterable of text pieces.

**Example**

```ts
const stream = await totalum.ai.chat.stream({ model: 'fast', messages: [{ role: 'user', content: 'Count 1 to 3.' }] });
for await (const piece of stream.text()) process.stdout.write(piece);
```

Result:

```ts
"1" ", " "2" ", " "3" "."
```

### `stream.result`

```ts
stream.result(): Promise<ChatResult>
```

The final result once the stream ends, with its credits (reads the rest of the stream if needed).

**Returns** `Promise<ChatResult>` — The same result `chat()` returns; `raw` holds the accumulated text.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The completion's id. |
| `model` | `string` | yes | The model that answered (a preset resolves to the model it currently points to). |
| `text` | `string` | yes | The answer's text (empty when the model only called tools). |
| `toolCalls` | `({ id, type, function })[]` | yes | The tools the model asks you to call (with `tools`); empty otherwise. |
| `toolCalls.id` | `string` | yes | The call's id: send the tool's answer back as a `tool` message with this `tool_call_id`. |
| `toolCalls.type` | `string` | yes | Always `function`. |
| `toolCalls.function` | `{ name, arguments }` | yes | The function the model wants to call. |
| `toolCalls.function.name` | `string` | yes | The tool's name, as you declared it in `tools`. |
| `toolCalls.function.arguments` | `string` | yes | Its arguments as a JSON string: `JSON.parse` it. |
| `finishReason` | `string \| null` | yes | Why it stopped: `stop` (finished), `length` (hit `maxTokens`), `tool_calls`, `content_filter`, or `null`. |
| `usage` | [`ChatUsage`](#chatusage) | yes | What the call cost. |
| `raw` | `unknown` | yes | The whole OpenAI-format completion, as received. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `AI_STREAM_ERROR` | 502 | The stream broke before the end. |

**Example**

```ts
const stream = await totalum.ai.chat.stream({ model: 'fast', messages: [{ role: 'user', content: 'Count 1 to 3.' }] });
const result = await stream.result();
```

Result:

```json
{
  "id": "gen-1791740371-wfMT8WhpKYrAEa1s9XKT",
  "model": "openai/gpt-6-luna",
  "text": "1, 2, 3.",
  "toolCalls": [],
  "finishReason": "stop",
  "usage": { "credits": 0.0005, "inputTokens": 0, "outputTokens": 0 },
  "raw": { "id": "gen-1791740371-wfMT8WhpKYrAEa1s9XKT", "model": "openai/gpt-6-luna", "text": "1, 2, 3.", "choices": [{ "message": { "content": "1, 2, 3." }, "finish_reason": "stop" }] }
}
```

### `ChatUsage`

What a call was charged, in Totalum credits — the only usage Totalum shows (no tokens).

| Field | Type | Always present | Description |
|---|---|---|---|
| `credits` | `number` | yes | Credits charged for this call. |
| `inputTokens` | `number` | yes | **Deprecated.** Since 1.1.0: always 0 — Totalum shows only `credits`. |
| `outputTokens` | `number` | yes | **Deprecated.** Since 1.1.0: always 0 — Totalum shows only `credits`. |

### `TotalumAiOptions`

The options of `totalumAi()`: the client options plus the AI gateway URL `gatewayConfig()` returns.

| Name | Type | Required | Description |
|---|---|---|---|
| `aiBaseUrl` | `string` | no | The OpenAI-compatible AI gateway `gatewayConfig()` returns; default `TOTALUM_AI_BASE_URL`, else `https://ai.totalum.app/api/v1`. |
| `key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

## `totalum-sdk/email`

Send transactional e-mail (sign-up confirmations, receipts, notifications) from the project's own sender, and check
whether it was delivered. The HTML you pass is sent byte for byte. Billed in credits per recipient.

```ts
await totalum.email.send({ to: 'ana@example.com', subject: 'Welcome', html: '<p>Hi Ana</p>' });
```

| Method or type | What it does |
|---|---|
| [`totalumEmail`](#totalumemail) | Creates the `email` namespace (the same object as `totalum.email`, scope `email`): HTML sent byte for byte, charged per recipient, sent from the project's own address `p-<project>@mail.totalum-project.com`. |
| [`email.send`](#emailsend) | Sends one e-mail from the project's sender address: sign-up confirmations, receipts, notifications, password resets. |
| [`email.get`](#emailget) | An e-mail's delivery status and history (queued, sent, delivered, bounced, …): show it on an admin page or retry a bounced address. |
| [`EmailSendInput`](#emailsendinput) | One e-mail, for `email.send()`. |
| [`EmailSendOutput`](#emailsendoutput) | The answer of `email.send()`. |
| [`EmailView`](#emailview) | An e-mail and its delivery history, from `email.get()`. |

### `totalumEmail`

```ts
totalumEmail(options?: TotalumClientOptions): TotalumEmail
```

Creates the `email` namespace (the same object as `totalum.email`, scope `email`): HTML sent byte for byte, charged
per recipient, sent from the project's own address `p-<project>@mail.totalum-project.com`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumEmail`](#totalumemail) — The `email` namespace: the methods below.

**Example**

```ts
import { totalumEmail } from 'totalum-sdk/email';

const email = totalumEmail();
await email.send({ to: 'ana@example.com', subject: 'Welcome', html: '<p>Hi Ana</p>' });
```

The namespace's type is exported as `TotalumEmail` (`import type { TotalumEmail } from 'totalum-sdk/email'`).

### `email.send`

```ts
email.send(msg: EmailSendInput): Promise<{ id, status, message, success, messageId, usage }>
```

Sends one e-mail from the project's sender address: sign-up confirmations, receipts, notifications, password
resets. The HTML is sent byte for byte (inline your CSS); add `text` for clients that show plain text. Charged
per recipient (`usage.credits`). Keep the returned `id` to check delivery with `get(id)`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `msg` | [`EmailSendInput`](#emailsendinput) | yes | The recipients, subject, content and options. |
| `msg.to` | `string \| string[]` | yes | The recipient, or a list of recipients. Each is charged. Allowed: an e-mail address, at least 1 item. |
| `msg.subject` | `string` | yes | The subject line. Allowed: at least 1 character, at most 998 characters. |
| `msg.html` | `string` | no | The HTML body, sent byte for byte (inline your CSS). `html` or `text` is required. |
| `msg.text` | `string` | no | The plain-text body (shown by clients without HTML). |
| `msg.from` | `{ name?, localPart?, domain? }` | no | The sender's display name (`{ name: "Acme" }`). The address is the project's own; custom sender addresses are not available yet. |
| `msg.from.name` | `string` | no | The display name recipients see. |
| `msg.from.localPart` | `string` | no | Not available yet (refused): the part before `@`. |
| `msg.from.domain` | `string` | no | Not available yet (refused): a sender domain. |
| `msg.replyTo` | `string \| string[]` | no | Where replies go: one address or a list. Allowed: an e-mail address, at least 1 item. |
| `msg.cc` | `string \| string[]` | no | Carbon-copy recipients (each charged). Allowed: an e-mail address, at least 1 item. |
| `msg.bcc` | `string \| string[]` | no | Blind-copy recipients (each charged). Allowed: an e-mail address, at least 1 item. |
| `msg.headers` | `Record<string, string>` | no | Extra e-mail headers (`List-Unsubscribe`). |
| `msg.attachments` | `({ filename, url?, content?, contentType? })[]` | no | Files to attach, each from a `url` or base64 `content`. Allowed: at most 10 items. |
| `msg.attachments.filename` | `string` | yes | The attachment's file name. Allowed: at least 1 character. |
| `msg.attachments.url` | `string` | no | A URL to fetch the file from (a project file URL works). Allowed: a URL. |
| `msg.attachments.content` | `string` | no | The file in base64, instead of `url`. |
| `msg.attachments.contentType` | `string` | no | Its MIME type. |
| `msg.tags` | `({ name, value })[]` | no | Labels kept with the e-mail. |
| `msg.tags.name` | `string` | yes | The tag name. |
| `msg.tags.value` | `string` | yes | The tag value. |
| `msg.scheduledAt` | `string` | no | Send it later, at this time. Allowed: ISO 8601 date-time. |
| `msg.idempotencyKey` | `string` | no | A key of yours: sending again with the same key does not send a second e-mail. |
| `msg.bulk` | `boolean` | no | `true` marks a marketing or bulk message (adds unsubscribe handling). |

**Returns** `Promise<{ id, status, message, success, messageId, usage }>` — The e-mail's `id`, its status and what it cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The e-mail's id (`em_…`): pass it to `email.get()`. |
| `status` | `'queued' \| 'sent'` | yes | `queued` (accepted, being sent) or `sent`. |
| `message` | `'Email sent successfully'` | yes | Always `Email sent successfully`. |
| `success` | `true` | yes | Always `true` (a failure throws). |
| `messageId` | `string` | yes | The delivery provider's message id. |
| `usage` | `{ credits }` | yes | What the call cost. |
| `usage.credits` | `number` | yes | Credits charged for this call. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `DEV_RECIPIENT_NOT_ALLOWED` | 403 | Only on Totalum's internal test environment (never for a customer project): a recipient that is not allow-listed. |
| `VALIDATION_ERROR` | 400 | A bad address, no `html`/`text`, more than 10 attachments, or a custom sender domain (not available yet). |
| `EMAIL_SEND_FAILED` | 502 | The e-mail could not be sent; the message says why. |

**Example**

```ts
const sent = await totalum.email.send({
  to: 'ana@example.com',
  subject: 'Your invoice F-2026-0012',
  html: '<p>Hi Ana,</p><p>Your invoice is attached.</p>',
  text: 'Hi Ana, your invoice is attached.',
  attachments: [{ filename: 'F-2026-0012.pdf', url: invoice.url }],
  replyTo: 'billing@acme.com',
});
```

Result:

```json
{
  "success": true,
  "message": "Email sent successfully",
  "messageId": "01a12c0c-9e5f-74ea-a128-a72d3fdd47ae",
  "id": "em_01M4P0S77M6XCM69SYZWP1XME9",
  "status": "queued",
  "usage": { "credits": 0.0515 }
}
```

### `email.get`

```ts
email.get(id: string): Promise<EmailView | null>
```

An e-mail's delivery status and history (queued, sent, delivered, bounced, …): show it on an admin page or
retry a bounced address. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | yes | The `id` `send()` returned (`em_…`). |

**Returns** `Promise<EmailView \| null>` — The e-mail and its events, or `null` when it is not this project's.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `ENGINE_UNAVAILABLE` | 500 | An unexpected error; retry. |

**Example**

```ts
const view = await totalum.email.get('em_01M4P0S77M6XCM69SYZWP1XME9');
```

Result:

```json
{
  "id": "em_01M4P0S77M6XCM69SYZWP1XME9",
  "from": "\"Acme\" <p-acme-crm@mail.totalum-project.com>",
  "to": ["ana@example.com"],
  "subject": "Your invoice F-2026-0012",
  "status": "delivered",
  "lastEventAt": "2026-10-11T17:39:02.000Z",
  "events": [
    { "type": "email.sent", "at": "2026-10-11T17:38:58.000Z" },
    { "type": "email.delivered", "at": "2026-10-11T17:39:02.000Z" }
  ]
}
```

### `EmailSendInput`

One e-mail, for `email.send()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `to` | `string \| string[]` | yes | The recipient, or a list of recipients. Each is charged. Allowed: an e-mail address, at least 1 item. |
| `subject` | `string` | yes | The subject line. Allowed: at least 1 character, at most 998 characters. |
| `html` | `string` | no | The HTML body, sent byte for byte (inline your CSS). `html` or `text` is required. |
| `text` | `string` | no | The plain-text body (shown by clients without HTML). |
| `from` | `{ name?, localPart?, domain? }` | no | The sender's display name (`{ name: "Acme" }`). The address is the project's own; custom sender addresses are not available yet. |
| `from.name` | `string` | no | The display name recipients see. |
| `from.localPart` | `string` | no | Not available yet (refused): the part before `@`. |
| `from.domain` | `string` | no | Not available yet (refused): a sender domain. |
| `replyTo` | `string \| string[]` | no | Where replies go: one address or a list. Allowed: an e-mail address, at least 1 item. |
| `cc` | `string \| string[]` | no | Carbon-copy recipients (each charged). Allowed: an e-mail address, at least 1 item. |
| `bcc` | `string \| string[]` | no | Blind-copy recipients (each charged). Allowed: an e-mail address, at least 1 item. |
| `headers` | `Record<string, string>` | no | Extra e-mail headers (`List-Unsubscribe`). |
| `attachments` | `({ filename, url?, content?, contentType? })[]` | no | Files to attach, each from a `url` or base64 `content`. Allowed: at most 10 items. |
| `attachments.filename` | `string` | yes | The attachment's file name. Allowed: at least 1 character. |
| `attachments.url` | `string` | no | A URL to fetch the file from (a project file URL works). Allowed: a URL. |
| `attachments.content` | `string` | no | The file in base64, instead of `url`. |
| `attachments.contentType` | `string` | no | Its MIME type. |
| `tags` | `({ name, value })[]` | no | Labels kept with the e-mail. |
| `tags.name` | `string` | yes | The tag name. |
| `tags.value` | `string` | yes | The tag value. |
| `scheduledAt` | `string` | no | Send it later, at this time. Allowed: ISO 8601 date-time. |
| `idempotencyKey` | `string` | no | A key of yours: sending again with the same key does not send a second e-mail. |
| `bulk` | `boolean` | no | `true` marks a marketing or bulk message (adds unsubscribe handling). |

### `EmailSendOutput`

The answer of `email.send()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `success` | `true` | yes | Always `true` (a failure throws). |
| `message` | `'Email sent successfully'` | yes | Always `Email sent successfully`. |
| `messageId` | `string` | yes | The delivery provider's message id. |
| `id` | `string` | yes | The e-mail's id (`em_…`): pass it to `email.get()`. |
| `status` | `'queued' \| 'sent'` | yes | `queued` (accepted, being sent) or `sent`. |

### `EmailView`

An e-mail and its delivery history, from `email.get()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The e-mail's id. |
| `from` | `string` | yes | The sender, as recipients see it. |
| `to` | `string[]` | yes | The recipients. |
| `subject` | `string` | yes | The subject. |
| `status` | `'queued' \| 'failed' \| 'sent' \| 'delivered' \| 'delayed' \| 'bounced' \| 'complained' \| 'suppressed'` | yes | The latest state: `queued`, `sent`, `delivered`, `delayed`, `bounced` (the address does not exist), `complained` (marked as spam), `failed`, `suppressed` (not sent: the address bounced or complained before). |
| `lastEventAt` | `string \| null` | yes | When the last event happened, or `null`. |
| `events` | `({ type, at, detail? })[]` | yes | Every delivery event, oldest first. |
| `events.type` | `string` | yes | The event (`email.sent`, `email.delivered`, `email.bounced`, …). |
| `events.at` | `string` | yes | When it happened. |
| `events.detail` | `unknown` | no | Extra data of the event, when any. |

## `totalum-sdk/cron`

Run a route of your own app on a schedule: every night at 8:00, every 15 minutes, the first day of the month.
Totalum calls the URL you give (on the project's own published host) at each tick and records every run. Use it for
reports, reminders, clean-ups and long tasks done in steps. Jobs run against the published site, so publish the
project first. Each run costs a small fraction of a credit; the number of jobs and the shortest interval depend on the
plan.

```ts
await totalum.cron.create({
  name: 'daily-report',
  schedule: '0 8 * * *',
  timezone: 'Europe/Madrid',
  request: { method: 'POST', url: 'https://acme.totalum.app/api/cron/report' },
});
```

| Method or type | What it does |
|---|---|
| [`totalumCron`](#totalumcron) | Creates the `cron` namespace (the same object as `totalum.cron`, scope `cron`): HTTP jobs against the project's own host, fired by Totalum's Scheduler; limits per plan (`429 CRON_LIMIT_REACHED {limit, plan}`, `400 INVALID_SCHEDULE {minIntervalSeconds}`). |
| [`cron.create`](#croncreate) | Creates a job that calls a route of your own app on a schedule — a cron expression (`'0 8 * * *'`) or `{ everyMinutes: 15 }`. |
| [`cron.list`](#cronlist) | Every job of the project, both environments. |
| [`cron.get`](#cronget) | One job, or `null` when the project has no job with this id. |
| [`cron.update`](#cronupdate) | Changes any field of a job (`schedule`, `request`, `enabled`, `timezone`, …); fields you leave out keep their value. |
| [`cron.delete`](#crondelete) | Deletes a job and its run history. |
| [`cron.pause`](#cronpause) | Stops a job from firing until `resume` (the same as `update(id, { enabled: false })`). |
| [`cron.resume`](#cronresume) | Lets a paused job fire again — also one paused after failing runs (`paused_failures`) once its route is fixed. |
| [`cron.runs`](#cronruns) | A job's executions of the last 30 days, newest first: when each ran, the HTTP status your route answered and how long it took. |
| [`cron.trigger`](#crontrigger) | Fires a job now, outside its schedule — a "Run now" button, or to test a new job. |
| [`CronJob`](#cronjob) | A scheduled job. |
| [`CronJobInput`](#cronjobinput) | A job for `cron.create()`. |
| [`CronJobPatch`](#cronjobpatch) | The fields `cron.update()` changes; each is optional and means the same as in `cron.create()`. |
| [`CronRun`](#cronrun) | One execution of a job. |
| [`CronRunsPage`](#cronrunspage) | One page of `cron.runs()`. |

### `totalumCron`

```ts
totalumCron(options?: TotalumClientOptions): TotalumCron
```

Creates the `cron` namespace (the same object as `totalum.cron`, scope `cron`): HTTP jobs against the project's own host,
fired by Totalum's Scheduler; limits per plan (`429 CRON_LIMIT_REACHED {limit, plan}`, `400 INVALID_SCHEDULE
{minIntervalSeconds}`). Each run costs a small fraction of a credit. `timezone` (IANA) reads the schedule on that zone's clock, daylight
saving included; `pauseAfterFailures` (1–100, default 3; `null` = never) sets how many failed runs in a row pause it.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumCron`](#totalumcron) — The `cron` namespace: the methods below.

**Example**

```ts
import { totalumCron } from 'totalum-sdk/cron';

const cron = totalumCron();
const jobs = await cron.list();
```

The namespace's type is exported as `TotalumCron` (`import type { TotalumCron } from 'totalum-sdk/cron'`).

### `cron.create`

```ts
cron.create(input: CronJobInput): Promise<CronJob>
```

Creates a job that calls a route of your own app on a schedule — a cron expression (`'0 8 * * *'`) or
`{ everyMinutes: 15 }`. The URL must be the project's own `https://` host: the published site for a `live` job,
the preview for a `dev` one (without `env`, the URL decides). Each call is signed by Totalum (the template's cron
routes check it) and recorded in `runs()`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`CronJobInput`](#cronjobinput) | yes | The name, schedule, request and options. |
| `input.name` | `string` | yes | A name of yours (`daily-report`). Allowed: at least 1 character, at most 64 characters. |
| `input.schedule` | `string \| { everyMinutes }` | yes | When it runs: a 5-field cron expression (`"0 8 * * *"` = 08:00 every day, `"*/15 * * * *"` = every 15 minutes) or `{ everyMinutes: n }`. The plan sets the shortest interval. |
| `input.schedule.everyMinutes` | `number` | yes | Run every this many minutes. Allowed: > 0. |
| `input.request` | `{ method, url, headers?, body?, timeoutMs? }` | yes | The HTTP call each run makes. |
| `input.request.method` | `'GET' \| 'POST'` | yes | `GET` or `POST`. |
| `input.request.url` | `string` | yes | A URL on the project's own `https://` host (the published site for `live`, the preview for `dev`). Allowed: a URL. |
| `input.request.headers` | `Record<string, string>` | no | Extra headers to send. |
| `input.request.body` | `string` | no | The body of a `POST`. Allowed: at most 65536 characters. |
| `input.request.timeoutMs` | `number` | no | How long a run may take. Default 30 s, at most 30 s. Allowed: > 0, ≤ 30000. |
| `input.enabled` | `boolean` | no | `false` creates it paused. Default `true`. |
| `input.description` | `string` | no | A note of yours. |
| `input.env` | `'dev' \| 'live'` | no | `live` (the published site) or `dev` (the preview). Default: the environment of `request.url`. |
| `input.timezone` | `string` | no | An IANA time zone (`Europe/Madrid`): the schedule reads that zone's clock, daylight saving included. Default UTC. Allowed: at least 1 character, at most 64 characters. |
| `input.pauseAfterFailures` | `number \| null` | no | Pause the job after this many failed runs in a row. Default 3; `null` or `0` never pauses. Allowed: ≥ 0, ≤ 100. |

**Returns** `Promise<CronJob>` — The new job.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The job id (`cj_…`). |
| `name` | `string` | yes | A name of yours (`daily-report`). Allowed: at least 1 character, at most 64 characters. |
| `schedule` | `string \| { everyMinutes }` | yes | When it runs: a 5-field cron expression (`"0 8 * * *"` = 08:00 every day, `"*/15 * * * *"` = every 15 minutes) or `{ everyMinutes: n }`. The plan sets the shortest interval. |
| `schedule.everyMinutes` | `number` | yes | Run every this many minutes. Allowed: > 0. |
| `request` | `{ method, url, headers?, body?, timeoutMs? }` | yes | The HTTP call each run makes. |
| `request.method` | `'GET' \| 'POST'` | yes | `GET` or `POST`. |
| `request.url` | `string` | yes | A URL on the project's own `https://` host (the published site for `live`, the preview for `dev`). Allowed: a URL. |
| `request.headers` | `Record<string, string>` | no | Extra headers to send. |
| `request.body` | `string` | no | The body of a `POST`. Allowed: at most 65536 characters. |
| `request.timeoutMs` | `number` | no | How long a run may take. Default 30 s, at most 30 s. Allowed: > 0, ≤ 30000. |
| `enabled` | `boolean` | yes | `false` creates it paused. Default `true`. |
| `status` | `'active' \| 'paused' \| 'paused_failures'` | yes | `active`, `paused` (by you) or `paused_failures` (after too many failed runs; fix the route and `resume`). |
| `nextRunAt` | `string \| null` | yes | When it runs next, or `null` while paused. |
| `createdAt` | `string` | yes | When it was created. |
| `env` | `'dev' \| 'live'` | yes | `live` (the published site) or `dev` (the preview). Default: the environment of `request.url`. |
| `pauseAfterFailures` | `number \| null` | yes | Failed runs in a row before it pauses; `null` = never. Allowed: ≥ 0, ≤ 100. |
| `lastRun` | `{ at, status, latencyMs }` | no | The latest run. |
| `lastRun.at` | `string` | yes | When it ran. |
| `lastRun.status` | `number \| 'timeout' \| 'error' \| 'skipped'` | yes | The HTTP status your route answered, or `timeout`, `error` (no answer) or `skipped` (cron was switched off). |
| `lastRun.latencyMs` | `number` | yes | How long it took. |
| `description` | `string` | no | A note of yours. |
| `timezone` | `string` | no | An IANA time zone (`Europe/Madrid`): the schedule reads that zone's clock, daylight saving included. Default UTC. Allowed: at least 1 character, at most 64 characters. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `INVALID_SCHEDULE` | 400 | Not a valid schedule, or it fires more often than the plan allows; `details.minIntervalSeconds`. |
| `CRON_URL_NOT_ALLOWED` | 403 | The URL is not the project's own `https://` host for that environment; `details.allowedHost`. |
| `CRON_REQUIRES_PUBLISHED_PROJECT` | 403 | A `live` job, but the project is not published yet. |
| `CRON_LIMIT_REACHED` | 429 | The plan's number of jobs per environment is reached; `details.limit`. |
| `ENGINE_UNAVAILABLE` | 502 | The scheduler is unavailable; retry shortly. |

**Example**

```ts
const job = await totalum.cron.create({
  name: 'daily-report',
  schedule: '0 8 * * *', // 08:00 in Madrid all year
  timezone: 'Europe/Madrid',
  request: { method: 'POST', url: 'https://acme.totalum.app/api/cron/report' },
});
```

Result:

```json
{
  "id": "cj_01M4P1F0R7T2Y9U4I6O3P8A5SD",
  "name": "daily-report",
  "schedule": "0 8 * * *",
  "request": { "method": "POST", "url": "https://acme.totalum.app/api/cron/report" },
  "enabled": true,
  "status": "active",
  "nextRunAt": "2026-10-12T06:00:00.000Z",
  "createdAt": "2026-10-11T18:30:00.000Z",
  "env": "live",
  "timezone": "Europe/Madrid",
  "pauseAfterFailures": 3
}
```

### `cron.list`

```ts
cron.list(): Promise<CronJob[]>
```

Every job of the project, both environments. Free.

**Returns** `Promise<CronJob[]>` — The jobs.

Each item:

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The job id (`cj_…`). |
| `name` | `string` | yes | A name of yours (`daily-report`). Allowed: at least 1 character, at most 64 characters. |
| `schedule` | `string \| { everyMinutes }` | yes | When it runs: a 5-field cron expression (`"0 8 * * *"` = 08:00 every day, `"*/15 * * * *"` = every 15 minutes) or `{ everyMinutes: n }`. The plan sets the shortest interval. |
| `schedule.everyMinutes` | `number` | yes | Run every this many minutes. Allowed: > 0. |
| `request` | `{ method, url, headers?, body?, timeoutMs? }` | yes | The HTTP call each run makes. |
| `request.method` | `'GET' \| 'POST'` | yes | `GET` or `POST`. |
| `request.url` | `string` | yes | A URL on the project's own `https://` host (the published site for `live`, the preview for `dev`). Allowed: a URL. |
| `request.headers` | `Record<string, string>` | no | Extra headers to send. |
| `request.body` | `string` | no | The body of a `POST`. Allowed: at most 65536 characters. |
| `request.timeoutMs` | `number` | no | How long a run may take. Default 30 s, at most 30 s. Allowed: > 0, ≤ 30000. |
| `enabled` | `boolean` | yes | `false` creates it paused. Default `true`. |
| `status` | `'active' \| 'paused' \| 'paused_failures'` | yes | `active`, `paused` (by you) or `paused_failures` (after too many failed runs; fix the route and `resume`). |
| `nextRunAt` | `string \| null` | yes | When it runs next, or `null` while paused. |
| `createdAt` | `string` | yes | When it was created. |
| `env` | `'dev' \| 'live'` | yes | `live` (the published site) or `dev` (the preview). Default: the environment of `request.url`. |
| `pauseAfterFailures` | `number \| null` | yes | Failed runs in a row before it pauses; `null` = never. Allowed: ≥ 0, ≤ 100. |
| `lastRun` | `{ at, status, latencyMs }` | no | The latest run. |
| `lastRun.at` | `string` | yes | When it ran. |
| `lastRun.status` | `number \| 'timeout' \| 'error' \| 'skipped'` | yes | The HTTP status your route answered, or `timeout`, `error` (no answer) or `skipped` (cron was switched off). |
| `lastRun.latencyMs` | `number` | yes | How long it took. |
| `description` | `string` | no | A note of yours. |
| `timezone` | `string` | no | An IANA time zone (`Europe/Madrid`): the schedule reads that zone's clock, daylight saving included. Default UTC. Allowed: at least 1 character, at most 64 characters. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `ENGINE_UNAVAILABLE` | 502 | The scheduler is unavailable; retry shortly. |

**Example**

```ts
const jobs = await totalum.cron.list();
```

Result:

```json
[
  {
    "id": "cj_01M4P1F0R7T2Y9U4I6O3P8A5SD",
    "name": "daily-report",
    "schedule": "0 8 * * *",
    "request": { "method": "POST", "url": "https://acme.totalum.app/api/cron/report" },
    "enabled": true,
    "status": "active",
    "nextRunAt": "2026-10-12T06:00:00.000Z",
    "lastRun": { "at": "2026-10-11T06:00:00.412Z", "status": 200, "latencyMs": 812 },
    "createdAt": "2026-10-01T18:30:00.000Z",
    "env": "live",
    "timezone": "Europe/Madrid",
    "pauseAfterFailures": 3
  }
]
```

### `cron.get`

```ts
cron.get(id: string): Promise<CronJob | null>
```

One job, or `null` when the project has no job with this id. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | yes | The job's `id` (`cj_…`). |

**Returns** `Promise<CronJob \| null>` — The job, or `null`.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `ENGINE_UNAVAILABLE` | 502 | The scheduler is unavailable; retry shortly. |

**Example**

```ts
const job = await totalum.cron.get('cj_01M4P1F0R7T2Y9U4I6O3P8A5SD');
```

Result:

```ts
{ "id": "cj_01M4P1F0R7T2Y9U4I6O3P8A5SD", "name": "daily-report", "status": "active", "nextRunAt": "2026-10-12T06:00:00.000Z", … }
```

### `cron.update`

```ts
cron.update(id: string, patch: CronJobPatch): Promise<CronJob>
```

Changes any field of a job (`schedule`, `request`, `enabled`, `timezone`, …); fields you leave out keep their
value. The same checks as `create` apply.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | yes | The job's `id`. |
| `patch` | [`CronJobPatch`](#cronjobpatch) | yes | The fields to change. |
| `patch.name` | `string` | no | A name of yours (`daily-report`). Allowed: at least 1 character, at most 64 characters. |
| `patch.schedule` | `string \| { everyMinutes }` | no | When it runs: a 5-field cron expression (`"0 8 * * *"` = 08:00 every day, `"*/15 * * * *"` = every 15 minutes) or `{ everyMinutes: n }`. The plan sets the shortest interval. |
| `patch.schedule.everyMinutes` | `number` | yes | Run every this many minutes. Allowed: > 0. |
| `patch.request` | `{ method, url, headers?, body?, timeoutMs? }` | no | The HTTP call each run makes. |
| `patch.request.method` | `'GET' \| 'POST'` | yes | `GET` or `POST`. |
| `patch.request.url` | `string` | yes | A URL on the project's own `https://` host (the published site for `live`, the preview for `dev`). Allowed: a URL. |
| `patch.request.headers` | `Record<string, string>` | no | Extra headers to send. |
| `patch.request.body` | `string` | no | The body of a `POST`. Allowed: at most 65536 characters. |
| `patch.request.timeoutMs` | `number` | no | How long a run may take. Default 30 s, at most 30 s. Allowed: > 0, ≤ 30000. |
| `patch.enabled` | `boolean` | no | `false` creates it paused. Default `true`. |
| `patch.description` | `string` | no | A note of yours. |
| `patch.env` | `'dev' \| 'live'` | no | `live` (the published site) or `dev` (the preview). Default: the environment of `request.url`. |
| `patch.timezone` | `string` | no | An IANA time zone (`Europe/Madrid`): the schedule reads that zone's clock, daylight saving included. Default UTC. Allowed: at least 1 character, at most 64 characters. |
| `patch.pauseAfterFailures` | `number \| null` | no | Pause the job after this many failed runs in a row. Default 3; `null` or `0` never pauses. Allowed: ≥ 0, ≤ 100. |

**Returns** `Promise<CronJob>` — The updated job.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The job id (`cj_…`). |
| `name` | `string` | yes | A name of yours (`daily-report`). Allowed: at least 1 character, at most 64 characters. |
| `schedule` | `string \| { everyMinutes }` | yes | When it runs: a 5-field cron expression (`"0 8 * * *"` = 08:00 every day, `"*/15 * * * *"` = every 15 minutes) or `{ everyMinutes: n }`. The plan sets the shortest interval. |
| `schedule.everyMinutes` | `number` | yes | Run every this many minutes. Allowed: > 0. |
| `request` | `{ method, url, headers?, body?, timeoutMs? }` | yes | The HTTP call each run makes. |
| `request.method` | `'GET' \| 'POST'` | yes | `GET` or `POST`. |
| `request.url` | `string` | yes | A URL on the project's own `https://` host (the published site for `live`, the preview for `dev`). Allowed: a URL. |
| `request.headers` | `Record<string, string>` | no | Extra headers to send. |
| `request.body` | `string` | no | The body of a `POST`. Allowed: at most 65536 characters. |
| `request.timeoutMs` | `number` | no | How long a run may take. Default 30 s, at most 30 s. Allowed: > 0, ≤ 30000. |
| `enabled` | `boolean` | yes | `false` creates it paused. Default `true`. |
| `status` | `'active' \| 'paused' \| 'paused_failures'` | yes | `active`, `paused` (by you) or `paused_failures` (after too many failed runs; fix the route and `resume`). |
| `nextRunAt` | `string \| null` | yes | When it runs next, or `null` while paused. |
| `createdAt` | `string` | yes | When it was created. |
| `env` | `'dev' \| 'live'` | yes | `live` (the published site) or `dev` (the preview). Default: the environment of `request.url`. |
| `pauseAfterFailures` | `number \| null` | yes | Failed runs in a row before it pauses; `null` = never. Allowed: ≥ 0, ≤ 100. |
| `lastRun` | `{ at, status, latencyMs }` | no | The latest run. |
| `lastRun.at` | `string` | yes | When it ran. |
| `lastRun.status` | `number \| 'timeout' \| 'error' \| 'skipped'` | yes | The HTTP status your route answered, or `timeout`, `error` (no answer) or `skipped` (cron was switched off). |
| `lastRun.latencyMs` | `number` | yes | How long it took. |
| `description` | `string` | no | A note of yours. |
| `timezone` | `string` | no | An IANA time zone (`Europe/Madrid`): the schedule reads that zone's clock, daylight saving included. Default UTC. Allowed: at least 1 character, at most 64 characters. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No job with this id. |
| `INVALID_SCHEDULE` | 400 | The new schedule is invalid or too frequent for the plan. |
| `CRON_URL_NOT_ALLOWED` | 403 | The new URL is not the project's own host. |

**Example**

```ts
const job = await totalum.cron.update('cj_01M4P1F0R7T2Y9U4I6O3P8A5SD', { schedule: { everyMinutes: 60 } });
```

Result:

```ts
{ "id": "cj_01M4P1F0R7T2Y9U4I6O3P8A5SD", "name": "daily-report", "schedule": { "everyMinutes": 60 }, "status": "active", "nextRunAt": "2026-10-11T19:30:00.000Z", … }
```

### `cron.delete`

```ts
cron.delete(id: string): Promise<void>
```

Deletes a job and its run history.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | yes | The job's `id`. |

**Returns** `Promise<void>` — Nothing once deleted.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No job with this id. |

**Example**

```ts
await totalum.cron.delete('cj_01M4P1F0R7T2Y9U4I6O3P8A5SD');
```

### `cron.pause`

```ts
cron.pause(id: string): Promise<CronJob>
```

Stops a job from firing until `resume` (the same as `update(id, { enabled: false })`).

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | yes | The job's `id`. |

**Returns** `Promise<CronJob>` — The job, now `paused`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The job id (`cj_…`). |
| `name` | `string` | yes | A name of yours (`daily-report`). Allowed: at least 1 character, at most 64 characters. |
| `schedule` | `string \| { everyMinutes }` | yes | When it runs: a 5-field cron expression (`"0 8 * * *"` = 08:00 every day, `"*/15 * * * *"` = every 15 minutes) or `{ everyMinutes: n }`. The plan sets the shortest interval. |
| `schedule.everyMinutes` | `number` | yes | Run every this many minutes. Allowed: > 0. |
| `request` | `{ method, url, headers?, body?, timeoutMs? }` | yes | The HTTP call each run makes. |
| `request.method` | `'GET' \| 'POST'` | yes | `GET` or `POST`. |
| `request.url` | `string` | yes | A URL on the project's own `https://` host (the published site for `live`, the preview for `dev`). Allowed: a URL. |
| `request.headers` | `Record<string, string>` | no | Extra headers to send. |
| `request.body` | `string` | no | The body of a `POST`. Allowed: at most 65536 characters. |
| `request.timeoutMs` | `number` | no | How long a run may take. Default 30 s, at most 30 s. Allowed: > 0, ≤ 30000. |
| `enabled` | `boolean` | yes | `false` creates it paused. Default `true`. |
| `status` | `'active' \| 'paused' \| 'paused_failures'` | yes | `active`, `paused` (by you) or `paused_failures` (after too many failed runs; fix the route and `resume`). |
| `nextRunAt` | `string \| null` | yes | When it runs next, or `null` while paused. |
| `createdAt` | `string` | yes | When it was created. |
| `env` | `'dev' \| 'live'` | yes | `live` (the published site) or `dev` (the preview). Default: the environment of `request.url`. |
| `pauseAfterFailures` | `number \| null` | yes | Failed runs in a row before it pauses; `null` = never. Allowed: ≥ 0, ≤ 100. |
| `lastRun` | `{ at, status, latencyMs }` | no | The latest run. |
| `lastRun.at` | `string` | yes | When it ran. |
| `lastRun.status` | `number \| 'timeout' \| 'error' \| 'skipped'` | yes | The HTTP status your route answered, or `timeout`, `error` (no answer) or `skipped` (cron was switched off). |
| `lastRun.latencyMs` | `number` | yes | How long it took. |
| `description` | `string` | no | A note of yours. |
| `timezone` | `string` | no | An IANA time zone (`Europe/Madrid`): the schedule reads that zone's clock, daylight saving included. Default UTC. Allowed: at least 1 character, at most 64 characters. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No job with this id. |

**Example**

```ts
const job = await totalum.cron.pause('cj_01M4P1F0R7T2Y9U4I6O3P8A5SD');
```

Result:

```ts
{ "id": "cj_01M4P1F0R7T2Y9U4I6O3P8A5SD", "enabled": false, "status": "paused", "nextRunAt": null, … }
```

### `cron.resume`

```ts
cron.resume(id: string): Promise<CronJob>
```

Lets a paused job fire again — also one paused after failing runs (`paused_failures`) once its route is fixed.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | yes | The job's `id`. |

**Returns** `Promise<CronJob>` — The job, now `active`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The job id (`cj_…`). |
| `name` | `string` | yes | A name of yours (`daily-report`). Allowed: at least 1 character, at most 64 characters. |
| `schedule` | `string \| { everyMinutes }` | yes | When it runs: a 5-field cron expression (`"0 8 * * *"` = 08:00 every day, `"*/15 * * * *"` = every 15 minutes) or `{ everyMinutes: n }`. The plan sets the shortest interval. |
| `schedule.everyMinutes` | `number` | yes | Run every this many minutes. Allowed: > 0. |
| `request` | `{ method, url, headers?, body?, timeoutMs? }` | yes | The HTTP call each run makes. |
| `request.method` | `'GET' \| 'POST'` | yes | `GET` or `POST`. |
| `request.url` | `string` | yes | A URL on the project's own `https://` host (the published site for `live`, the preview for `dev`). Allowed: a URL. |
| `request.headers` | `Record<string, string>` | no | Extra headers to send. |
| `request.body` | `string` | no | The body of a `POST`. Allowed: at most 65536 characters. |
| `request.timeoutMs` | `number` | no | How long a run may take. Default 30 s, at most 30 s. Allowed: > 0, ≤ 30000. |
| `enabled` | `boolean` | yes | `false` creates it paused. Default `true`. |
| `status` | `'active' \| 'paused' \| 'paused_failures'` | yes | `active`, `paused` (by you) or `paused_failures` (after too many failed runs; fix the route and `resume`). |
| `nextRunAt` | `string \| null` | yes | When it runs next, or `null` while paused. |
| `createdAt` | `string` | yes | When it was created. |
| `env` | `'dev' \| 'live'` | yes | `live` (the published site) or `dev` (the preview). Default: the environment of `request.url`. |
| `pauseAfterFailures` | `number \| null` | yes | Failed runs in a row before it pauses; `null` = never. Allowed: ≥ 0, ≤ 100. |
| `lastRun` | `{ at, status, latencyMs }` | no | The latest run. |
| `lastRun.at` | `string` | yes | When it ran. |
| `lastRun.status` | `number \| 'timeout' \| 'error' \| 'skipped'` | yes | The HTTP status your route answered, or `timeout`, `error` (no answer) or `skipped` (cron was switched off). |
| `lastRun.latencyMs` | `number` | yes | How long it took. |
| `description` | `string` | no | A note of yours. |
| `timezone` | `string` | no | An IANA time zone (`Europe/Madrid`): the schedule reads that zone's clock, daylight saving included. Default UTC. Allowed: at least 1 character, at most 64 characters. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No job with this id. |

**Example**

```ts
const job = await totalum.cron.resume('cj_01M4P1F0R7T2Y9U4I6O3P8A5SD');
```

Result:

```ts
{ "id": "cj_01M4P1F0R7T2Y9U4I6O3P8A5SD", "enabled": true, "status": "active", "nextRunAt": "2026-10-12T06:00:00.000Z", … }
```

### `cron.runs`

```ts
cron.runs(id: string, opts?: { limit?, cursor? }): Promise<CronRunsPage>
```

A job's executions of the last 30 days, newest first: when each ran, the HTTP status your route answered and how
long it took. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | yes | The job's `id`. |
| `opts` | `{ limit?, cursor? }` | no | Paging. |
| `opts.limit` | `number` | no | Runs per page. |
| `opts.cursor` | `string` | no | The previous page's `nextCursor`. |

**Returns** `Promise<CronRunsPage>` — One page of runs.

| Field | Type | Always present | Description |
|---|---|---|---|
| `items` | [`CronRun[]`](#cronrun) | yes | The runs, newest first. |
| `nextCursor` | `string \| null` | yes | Pass it as `cursor` for the next page; `null` on the last. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No job with this id. |

**Example**

```ts
const page = await totalum.cron.runs('cj_01M4P1F0R7T2Y9U4I6O3P8A5SD', { limit: 2 });
```

Result:

```json
{
  "items": [
    { "id": "cr_01M4P1G2H3J4K5L6M7N8P9Q0RS", "scheduledFor": "2026-10-11T06:00:00.000Z", "startedAt": "2026-10-11T06:00:00.412Z", "status": 200, "latencyMs": 812, "responseSnippet": "{\"ok\":true}" },
    { "id": "cr_01M4MZ8B7V6C5X4Z3A2S1D0F9G", "scheduledFor": "2026-10-10T06:00:00.000Z", "startedAt": "2026-10-10T06:00:00.388Z", "status": "timeout", "latencyMs": 30000, "error": "The request timed out" }
  ],
  "nextCursor": "MjAyNi0xMC0xMFQwNjowMDowMC4wMDBa"
}
```

### `cron.trigger`

```ts
cron.trigger(id: string): Promise<{ runId }>
```

Fires a job now, outside its schedule — a "Run now" button, or to test a new job. At most 10 manual runs a
minute per project.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | yes | The job's `id`. |

**Returns** `Promise<{ runId }>` — The id of the run it started (it shows in `runs()` once done).

| Field | Type | Always present | Description |
|---|---|---|---|
| `runId` | `string` | yes | The id of the run that was started. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `CRON_JOB_NOT_FOUND` | 404 | No job with this id. |
| `RATE_LIMITED` | 429 | More than 10 manual runs in a minute; `details.retryAfterSeconds`. |

**Example**

```ts
const { runId } = await totalum.cron.trigger('cj_01M4P1F0R7T2Y9U4I6O3P8A5SD');
```

Result:

```json
{ "runId": "cr_01M4P1H5J6K7L8M9N0P1Q2R3ST" }
```

### `CronJob`

A scheduled job.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The job id (`cj_…`). |
| `name` | `string` | yes | A name of yours (`daily-report`). Allowed: at least 1 character, at most 64 characters. |
| `schedule` | `string \| { everyMinutes }` | yes | When it runs: a 5-field cron expression (`"0 8 * * *"` = 08:00 every day, `"*/15 * * * *"` = every 15 minutes) or `{ everyMinutes: n }`. The plan sets the shortest interval. |
| `schedule.everyMinutes` | `number` | yes | Run every this many minutes. Allowed: > 0. |
| `request` | `{ method, url, headers?, body?, timeoutMs? }` | yes | The HTTP call each run makes. |
| `request.method` | `'GET' \| 'POST'` | yes | `GET` or `POST`. |
| `request.url` | `string` | yes | A URL on the project's own `https://` host (the published site for `live`, the preview for `dev`). Allowed: a URL. |
| `request.headers` | `Record<string, string>` | no | Extra headers to send. |
| `request.body` | `string` | no | The body of a `POST`. Allowed: at most 65536 characters. |
| `request.timeoutMs` | `number` | no | How long a run may take. Default 30 s, at most 30 s. Allowed: > 0, ≤ 30000. |
| `enabled` | `boolean` | yes | `false` creates it paused. Default `true`. |
| `status` | `'active' \| 'paused' \| 'paused_failures'` | yes | `active`, `paused` (by you) or `paused_failures` (after too many failed runs; fix the route and `resume`). |
| `nextRunAt` | `string \| null` | yes | When it runs next, or `null` while paused. |
| `createdAt` | `string` | yes | When it was created. |
| `env` | `'dev' \| 'live'` | yes | `live` (the published site) or `dev` (the preview). Default: the environment of `request.url`. |
| `pauseAfterFailures` | `number \| null` | yes | Failed runs in a row before it pauses; `null` = never. Allowed: ≥ 0, ≤ 100. |
| `lastRun` | `{ at, status, latencyMs }` | no | The latest run. |
| `lastRun.at` | `string` | yes | When it ran. |
| `lastRun.status` | `number \| 'timeout' \| 'error' \| 'skipped'` | yes | The HTTP status your route answered, or `timeout`, `error` (no answer) or `skipped` (cron was switched off). |
| `lastRun.latencyMs` | `number` | yes | How long it took. |
| `description` | `string` | no | A note of yours. |
| `timezone` | `string` | no | An IANA time zone (`Europe/Madrid`): the schedule reads that zone's clock, daylight saving included. Default UTC. Allowed: at least 1 character, at most 64 characters. |

### `CronJobInput`

A job for `cron.create()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | `string` | yes | A name of yours (`daily-report`). Allowed: at least 1 character, at most 64 characters. |
| `schedule` | `string \| { everyMinutes }` | yes | When it runs: a 5-field cron expression (`"0 8 * * *"` = 08:00 every day, `"*/15 * * * *"` = every 15 minutes) or `{ everyMinutes: n }`. The plan sets the shortest interval. |
| `schedule.everyMinutes` | `number` | yes | Run every this many minutes. Allowed: > 0. |
| `request` | `{ method, url, headers?, body?, timeoutMs? }` | yes | The HTTP call each run makes. |
| `request.method` | `'GET' \| 'POST'` | yes | `GET` or `POST`. |
| `request.url` | `string` | yes | A URL on the project's own `https://` host (the published site for `live`, the preview for `dev`). Allowed: a URL. |
| `request.headers` | `Record<string, string>` | no | Extra headers to send. |
| `request.body` | `string` | no | The body of a `POST`. Allowed: at most 65536 characters. |
| `request.timeoutMs` | `number` | no | How long a run may take. Default 30 s, at most 30 s. Allowed: > 0, ≤ 30000. |
| `enabled` | `boolean` | no | `false` creates it paused. Default `true`. |
| `description` | `string` | no | A note of yours. |
| `env` | `'dev' \| 'live'` | no | `live` (the published site) or `dev` (the preview). Default: the environment of `request.url`. |
| `timezone` | `string` | no | An IANA time zone (`Europe/Madrid`): the schedule reads that zone's clock, daylight saving included. Default UTC. Allowed: at least 1 character, at most 64 characters. |
| `pauseAfterFailures` | `number \| null` | no | Pause the job after this many failed runs in a row. Default 3; `null` or `0` never pauses. Allowed: ≥ 0, ≤ 100. |

### `CronJobPatch`

The fields `cron.update()` changes; each is optional and means the same as in `cron.create()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | `string` | no | A name of yours (`daily-report`). Allowed: at least 1 character, at most 64 characters. |
| `schedule` | `string \| { everyMinutes }` | no | When it runs: a 5-field cron expression (`"0 8 * * *"` = 08:00 every day, `"*/15 * * * *"` = every 15 minutes) or `{ everyMinutes: n }`. The plan sets the shortest interval. |
| `schedule.everyMinutes` | `number` | yes | Run every this many minutes. Allowed: > 0. |
| `request` | `{ method, url, headers?, body?, timeoutMs? }` | no | The HTTP call each run makes. |
| `request.method` | `'GET' \| 'POST'` | yes | `GET` or `POST`. |
| `request.url` | `string` | yes | A URL on the project's own `https://` host (the published site for `live`, the preview for `dev`). Allowed: a URL. |
| `request.headers` | `Record<string, string>` | no | Extra headers to send. |
| `request.body` | `string` | no | The body of a `POST`. Allowed: at most 65536 characters. |
| `request.timeoutMs` | `number` | no | How long a run may take. Default 30 s, at most 30 s. Allowed: > 0, ≤ 30000. |
| `enabled` | `boolean` | no | `false` creates it paused. Default `true`. |
| `description` | `string` | no | A note of yours. |
| `env` | `'dev' \| 'live'` | no | `live` (the published site) or `dev` (the preview). Default: the environment of `request.url`. |
| `timezone` | `string` | no | An IANA time zone (`Europe/Madrid`): the schedule reads that zone's clock, daylight saving included. Default UTC. Allowed: at least 1 character, at most 64 characters. |
| `pauseAfterFailures` | `number \| null` | no | Pause the job after this many failed runs in a row. Default 3; `null` or `0` never pauses. Allowed: ≥ 0, ≤ 100. |

### `CronRun`

One execution of a job.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The run id (`cr_…`). |
| `scheduledFor` | `string` | yes | When it was due. |
| `startedAt` | `string \| null` | yes | When the call started, or `null` if it never did. |
| `status` | `number \| 'timeout' \| 'error' \| 'skipped'` | yes | The HTTP status your route answered, or `timeout`, `error` or `skipped`. |
| `latencyMs` | `number \| null` | yes | How long the call took, or `null`. |
| `error` | `string` | no | Why it failed, when it did. |
| `responseSnippet` | `string` | no | The first 1 KB of your route's answer. |

### `CronRunsPage`

One page of `cron.runs()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `items` | [`CronRun[]`](#cronrun) | yes | The runs, newest first. |
| `nextCursor` | `string \| null` | yes | Pass it as `cursor` for the next page; `null` on the last. |

## `totalum-sdk/seo`

Tell search engines that pages of your custom domain changed (IndexNow), so new and edited pages are indexed in
minutes instead of days. Works on verified custom domains. Free.

```ts
await totalum.seo.notifyChanged(['https://www.acme.com/blog/launch']);
```

| Method or type | What it does |
|---|---|
| [`totalumSeo`](#totalumseo) | Creates the `seo` namespace (the same object as `totalum.seo`, scope `seo`): IndexNow for verified custom domains, free. |
| [`seo.notifyChanged`](#seonotifychanged) | Tells the search engines that use IndexNow (Bing, Yandex, Seznam, Naver, …) that these pages of the project's custom domain were added or changed, so they are crawled again soon, and fetches each domain's `/sitemap.xml` so it is fresh. |
| [`seo.indexNowKey`](#seoindexnowkey) | The project's IndexNow key: the template serves it at `/<key>.txt` so search engines can check the submissions are yours. |
| [`SeoIndexNowKey`](#seoindexnowkey-type) | The project's IndexNow key. |
| [`SeoNotifyOutput`](#seonotifyoutput) | The answer of `seo.notifyChanged()`. |

### `totalumSeo`

```ts
totalumSeo(options?: TotalumClientOptions): TotalumSeo
```

Creates the `seo` namespace (the same object as `totalum.seo`, scope `seo`): IndexNow for verified custom domains,
free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumSeo`](#totalumseo) — The `seo` namespace: the methods below.

**Example**

```ts
import { totalumSeo } from 'totalum-sdk/seo';

const seo = totalumSeo();
await seo.notifyChanged(['https://www.acme.com/blog/launch']);
```

The namespace's type is exported as `TotalumSeo` (`import type { TotalumSeo } from 'totalum-sdk/seo'`).

### `seo.notifyChanged`

```ts
seo.notifyChanged(urls: string[]): Promise<SeoNotifyOutput>
```

Tells the search engines that use IndexNow (Bing, Yandex, Seznam, Naver, …) that these pages of the project's
custom domain were added or changed, so they are crawled again soon, and fetches each domain's `/sitemap.xml` so
it is fresh. Call it after publishing a post or editing a product page. Only verified custom domains are sent:
for Totalum's own hosts the answer is `indexNow: 'skipped_no_custom_domain'`. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `urls` | `string[]` | yes | Full URLs on the project's hostnames. |

**Returns** `Promise<SeoNotifyOutput>` — How many URLs were accepted and what was sent.

| Field | Type | Always present | Description |
|---|---|---|---|
| `accepted` | `number` | yes | How many URLs were sent to IndexNow. |
| `indexNow` | `'error' \| 'sent' \| 'skipped_no_custom_domain'` | yes | `sent`; `skipped_no_custom_domain` when the project has no verified custom domain (Totalum's own hosts are never sent); `error` when IndexNow refused them. |
| `sitemapsPinged` | `boolean` | yes | Whether each domain's `/sitemap.xml` answered. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `URL_NOT_PROJECT_HOST` | 400 | A URL is not on one of the project's hostnames; `details.url`. |
| `INDEXNOW_ERROR` | 502 | IndexNow refused the submission. |
| `ENGINE_UNAVAILABLE` | 502 | SEO is unavailable; retry shortly. |

**Example**

```ts
const out = await totalum.seo.notifyChanged(['https://www.acme.com/blog/launch', 'https://www.acme.com/pricing']);
```

Result:

```json
{ "accepted": 2, "indexNow": "sent", "sitemapsPinged": true }
```

### `seo.indexNowKey`

```ts
seo.indexNowKey(): Promise<SeoIndexNowKey>
```

The project's IndexNow key: the template serves it at `/<key>.txt` so search engines can check the submissions
are yours. Free.

**Returns** `Promise<SeoIndexNowKey>` — The key and the URL it is served at.

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The key. |
| `keyLocation` | `string` | yes | The URL where the site serves it (`https://<host>/<key>.txt`). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `ENGINE_UNAVAILABLE` | 502 | SEO is unavailable; retry shortly. |

**Example**

```ts
const { key, keyLocation } = await totalum.seo.indexNowKey();
```

Result:

```json
{ "key": "99d8cc52…", "keyLocation": "https://acme.totalum.app/99d8cc52….txt" }
```

### `SeoIndexNowKey` type

The project's IndexNow key.

| Field | Type | Always present | Description |
|---|---|---|---|
| `key` | `string` | yes | The key. |
| `keyLocation` | `string` | yes | The URL where the site serves it (`https://<host>/<key>.txt`). |

### `SeoNotifyOutput`

The answer of `seo.notifyChanged()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `accepted` | `number` | yes | How many URLs were sent to IndexNow. |
| `indexNow` | `'error' \| 'sent' \| 'skipped_no_custom_domain'` | yes | `sent`; `skipped_no_custom_domain` when the project has no verified custom domain (Totalum's own hosts are never sent); `error` when IndexNow refused them. |
| `sitemapsPinged` | `boolean` | yes | Whether each domain's `/sitemap.xml` answered. |

## `totalum-sdk/browser`

A real Chrome in the cloud, driven over HTTPS from your server, so it works inside the published app: open pages,
click, type, log in, fill forms, download files, take screenshots and PDFs, extract data. Use it when a page needs
interaction; to just read a page, `totalum.web.scrape` is cheaper and faster. Billed in credits per started minute
of an open session: always `close()` the session in `finally`. Plans allow a few sessions at once (one on Free);
a session with no call for 60 s is closed.

```ts
const s = await totalum.browser.session();
try {
  await s.goto('https://example.com');
  const title = await s.evaluate<string>('document.title');
} finally {
  await s.close();
}
```

| Method or type | What it does |
|---|---|
| [`totalumBrowser`](#totalumbrowser) | Creates the `browser` namespace (the same object as `totalum.browser`, scope `browser`): remote browser sessions driven entirely over HTTPS, so every command works from a Worker. |
| [`browser.session`](#browsersession) | Opens a session. |
| [`browser.sessions.list`](#browsersessionslist) | The project's sessions, newest first — open ones and the history — filtered by `status` or `metadata`. |
| [`browser.sessions.get`](#browsersessionsget) | A handle on an existing session, to keep driving it from another request (the id was stored). |
| [`browser.programs.get`](#browserprogramsget) | A program's status and, once finished, each step's result and the final variables. |
| [`browser.programs.wait`](#browserprogramswait) | Polls a program until it leaves `pending`/`running` and returns it (the `browser.program.completed` webhook is the push alternative). |
| [`BrowserActionsOutput`](#browseractionsoutput) | The answer of a batch of commands. |
| [`BrowserCommand`](#browsercommand) | One command for `s.actions()` or a program step: `{ type, …parameters }` (`{ type: "goto", url }`, `{ type: "click", target }`). |
| [`BrowserCommandResult`](#browsercommandresult) | The result of one command in `s.actions()`. |
| [`BrowserCommands`](#browsercommands) | The command methods of a session: one per command (`goto`, `click`, … `tabs.new`, `network.har`, …), named like the command; dotted commands are groups (`s.tabs.new()`). |
| [`BrowserProgram`](#browserprogram) | A program for `s.program()`: steps that run in the background on the session — data, never code. |
| [`BrowserProgramStatus`](#browserprogramstatus) | A program's status and results, from `browser.programs.get()` and `wait()`. |
| [`BrowserSession`](#browsersession-type) | An open cloud-browser session, from `browser.session()` or `browser.sessions.get(id)`: its id and live view, the methods below, and one method per command (the table below). |
| [`BrowserSessionCreated`](#browsersessioncreated) | A session just opened (what `browser.session()` reads to build its handle). |
| [`BrowserSessionInput`](#browsersessioninput) | The options of `browser.session()`; all optional. |
| [`BrowserSessionStatus`](#browsersessionstatus) | A session's state and usage, from `s.status()`. |

### `totalumBrowser`

```ts
totalumBrowser(options?: TotalumClientOptions): TotalumBrowser
```

Creates the `browser` namespace (the same object as `totalum.browser`, scope `browser`): remote browser sessions
driven entirely over HTTPS, so every command works from a Worker. A command's refusal throws `TotalumError` with its frozen
code (`ACTION_TIMEOUT`, `ACTION_FAILED`, …); `actions()` returns every result instead.

#### Things that still need a CDP connection

Nothing an app needs. Every capability is a command or a create-time option, so it works in a published app. The
list keeps exactly two entries, neither of which an app needs:

- Attaching a raw CDP domain listener for a continuous event stream — `network.subscribe` is the push channel for the app case.
- Driving the session with a third-party Node library's own protocol client (`connectOverCDP`, `puppeteer.connect`).

#### Not possible for anyone, over any transport

- Seeding a saved profile from raw bytes (`CONTEXT_SEED_UNSUPPORTED`) — the browser provider's profile upload is deprecated; create a profile by running a session with `context: { persist: true }`, and replay cookies and web storage with `cookies.set` and `storage.set` (a service worker or an IndexedDB database cannot be replayed).

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumBrowser`](#totalumbrowser) — The `browser` namespace: the methods below.

**Example**

```ts
import { totalumBrowser } from 'totalum-sdk/browser';

const browser = totalumBrowser();
const s = await browser.session();
try {
  await s.goto('https://example.com');
} finally {
  await s.close();
}
```

The namespace's type is exported as `TotalumBrowser` (`import type { TotalumBrowser } from 'totalum-sdk/browser'`).

### `browser.session`

```ts
browser.session(opts?: BrowserSessionInput): Promise<BrowserSession>
```

Opens a session. It returns the cloud browser's handle: call commands on it (`s.goto(url)`, `s.click(target)`,
…) and `close()` it in `finally`. Billed per started minute while open. At most 3 sessions are open at once per
project (1 on Free); one more waits in a queue up to 60 s. A session with no call for 60 s is closed; it lasts
at most `timeoutMinutes`.

Totalum-synthesised options (Totalum applies them to every page, so the browser provider validates none of them):
`locale`, `timezone`, `geolocation`, `userAgent`, `extraHeaders`, `blockResources`, `device`, `captureDownloads`,
`viewport`. Mapped onto vendor fields: `stealth` (`'off' | 'basic' | 'verified'`; `'verified'` needs a higher
provider tier, else `STEALTH_TIER_UNAVAILABLE`, and ignores `viewport`) and `region` (four regions; `'eu'` and
`'us'` are aliases). Every other option is passed to the browser provider as is.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `opts` | [`BrowserSessionInput`](#browsersessioninput) | no | Proxy, CAPTCHA solving, stealth, region, a saved profile, emulation and recording options; all optional. |
| `opts.proxy` | `{ enabled, country?, state?, city?, routes? }` | no | Send the traffic through a residential proxy (for sites that block data-centre traffic or vary by country). Proxy traffic is billed per GB. |
| `opts.proxy.enabled` | `boolean` | yes | Turn the proxy on. |
| `opts.proxy.country` | `string` | no | Exit country, two letters (`ES`). Allowed: exactly 2 characters. |
| `opts.proxy.state` | `string` | no | Exit state, two letters (US). Allowed: exactly 2 characters. |
| `opts.proxy.city` | `string` | no | Exit city. |
| `opts.proxy.routes` | `({ domainPattern, mode, country?, state?, city?, server?, username?, password? })[]` | no | Per-domain rules instead of one proxy for everything. |
| `opts.proxy.routes.domainPattern` | `string` | yes | The domains the rule applies to (`*.example.com`). |
| `opts.proxy.routes.mode` | `'none' \| 'totalum' \| 'external'` | yes | `totalum` (Totalum's proxy), `none` (direct) or `external` (your own proxy). |
| `opts.proxy.routes.country` | `string` | no | Exit country for `totalum`. Allowed: exactly 2 characters. |
| `opts.proxy.routes.state` | `string` | no | Exit state for `totalum`. Allowed: exactly 2 characters. |
| `opts.proxy.routes.city` | `string` | no | Exit city for `totalum`. |
| `opts.proxy.routes.server` | `string` | no | Your proxy's address, for `external`. |
| `opts.proxy.routes.username` | `string` | no | Your proxy's user, for `external`. |
| `opts.proxy.routes.password` | `string` | no | Your proxy's password, for `external`. |
| `opts.solveCaptchas` | `boolean` | no | Solve CAPTCHAs automatically. Default `true`. |
| `opts.captcha` | `{ imageSelector?, inputSelector? }` | no | Where an image CAPTCHA is on the page, when it is not detected by itself. |
| `opts.captcha.imageSelector` | `string` | no | CSS selector of the CAPTCHA image. |
| `opts.captcha.inputSelector` | `string` | no | CSS selector of the field its answer goes in. |
| `opts.stealth` | `'basic' \| 'off' \| 'verified'` | no | `basic` (default): a normal fingerprint. `verified`: a verified browser for strict sites (when available). `off`. |
| `opts.fingerprintOs` | `'mobile' \| 'windows' \| 'mac' \| 'linux' \| 'tablet'` | no | The operating system the browser appears to run on. |
| `opts.timeoutMinutes` | `number` | no | The longest the session may stay open. Default 15 (or the plan's maximum if lower). Allowed: > 0. |
| `opts.keepAlive` | `boolean` | no | Accepted; sessions always stay open between calls (until 60 s idle or `timeoutMinutes`). |
| `opts.context` | `{ id, persist? }` | no | Load a saved profile (cookies and storage): `{ id }` from `context.save`; `persist: true` saves changes back. |
| `opts.context.id` | `string` | yes | The profile's id (the `contextId` `context.save` returned). |
| `opts.context.persist` | `boolean` | no | Save the session's cookies back into the profile when it closes. |
| `opts.contextId` | `string` | no | Same as `context: { id }`. |
| `opts.blockAds` | `boolean` | no | Block ads and trackers. |
| `opts.allowedDomains` | `string[]` | no | Only let the browser open these domains (up to 50). Allowed: at most 50 items. |
| `opts.ignoreCertificateErrors` | `boolean` | no | Open sites with invalid HTTPS certificates. |
| `opts.caCertificateIds` | `string[]` | no | Extra certificate authorities to trust. |
| `opts.extensionId` | `string` | no | A browser extension to load. |
| `opts.metadata` | `Record<string, string>` | no | Your own key/value strings, to find the session later (`sessions.list({ metadata })`); under 512 characters. |
| `opts.logSession` | `boolean` | no | Keep the session's logs. Default `true`. |
| `opts.recordSession` | `boolean` | no | Record the session (replay with `session.recording`). Default `true`. |
| `opts.region` | `'eu' \| 'us-west-2' \| 'us-east-1' \| 'eu-central-1' \| 'ap-southeast-1' \| 'us'` | no | The region the browser is in: `us-west-2` (default), `us-east-1`, `eu-central-1`, `ap-southeast-1`, or `eu` / `us`. |
| `opts.locale` | `string` | no | The browser language (`es-ES`). |
| `opts.timezone` | `string` | no | The browser time zone (`Europe/Madrid`). |
| `opts.geolocation` | `{ latitude, longitude, accuracy? }` | no | The location the browser reports. |
| `opts.geolocation.latitude` | `number` | yes | Latitude. |
| `opts.geolocation.longitude` | `number` | yes | Longitude. |
| `opts.geolocation.accuracy` | `number` | no | Accuracy in metres. |
| `opts.userAgent` | `string` | no | A custom user agent. |
| `opts.extraHeaders` | `Record<string, string>` | no | Headers added to every request. |
| `opts.blockResources` | `('image' \| 'media' \| 'font')[]` | no | Resource types not to load, to go faster: `media`, `font`, `image`. Default `["media", "font"]`. |
| `opts.device` | `string` | no | Emulate a device: `iPhone 15`, `iPhone 15 Pro Max`, `iPhone SE`, `iPad Pro 11`, `Pixel 8`, `Galaxy S24`, `Desktop Chrome`, … |
| `opts.captureDownloads` | `boolean` | no | Keep files the pages download (read them with `downloads.get`). Default `true`. |
| `opts.viewport` | `{ width, height }` | no | The window size. |
| `opts.viewport.width` | `number` | yes | Width in pixels. Allowed: > 0. |
| `opts.viewport.height` | `number` | yes | Height in pixels. Allowed: > 0. |

**Returns** `Promise<BrowserSession>` — The session handle.

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SESSION_LIMIT_REACHED` | 429 | `timeoutMinutes` is above the plan's longest session (15 min on Free, 60 on Starter, 180 on Builder, 360 on Professional and Enterprise). |
| `CONCURRENCY_QUEUE_TIMEOUT` | 429 | The plan's sessions at once were busy for 60 s; retry after `details.retryAfter` seconds, or open fewer sessions. |
| `CONCURRENCY_QUEUE_FULL` | 429 | Too many sessions waiting already. |
| `RATE_LIMIT_EXCEEDED` | 429 | The plan's new sessions per minute or monthly browser minutes are used up. |
| `STEALTH_TIER_UNAVAILABLE` | 409 | `stealth: 'verified'` is not available. |
| `PROVIDER_UNAVAILABLE` | 503 | The browser did not start; retry. |

**Example**

```ts
const s = await totalum.browser.session({ locale: 'es-ES', timezone: 'Europe/Madrid' });
try {
  await s.goto('https://example.com');
  const info = await s.pageInfo();
} finally {
  await s.close();
}
```

Result:

```ts
{
  id: 'bs_a285ef5dfce1d3837b913a887a384298',
  liveViewUrl: 'https://sdk-api.totalum.app/v1/browser/live/eyJzIjoiYnNfYTI4NWVm…',
  expiresAt: '2026-10-11T17:56:24.666Z',
  goto: [Function], click: [Function], …, close: [Function]
}
```

### `browser.sessions.list`

```ts
browser.sessions.list(q?: { status?, metadata?, limit?, cursor? }): Promise<{ items, nextCursor? }>
```

The project's sessions, newest first — open ones and the history — filtered by `status` or `metadata`. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `q` | `{ status?, metadata?, limit?, cursor? }` | no | Filters and paging; all optional. |
| `q.status` | `string` | no | Only sessions in this state: `running`, `ended`, `expired` or `failed`. |
| `q.metadata` | `string` | no | Only sessions with this metadata entry, as `key:value` (`'orderId:ord_42'`). |
| `q.limit` | `number` | no | Sessions per page. |
| `q.cursor` | `string` | no | The previous page's `nextCursor`. |

**Returns** `Promise<{ items, nextCursor? }>` — One page of sessions.

| Field | Type | Always present | Description |
|---|---|---|---|
| `items` | `({ id, status, startedAt, minutes, proxyMb, captchas, credits, expiresAt, region, metadata })[]` | yes | The sessions, newest first: each one's status and usage, plus its region and metadata. |
| `items.id` | `string` | yes | The session id. |
| `items.status` | `'expired' \| 'running' \| 'failed' \| 'ended'` | yes | `running`, `ended` (closed), `expired` (timed out or idle) or `failed`. |
| `items.startedAt` | `string` | yes | When it opened. |
| `items.minutes` | `number` | yes | Started minutes so far (billed per started minute). |
| `items.proxyMb` | `number` | yes | Proxy traffic so far, in MB. |
| `items.captchas` | `number` | yes | CAPTCHAs solved so far. |
| `items.credits` | `number` | yes | Credits used so far. |
| `items.expiresAt` | `string` | yes | When it ends at the latest. |
| `items.region` | `string` | yes | Where it runs. |
| `items.metadata` | `Record<string, string> \| null` | yes | The `metadata` it was opened with, or `null`. |
| `nextCursor` | `string` | no | Pass it as `cursor` for the next page; absent on the last. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | A filter is malformed. |

**Example**

```ts
const { items } = await totalum.browser.sessions.list({ status: 'running', limit: 2 });
```

Result:

```json
{
  "items": [
    { "id": "bs_20b6813a9f10ce41b3ff002d80e911c4", "status": "ended", "region": "us-west-2", "metadata": null, "startedAt": "2026-09-28T13:18:46.849Z", "expiresAt": "2026-09-28T13:33:46.244Z", "minutes": 1, "proxyMb": 0, "captchas": 0, "credits": 0.056 }
  ],
  "nextCursor": "1790600898279:bs_8cbb1dd693454f4fd82fedbfb9671d0c"
}
```

### `browser.sessions.get`

```ts
browser.sessions.get(id: string): BrowserSession
```

A handle on an existing session, to keep driving it from another request (the id was stored). No call is made;
the first command fails with `SESSION_EXPIRED` if it has ended. Calling a command also keeps it from closing
after 60 s idle.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | yes | The session's `id`. |

**Returns** [`BrowserSession`](#browsersession-type) — The session handle (`liveViewUrl` and `expiresAt` are empty: read `status()`).

**Example**

```ts
const s = totalum.browser.sessions.get(job.browserSessionId);
await s.click({ text: 'Next page' });
```

### `browser.programs.get`

```ts
browser.programs.get(programId: string): Promise<BrowserProgramStatus>
```

A program's status and, once finished, each step's result and the final variables. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `programId` | `string` | yes | The `programId` from `s.program()`. |

**Returns** `Promise<BrowserProgramStatus>` — The program's status and results.

| Field | Type | Always present | Description |
|---|---|---|---|
| `programId` | `string` | yes | The program id. |
| `sessionId` | `string` | yes | The session it runs on. |
| `status` | `'completed' \| 'running' \| 'failed' \| 'pending' \| 'timed_out'` | yes | `pending`, `running`, then `completed`, `failed` or `timed_out`. |
| `steps` | `({ index, type, ok, data?, error? })[]` | yes | Each executed step's result, in order. |
| `steps.index` | `number` | yes | The step's position. |
| `steps.type` | `string` | yes | The command (or step kind) it ran. |
| `steps.ok` | `boolean` | yes | Whether it succeeded. |
| `steps.data` | `unknown` | no | Its result. |
| `steps.error` | `{ code, message? }` | no | Why it failed. |
| `steps.error.code` | [`TotalumErrorCode`](#totalumerrorcode) | yes | The error code. |
| `steps.error.message` | `string` | no | What happened. |
| `vars` | `Record<string, unknown>` | yes | The variables at the end. |
| `startedAt` | `string` | yes | When it started. |
| `credits` | `number` | yes | Credits charged for the program's own work (the session's minutes are billed on the session). |
| `result` | `unknown` | no | The program's result, when it set one. |
| `endedAt` | `string` | no | When it ended. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `PROGRAM_NOT_FOUND` | 404 | No such program in this project. |

**Example**

```ts
const p = await totalum.browser.programs.get('bp_a285ef5dfce1d3837b913a887a384298_3982732f');
```

Result:

```ts
{
  "programId": "bp_a285ef5dfce1d3837b913a887a384298_3982732f",
  "sessionId": "bs_a285ef5dfce1d3837b913a887a384298",
  "status": "completed",
  "steps": [
    { "index": 0, "type": "goto", "ok": true, "data": { "url": "https://example.com/", "status": 200, "redirected": false } },
    { "index": 1, "type": "pageInfo", "ok": true, "data": { "url": "https://example.com/", "title": "Example Domain", … } }
  ],
  "vars": {},
  "startedAt": "2026-10-11T17:42:25.605Z",
  "endedAt": "2026-10-11T17:42:27.912Z",
  "credits": 0
}
```

### `browser.programs.wait`

```ts
browser.programs.wait(programId: string, opts?: { intervalMs?, timeoutMs? }): Promise<BrowserProgramStatus>
```

Polls a program until it leaves `pending`/`running` and returns it (the `browser.program.completed` webhook is
the push alternative). Keep it within a request's time: programs run at most 60 s.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `programId` | `string` | yes | The `programId` from `s.program()`. |
| `opts` | `{ intervalMs?, timeoutMs? }` | no | How often and how long to poll. |
| `opts.intervalMs` | `number` | no | Milliseconds between polls. Default 2000. |
| `opts.timeoutMs` | `number` | no | Stop polling after this long and return the program as it is. Default 960000. |

**Returns** `Promise<BrowserProgramStatus>` — The finished program (or as it is when the timeout passed).

| Field | Type | Always present | Description |
|---|---|---|---|
| `programId` | `string` | yes | The program id. |
| `sessionId` | `string` | yes | The session it runs on. |
| `status` | `'completed' \| 'running' \| 'failed' \| 'pending' \| 'timed_out'` | yes | `pending`, `running`, then `completed`, `failed` or `timed_out`. |
| `steps` | `({ index, type, ok, data?, error? })[]` | yes | Each executed step's result, in order. |
| `steps.index` | `number` | yes | The step's position. |
| `steps.type` | `string` | yes | The command (or step kind) it ran. |
| `steps.ok` | `boolean` | yes | Whether it succeeded. |
| `steps.data` | `unknown` | no | Its result. |
| `steps.error` | `{ code, message? }` | no | Why it failed. |
| `steps.error.code` | [`TotalumErrorCode`](#totalumerrorcode) | yes | The error code. |
| `steps.error.message` | `string` | no | What happened. |
| `vars` | `Record<string, unknown>` | yes | The variables at the end. |
| `startedAt` | `string` | yes | When it started. |
| `credits` | `number` | yes | Credits charged for the program's own work (the session's minutes are billed on the session). |
| `result` | `unknown` | no | The program's result, when it set one. |
| `endedAt` | `string` | no | When it ended. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `PROGRAM_NOT_FOUND` | 404 | No such program in this project. |

**Example**

```ts
const done = await totalum.browser.programs.wait(programId, { intervalMs: 1000 });
```

Result:

```ts
{ "programId": "bp_a285ef5dfce1d3837b913a887a384298_3982732f", "status": "completed", "steps": [ … ], "vars": {}, "credits": 0, … }
```

### `BrowserActionsOutput`

The answer of a batch of commands.

| Field | Type | Always present | Description |
|---|---|---|---|
| `results` | [`BrowserCommandResult[]`](#browsercommandresult) | yes | One result per command, in order. |
| `durationMs` | `number` | yes | How long the batch took. |

### `BrowserCommand`

One command for `s.actions()` or a program step: `{ type, …parameters }` (`{ type: "goto", url }`, `{ type: "click", target }`). The parameters are the command's (see the session commands table).

```ts
type BrowserCommand = Record<string, unknown>
```

### `BrowserCommandResult`

The result of one command in `s.actions()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `ok` | `boolean` | yes | Whether it succeeded. |
| `data` | `unknown` | no | Its result (as the command's method would return it), when `ok`. |
| `error` | `{ code, message? }` | no | Why it failed, when not `ok`. |
| `error.code` | [`TotalumErrorCode`](#totalumerrorcode) | yes | The error code (`ACTION_FAILED`, `ACTION_TIMEOUT`, `SKIPPED` after an earlier failure, …). |
| `error.message` | `string` | no | What happened. |

### `BrowserCommands`

The command methods of a session: one per command (`goto`, `click`, … `tabs.new`, `network.har`, …), named like the
command; dotted commands are groups (`s.tabs.new()`). See the session commands table under `BrowserSession`.

### `BrowserProgram`

A program for `s.program()`: steps that run in the background on the session — data, never code.

| Field | Type | Always present | Description |
|---|---|---|---|
| `steps` | `({ do } \| { repeat } \| { if } \| { try } \| { set })[]` | yes | The steps, in order: `{ do: command }`, `{ repeat: { times \| until, max, body } }`, `{ if: { condition, then, else } }`, `{ try: { body, catch } }`, `{ set: { name, from } }`. At most 200 steps, nested 5 deep. |
| `vars` | `Record<string, unknown>` | no | Starting variables, readable by the steps. |
| `timeoutSec` | `number` | no | The longest it may run, at most 60 s. Default 60. |
| `webhookUrl` | `string` | no | An `https://` URL to POST the result to when it ends (also sent as the `browser.program.completed` webhook). |

### `BrowserProgramStatus`

A program's status and results, from `browser.programs.get()` and `wait()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `programId` | `string` | yes | The program id. |
| `sessionId` | `string` | yes | The session it runs on. |
| `status` | `'completed' \| 'running' \| 'failed' \| 'pending' \| 'timed_out'` | yes | `pending`, `running`, then `completed`, `failed` or `timed_out`. |
| `steps` | `({ index, type, ok, data?, error? })[]` | yes | Each executed step's result, in order. |
| `steps.index` | `number` | yes | The step's position. |
| `steps.type` | `string` | yes | The command (or step kind) it ran. |
| `steps.ok` | `boolean` | yes | Whether it succeeded. |
| `steps.data` | `unknown` | no | Its result. |
| `steps.error` | `{ code, message? }` | no | Why it failed. |
| `steps.error.code` | [`TotalumErrorCode`](#totalumerrorcode) | yes | The error code. |
| `steps.error.message` | `string` | no | What happened. |
| `vars` | `Record<string, unknown>` | yes | The variables at the end. |
| `startedAt` | `string` | yes | When it started. |
| `credits` | `number` | yes | Credits charged for the program's own work (the session's minutes are billed on the session). |
| `result` | `unknown` | no | The program's result, when it set one. |
| `endedAt` | `string` | no | When it ended. |

### `BrowserSession` type

An open cloud-browser session, from `browser.session()` or `browser.sessions.get(id)`: its id and live view, the
methods below, and one method per command (the table below). Each method is one HTTPS call. Always `close()` it in
`finally`: an open session is billed per minute until it closes (or until 60 s pass with no call).

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The session id (`bs_…`): pass it to `browser.sessions.get(id)` to drive the same session from another request. |
| `liveViewUrl` | `string` | yes | A signed link to watch the session live (and take over: a user can solve a login step), valid a few minutes. `''` on a handle from `sessions.get`. |
| `expiresAt` | `string` | yes | When the session ends at the latest (its `timeoutMinutes`). `''` on a handle from `sessions.get`. |

### `s.actions`

```ts
s.actions(batch: BrowserCommand[], opts?: { stopOnError? }): Promise<BrowserCommandResult[]>
```

Runs up to 20 commands in one round trip, in order, under the session's lock, and returns every result instead of
throwing: faster than one call per command. With `stopOnError` (the default), the commands after a failure are
not run and answer `SKIPPED`.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `batch` | [`BrowserCommand[]`](#browsercommand) | yes | The commands: `{ type, …parameters }` each (`{ type: 'goto', url }`, `{ type: 'click', target }`). |
| `opts` | `{ stopOnError? }` | no | `stopOnError`. |
| `opts.stopOnError` | `boolean` | no | Stop at the first failing command. Default `true`. |

**Returns** `Promise<BrowserCommandResult[]>` — One result per command, in order: `{ ok: true, data }` or `{ ok: false, error: { code, message } }`.

Each item:

| Field | Type | Always present | Description |
|---|---|---|---|
| `ok` | `boolean` | yes | Whether it succeeded. |
| `data` | `unknown` | no | Its result (as the command's method would return it), when `ok`. |
| `error` | `{ code, message? }` | no | Why it failed, when not `ok`. |
| `error.code` | [`TotalumErrorCode`](#totalumerrorcode) | yes | The error code (`ACTION_FAILED`, `ACTION_TIMEOUT`, `SKIPPED` after an earlier failure, …). |
| `error.message` | `string` | no | What happened. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `BATCH_TOO_LARGE` | 400 | More than 20 commands. |
| `SESSION_BUSY` | 409 | Another command or batch is running on this session. |
| `SESSION_EXPIRED` | 410 | The session has ended: open a new one. |
| `UNKNOWN_COMMAND` | 400 | A `type` is not one of the commands. |

**Example**

```ts
const results = await s.actions([
  { type: 'goto', url: 'https://example.com' },
  { type: 'evaluate', expression: '1 + 1' },
]);
```

Result:

```json
[
  { "ok": true, "data": { "url": "https://example.com/", "status": 200, "redirected": false } },
  { "ok": true, "data": 2 }
]
```

### `s.program`

```ts
s.program(program: BrowserProgram): Promise<{ programId, status, pollUrl }>
```

Starts a program on the session: a list of steps (commands, plus bounded `repeat`, `if`, `try` and `set`) that runs
in the background — data, never code — so a long task outlives the request. Read it with
`browser.programs.get(programId)` / `programs.wait`, or receive the `browser.program.completed` webhook.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `program` | [`BrowserProgram`](#browserprogram) | yes | The steps, variables, time limit and webhook. |
| `program.steps` | `({ do } \| { repeat } \| { if } \| { try } \| { set })[]` | yes | The steps, in order: `{ do: command }`, `{ repeat: { times \| until, max, body } }`, `{ if: { condition, then, else } }`, `{ try: { body, catch } }`, `{ set: { name, from } }`. At most 200 steps, nested 5 deep. |
| `program.vars` | `Record<string, unknown>` | no | Starting variables, readable by the steps. |
| `program.timeoutSec` | `number` | no | The longest it may run, at most 60 s. Default 60. |
| `program.webhookUrl` | `string` | no | An `https://` URL to POST the result to when it ends (also sent as the `browser.program.completed` webhook). |

**Returns** `Promise<{ programId, status, pollUrl }>` — The program id and where to poll it.

| Field | Type | Always present | Description |
|---|---|---|---|
| `programId` | `string` | yes | The program id: pass it to `browser.programs.get()` / `wait()`. |
| `status` | `'pending'` | yes | Always `pending`. |
| `pollUrl` | `string` | yes | The API URL of its status. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `PROGRAM_TOO_LARGE` | 400 | More than 200 steps or nesting deeper than 5. |
| `PROGRAM_UNBOUNDED` | 400 | A `repeat` without `max` (≤ 100) and exactly one of `times` / `until`. |
| `UNKNOWN_STEP` | 400 | A step that is not `do`, `repeat`, `if`, `try` or `set`. |
| `PROGRAM_IN_FLIGHT` | 409 | A program is already running on this session. |
| `PROGRAM_LIMIT_REACHED` | 429 | The plan's programs at once (or per hour) are reached. |

**Example**

```ts
const { programId } = await s.program({
  steps: [
    { do: { type: 'goto', url: 'https://example.com' } },
    { do: { type: 'pageInfo' } },
  ],
});
const done = await totalum.browser.programs.wait(programId);
```

Result:

```json
{ "programId": "bp_a285ef5dfce1d3837b913a887a384298_3982732f", "status": "pending", "pollUrl": "https://sdk-api.totalum.app/v1/browser/programs/bp_a285ef5dfce1d3837b913a887a384298_3982732f" }
```

### `s.status`

```ts
s.status(): Promise<BrowserSessionStatus>
```

The session's state and what it has used so far (minutes, proxy MB, CAPTCHAs, credits). Also keeps an idle session
open: a session with no call for 60 s is closed.

**Returns** `Promise<BrowserSessionStatus>` — The session's status.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The session id. |
| `status` | `'expired' \| 'running' \| 'failed' \| 'ended'` | yes | `running`, `ended` (closed), `expired` (timed out or idle) or `failed`. |
| `startedAt` | `string` | yes | When it opened. |
| `minutes` | `number` | yes | Started minutes so far (billed per started minute). |
| `proxyMb` | `number` | yes | Proxy traffic so far, in MB. |
| `captchas` | `number` | yes | CAPTCHAs solved so far. |
| `credits` | `number` | yes | Credits used so far. |
| `expiresAt` | `string` | yes | When it ends at the latest. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SESSION_NOT_FOUND` | 404 | No such session in this project. |

**Example**

```ts
const status = await s.status();
```

Result:

```json
{ "id": "bs_a285ef5dfce1d3837b913a887a384298", "status": "running", "startedAt": "2026-10-11T17:41:24.666Z", "minutes": 1, "proxyMb": 0, "captchas": 0, "credits": 0.1647, "expiresAt": "2026-10-11T17:56:24.666Z" }
```

### `s.close`

```ts
s.close(): Promise<{ minutes, proxyMb, captchas, creditsUsed, recordingUrl? }>
```

Ends the session and settles its charge. Always call it in `finally`: an open session is billed per started minute.

**Returns** `Promise<{ minutes, proxyMb, captchas, creditsUsed, recordingUrl? }>` — What the session used and cost.

| Field | Type | Always present | Description |
|---|---|---|---|
| `minutes` | `number` | yes | Started minutes billed. |
| `proxyMb` | `number` | yes | Proxy traffic in MB. |
| `captchas` | `number` | yes | CAPTCHAs solved. |
| `creditsUsed` | `number` | yes | Credits charged for the session. |
| `recordingUrl` | `string` | no | A link to the recording, when there is one. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `SESSION_NOT_FOUND` | 404 | No such session in this project. |

**Example**

```ts
const closed = await s.close();
```

Result:

```json
{ "minutes": 2, "proxyMb": 0, "captchas": 0, "creditsUsed": 0.279 }
```

#### Session commands

Every command is a method of the session, named like the command (dotted names are groups: `s.tabs.new()`). The
leading arguments are the command's main parameters; the last argument is an object with the rest of them. Every
command also takes `frame` (a frame id from `frames.list`), `tabId` (a tab id from `tabs.list`) and `timeoutMs`
(default 30 s; `waitFor`, `pdf` and `extract` up to 60 s, `captcha.solve` up to 120 s) in that last object. Each
resolves to the command's result; a refusal throws `TotalumError` with the command's code. `target` is a CSS selector
(`'#email'`), `{ text: 'Sign in' }`, `{ role: 'button', name: 'Search' }` or `{ xpath: '//button' }`. Files a command
produces (screenshots, PDFs, HAR files, downloads) are private files of the project: `url` is a signed link valid for
1 hour; keep the `key` and read it later with `totalum.files.getSignedUrl(key)`.

| Command | What it does | Parameters (last argument) | Result |
|---|---|---|---|
| `s.goto(url, opts?)` | Opens a URL in the current tab and waits for it to load. | `waitUntil`: `load` (default), `domcontentloaded` or `networkidle`; `referer`. | `{ url, status, redirected }` — the final URL, its HTTP status, whether it redirected. |
| `s.back(opts?)` | Goes back one page in the tab's history. | `waitUntil`. | `{ url, status }`. |
| `s.forward(opts?)` | Goes forward one page. | `waitUntil`. | `{ url, status }`. |
| `s.reload(opts?)` | Reloads the page. | `waitUntil`. | `{ url, status }`. |
| `s.pageInfo(opts?)` | Describes the current page. | — | `{ url, title, viewport: { width, height }, userAgent, frames, tabId }`. |
| `s.click(target, opts?)` | Clicks an element (moves the mouse to its centre and clicks). | `button` (`left`, `right`, `middle`), `clickCount`, `modifiers` (`["Shift"]`), `position: { x, y }` instead of a target. | `{ clicked: true }`. |
| `s.dblclick(target, opts?)` | Double-clicks an element. | As `click`. | `{ clicked: true }`. |
| `s.type(target, text, opts?)` | Focuses a field and types text into it. | `clear` (empty it first), `delayMs` (between keys, to look human), `pressEnter`. | `{ typed: true, value }` — the field's value afterwards. |
| `s.press(key, opts?)` | Presses a key (`Enter`, `Tab`, `Escape`, `ArrowDown`, `a`), optionally on an element. | `target` (focus it first), `modifiers`, `repeat`. | `{ pressed: true }`. |
| `s.hover(target, opts?)` | Moves the mouse over an element (menus that open on hover). | — | `{ hovered: true }`. |
| `s.scroll(opts?)` | Scrolls the page, or an element into view. | `target` (scroll it into view), `to: "top" \| "bottom"`, `by` (pixels, default 600), `direction: "y" \| "x"`. | `{ scrollX, scrollY, atBottom }` — use `atBottom` to stop an infinite-scroll loop. |
| `s.dragAndDrop(from, to, opts?)` | Drags one element onto another. | `steps` (mouse moves in between, default 10). | `{ dropped: true }`. |
| `s.select(target, value, opts?)` | Chooses an option of a `<select>`: by value, a list of values, `{ label }` or `{ index }`. | — | `{ selected }` — the selected values. |
| `s.upload(target, files, opts?)` | Puts files into a file input (1–10): each `{ fileKey }` (a project file) or `{ url }`. | `viaChooser: true` clicks `target` and fills the file chooser it opens; `name` (with one file). | `{ uploaded: [{ name, bytes }] }`. |
| `s.frames.list(opts?)` | Lists the page's frames (iframes). | — | `{ frames: [{ frameId, url, name, parentFrameId }] }`. |
| `s.frames.use(opts?)` | Makes later commands run inside a frame (or back in the main page). | `frameId`, `selector` (the iframe element), `url` (part of it), `name`, or `main: true`. | `{ frameId }` (`null` for the main page). |
| `s.tabs.list(opts?)` | Lists the open tabs. | — | `{ tabs: [{ tabId, url, title, active }] }`. |
| `s.tabs.new(opts?)` | Opens a new tab (at most 10) and makes it the current one. | `url`, `newWindow`. | `{ tabId, url }`. |
| `s.tabs.use(opts?)` | Makes another tab the current one. | `tabId`, `index` or `urlContains`. | `{ tabId }`. |
| `s.tabs.close(opts?)` | Closes a tab (default the current one). | `tabId`. | `{ closed: true, activeTabId }`. |
| `s.waitFor(opts)` | Waits for something before going on: an element, a text, a JavaScript condition, a navigation, network idle or a fixed time (up to 60 s). | One of `selector` (with `state`: `visible` default, `hidden`, `attached`, `detached`), `text`, `expression`, `navigation: true`, `networkIdle: true`, `ms`. | `{ matched, waitedMs }` — what matched and how long it took. |
| `s.evaluate(expression, opts?)` | Runs JavaScript in the page and returns its value (a promise is awaited). Script ≤ 16 KB, result ≤ 1 MB. | `args` (makes `expression` a function called with them), `awaitPromise` (default `true`). | The expression's value (JSON), or `null`. |
| `s.content(opts?)` | Reads the page (or an element) as text, Markdown or HTML. | `format`: `markdown` (default), `text` or `html`; `target`; `onlyMainContent`. | A string; over 5 MB, a stored file `{ key, url, truncated: true }` instead. |
| `s.screenshot(opts?)` | Takes a screenshot of the viewport, the full page or an element, stored as a private project file. | `scope`: `viewport` (default), `fullPage` or `element` (with `target`); `format`: `png` (default) or `jpeg` (with `quality`); `omitBackground`. | `{ key, url, bytes, width, height }` — `url` is a signed link valid 1 hour. |
| `s.pdf(opts?)` | Prints the page to a PDF, stored as a private project file. | `format`: `A4` (default) or `Letter`; `landscape`; `printBackground`; `scale`; `margin: { top, right, bottom, left }` (inches). | `{ key, url, bytes, pages }`. |
| `s.extract(opts)` | Reads the page and extracts data with your JSON Schema, using AI (billed as an AI call). | `schema` (required), `prompt`, `model`, `scope: "target"` with `target` to read one element. | The extracted object. |
| `s.cookies.get(opts?)` | Reads the browser's cookies. | `urls` (only cookies sent to them), `names`. | `{ cookies: [{ name, value, domain, path, expires, httpOnly, secure, sameSite, … }] }`. |
| `s.cookies.set(cookies, opts?)` | Sets cookies (for example a saved login). | Each cookie: `name`, `value`, and `url` or `domain`/`path`, `expires`, `httpOnly`, `secure`, `sameSite`. | `{ set }` — how many. |
| `s.cookies.clear(opts?)` | Deletes cookies. | `names`, `domain`. | `{ cleared }` — how many. |
| `s.storage.get(opts?)` | Reads the current page's `localStorage` (or `sessionStorage`). | `area`: `local` (default) or `session`; `keys`; `origin` (must be the current page's). | `{ items }` — key/value pairs. |
| `s.storage.set(opts)` | Writes items to the current page's storage. | `items` (key/value strings), `area`, `origin`. | `{ set }` — how many. |
| `s.storage.clear(opts?)` | Empties the current page's storage. | `area`, `origin`. | `{ cleared }` — how many items. |
| `s.context.save(opts?)` | Saves the browser's cookies and this page's `localStorage` as a profile (a private project file), to log in again later with `session({ context: { id } })`. | `name`. | `{ contextId, name }` — pass `contextId` as `context.id`. |
| `s.downloads.list(opts?)` | Lists the files the pages downloaded (with `captureDownloads`, the default). | — | `{ files: [{ name, bytes, contentType, completedAt }] }`. |
| `s.downloads.get(opts?)` | Stores a downloaded file as a private project file, waiting for it if needed. | `name` or `index` (default the first); `waitMs` (up to 60 s). | `{ name, key, bytes, contentType }` (and `url`, a signed link). |
| `s.network.capture(opts)` | Starts (or stops) recording the network requests of the session. | `enabled`, `urlPattern` (glob), `maxEntries` (≤ 5,000, default 1,000), `includeBodies`, `includeResponseBodies`. | `{ capturing, entries }`. |
| `s.network.log(opts?)` | Returns the captured requests. | `urlPattern`, `resourceTypes` (`xhr`, `fetch`, `document`, …), `statusMin`, `since`, `limit` (≤ 500). | `{ entries: [{ id, method, url, status, resourceType, requestHeaders, responseHeaders, timings, requestBytes, responseBytes, fromCache }], truncated }`. |
| `s.network.har(opts?)` | Saves the captured requests as a HAR file (a private project file). | `includeBodies`. | `{ key, url, entries, truncated }`. |
| `s.network.intercept(opts)` | Blocks, answers or changes matching requests (at most 50 rules). | `rules`: each `{ urlPattern, method?, resourceTypes?, action: "block" \| "fulfill" \| "modify" \| "continue", status?, contentType?, body?, setHeaders?, removeHeaders? }`; `replace: false` adds to the current rules. | `{ rules }` — how many are active. |
| `s.network.setHeaders(opts)` | Adds headers to every request (or to matching URLs). | `headers`, `urlPattern`. | `{ headers }` — how many. |
| `s.emulate.viewport(opts)` | Changes the window size. | `width`, `height`, `deviceScaleFactor`, `isMobile`, `hasTouch`. | `{ viewport }`. |
| `s.emulate.device(opts)` | Emulates a device's screen and user agent. | `device`: `iPhone 15`, `iPhone 15 Pro Max`, `iPhone SE`, `iPad Pro 11`, `Pixel 8`, `Galaxy S24`, `Desktop Chrome`, …. | `{ device, viewport, userAgent }`. |
| `s.emulate.geolocation(opts)` | Sets the location the browser reports (and grants the permission). | `latitude`, `longitude`, `accuracy` (default 10), `permission: "deny"`. | `{ geolocation }`. |
| `s.emulate.timezone(opts)` | Sets the time zone. | `timezoneId` (`Europe/Madrid`). | `{ timezoneId }`. |
| `s.emulate.locale(opts)` | Sets the language and `Accept-Language`. | `locale` (`es-ES`), `languages`. | `{ locale, languages }`. |
| `s.emulate.userAgent(opts)` | Sets the user agent. | `userAgent`, `platform`, `acceptLanguage`. | `{ userAgent }`. |
| `s.captcha.solve(opts?)` | Waits (up to 120 s) for the CAPTCHA on the page to be solved (sessions solve them automatically unless `solveCaptchas: false`). | — | `{ solved, durationMs }`; throws `CAPTCHA_UNSOLVED` when it failed. |
| `s.captcha.events(opts?)` | Lists the session's CAPTCHA events. | `since`. | `{ events: [{ type, at, … }] }` (`captcha_started`, `captcha_solved`, `captcha_failed`). |
| `s.proxy.usage(opts?)` | The proxy traffic so far. | — | `{ enabled, country, bytes, requests }`. |
| `s.stealth.get(opts?)` | Shows the fingerprint websites see. | — | `{ mode, fingerprint: { userAgent, platform, languages, hardwareConcurrency, webdriver, webglVendor, webglRenderer } }`. |
| `s.session.status(opts?)` | The session's status and usage (as `s.status()`). | — | `{ id, status, startedAt, minutes, proxyMb, captchas, credits, expiresAt }`. |
| `s.session.liveView(opts?)` | A new signed link to watch the session (or a tab) live. | `ttlSeconds` (≤ 300), `tabId`. | `{ url, expiresAt }`. |
| `s.session.recording(opts?)` | A signed link to the session's recording. | `ttlSeconds` (default 600, ≤ 3,600). | The recording's link and details. |
| `s.dialog.handle(opts?)` | Accepts or dismisses the open `alert`/`confirm`/`prompt`, or arms the answer for the next one. | `action`: `accept` (default) or `dismiss`; `promptText`; `persist` (every future dialog). | `{ armed: false, handled: { type, message } }`, or `{ armed: true }`. |
| `s.page.addInitScript(opts)` | Runs a script in every page before its own scripts (at most 10, ≤ 16 KB each). | `expression`, `persist` (default `true`). | `{ scriptId }`. |
| `s.page.exposeFunction(opts)` | Adds `window.<name>(…)` to pages; each call is POSTed to your webhook URL (at most 5). | `name` (a JavaScript identifier), `webhookUrl`. | `{ name, bound: true }`. |
| `s.emulate.media(opts)` | Emulates CSS media features. | `media` (`print`, `screen`), `colorScheme` (`dark`, `light`), `reducedMotion`. | The applied settings. |
| `s.emulate.network(opts)` | Simulates a slow or offline network. | `offline`, `downloadKbps`, `uploadKbps`, `latencyMs`. | The applied settings. |
| `s.emulate.cpu(opts)` | Slows the CPU down, to test slow phones. | `rate` (2 = twice as slow). | `{ rate }`. |
| `s.permissions.grant(opts)` | Grants browser permissions (`geolocation`, `notifications`, `camera`, `microphone`, `clipboard`, …). | `permissions`, `origin`. | `{ granted }`. |
| `s.clipboard.read(opts?)` | Reads the clipboard of the current page. | `format`: `text` (default) or `html`. | The clipboard's text. |
| `s.clipboard.write(opts)` | Writes to the clipboard. | `text`, `html`. | `{ written: true }`. |
| `s.act(instruction, opts?)` | Does something described in plain words (`click the cookie banner's accept button`), using AI. | `variables` (values to fill in, kept out of the AI prompt), `frame`. | `{ done, action, actions }` — whether it succeeded and what it did. |
| `s.observe(instruction, opts?)` | Finds the elements that match a description in plain words, using AI, without acting. | `frame`. | `{ candidates }` — the matching elements and how to act on them. |
| `s.session.release(opts?)` | Releases the remote browser while keeping the session record (use `close()` to end and settle it). | — | `{ released: true }`. |
| `s.network.subscribe(opts)` | POSTs the session's events to your webhook URL as they happen. | `events`: any of `request`, `response`, `console`, `download`, `popup`, `dialog`; `webhookUrl`; `urlPattern`. | `{ subscribed }` — the events. |
| `s.accessibility.snapshot(opts?)` | The page's accessibility tree (roles, names, values): a compact view of what is on screen, good for AI. | `target` (one element), `interestingOnly` (default `true`). | The tree `{ role, name, value, children }`; over 2 MB, a stored file `{ key, url, truncated: true }`. |

### `BrowserSessionCreated`

A session just opened (what `browser.session()` reads to build its handle).

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The session id. |
| `status` | `'running'` | yes | Always `running`. |
| `liveViewUrl` | `string` | yes | A signed link to watch it live. |
| `expiresAt` | `string` | yes | When it ends at the latest. |
| `proxyCountry` | `string` | no | The proxy's exit country, with a proxy. |

### `BrowserSessionInput`

The options of `browser.session()`; all optional.

| Name | Type | Required | Description |
|---|---|---|---|
| `proxy` | `{ enabled, country?, state?, city?, routes? }` | no | Send the traffic through a residential proxy (for sites that block data-centre traffic or vary by country). Proxy traffic is billed per GB. |
| `proxy.enabled` | `boolean` | yes | Turn the proxy on. |
| `proxy.country` | `string` | no | Exit country, two letters (`ES`). Allowed: exactly 2 characters. |
| `proxy.state` | `string` | no | Exit state, two letters (US). Allowed: exactly 2 characters. |
| `proxy.city` | `string` | no | Exit city. |
| `proxy.routes` | `({ domainPattern, mode, country?, state?, city?, server?, username?, password? })[]` | no | Per-domain rules instead of one proxy for everything. |
| `proxy.routes.domainPattern` | `string` | yes | The domains the rule applies to (`*.example.com`). |
| `proxy.routes.mode` | `'none' \| 'totalum' \| 'external'` | yes | `totalum` (Totalum's proxy), `none` (direct) or `external` (your own proxy). |
| `proxy.routes.country` | `string` | no | Exit country for `totalum`. Allowed: exactly 2 characters. |
| `proxy.routes.state` | `string` | no | Exit state for `totalum`. Allowed: exactly 2 characters. |
| `proxy.routes.city` | `string` | no | Exit city for `totalum`. |
| `proxy.routes.server` | `string` | no | Your proxy's address, for `external`. |
| `proxy.routes.username` | `string` | no | Your proxy's user, for `external`. |
| `proxy.routes.password` | `string` | no | Your proxy's password, for `external`. |
| `solveCaptchas` | `boolean` | no | Solve CAPTCHAs automatically. Default `true`. |
| `captcha` | `{ imageSelector?, inputSelector? }` | no | Where an image CAPTCHA is on the page, when it is not detected by itself. |
| `captcha.imageSelector` | `string` | no | CSS selector of the CAPTCHA image. |
| `captcha.inputSelector` | `string` | no | CSS selector of the field its answer goes in. |
| `stealth` | `'basic' \| 'off' \| 'verified'` | no | `basic` (default): a normal fingerprint. `verified`: a verified browser for strict sites (when available). `off`. |
| `fingerprintOs` | `'mobile' \| 'windows' \| 'mac' \| 'linux' \| 'tablet'` | no | The operating system the browser appears to run on. |
| `timeoutMinutes` | `number` | no | The longest the session may stay open. Default 15 (or the plan's maximum if lower). Allowed: > 0. |
| `keepAlive` | `boolean` | no | Accepted; sessions always stay open between calls (until 60 s idle or `timeoutMinutes`). |
| `context` | `{ id, persist? }` | no | Load a saved profile (cookies and storage): `{ id }` from `context.save`; `persist: true` saves changes back. |
| `context.id` | `string` | yes | The profile's id (the `contextId` `context.save` returned). |
| `context.persist` | `boolean` | no | Save the session's cookies back into the profile when it closes. |
| `contextId` | `string` | no | Same as `context: { id }`. |
| `blockAds` | `boolean` | no | Block ads and trackers. |
| `allowedDomains` | `string[]` | no | Only let the browser open these domains (up to 50). Allowed: at most 50 items. |
| `ignoreCertificateErrors` | `boolean` | no | Open sites with invalid HTTPS certificates. |
| `caCertificateIds` | `string[]` | no | Extra certificate authorities to trust. |
| `extensionId` | `string` | no | A browser extension to load. |
| `metadata` | `Record<string, string>` | no | Your own key/value strings, to find the session later (`sessions.list({ metadata })`); under 512 characters. |
| `logSession` | `boolean` | no | Keep the session's logs. Default `true`. |
| `recordSession` | `boolean` | no | Record the session (replay with `session.recording`). Default `true`. |
| `region` | `'eu' \| 'us-west-2' \| 'us-east-1' \| 'eu-central-1' \| 'ap-southeast-1' \| 'us'` | no | The region the browser is in: `us-west-2` (default), `us-east-1`, `eu-central-1`, `ap-southeast-1`, or `eu` / `us`. |
| `locale` | `string` | no | The browser language (`es-ES`). |
| `timezone` | `string` | no | The browser time zone (`Europe/Madrid`). |
| `geolocation` | `{ latitude, longitude, accuracy? }` | no | The location the browser reports. |
| `geolocation.latitude` | `number` | yes | Latitude. |
| `geolocation.longitude` | `number` | yes | Longitude. |
| `geolocation.accuracy` | `number` | no | Accuracy in metres. |
| `userAgent` | `string` | no | A custom user agent. |
| `extraHeaders` | `Record<string, string>` | no | Headers added to every request. |
| `blockResources` | `('image' \| 'media' \| 'font')[]` | no | Resource types not to load, to go faster: `media`, `font`, `image`. Default `["media", "font"]`. |
| `device` | `string` | no | Emulate a device: `iPhone 15`, `iPhone 15 Pro Max`, `iPhone SE`, `iPad Pro 11`, `Pixel 8`, `Galaxy S24`, `Desktop Chrome`, … |
| `captureDownloads` | `boolean` | no | Keep files the pages download (read them with `downloads.get`). Default `true`. |
| `viewport` | `{ width, height }` | no | The window size. |
| `viewport.width` | `number` | yes | Width in pixels. Allowed: > 0. |
| `viewport.height` | `number` | yes | Height in pixels. Allowed: > 0. |

### `BrowserSessionStatus`

A session's state and usage, from `s.status()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The session id. |
| `status` | `'expired' \| 'running' \| 'failed' \| 'ended'` | yes | `running`, `ended` (closed), `expired` (timed out or idle) or `failed`. |
| `startedAt` | `string` | yes | When it opened. |
| `minutes` | `number` | yes | Started minutes so far (billed per started minute). |
| `proxyMb` | `number` | yes | Proxy traffic so far, in MB. |
| `captchas` | `number` | yes | CAPTCHAs solved so far. |
| `credits` | `number` | yes | Credits used so far. |
| `expiresAt` | `string` | yes | When it ends at the latest. |

## `totalum-sdk/realtime`

Live updates between the people using your app: chat, notifications, presence ("who is online"), collaborative
editing, live dashboards and games. Your server mints a short-lived ticket for a room after its own auth check
(`ticket`), the page opens the room's WebSocket with it (`totalum-sdk/react`), and your server can also publish to a
room without a socket (`publish`). Billed in credits for each minute a room is awake and for each message, never per
connection.

```ts
const { url } = await totalum.realtime.ticket({ room: 'chat:team-1', identity: { id: user.id, name: user.name } });
```

| Method or type | What it does |
|---|---|
| [`totalumRealtime`](#totalumrealtime) | Creates the `realtime` namespace (the same object as `totalum.realtime`, scope `realtime`), server side. |
| [`realtime.ticket`](#realtimeticket) | Mints a short-lived ticket that lets one browser join one room as one identity. |
| [`realtime.publish`](#realtimepublish) | Publishes one message to every socket subscribed to `room`, from the app's own server (a route handler, a cron job): no socket needed. |
| [`RealtimePublishInput`](#realtimepublishinput) | One message for `realtime.publish()`. |
| [`RealtimePublishOutput`](#realtimepublishoutput) | The answer of `realtime.publish()`. |
| [`RealtimeTicketInput`](#realtimeticketinput) | The input of `realtime.ticket()`. |
| [`RealtimeTicketOutput`](#realtimeticketoutput) | A ticket to join one room, from `realtime.ticket()`. |

### `totalumRealtime`

```ts
totalumRealtime(options?: TotalumClientOptions): TotalumRealtime
```

Creates the `realtime` namespace (the same object as `totalum.realtime`, scope `realtime`), server side. The app's own
server mints a short-lived ticket after its own auth check and hands `{ticket, url}` to the page; the page opens the
`wss://` `url` and never holds a key.

A room is billed in credits for each minute it is awake and for each message it receives, never per connection: a
chat costs almost nothing, a busy game pays for its messages. A connection may send 120 messages a minute on the
Free plan, 600 on Starter, 1,200 on Builder, 1,800 on Professional and 3,600 on Enterprise; the next one closes its
socket with code 4429 (`REALTIME_MESSAGE_RATE_EXCEEDED`). Keep one room under about 1,000 messages a second in
total: give each match or table its own room.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumRealtime`](#totalumrealtime) — The `realtime` namespace: the methods below.

**Example**

```ts
import { totalumRealtime } from 'totalum-sdk/realtime';

const realtime = totalumRealtime();
await realtime.publish('orders:shop-1', { event: 'order.created', data: { orderId: 'ord_42' } });
```

The namespace's type is exported as `TotalumRealtime` (`import type { TotalumRealtime } from 'totalum-sdk/realtime'`).

### `realtime.ticket`

```ts
realtime.ticket(input: RealtimeTicketInput): Promise<RealtimeTicketOutput>
```

Mints a short-lived ticket that lets one browser join one room as one identity. Call it from your own route
after your auth check (is this user allowed in this room?) and hand `{ url }` to the page, which opens the
WebSocket with it (`useRealtimeChannel` does this for you, from `POST /api/realtime/ticket`). By default the
ticket only receives; pass `permissions: { publish: true }` to let the page send. The ticket must be used within
`ttl` seconds (5–300, default 60); the socket then stays open. Minting is free. When the page opens the socket,
the plan's live rooms and simultaneous connections are checked (the socket is refused with
`REALTIME_ROOM_LIMIT_REACHED` or `REALTIME_CONNECTION_LIMIT_REACHED`).

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `input` | [`RealtimeTicketInput`](#realtimeticketinput) | yes | The room, who joins, what they may do and how long the ticket is valid. |
| `input.room` | `string` | yes | The room to join: letters, digits and `._:-` (`chat:team-1`, `game.42`). Rooms exist while someone uses them. |
| `input.identity` | `{ id, name?, metadata? }` | yes | Who joins: shown to the others on every message they send. |
| `input.identity.id` | `string` | yes | Your user's id. Allowed: at least 1 character, at most 128 characters. |
| `input.identity.name` | `string` | no | A display name. Allowed: at most 128 characters. |
| `input.identity.metadata` | `Record<string, unknown>` | no | Extra data about the user (avatar URL, role), at most 2 KB. |
| `input.permissions` | `{ subscribe?, publish? }` | no | What the ticket allows. Default: receive only. |
| `input.permissions.subscribe` | `boolean` | no | Receive the room's messages. Default `true`. |
| `input.permissions.publish` | `boolean` | no | Send messages to the room. Default `false`. |
| `input.ttl` | `number` | no | Seconds the ticket can be used to join. Default 60. Allowed: ≥ 5, ≤ 300. |

**Returns** `Promise<RealtimeTicketOutput>` — The ticket, the ready-to-open `wss://` URL and what it grants.

| Field | Type | Always present | Description |
|---|---|---|---|
| `ticket` | `string` | yes | The signed ticket. |
| `url` | `string` | yes | The `wss://` URL to open (the ticket included): hand it to the page. |
| `room` | `string` | yes | The room. |
| `identity` | `{ id, name?, metadata? }` | yes | Who it is for. |
| `identity.id` | `string` | yes | The user's id. |
| `identity.name` | `string` | no | The display name. |
| `identity.metadata` | `Record<string, unknown>` | no | The extra user data. |
| `permissions` | `{ subscribe, publish }` | yes | What it allows. |
| `permissions.subscribe` | `boolean` | yes | Whether it receives messages. |
| `permissions.publish` | `boolean` | yes | Whether it may send. |
| `expiresAt` | `string` | yes | When the ticket can no longer be used to join (an open socket stays open). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | A room name with other characters than letters, digits and `._:-`, or over 128. |
| `RATE_LIMIT_EXCEEDED` | 429 | The plan's tickets per minute are used up; `details.retryAfterSeconds`. |

**Example**

```ts
// src/app/api/realtime/ticket/route.ts
export async function POST(request: Request) {
  const user = await requireUser(); // your own session
  const { room } = (await request.json()) as { room: string };
  if (room !== `chat:${user.teamId}`) return new Response('Forbidden', { status: 403 });
  const ticket = await totalum.realtime.ticket({
    room,
    identity: { id: user.id, name: user.name },
    permissions: { publish: true },
  });
  return Response.json({ url: ticket.url });
}
```

Result:

```json
{
  "ticket": "eyJ2IjoxLCJraWQiOiJrMjAyNnEzIiwicHJvamVjdElkIjoiYWNtZS1jcm0iLCJyb29tIjoiY2hhdDp0ZWFtLTEi…",
  "url": "wss://realtime.totalum.app/v1/rooms/chat:team-1?ticket=eyJ2IjoxLCJraWQiOiJrMjAyNnEz…",
  "room": "chat:team-1",
  "identity": { "id": "user_42", "name": "Ana" },
  "permissions": { "subscribe": true, "publish": true },
  "expiresAt": "2026-10-11T17:39:44.317Z"
}
```

### `realtime.publish`

```ts
realtime.publish(room: string, input: RealtimePublishInput): Promise<RealtimePublishOutput>
```

Publishes one message to every socket subscribed to `room`, from the app's own server (a route handler, a cron
job): no socket needed. Subscribers receive the same `message` frame a socket publish sends, with `event`
(default `message`) and `identity` (default `{ id: 'server' }`). Resolves with the message id, its timestamp
and the number of sockets it was sent to (`0` when nobody is connected — it is not queued). Billed per message.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `room` | `string` | yes | The room's name (letters, digits and `._:-`, at most 128 characters). |
| `input` | [`RealtimePublishInput`](#realtimepublishinput) | yes | The message: its `data`, an `event` name and the sender's identity. |
| `input.data` | `unknown` | yes | What to send: any JSON value. |
| `input.event` | `string` | no | An event name subscribers can switch on (`order.created`). Default `message`. Allowed: at least 1 character, at most 64 characters. |
| `input.identity` | `{ id, name? }` | no | Who it appears to come from. Default `{ id: "server" }`. |
| `input.identity.id` | `string` | yes | The sender id shown. Allowed: at least 1 character, at most 128 characters. |
| `input.identity.name` | `string` | no | The sender name shown. Allowed: at most 128 characters. |

**Returns** `Promise<RealtimePublishOutput>` — The message id, when it was sent, and how many sockets received it.

| Field | Type | Always present | Description |
|---|---|---|---|
| `messageId` | `string` | yes | The message id. |
| `at` | `string` | yes | When it was sent. |
| `connections` | `number` | yes | How many sockets received it; `0` when nobody is connected (it is not queued). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | A bad room name, or an `event` over 64 characters. |
| `INSUFFICIENT_CREDITS` | 402 | No credits left. |

**Example**

```ts
const out = await totalum.realtime.publish('orders:shop-1', {
  event: 'order.created',
  data: { orderId: 'ord_42', total: 4900 },
});
```

Result:

```json
{ "messageId": "d4d3ef78-9c21-4d15-a385-dc55af625cd3", "at": "2026-10-11T17:38:45.234Z", "connections": 3 }
```

### `RealtimePublishInput`

One message for `realtime.publish()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `data` | `unknown` | yes | What to send: any JSON value. |
| `event` | `string` | no | An event name subscribers can switch on (`order.created`). Default `message`. Allowed: at least 1 character, at most 64 characters. |
| `identity` | `{ id, name? }` | no | Who it appears to come from. Default `{ id: "server" }`. |
| `identity.id` | `string` | yes | The sender id shown. Allowed: at least 1 character, at most 128 characters. |
| `identity.name` | `string` | no | The sender name shown. Allowed: at most 128 characters. |

### `RealtimePublishOutput`

The answer of `realtime.publish()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `messageId` | `string` | yes | The message id. |
| `at` | `string` | yes | When it was sent. |
| `connections` | `number` | yes | How many sockets received it; `0` when nobody is connected (it is not queued). |

### `RealtimeTicketInput`

The input of `realtime.ticket()`.

| Name | Type | Required | Description |
|---|---|---|---|
| `room` | `string` | yes | The room to join: letters, digits and `._:-` (`chat:team-1`, `game.42`). Rooms exist while someone uses them. |
| `identity` | `{ id, name?, metadata? }` | yes | Who joins: shown to the others on every message they send. |
| `identity.id` | `string` | yes | Your user's id. Allowed: at least 1 character, at most 128 characters. |
| `identity.name` | `string` | no | A display name. Allowed: at most 128 characters. |
| `identity.metadata` | `Record<string, unknown>` | no | Extra data about the user (avatar URL, role), at most 2 KB. |
| `permissions` | `{ subscribe?, publish? }` | no | What the ticket allows. Default: receive only. |
| `permissions.subscribe` | `boolean` | no | Receive the room's messages. Default `true`. |
| `permissions.publish` | `boolean` | no | Send messages to the room. Default `false`. |
| `ttl` | `number` | no | Seconds the ticket can be used to join. Default 60. Allowed: ≥ 5, ≤ 300. |

### `RealtimeTicketOutput`

A ticket to join one room, from `realtime.ticket()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `ticket` | `string` | yes | The signed ticket. |
| `url` | `string` | yes | The `wss://` URL to open (the ticket included): hand it to the page. |
| `room` | `string` | yes | The room. |
| `identity` | `{ id, name?, metadata? }` | yes | Who it is for. |
| `identity.id` | `string` | yes | The user's id. |
| `identity.name` | `string` | no | The display name. |
| `identity.metadata` | `Record<string, unknown>` | no | The extra user data. |
| `permissions` | `{ subscribe, publish }` | yes | What it allows. |
| `permissions.subscribe` | `boolean` | yes | Whether it receives messages. |
| `permissions.publish` | `boolean` | yes | Whether it may send. |
| `expiresAt` | `string` | yes | When the ticket can no longer be used to join (an open socket stays open). |

## `totalum-sdk/logs`

Read your app's own server logs: the requests the published app (`env: 'live'`) or the preview (`'dev'`) handled,
with their `console` lines, status codes and errors. Use it for an admin page that shows recent errors, or to debug.
Free.

```ts
const { items } = await totalum.logs.query({ level: 'error', env: 'live' });
```

| Method or type | What it does |
|---|---|
| [`totalumLogs`](#totalumlogs) | Creates the `logs` namespace (the same object as `totalum.logs`, scope `logs:read`): the requests the published app (`env: 'live'`) or the preview (`'dev'`) handled, with their `console` lines and errors. |
| [`logs.query`](#logsquery) | Reads the app's server logs, newest first: one item per request (or per `console` line), with its level, message, path, status and timings. |
| [`LogsPage`](#logspage) | Log items from `logs.query()`. |
| [`LogsQuery`](#logsquery-type) | A `logs.query` filter: the time window (default the last hour), level, text, status, path, request id and environment; every field is optional. |

### `totalumLogs`

```ts
totalumLogs(options?: TotalumClientOptions): TotalumLogs
```

Creates the `logs` namespace (the same object as `totalum.logs`, scope `logs:read`): the requests the published app
(`env: 'live'`) or the preview (`'dev'`) handled, with their `console` lines and errors. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumLogs`](#totalumlogs) — The `logs` namespace: the method below.

**Example**

```ts
import { totalumLogs } from 'totalum-sdk/logs';

const logs = totalumLogs();
const { items } = await logs.query({ level: 'error' });
```

The namespace's type is exported as `TotalumLogs` (`import type { TotalumLogs } from 'totalum-sdk/logs'`).

### `logs.query`

```ts
logs.query(q?: LogsQuery): Promise<LogsPage>
```

Reads the app's server logs, newest first: one item per request (or per `console` line), with its level,
message, path, status and timings. Use it for an admin "recent errors" page or to debug. `window` in the answer
is the window actually searched. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `q` | [`LogsQuery`](#logsquery-type) | no | The filters; all optional. |
| `q.limit` | `number` | no | At most this many items. Default 100. Allowed: ≥ 1, ≤ 500. |
| `q.status` | `string` | no | Only requests with this HTTP status (`500`) or class (`5xx`). |
| `q.env` | `'dev' \| 'live'` | no | `live` (the published app, default) or `dev` (the preview). |
| `q.requestId` | `string` | no | Only this request (from a `TotalumError.requestId`, or a log item). Allowed: at most 64 characters. |
| `q.source` | `'live' \| 'auto' \| 'buffer'` | no | `auto` (default), `buffer` (the last hours, fast) or `live`. |
| `q.to` | `string` | no | The window's end; default now. Allowed: ISO 8601 date-time. |
| `q.from` | `string` | no | The window's start; default one hour before `to`. Allowed: ISO 8601 date-time. |
| `q.level` | `'error' \| 'log' \| 'debug' \| 'info' \| 'warn'` | no | Only this level: `error`, `warn`, `info`, `log` or `debug`. |
| `q.q` | `string` | no | Only items whose message contains this text. Allowed: at most 200 characters. |
| `q.path` | `string` | no | Only requests to this path (`/api/orders`). Allowed: at most 512 characters. |

**Returns** `Promise<LogsPage>` — Up to `limit` (≤ 500) log items and the window searched.

| Field | Type | Always present | Description |
|---|---|---|---|
| `items` | `({ ts, level, message, requestId, method, path, status, cpuMs, wallMs, outcome, exception? })[]` | yes | The items, newest first. |
| `items.ts` | `string` | yes | When it happened. |
| `items.level` | `'error' \| 'log' \| 'debug' \| 'info' \| 'warn'` | yes | `error`, `warn`, `info`, `log` or `debug`. |
| `items.message` | `string` | yes | The log line (secrets are redacted). |
| `items.requestId` | `string \| null` | yes | The request it belongs to. |
| `items.method` | `string \| null` | yes | The request's HTTP method. |
| `items.path` | `string \| null` | yes | The request's path. |
| `items.status` | `number \| null` | yes | The HTTP status the app answered. |
| `items.cpuMs` | `number \| null` | yes | CPU time the request used, in ms. |
| `items.wallMs` | `number \| null` | yes | Total time the request took, in ms. |
| `items.outcome` | `string` | yes | How the request ended (`ok`, `exception`, `exceededCpu`, `canceled`). |
| `items.exception` | `{ name, message, stack? }` | no | The uncaught error, when there was one. |
| `items.exception.name` | `string` | yes | Its name (`TypeError`). |
| `items.exception.message` | `string` | yes | Its message. |
| `items.exception.stack` | `string` | no | Its stack trace. |
| `window` | `{ from, to, source }` | yes | The window actually searched. |
| `window.from` | `string` | yes | Its start. |
| `window.to` | `string` | yes | Its end. |
| `window.source` | `'live' \| 'buffer'` | yes | Where the items came from: `buffer` (recent) or `live`. |
| `nextCursor` | `string \| null` | yes | Always `null` today: narrow the window to see more. |
| `sampled` | `boolean` | yes | Whether only a sample of the items was kept (very busy apps). |
| `hint` | `string` | no | A tip when nothing matched (a wider window, the other environment). |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | A filter has the wrong format (`status` must look like `500` or `5xx`). |
| `LOGS_UPSTREAM_ERROR` | 502 | Logs are unavailable; retry shortly. |

**Example**

```ts
const { items } = await totalum.logs.query({ level: 'error', env: 'live', limit: 2 });
```

Result:

```json
{
  "items": [
    {
      "ts": "2026-10-11T17:20:41.118Z",
      "level": "error",
      "message": "TypeError: Cannot read properties of undefined (reading 'id')",
      "requestId": "8f1c2a9e4b7d3c6a",
      "method": "POST",
      "path": "/api/orders",
      "status": 500,
      "cpuMs": 4.1,
      "wallMs": 37.9,
      "outcome": "ok",
      "exception": { "name": "TypeError", "message": "Cannot read properties of undefined (reading 'id')" }
    }
  ],
  "window": { "from": "2026-10-11T16:38:39.984Z", "to": "2026-10-11T17:38:39.984Z", "source": "live" },
  "nextCursor": null,
  "sampled": false
}
```

### `LogsPage`

Log items from `logs.query()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `items` | `({ ts, level, message, requestId, method, path, status, cpuMs, wallMs, outcome, exception? })[]` | yes | The items, newest first. |
| `items.ts` | `string` | yes | When it happened. |
| `items.level` | `'error' \| 'log' \| 'debug' \| 'info' \| 'warn'` | yes | `error`, `warn`, `info`, `log` or `debug`. |
| `items.message` | `string` | yes | The log line (secrets are redacted). |
| `items.requestId` | `string \| null` | yes | The request it belongs to. |
| `items.method` | `string \| null` | yes | The request's HTTP method. |
| `items.path` | `string \| null` | yes | The request's path. |
| `items.status` | `number \| null` | yes | The HTTP status the app answered. |
| `items.cpuMs` | `number \| null` | yes | CPU time the request used, in ms. |
| `items.wallMs` | `number \| null` | yes | Total time the request took, in ms. |
| `items.outcome` | `string` | yes | How the request ended (`ok`, `exception`, `exceededCpu`, `canceled`). |
| `items.exception` | `{ name, message, stack? }` | no | The uncaught error, when there was one. |
| `items.exception.name` | `string` | yes | Its name (`TypeError`). |
| `items.exception.message` | `string` | yes | Its message. |
| `items.exception.stack` | `string` | no | Its stack trace. |
| `window` | `{ from, to, source }` | yes | The window actually searched. |
| `window.from` | `string` | yes | Its start. |
| `window.to` | `string` | yes | Its end. |
| `window.source` | `'live' \| 'buffer'` | yes | Where the items came from: `buffer` (recent) or `live`. |
| `nextCursor` | `string \| null` | yes | Always `null` today: narrow the window to see more. |
| `sampled` | `boolean` | yes | Whether only a sample of the items was kept (very busy apps). |
| `hint` | `string` | no | A tip when nothing matched (a wider window, the other environment). |

### `LogsQuery` type

A `logs.query` filter: the time window (default the last hour), level, text, status, path, request id and
environment; every field is optional.

| Name | Type | Required | Description |
|---|---|---|---|
| `limit` | `number` | no | At most this many items. Default 100. Allowed: ≥ 1, ≤ 500. |
| `status` | `string` | no | Only requests with this HTTP status (`500`) or class (`5xx`). |
| `env` | `'dev' \| 'live'` | no | `live` (the published app, default) or `dev` (the preview). |
| `requestId` | `string` | no | Only this request (from a `TotalumError.requestId`, or a log item). Allowed: at most 64 characters. |
| `source` | `'live' \| 'auto' \| 'buffer'` | no | `auto` (default), `buffer` (the last hours, fast) or `live`. |
| `to` | `string` | no | The window's end; default now. Allowed: ISO 8601 date-time. |
| `from` | `string` | no | The window's start; default one hour before `to`. Allowed: ISO 8601 date-time. |
| `level` | `'error' \| 'log' \| 'debug' \| 'info' \| 'warn'` | no | Only this level: `error`, `warn`, `info`, `log` or `debug`. |
| `q` | `string` | no | Only items whose message contains this text. Allowed: at most 200 characters. |
| `path` | `string` | no | Only requests to this path (`/api/orders`). Allowed: at most 512 characters. |

## `totalum-sdk/analytics`

Your site's own traffic: page views, visits, unique visitors, errors, bytes and response times, as a time series or
a top list (top pages, referrers, countries, devices, browsers). Use it for a stats page in your app's admin. Free.

```ts
const { top } = await totalum.analytics.site({ dimension: 'path', metric: 'page_views', limit: 10 });
```

| Method or type | What it does |
|---|---|
| [`totalumAnalytics`](#totalumanalytics) | Creates the `analytics` namespace (the same object as `totalum.analytics`, scope `analytics:read`): the site's own traffic. |
| [`analytics.site`](#analyticssite) | The site's traffic: page views, hits, unique visitors, errors, bytes or response time (TTFB), as a time series (no `dimension`) or a top list by path, referrer, country, device, browser, OS, status or hostname. |
| [`SiteAnalyticsOutput`](#siteanalyticsoutput) | The answer of `analytics.site()`. |
| [`SiteAnalyticsQuery`](#siteanalyticsquery) | What `analytics.site()` reads: the window, the metrics and, for a top list, the dimension; all optional. |
| [`SiteDimension`](#sitedimension) | What a top list of `analytics.site()` groups by. |
| [`SiteMetric`](#sitemetric) | What `analytics.site()` counts. |

### `totalumAnalytics`

```ts
totalumAnalytics(options?: TotalumClientOptions): TotalumAnalytics
```

Creates the `analytics` namespace (the same object as `totalum.analytics`, scope `analytics:read`): the site's own
traffic. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `options` | [`TotalumClientOptions`](#totalumclientoptions) | no | The project key, API URL and environment; each falls back to its environment variable. |
| `options.key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `options.apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `options.env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

**Returns** [`TotalumAnalytics`](#totalumanalytics) — The `analytics` namespace: the method below.

**Example**

```ts
import { totalumAnalytics } from 'totalum-sdk/analytics';

const analytics = totalumAnalytics();
const { series } = await analytics.site({ metric: 'page_views', granularity: 'day' });
```

The namespace's type is exported as `TotalumAnalytics` (`import type { TotalumAnalytics } from 'totalum-sdk/analytics'`).

### `analytics.site`

```ts
analytics.site(q?: SiteAnalyticsQuery): Promise<SiteAnalyticsOutput>
```

The site's traffic: page views, hits, unique visitors, errors, bytes or response time (TTFB), as a time series
(no `dimension`) or a top list by path, referrer, country, device, browser, OS, status or hostname. Counts come
from sampled hits, so very small numbers are approximate. Free.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `q` | [`SiteAnalyticsQuery`](#siteanalyticsquery) | no | The window, metrics and dimension; all optional. |
| `q.from` | `string` | no | The window's start (ISO date-time); default 7 days ago. Raw hits are kept 90 days. Allowed: ISO 8601 date-time. |
| `q.to` | `string` | no | The window's end (ISO date-time); default now. Allowed: ISO 8601 date-time. |
| `q.granularity` | `'day' \| 'minute' \| 'hour'` | no | The series' step: `minute` (only within the last 30 minutes), `hour` or `day` (default). |
| `q.metric` | `'uniques' \| 'bytes' \| 'errors' \| 'page_views' \| 'hits' \| 'ttfb_p50' \| 'ttfb_p95' \| SiteMetric[]` | no | What to count: one metric or up to 4. Default `page_views`. |
| `q.dimension` | `'browser' \| 'country' \| 'status' \| 'device' \| 'path' \| 'hostname' \| 'os' \| 'referrer'` | no | Omit for a time series (`series`); set for a top list by this dimension (`top`). |
| `q.limit` | `number` | no | With `dimension`: how many entries, at most 100. Default 10. Allowed: ≥ 1, ≤ 100. |
| `q.bots` | `'exclude' \| 'include' \| 'only'` | no | Bot traffic: `exclude` (default), `include` or `only`. |
| `q.env` | `'dev' \| 'live'` | no | `dev` = the preview host, `live` = the published and custom hosts; omitted = both. |

**Returns** `Promise<SiteAnalyticsOutput>` — `series` (a time series) or `top` (a top list), the window used and when the numbers were computed.

| Field | Type | Always present | Description |
|---|---|---|---|
| `window` | `{ from, to, granularity }` | yes | The window used. |
| `window.from` | `string` | yes | Its start. |
| `window.to` | `string` | yes | Its end. |
| `window.granularity` | `'day' \| 'minute' \| 'hour'` | yes | Its step. |
| `source` | `'wae'` | yes | Always `wae`. |
| `approximate` | `{ uniques }` | yes | Which numbers are estimates. |
| `approximate.uniques` | `boolean` | yes | `true` when `uniques` is estimated. |
| `cachedAt` | `string` | yes | When the numbers were computed (cached for a short time). |
| `series` | `({  })[]` | no | Without `dimension`: one entry per step, `{ t, <metric>: value, … }`. |
| `top` | `({  })[]` | no | With `dimension`: the top entries, `{ key, share, <metric>: value, … }`. |

**Errors** — besides the [errors every call can throw](#errors):

| Code | HTTP | When |
|---|---|---|
| `ANALYTICS_INVALID_QUERY` | 400 | `from` after `to`, `minute` over more than 30 minutes, or a bad metric. |
| `ANALYTICS_WINDOW_OUT_OF_RETENTION` | 400 | The window starts more than 90 days ago. |
| `ANALYTICS_UPSTREAM_ERROR` | 502 | Analytics are unavailable; retry shortly. |

**Examples**

```ts
const { series } = await totalum.analytics.site({ metric: ['page_views', 'uniques'], granularity: 'day' });
```

Result:

```json
{
  "series": [
    { "t": "2026-10-09T00:00:00.000Z", "page_views": 412, "uniques": 128 },
    { "t": "2026-10-10T00:00:00.000Z", "page_views": 538, "uniques": 171 }
  ],
  "window": { "from": "2026-10-04T17:39:00.000Z", "to": "2026-10-11T17:39:00.000Z", "granularity": "day" },
  "source": "wae",
  "approximate": { "uniques": true },
  "cachedAt": "2026-10-11T17:38:40.720Z"
}
```

```ts
const { top } = await totalum.analytics.site({ dimension: 'path', metric: 'page_views', limit: 3 });
```

Result:

```json
{
  "top": [
    { "key": "/", "share": 0.41, "page_views": 1210 },
    { "key": "/pricing", "share": 0.18, "page_views": 532 },
    { "key": "/blog/launch", "share": 0.07, "page_views": 207 }
  ],
  "window": { "from": "2026-10-04T17:39:00.000Z", "to": "2026-10-11T17:39:00.000Z", "granularity": "day" },
  "source": "wae",
  "approximate": { "uniques": false },
  "cachedAt": "2026-10-11T17:38:41.388Z"
}
```

### `SiteAnalyticsOutput`

The answer of `analytics.site()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `window` | `{ from, to, granularity }` | yes | The window used. |
| `window.from` | `string` | yes | Its start. |
| `window.to` | `string` | yes | Its end. |
| `window.granularity` | `'day' \| 'minute' \| 'hour'` | yes | Its step. |
| `source` | `'wae'` | yes | Always `wae`. |
| `approximate` | `{ uniques }` | yes | Which numbers are estimates. |
| `approximate.uniques` | `boolean` | yes | `true` when `uniques` is estimated. |
| `cachedAt` | `string` | yes | When the numbers were computed (cached for a short time). |
| `series` | `({  })[]` | no | Without `dimension`: one entry per step, `{ t, <metric>: value, … }`. |
| `top` | `({  })[]` | no | With `dimension`: the top entries, `{ key, share, <metric>: value, … }`. |

### `SiteAnalyticsQuery`

What `analytics.site()` reads: the window, the metrics and, for a top list, the dimension; all optional.

| Name | Type | Required | Description |
|---|---|---|---|
| `from` | `string` | no | The window's start (ISO date-time); default 7 days ago. Raw hits are kept 90 days. Allowed: ISO 8601 date-time. |
| `to` | `string` | no | The window's end (ISO date-time); default now. Allowed: ISO 8601 date-time. |
| `granularity` | `'day' \| 'minute' \| 'hour'` | no | The series' step: `minute` (only within the last 30 minutes), `hour` or `day` (default). |
| `metric` | `'uniques' \| 'bytes' \| 'errors' \| 'page_views' \| 'hits' \| 'ttfb_p50' \| 'ttfb_p95' \| SiteMetric[]` | no | What to count: one metric or up to 4. Default `page_views`. |
| `dimension` | `'browser' \| 'country' \| 'status' \| 'device' \| 'path' \| 'hostname' \| 'os' \| 'referrer'` | no | Omit for a time series (`series`); set for a top list by this dimension (`top`). |
| `limit` | `number` | no | With `dimension`: how many entries, at most 100. Default 10. Allowed: ≥ 1, ≤ 100. |
| `bots` | `'exclude' \| 'include' \| 'only'` | no | Bot traffic: `exclude` (default), `include` or `only`. |
| `env` | `'dev' \| 'live'` | no | `dev` = the preview host, `live` = the published and custom hosts; omitted = both. |

### `SiteDimension`

What a top list of `analytics.site()` groups by.

| Value | Meaning |
|---|---|
| `'browser'` | The browser. |
| `'country'` | The visitor's country (two-letter code). |
| `'status'` | The HTTP status. |
| `'device'` | `desktop`, `mobile` or `tablet`. |
| `'path'` | The page path. |
| `'hostname'` | The host (the published domain, a custom domain, the preview). |
| `'os'` | The operating system. |
| `'referrer'` | Where visitors came from. |

### `SiteMetric`

What `analytics.site()` counts.

| Value | Meaning |
|---|---|
| `'uniques'` | Unique visitors (approximate). |
| `'bytes'` | Bytes sent. |
| `'errors'` | Requests answered with a 4xx or 5xx status. |
| `'page_views'` | Page views (HTML pages only). |
| `'hits'` | Every request (pages, assets, API). |
| `'ttfb_p50'` | Median time to first byte, in ms. |
| `'ttfb_p95'` | 95th-percentile time to first byte, in ms. |

## `totalum-sdk/react`

The browser side of realtime rooms for React client components: `useRealtimeChannel(room)` connects to a room,
keeps the messages received, shows who else is present, and reconnects by itself (every deploy drops sockets). The page
never holds a key: it asks your own server route for a ticket (default `POST /api/realtime/ticket`), which calls
`totalum.realtime.ticket`. `RealtimeChannel` is the same thing without React.

```tsx
'use client';
import { useRealtimeChannel } from 'totalum-sdk/react';
const { messages, publish } = useRealtimeChannel('chat:team-1');
```

| Method or type | What it does |
|---|---|
| [`useRealtimeChannel`](#userealtimechannel) | Joins a realtime room from a React client component and re-renders on every message: `status`, the `messages` received since it opened, `others` (with `presence`), and `publish(data, event?)`. |
| [`RealtimeChannel`](#realtimechannel) | One room over one socket, without React (Vue, Svelte, plain scripts): `connect()`, read `getSnapshot()`, listen with `subscribe()`, send with `publish()`, `close()` when done. |
| [`RealtimeChannelOptions`](#realtimechanneloptions) | How `useRealtimeChannel` / `RealtimeChannel` get their ticket, announce presence and keep messages. |
| [`RealtimeMessage`](#realtimemessage) | One message received in a room. |
| [`RealtimePeer`](#realtimepeer) | Another connection present in the room (with the `presence` option). |
| [`RealtimeSnapshot`](#realtimesnapshot) | The current state of a room: what `useRealtimeChannel` returns (plus `publish`), and `RealtimeChannel.getSnapshot()`. |
| [`RealtimeStatus`](#realtimestatus) | The state of a room's connection: `connecting` (first ticket and socket), `open`, `reconnecting` (after a drop — every deploy drops sockets — show it as an ordinary state, not an error) or `closed` (after `close()` or unmount). |

### `useRealtimeChannel`

```ts
useRealtimeChannel(room: string, options?: RealtimeChannelOptions): { publish, status, messages, others }
```

Joins a realtime room from a React client component and re-renders on every message: `status`, the `messages`
received since it opened, `others` (with `presence`), and `publish(data, event?)`. It asks your route for a ticket
(`POST /api/realtime/ticket` with `{ room }`, which calls `totalum.realtime.ticket` after your auth check), opens the
socket, and re-tickets and reconnects with backoff after every drop. Render `status === 'reconnecting'` as an
ordinary state, not an error. The options of the first render are kept; a new `room` opens a new connection.

**Parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `room` | `string` | yes | The room's name (the same your ticket route allows). |
| `options` | [`RealtimeChannelOptions`](#realtimechanneloptions) | no | Where to get the ticket, presence data, and how many messages to keep. |
| `options.ticketUrl` | `string` | no | The app's route that mints the ticket; it receives `{ room }` and answers `{ url }` or `{ data: { url } }`. |
| `options.getTicket` | `(room: string) => Promise<{ url: string; }>` | no | Or mint the ticket yourself. |
| `options.presence` | `M` | no | Announce this connection's presence with this metadata (name, colour…); `others` lists everyone else's. |
| `options.keep` | `number` | no | Messages kept in memory, newest last (default 100). |
| `options.WebSocket` | `{ new (url: string \| URL, protocols?: string \| string[] \| undefined): WebSocket; prototype: WebSocket; readonly CONNECTING: 0; readonly OPEN: 1; readonly CLOSING: 2; readonly CLOSED: 3; }` | no | A `WebSocket` implementation, for tests only. Default: the browser's. |

**Returns** `{ publish, status, messages, others }` — The room's snapshot (`status`, `messages`, `others`) and `publish(data, event?)`, which sends to everyone in
the room (you included) and returns `false` when the socket is not open.

**Example**

```ts
'use client';
import { useRealtimeChannel } from 'totalum-sdk/react';

export function Chat({ teamId, name }: { teamId: string; name: string }) {
  const { messages, others, publish, status } = useRealtimeChannel(`chat:${teamId}`, { presence: { name } });
  return (
    <div>
      <p>{status === 'open' ? `${others.length} others online` : 'Connecting…'}</p>
      {messages.map((m) => <p key={m.id}><b>{m.identity.name}</b>: {String(m.data)}</p>)}
      <button onClick={() => publish('Hello!')}>Say hello</button>
    </div>
  );
}
```

Result:

```ts
{
  status: 'open',
  messages: [{ id: '5f0c…', event: 'message', identity: { id: 'user_7', name: 'Bob' }, data: 'Hello!', ts: '2026-10-11T18:02:11.204Z' }],
  others: [{ connectionId: '1b9e…', identity: { id: 'user_7', name: 'Bob' }, meta: { name: 'Bob' } }],
  publish: [Function]
}
```

### `RealtimeChannel`

One room over one socket, without React (Vue, Svelte, plain scripts): `connect()`, read `getSnapshot()`, listen with
`subscribe()`, send with `publish()`, `close()` when done. It re-tickets and reconnects by itself after a drop.
`useRealtimeChannel` is a thin React wrapper over it.
| Member | Type | Description |
|---|---|---|
| `room` | `string` | The room's name. |

| Method | Description |
|---|---|
| `subscribe(listener)` → `() => void` | Calls `listener` on every change of the snapshot; returns the function that stops it. |
| `getSnapshot()` → [`RealtimeSnapshot`](#realtimesnapshot) | The current state: `status`, `messages` and `others` (a new object after each change). |
| `connect()` → `void` | Fetches a ticket and opens the socket (and starts the presence heartbeat); does nothing if already connected. |
| `publish(data, event?)` → `boolean` | Sends `data` to everyone in the room (you included); `false` when the socket is not open. |
| `close()` → `void` | Leaves the room (announcing it to the others), closes the socket and stops reconnecting. |

**Example**

```ts
import { RealtimeChannel } from 'totalum-sdk/react';

const channel = new RealtimeChannel('chat:team-1', { presence: { name: 'Ana' } });
channel.subscribe(() => render(channel.getSnapshot()));
channel.connect();
channel.publish({ text: 'Hello' });
// later
channel.close();
```

### `RealtimeChannelOptions`

How `useRealtimeChannel` / `RealtimeChannel` get their ticket, announce presence and keep messages.

| Name | Type | Required | Description |
|---|---|---|---|
| `ticketUrl` | `string` | no | The app's route that mints the ticket; it receives `{ room }` and answers `{ url }` or `{ data: { url } }`. |
| `getTicket` | `(room: string) => Promise<{ url: string; }>` | no | Or mint the ticket yourself. |
| `presence` | `M` | no | Announce this connection's presence with this metadata (name, colour…); `others` lists everyone else's. |
| `keep` | `number` | no | Messages kept in memory, newest last (default 100). |
| `WebSocket` | `{ new (url: string \| URL, protocols?: string \| string[] \| undefined): WebSocket; prototype: WebSocket; readonly CONNECTING: 0; readonly OPEN: 1; readonly CLOSING: 2; readonly CLOSED: 3; }` | no | A `WebSocket` implementation, for tests only. Default: the browser's. |

### `RealtimeMessage`

One message received in a room.

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | `string` | yes | The message id. |
| `event` | `string` | yes | The event name the sender gave (`message` by default). |
| `identity` | `{ id, name? }` | yes | Who sent it: the identity of their ticket, or `{ id: 'server' }` for a server-side publish. |
| `identity.id` | `string` | yes | The sender's user id. |
| `identity.name` | `string` | no | The sender's display name, when their ticket had one. |
| `data` | `T` | yes | What was sent. |
| `ts` | `string` | yes | When the room received it (ISO date-time). |

### `RealtimePeer`

Another connection present in the room (with the `presence` option).

| Field | Type | Always present | Description |
|---|---|---|---|
| `connectionId` | `string` | yes | One per open socket: the same user in two tabs is two peers with the same `identity`. |
| `identity` | `{ id, name? }` | yes | The peer's user, from their ticket. |
| `identity.id` | `string` | yes | Their user id. |
| `identity.name` | `string` | no | Their display name, when their ticket had one. |
| `meta` | `M` | yes | The `presence` data they announced (name, colour, cursor…). |

### `RealtimeSnapshot`

The current state of a room: what `useRealtimeChannel` returns (plus `publish`), and `RealtimeChannel.getSnapshot()`.

| Field | Type | Always present | Description |
|---|---|---|---|
| `status` | [`RealtimeStatus`](#realtimestatus) | yes | The connection's state. |
| `messages` | [`RealtimeMessage[]`](#realtimemessage) | yes | The messages received since the room opened, oldest first (at most `keep`). |
| `others` | [`RealtimePeer[]`](#realtimepeer) | yes | Everyone else present (with `presence`); empty otherwise. |

### `RealtimeStatus`

The state of a room's connection: `connecting` (first ticket and socket), `open`, `reconnecting` (after a drop —
every deploy drops sockets — show it as an ordinary state, not an error) or `closed` (after `close()` or unmount).

| Value | Meaning |
|---|---|
| `'open'` | Connected: messages arrive and `publish` works. |
| `'connecting'` | Getting the first ticket and opening the socket. |
| `'reconnecting'` | The socket dropped (every deploy drops sockets): getting a new ticket and reconnecting with backoff. An ordinary state, not an error. |
| `'closed'` | Closed by `close()` or unmount. |

## Types

The types every module shares: the client options and the error.

### `TotalumClientOptions`

The options every namespace takes; each one falls back to the same environment variable everywhere.

| Name | Type | Required | Description |
|---|---|---|---|
| `key` | `string` | no | Project key `tlm_pk_…`; default `TOTALUM_PROJECT_KEY`. Server-side only. |
| `apiUrl` | `string` | no | The Totalum API's URL: default `TOTALUM_SDK_API_URL`, else `https://sdk-api.totalum.app`. |
| `env` | `'dev' \| 'live'` | no | Sent as `x-totalum-env` on every call (the database it targets, and dev vs live usage); default `TOTALUM_ENV`, else `live`. |

### `TotalumError`

The one error every `totalum-sdk` call throws: the Totalum API envelope's frozen `errorCode`, the HTTP
status and `details`. `TotalumD1Error` (`totalum-sdk/d1`) and `TotalumWebhookError` (`totalum-sdk/webhooks`)
extend it, so one `isTotalumError(e)` check covers the whole package.
| Member | Type | Description |
|---|---|---|
| `name` | `string` | `'TotalumError'` (or the subclass's name). |
| `errorCode` | [`TotalumErrorCode`](#totalumerrorcode) | The frozen error code (`FILE_NOT_FOUND`, `INSUFFICIENT_CREDITS`, …): branch on it, never on `message`. |
| `status` | `number` | HTTP status; 0 for network errors, 500 for client-side refusals. |
| `details` | `Record<string, unknown>` | Extra data about the failure (`maxBytes`, `retryAfterMs`, the field that failed validation), when any. |
| `requestId` | `string` | the Totalum API's request id, for support and the logs explorer. |

| Method | Description |
|---|---|
| `toString()` → `string` | `TotalumError [CODE]: message` — what a log shows for `String(err)` (workerd's tail serialises an Error so). |
| `toJSON()` → `Record<string, unknown>` | Every field a log or `Response.json` needs (undefined ones drop out); workerd's tail logs an Error through this. |

### `TotalumErrorCode`

Every code a `TotalumError` can carry: the Totalum API's frozen codes plus the client-side ones (see Errors).

```ts
type TotalumErrorCode = 'SERVICE_UNAUTHORIZED' | 'SERVICE_REPLAY' | 'ACTOR_MISMATCH' | 'PAYLOAD_TOO_LARGE' | 'VALIDATION_ERROR' | 'PROJECT_NOT_FOUND' | 'PROJECT_NOT_READY' | 'OPERATION_IN_PROGRESS' | … // 421 values
```

### `isTotalumError` type

Whether `e` is an error thrown by `totalum-sdk` (`TotalumError` or a subclass): then `e.errorCode`, `e.status`,
`e.details` and `e.requestId` are typed.

```ts
type isTotalumError = any
```
