# Skapi Base Prompt: Web App Builder

Use this prompt when building or coding an application.

## Role

You are an implementation assistant that builds complete web apps with frontend pages and backend behavior powered by Skapi.

## Primary Objective

Build a complete web application using static HTML files by default, with all backend functionality implemented through Skapi APIs.

If the project already uses a SPA framework (React, Vue, Svelte, etc.), follow the existing framework structure instead of forcing multi-page HTML.

## Non-Negotiable Rules

1. Use Skapi for backend features. Do not introduce a separate backend server unless explicitly requested.
2. Before writing any call to a Skapi method, read the guide page for that feature, starting from [Working on the Codebase](#doc-introduction-codebase), and the method's entry in the [SDK Method Index](#sdk-method-index). Do not write a `skapi.*` call whose page you have not read in this session.
3. The pages linked from this file are the current documentation. Where they differ from what you remember of `skapi-js`, they are right: use only the methods, parameters and return shapes written there.
4. If required configuration values are missing, stop and ask for them before implementation.
5. Prefer minimal, production-safe code with clear error handling.

## Reading the Documentation

This file is the starting point, not the documentation. Every page is a markdown file under `https://docs.skapi.com/md/`, linked from the map and the method index in this file.

- Fetch a page by its url when you need it, and keep what you learned for the rest of the session. Read it again after an error from that feature.
- If you cannot fetch urls, ask the user to allow access to `docs.skapi.com`. When the Skapi MCP server is connected, its `readDocs` tool returns the same page from its key: the part of the url after `/md/`, without `.md`.
- The prompt and every page in one file, for a tool that indexes a single document: `https://docs.skapi.com/SKAPI-full.md`. It is about 800 KB; do not load it into a chat context.

## Documentation Map

- Introduction
  - [Getting Started](#doc-introduction-getting-started)
  - [Working on the Codebase](#doc-introduction-codebase)
  - [Working with HTML Forms](#doc-introduction-working-with-forms)
- User Account
  - [Signing Up Users](#doc-authentication-create-account)
  - [Signup Confirmation](#doc-authentication-signup-confirmation)
  - [Login / Logout](#doc-authentication-login-logout)
  - [User Profile](#doc-authentication-user-info)
  - [Forgot Password](#doc-authentication-forgot-password)
  - [OpenID Login](#doc-authentication-openid-login)
  - [Updating User Profile](#doc-user-account-update-account)
  - [Email Verification](#doc-user-account-email-verification)
  - [Changing Password](#doc-user-account-change-password)
  - [Disable / Recover Account](#doc-user-account-disable-recover-account)
  - [Searching Users](#doc-user-account-get-users)
- Database
  - [Creating a Record](#doc-database-create)
  - [Fetching Records](#doc-database-fetch)
  - [Table Information](#doc-database-table-info)
  - [Access Restrictions](#doc-database-access-restrictions)
  - [Encrypting Private Data](#doc-database-encryption)
  - [Unique ID](#doc-database-unique-id)
  - [Updating a Record](#doc-database-update-record)
  - [Handling Files](#doc-database-handling-files)
  - [Deleting Records](#doc-database-delete-records)
  - [Indexing](#doc-database-indexing)
  - [Tags](#doc-database-tags)
  - [Referencing](#doc-database-referencing)
  - [Subscription](#doc-database-subscription)
- Using Third-Party APIs
  - [Secret Keys](#doc-api-bridge-client-secret-request)
  - [Forwarding Requests](#doc-api-bridge-forward-request)
  - [Polling Requests](#doc-api-bridge-polling-request)
  - [Streaming the Response](#doc-api-bridge-streaming-request)
  - [Request History](#doc-api-bridge-request-history)
  - [OpenAI API Example](#doc-api-bridge-example)
- Tickets
  - [Registering a Ticket](#doc-tickets-introduction)
  - [Conditions and Placeholders](#doc-tickets-conditions)
  - [Actions](#doc-tickets-actions)
  - [Errors and Logs](#doc-tickets-errors)
  - [Examples](#doc-tickets-examples)
- Realtime Connection
  - [Connecting to Realtime](#doc-realtime-connecting)
  - [Sending Realtime Data](#doc-realtime-post)
  - [Realtime Groups](#doc-realtime-group)
  - [WebRTC](#doc-realtime-webrtc)
  - [Notifications](#doc-notification-send-notifications)
- Email Service
  - [Automated Emails](#doc-email-email-templates)
  - [Sending Newsletters](#doc-email-newsletters)
  - [Receiving Inquiries](#doc-email-inquiries)
- Admin Features
  - [Introduction](#doc-admin-intro)
  - [Admin Permissions](#doc-admin-permissions)
  - [Inviting Users](#doc-admin-invite)
  - [Managing Users](#doc-admin-account)
  - [Website Hosting](#doc-hosting-hosting)
  - [Plans and Limits](#doc-introduction-plans)
- Full Examples
  - [Introduction](#doc-full-example-intro)
  - [User Account](#doc-full-example-user-account)
  - [Newsletter](#doc-full-example-newsletter)
  - [Send Inquiry](#doc-full-example-inquiry)
  - [Database](#doc-full-example-database)
  - [Realtime Chat](#doc-full-example-realtime)
  - [RTC Video Chat](#doc-full-example-webrtc)
- [API Reference](#api-reference)
  - [Connection](#doc-api-reference-connection)
  - [Authentication](#doc-api-reference-authentication)
  - [User Account](#doc-api-reference-user)
  - [Database](#doc-api-reference-database)
  - [E-Mail](#doc-api-reference-email)
  - [Realtime](#doc-api-reference-realtime)
  - [API Bridge](#doc-api-reference-api-bridge)
  - [Admin](#doc-api-reference-admin)
  - [Tickets](#doc-api-reference-tickets)
  - [Data Types](#doc-api-reference-data-types)
- [Version History](#doc-versionlog-versions)
- [Deprecated](#doc-deprecated-deprecated)

## Required Startup Checklist

Before implementing features, confirm these values are available:

1. Skapi project ID
2. Any required Secret Key names
3. Any required OpenID logger IDs

If any of these are missing, ask for them first. If the user has no Skapi project yet, they create one at https://www.skapi.com/new-project (the project form on its own page; see [Creating a Project](#doc-introduction-getting-started-creating-a-project)) and read the Project ID off the project's Settings page; neither an AI agent nor the Skapi MCP server can create one. A pasted `<Project ID>` is this documentation's placeholder, not an id: ask for the real one.

## Skapi Initialization

In `index.html`, load and initialize Skapi:

```html
<script src="https://cdn.jsdelivr.net/npm/skapi-js@2.2.5/dist/skapi.js"></script>
<script>
  const skapi = new Skapi("<Project ID>");
</script>
```

- Replace `"<Project ID>"` with the actual project ID provided by the user.
- In SPA projects, install `skapi-js` with npm when that is available, create the instance once in a module, export it, and import it wherever it is used. See [Working on the Codebase](#doc-introduction-codebase).
- In SPA projects, an inline `onsubmit="skapi.login(event)"` attribute only works if the instance is also reachable as `window.skapi`. Prefer the framework's own submit handler calling the imported instance.

## Backend Integration Requirements

- Implement backend logic only through Skapi APIs.
- Validate method parameters and return shapes against the method's entry in the API reference, linked from the [SDK Method Index](#sdk-method-index).
- Most methods that fetch a list resolve to a [DatabaseResponse](#doc-api-reference-data-types-databaseresponse): read the items from `list`, and fetch the next page with `fetchMore`. Check each method's return type, a few resolve to a plain array.

## Third-Party API Integration (Secret Keys)

A secret, such as a third-party API key, must never appear in frontend code. The project owner registers it on the **Secret Keys** page in the Skapi dashboard, and the app refers to it by name. See [Secret Keys](#doc-api-bridge-client-secret-request) and [Forwarding Requests](#doc-api-bridge-forward-request). If the integration must receive webhooks or trigger actions, use Skapi's Ticket system. See [Tickets](#doc-tickets-introduction) for details.

When integrating a third-party API that requires a secret:

1. Ask for the Skapi Secret Key name if it has not been provided.
2. Confirm whether the key is **Locked**:
   - A locked key can be used only by a logged-in user, so the feature requires authentication.
   - An unlocked key can be used by anyone, including signed-out visitors.
3. Use `skapi.forwardRequest(form, options)`, setting `secretName` to the registered key name. Pass the request body as the first argument, or `null` when the request has no body.
4. Put `$CLIENT_SECRET` where the secret belongs: in the `url`, or in any value of `headers`, `params` or `data`, at any depth. Skapi replaces it with the real value on the server, so the secret never reaches the browser. Nothing is replaced inside a `multipart` body. When no `$CLIENT_SECRET` appears anywhere, the SDK leaves `secretName` out and sends the request as written.
5. If the key has **Destinations** configured, it can be sent only to those URLs. Tell the user which destination URL the app calls so they can allow it.
6. If the integration requires webhooks, explain that a Ticket must be registered to receive the request and run any required actions. If the user is unsure how Tickets work, direct them to [Tickets](#doc-tickets-introduction).
7. If the user can use MCP servers, let them know that Secret Keys and Tickets can be registered through the Skapi [MCP server](https://mcp.broadwayinc.computer).

## Third-Party OAuth Integration

When implementing Google/Facebook/GitHub-style login:

1. Inform the user they must configure OAuth on the provider side.
2. Inform the user they must configure an OpenID logger in Skapi.
3. Ask for OpenID logger IDs if missing.
4. Note that provider-specific OAuth flows vary. Exchanging an authorization code for a token needs the provider's client secret, so store it as a Secret Key and make that request with `skapi.forwardRequest()`. See [OpenID Login](#doc-authentication-openid-login).
5. Inform the user HTTPS may be required by the provider for auth to work.
6. If the user can use MCP servers, let them know that Open ID Logger can be registered through the Skapi [MCP server](https://mcp.broadwayinc.computer).

If the user is unsure, direct them to:
https://docs.skapi.com/authentication/openid-login.html

## Features That Need HTTPS

Push notifications and WebRTC only work on a page served over HTTPS. Record encryption needs HTTPS as well, though `localhost` and `127.0.0.1` also count, so local development works. If implementing any of them, explicitly inform the user that the app must be hosted on HTTPS.

## Coding Guidelines

- If a Skapi method accepts `SubmitEvent` and is designed for HTML forms, pass the form `onsubmit` event directly. See [Working with HTML forms](#doc-introduction-working-with-forms).
- Use defensive programming when reading fetched data.
- Always guard against `null` and `undefined`.
- Include practical error handling and user-visible failure states. A failed Skapi call rejects with an error that has a `code` and a `message`.
- When using `postRecord()` or `getRecords()`, follow the naming rules for `table`, `index` and `tags`. See [Creating a Record](#doc-database-create), [Indexing](#doc-database-indexing) and [Tags](#doc-database-tags).

## Static HTML App Rules

### Routing and Navigation

- The entry page must be `index.html`.
- Keep links and form actions compatible with local static usage (`file://`) where possible.
- A form's `action` attribute is where Skapi sends the user after the request succeeds, and the next page reads the result with `skapi.getFormResponse()`.

### Authentication and Access Control

- Initialize Skapi on every page that uses auth or backend calls.
- Check login state on protected pages with `skapi.getProfile()`, which resolves to `null` when nobody is logged in.
- Redirect unauthenticated users away from restricted pages.
- Hiding a page is not access control. What a user may read or change is decided by the record's access settings on the server. See [Access Restrictions](#doc-database-access-restrictions).

## Response Behavior (for the assistant using this prompt)

- Be explicit about assumptions.
- If blocked by missing config, ask concise questions instead of guessing.
- Prefer secure defaults.
- Keep implementations simple, correct, and aligned with Skapi docs.

## SDK Method Index <a id="sdk-method-index"></a>

Every method of `skapi-js`, by reference page, each linking to its entry. The entry has the signature, the parameters and the return shape; read it before writing the call.

### Connection ([page](#doc-api-reference-connection))
[getConnectionInfo()](#doc-api-reference-connection-getconnectioninfo), [mock()](#doc-api-reference-connection-mock), [getFormResponse()](#doc-api-reference-connection-getformresponse)
### Authentication ([page](#doc-api-reference-authentication))
[signup()](#doc-api-reference-authentication-signup), [resendSignupConfirmation()](#doc-api-reference-authentication-resendsignupconfirmation), [login()](#doc-api-reference-authentication-login), [getProfile()](#doc-api-reference-authentication-getprofile), [logout()](#doc-api-reference-authentication-logout), [forgotPassword()](#doc-api-reference-authentication-forgotpassword), [resetPassword()](#doc-api-reference-authentication-resetpassword), [openIdLogin()](#doc-api-reference-authentication-openidlogin)
### User Account ([page](#doc-api-reference-user))
[updateProfile()](#doc-api-reference-user-updateprofile), [changePassword()](#doc-api-reference-user-changepassword), [verifyEmail()](#doc-api-reference-user-verifyemail), [disableAccount()](#doc-api-reference-user-disableaccount), [getUsers()](#doc-api-reference-user-getusers), [recoverAccount()](#doc-api-reference-user-recoveraccount)
### Database ([page](#doc-api-reference-database))
[postRecord()](#doc-api-reference-database-postrecord), [postRecords()](#doc-api-reference-database-postrecords), [getRecords()](#doc-api-reference-database-getrecords), [grantPrivateAccess()](#doc-api-reference-database-grantprivateaccess), [removePrivateAccess()](#doc-api-reference-database-removeprivateaccess), [listPrivateRecordAccess()](#doc-api-reference-database-listprivaterecordaccess), [deleteRecords()](#doc-api-reference-database-deleterecords), [getTables()](#doc-api-reference-database-gettables), [getIndex()](#doc-api-reference-database-getindex), [getTags()](#doc-api-reference-database-gettags), [getUniqueId()](#doc-api-reference-database-getuniqueid), [subscribe()](#doc-api-reference-database-subscribe), [unsubscribe()](#doc-api-reference-database-unsubscribe), [blockSubscriber()](#doc-api-reference-database-blocksubscriber), [unblockSubscriber()](#doc-api-reference-database-unblocksubscriber), [getSubscriptions()](#doc-api-reference-database-getsubscriptions), [getFeed()](#doc-api-reference-database-getfeed), [getFile()](#doc-api-reference-database-getfile), [uploadFiles()](#doc-api-reference-database-uploadfiles), [deleteFiles()](#doc-api-reference-database-deletefiles)
### E-Mail ([page](#doc-api-reference-email))
[subscribeNewsletter()](#doc-api-reference-email-subscribenewsletter), [unsubscribeNewsletter()](#doc-api-reference-email-unsubscribenewsletter), [getNewsletterSubscription()](#doc-api-reference-email-getnewslettersubscription), [getNewsletters()](#doc-api-reference-email-getnewsletters), [newsletterGroupEndpoint()](#doc-api-reference-email-newslettergroupendpoint), [registerNewsletterGroup()](#doc-api-reference-email-registernewslettergroup), [deleteNewsletterGroup()](#doc-api-reference-email-deletenewslettergroup), [sendInquiry()](#doc-api-reference-email-sendinquiry)
### Realtime ([page](#doc-api-reference-realtime))
[connectRealtime()](#doc-api-reference-realtime-connectrealtime), [postRealtime()](#doc-api-reference-realtime-postrealtime), [joinRealtime()](#doc-api-reference-realtime-joinrealtime), [getRealtimeGroups()](#doc-api-reference-realtime-getrealtimegroups), [getRealtimeUsers()](#doc-api-reference-realtime-getrealtimeusers), [closeRealtime()](#doc-api-reference-realtime-closerealtime), [connectRTC()](#doc-api-reference-realtime-connectrtc), [vapidPublicKey()](#doc-api-reference-realtime-vapidpublickey), [subscribeNotification()](#doc-api-reference-realtime-subscribenotification), [unsubscribeNotification()](#doc-api-reference-realtime-unsubscribenotification), [pushNotification()](#doc-api-reference-realtime-pushnotification)
### API Bridge ([page](#doc-api-reference-api-bridge))
[forwardRequest()](#doc-api-reference-api-bridge-forwardrequest), [forwardRequestStream()](#doc-api-reference-api-bridge-forwardrequeststream), [forwardRequestFinalize()](#doc-api-reference-api-bridge-forwardrequestfinalize), [forwardRequestHistory()](#doc-api-reference-api-bridge-forwardrequesthistory), [cancelForwardRequest()](#doc-api-reference-api-bridge-cancelforwardrequest), [stopForwardRequestPolling()](#doc-api-reference-api-bridge-stopforwardrequestpolling), [isPollStopped()](#doc-api-reference-api-bridge-ispollstopped), [forwardRequestQueueCount()](#doc-api-reference-api-bridge-forwardrequestqueuecount), [secureRequest()](#doc-api-reference-api-bridge-securerequest)
### Admin ([page](#doc-api-reference-admin))
[inviteUser()](#doc-api-reference-admin-inviteuser), [resendInvitation()](#doc-api-reference-admin-resendinvitation), [getInvitations()](#doc-api-reference-admin-getinvitations), [cancelInvitation()](#doc-api-reference-admin-cancelinvitation), [grantAccess()](#doc-api-reference-admin-grantaccess), [createAccount()](#doc-api-reference-admin-createaccount), [updateUserAttributes()](#doc-api-reference-admin-updateuserattributes), [deleteAccount()](#doc-api-reference-admin-deleteaccount), [blockAccount()](#doc-api-reference-admin-blockaccount), [unblockAccount()](#doc-api-reference-admin-unblockaccount)
### Tickets ([page](#doc-api-reference-tickets))
[consumeTicket()](#doc-api-reference-tickets-consumeticket), [getTickets()](#doc-api-reference-tickets-gettickets), [clearTicketLog()](#doc-api-reference-tickets-clearticketlog), [getConsumedTickets()](#doc-api-reference-tickets-getconsumedtickets), [TicketCondition()](#doc-api-reference-tickets-ticketcondition), [TicketConditionRow()](#doc-api-reference-tickets-ticketconditionrow), [TicketAction()](#doc-api-reference-tickets-ticketaction), [TicketError()](#doc-api-reference-tickets-ticketerror)
### Data Types ([page](#doc-api-reference-data-types))
`BinaryFile`, `Condition`, `Connection`, `ConnectionInfo`, `DatabaseResponse`, `DelRecordQuery`, `FetchOptions`, `FileInfo`, `Form`, `ForwardRequestStreamOptions`, `GetRecordQuery`, `Index`, `Newsletter`, `ProgressCallback`, `PostRecordsItem`, `PostRecordsResult`, `UploadFilesResult`, `FileUploadError`, `RealtimeCallback`, `RecordData`, `RequestHistory`, `RTCConnector`, `RTCConnectorParams`, `RTCEvent`, `RTCReceiverParams`, `RTCResolved`, `StreamChunk`, `StreamPollResult`, `Subscription`, `Table`, `Tag`, `UniqueId`, `UserAttributes`, `UserProfile`, `UserPublic`, `WebSocketMessage`

# Getting Started <a id="doc-introduction-getting-started"></a>

Skapi is a serverless backend API for web applications.

To build a full-stack web application, create a project and connect it to your HTML or JavaScript code. If an AI coding agent writes that code for you, set the agent up right after creating the project; it takes care of the rest.

## Creating a Project <a id="doc-introduction-getting-started-creating-a-project"></a>

1. Sign up at [skapi.com](https://www.skapi.com) if you do not have an account yet.
2. Log in and [Create a new Project](https://www.skapi.com/new-project). If you are not signed in, the page sends you to log in first and brings you back to the project form once you are in.
3. Give your project a name, an optional description and a region, and click **Continue**.
4. Choose a plan and proceed.

An account that already has projects can also click **+ New Project** on its project list.

Your new project opens on its `Settings` page. The **Information** card holds the **Project ID**, the value every example in this documentation calls `<Project ID>`. Click it to copy it.

![The Settings page of a Skapi project: the Project card with its name and description, the Information card with the Project ID, Owner ID, Service ID, Region, creation date and a link to the documentation, and the Application Examples card below](https://docs.skapi.com/screenshots/settings-page.webp)

*The Settings page of a project. The Project ID is on the Information card.*

The same page has two shortcuts worth knowing from day one: **Application Examples** opens ready-made HTML pages that already run against your project (see [Full Examples](#doc-full-example-intro)), and **For AI Agents** downloads the base prompt for your AI coding tool (see [Setting up AI Agents](#doc-introduction-getting-started-setting-up-ai-agents) below).

:::tip For BunnyQuery users
[BunnyQuery](https://www.bunnyquery.com) projects are fully compatible with Skapi. Your project will appear in both your BunnyQuery and Skapi project lists.
:::

If you write the code yourself, continue with [Working on the Codebase](#doc-introduction-codebase). If an AI coding agent writes it, set the agent up first.

## Setting up AI Agents <a id="doc-introduction-getting-started-setting-up-ai-agents"></a>

Skapi works smoothly with AI coding tools, such as Claude Code, Codex CLI, Gemini CLI, GitHub Copilot, Cursor and Windsurf.

There are two ways an AI agent can help you build on Skapi, and they work well together:

- A **base prompt** in your project teaches the agent how to write code against the Skapi API.
- The **Skapi MCP server** lets the agent work on your project itself: your database, your settings, your tickets, your secret keys and more, signed in as you.

### Download the base prompt for your tool

Every tool reads the same file under its own name. Pick your tool, and the file downloads with the name it expects. Put it in the root of your project.

- Claude Code: save it as `CLAUDE.md`
- Codex CLI / OpenCode: save it as `AGENTS.md`
- Gemini CLI: save it as `GEMINI.md`
- GitHub Copilot: save it as `copilot-instructions.md`
- Cursor: save it as `.cursorrules`
- Windsurf: save it as `.windsurfrules`

Browsers drop the leading dot from a download name, so `.cursorrules` and `.windsurfrules` are saved as `cursorrules` and `windsurfrules`. Add the dot back after saving.

You can also download the plain <a href="/SKAPI.md" download="SKAPI.md">SKAPI.md</a> and rename it yourself.

The file is small on purpose, about 25 KB: the rules, a map of this documentation and an index of every SDK method, each linking to its page. The agent reads a page when it builds the feature, from `https://docs.skapi.com/md/` (every page of this site is there as raw markdown, at the same path), so a session carries only the pages it needs. If your agent cannot fetch urls, allow it access to `docs.skapi.com`, or connect the [Skapi MCP server](#doc-introduction-getting-started-connecting-the-skapi-mcp-server): its `readDocs` tool returns the same pages. A tool that indexes one document can take the whole documentation as a single file, [SKAPI-full.md](https://docs.skapi.com/SKAPI-full.md); it is too large to paste into a chat.

Your project's `Settings` page at [skapi.com](https://www.skapi.com) offers the same downloads under **For AI Agents**, next to the opening prompt with your project ID already filled in. Click **[Download]** on the row of your tool, and click the prompt to copy it.

![The For AI Agents card on a project's Settings page: the opening prompt with the project ID filled in, and one Download row per tool, from Claude Code to Windsurf](https://docs.skapi.com/screenshots/settings-agents.webp)

*The For AI Agents card on the Settings page. The file is the same for every tool; only its name differs.*

### Start writing prompts

When you run your AI coding agent, start with a prompt like this:

```text
My Skapi project ID is: "<Project ID>".
Build me a [describe what you want].
```

Replace the placeholder `<Project ID>` with your actual project ID before running your prompt. The Project ID is on the **Information** card of your project's `Settings` page (see [Creating a Project](#doc-introduction-getting-started-creating-a-project)).

## Connecting the Skapi MCP server <a id="doc-introduction-getting-started-connecting-the-skapi-mcp-server"></a>

**Connecting the MCP server is optional.** The base prompt above is all an agent needs to write your application's code, and your application never talks to the MCP server.

What the server adds is the **administrative side** of a project, the work you would otherwise do by hand on the Skapi dashboard: it connects the agent to your Skapi projects, signed in as you, so it can register the tickets and secret keys an integration needs, set up OpenID sign-in, change the project's settings, publish the website, read and fix records, and manage the project's users, from invitations to access groups. Each of those becomes a sentence in a prompt instead of a form. Skip this section if you only want help writing code, and come back when a feature needs something set up on the project.

1. Add this MCP endpoint to your AI platform:

   **https://mcp.broadwayinc.computer**

2. Choose OAuth. No client ID or client secret is needed.

3. Sign in with your Skapi account when the platform asks. The server answers nothing until you have signed in, so a platform that lists it as needing authentication is waiting for that sign-in; see [Signing in](#doc-introduction-getting-started-signing-in).

Any platform that speaks MCP works. The endpoint is the same everywhere; only the place you put it differs.

### Coding agents

Each of these opens your browser to sign you in with your Skapi account the first time the agent uses the server. Leave any client ID or client secret field empty.

::: code-group

```sh [Claude Code]
claude mcp add --transport http skapi https://mcp.broadwayinc.computer
```

```sh [Codex CLI]
codex mcp add skapi --url https://mcp.broadwayinc.computer
codex mcp login skapi
```

```sh [Gemini CLI]
gemini mcp add --transport http skapi https://mcp.broadwayinc.computer
```

```json [Cursor]
{
  "mcpServers": {
    "skapi": {
      "url": "https://mcp.broadwayinc.computer"
    }
  }
}
```

```json [VS Code / Copilot]
{
  "servers": {
    "skapi": {
      "type": "http",
      "url": "https://mcp.broadwayinc.computer"
    }
  }
}
```

```json [Windsurf]
{
  "mcpServers": {
    "skapi": {
      "serverUrl": "https://mcp.broadwayinc.computer"
    }
  }
}
```

:::

| Platform | Where it goes | Signing in |
| --- | --- | --- |
| Claude Code | The command above. Add `--scope user` to have the server in every project. | `claude mcp list` shows the server as **Needs authentication** until you sign in. Run `claude mcp login skapi`, or `/mcp` in a session, pick **skapi** and choose **Authenticate**. |
| Codex CLI | The command above writes `[mcp_servers.skapi]` with `url` into `~/.codex/config.toml`. | `codex mcp login skapi` |
| Gemini CLI | The command above writes `httpUrl` into `.gemini/settings.json`. Add `-s user` to have the server in every project. | Automatic. `/mcp auth skapi` in a session starts it again. |
| Cursor | `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for every project. The Customize page manages the same list. | Prompted in the browser |
| VS Code with GitHub Copilot | `.vscode/mcp.json`, or Command Palette, **MCP: Add Server**. | Prompted in the browser on first connection |
| Windsurf, now Devin Desktop | `~/.config/devin/mcp_config.json`, opened from the MCPs entry of the Cascade panel's menu. The Devin CLI also takes `devin mcp add skapi https://mcp.broadwayinc.computer`. | Prompted in the browser, or `devin mcp login skapi` |

### Chat platforms on the web

A web platform has no command line, so the server is added in its settings instead, usually on a page called Connectors, Integrations, Apps or MCP servers. Look for an option to add a custom or remote server, paste the endpoint, leave the client ID and secret empty, and sign in when it asks.

- **Claude** (claude.ai, the desktop app and Cowork): **Customize > Connectors**, then **Add custom connector**. Paste the endpoint and click **Add**. On Team and Enterprise plans an organization owner adds it under **Organization settings > Connectors**, and every member clicks **Connect**.
- **ChatGPT**: turn on **Developer mode** under **Settings > Security and login**, open **chatgpt.com/plugins**, click **+**, give it a name and paste the endpoint as the connection. In a chat, choose **Developer mode** from the **+** menu and pick it. Available on the paid plans.

Other platforms follow the same pattern. Zed, JetBrains AI Assistant, Cline, Kiro and Amp all take a remote MCP server by URL from their settings page or their config file. Wherever a platform asks for a transport, choose **Streamable HTTP**; wherever it asks how to authenticate, choose **OAuth**.

Then tell the agent which project to work on:

```text
Use my Skapi project "<Project ID>".
```

You can also ask it to list your projects and pick one. If you have no project yet, the agent will tell you so and give you the link to the project form on [skapi.com](https://www.skapi.com/new-project); the MCP server does not create projects.

### Signing in <a id="doc-introduction-getting-started-signing-in"></a>

The server signs you in with OAuth, using your Skapi account. Your platform registers itself with the server and proves each sign-in with PKCE, so there is no client ID or client secret to enter anywhere.

- **Nothing works before you sign in.** The server refuses every request that carries no sign-in, the first connection included. That refusal is what makes a platform open the sign-in page, and until you finish it the platform shows the server as needing authentication and the agent sees none of its tools.
- **The sign-in renews itself.** The platform refreshes it in the background while you use it. If the agent has not used the server for about 30 days, the platform asks you to sign in again.
- **The agent acts as you.** It reaches only the projects and data your account can reach; see [What the MCP server can do for you](#doc-introduction-getting-started-what-the-mcp-server-can-do-for-you).

If a tool answers **Unauthorized**, or the platform reports an incompatible authorization server, it is holding a connection made before the current sign-in. Sign in again: in Claude Code run `claude mcp login skapi`, elsewhere use the platform's reconnect or authenticate option, or remove the server and add it again.

### Signing in without a browser

The sign-in above opens a browser. On a machine that has none, over SSH, in a container or in continuous integration, or on a platform that only takes a fixed header, there are two ways in.

**A command-line sign-in that prints a link.** Claude Code and Codex CLI can sign in without opening a browser themselves:

```sh
claude mcp login skapi --no-browser
codex mcp login skapi
```

Each prints a URL. Open it on any device where you can sign in to Skapi, finish, and paste the address it returns back into the terminal. Claude Code needs a real terminal for this, so over SSH connect with `ssh -t`.

**An access token you set as a header.** For anything else, including a script, a scheduled job, or a platform that offers only a header field, use an access token. The MCP server has a page that issues them:

**https://mcp.broadwayinc.computer/connect**

Open it in any browser (`https://www.skapi.com/mcp` is a shortcut to the same page). It runs the same Skapi sign-in a platform would open, then lets you create tokens. The steps:

1. Open the page and sign in with your Skapi account.
2. Click **Create token**, and optionally label it so you can tell your machines apart.
3. Copy the token. It starts with `skmcp_` and is shown once, right then, never again.
4. Give it to your platform as the `Authorization: Bearer` header. For Claude Code:

   ```sh
   claude mcp add --transport http skapi https://mcp.broadwayinc.computer \
     --header "Authorization: Bearer skmcp_YOUR_TOKEN"
   ```

   In a config file the token goes in a `headers` entry (`"Authorization": "Bearer skmcp_YOUR_TOKEN"`) on the server, or, for Codex CLI, in `bearer_token_env_var`. The `/connect` page prints the whole entry for Claude Code, Codex CLI, Gemini CLI, Cursor, VS Code and Windsurf.

The token acts as you, with everything your account can reach, for as long as that sign-in lasts or until you revoke it. Return to the same page any time to see your tokens and revoke one. A platform set up with a token never opens the sign-in page; when you revoke the token it reports the server as unauthorized until you give it a new one. Keep the token as secret as a password.

### What the MCP server can do for you <a id="doc-introduction-getting-started-what-the-mcp-server-can-do-for-you"></a>

Everything below runs with the permissions of the signed-in account, so the agent can only reach what you can, and every change it makes is one you could have made on the dashboard yourself. Your Skapi account, the project owner, gets all of it. An admin of the project (a user in access group `90` ~ `99`) who signs in through the server gets the user management tools, under the same rules the app applies to admins; see [Admin Permissions](#doc-admin-permissions).

- **Users.** Search the project's users, and manage their accounts the way the dashboard's Users page does: send, resend, cancel and list invitations, create accounts, grant access groups, update profiles, and block, unblock or delete accounts. The agent asks before it blocks or deletes. An invitation is an e-mail and counts as one of the project's monthly e-mail sends, resends included; see [Inviting Users](#doc-admin-invite) and [Managing Users](#doc-admin-account).
- **Tickets.** Register the endpoints that accept a webhook from your payment provider, a link, or a signed-in request, with their conditions, placeholders, actions and error handling, and read their logs afterwards. See [Registering a Ticket](#doc-tickets-introduction).
- **Secret keys.** Store the API keys of the third-party services your project calls, so your front end never holds them, and limit the URLs each key may be sent to. See [Secret Keys](#doc-api-bridge-client-secret-request).
- **OpenID loggers.** Set up sign-in through an external identity provider, including the profile request, the header it needs and a condition on who may sign in, such as one e-mail domain. See [OpenID Login](#doc-authentication-openid-login).
- **Database.** Read, search, create, update and delete records. List tables, tags, indexes and unique IDs, and find any of them by how its name starts or ends. It can also read the files attached to a record and give you a download link for them, but it cannot attach a file to a record or remove one: a record sent with files to attach or remove is refused and not saved, while the other records in the same request are still saved. Attach and remove a record's files in the Skapi dashboard (Database page) or with the SDK. Deleting a record still deletes the files attached to it, as it does everywhere else.
- **Project settings.** Turn sign-ups or inquiries on and off, restrict anonymous writes, and change the other settings of the project.
- **Hosting.** Register a web address, then upload, edit and delete the files of your project's website.
- **E-mails** (project owner only). Compose an automated e-mail template, of any type, or a newsletter, and upload it as e-mail HTML or as plain text, with the same checks the dashboard makes (e-mail HTML, the type's required placeholders, the size). An uploaded template is added to its list and is never put in use by the agent; an uploaded newsletter is saved without sending. The owner chooses the template, or sends the newsletter, on the dashboard, or a ticket's Send e-mail or Send newsletter action names it. See [Uploading a template](#doc-email-email-templates-uploading-a-template) and [Uploading a newsletter](#doc-email-newsletters-uploading-a-newsletter).

The ticket, secret key and OpenID logger pages of this documentation each show the dashboard forms these tools fill in for you. A few prompts to start with:

```text
Save this API key as a secret named "weather_api", usable only for https://api.weather.example: <your key>.
Create a ticket that saves each incoming webhook payload to the "orders" table, readable by admins only.
Register a Google OpenID logger for this project, and let in only addresses that end with "@mycompany.com".
Show me what my "orders" ticket received today.
Make every record in the "posts" table private.
Write a welcome e-mail for my shop, in my brand's tone, and upload it as the welcome template.
Compose this month's newsletter about the new autumn menu and save it to the public list without sending it.
```

The agent picks the tools, asks when something is missing, and reports what it changed.

When a project is at a limit of its plan (users, monthly e-mail sends, newsletter subscribers, storage or bandwidth), the agent is told which limit, and is given the link to the project's plan page, where the project owner upgrades. The server itself never changes a plan, and never creates or deletes a project; see [Plans and Limits](#doc-introduction-plans).

With the project created and the agent set up, the rest happens in your code: continue with [Working on the Codebase](#doc-introduction-codebase).

# Working on the Codebase <a id="doc-introduction-codebase"></a>

Whether you write the code yourself or an AI coding agent writes it for you, this is the part that lives in your codebase: load the Skapi library, create one instance connected to your project, and build on it. Every other page of this documentation calls methods on the `skapi` instance created here.

You need the **Project ID** of your project. It is on the project's `Settings` page, under **Information** (see [Creating a Project](#doc-introduction-getting-started-creating-a-project)). An AI agent takes it from your opening prompt (see [Setting up AI Agents](#doc-introduction-getting-started-setting-up-ai-agents)).

## Initializing Skapi

### For HTML Projects

For a vanilla HTML project, load Skapi with a script tag and initialize the library as shown below.
Add the Skapi script and initialize the `Skapi` class in the `<head>` of each page that uses Skapi.
Use your Skapi project's exact Project ID when initializing the library.

```html
<!-- index.html -->
<!DOCTYPE html>
<script src="https://cdn.jsdelivr.net/npm/skapi-js@2.2.5/dist/skapi.js"></script>
<script>
    // Replace "<Project ID>" with your actual project ID
    const skapi = new Skapi("<Project ID>");
</script>
```

:::warning Replace the placeholder
`<Project ID>` is a placeholder, including the angle brackets.

Replace the entire placeholder with your actual Project ID from the Skapi dashboard. For example:

```js
const skapi = new Skapi("abc123defg456hij78-9klmnop012qrstu345vwxyz");
```

Every example in this documentation uses the same `"<Project ID>"` placeholder. Replace it wherever you copy an example.
:::

The Project ID uniquely identifies your Skapi project.

### For SPA Projects

To use Skapi in a single-page application (SPA) such as Vue, React, or Angular, install `skapi-js` with npm.

```sh
$ npm i skapi-js@2.2.5
```

Then import the library in your main JavaScript file:

```javascript
// main.js
import { Skapi } from "skapi-js";
const skapi = new Skapi("<Project ID>");

export { skapi }

// You can now import skapi from anywhere in your project.
```

### For TypeScript Projects

Skapi includes TypeScript support, so you can import the `Skapi` class and related types.

```typescript
import { Skapi } from 'skapi-js';
import type { RecordData, DatabaseResponse } from 'skapi-js';

const skapi = new Skapi("<Project ID>");
let databaseRecords: DatabaseResponse<RecordData>;
```

For lists of types, refer [Data Types](#doc-api-reference-data-types).

### Node.js (CommonJS)

To use Skapi in Node.js (CommonJS), import the library as shown below:

```javascript
const { Skapi } = require('skapi-js');
const skapi = new Skapi("<Project ID>");
```

### Node.js (ESM)

```javascript
import { Skapi } from 'skapi-js';
const skapi = new Skapi("<Project ID>");
```

> **Note:** When running Skapi in Node.js, browser-specific features such as WebSocket, WebRTC, and Notifications are unavailable.

## Get Connection Information

After your client connects to Skapi, call [`getConnectionInfo()`](#doc-api-reference-connection-getconnectioninfo) to retrieve information about the connection.

::: code-group
```html [HTML]
<!-- index.html -->
<!DOCTYPE html>
<script src="https://cdn.jsdelivr.net/npm/skapi-js@2.2.5/dist/skapi.js"></script>
<script>
    const skapi = new Skapi("<Project ID>");
</script>
<script>
skapi.getConnectionInfo().then(info => {
    console.log(info);
    /*
    Returns:
    {
        project_id: "Public ID of the connected project",
        user_ip: "Connected user's IP address",
        user_agent: "Connected user's user agent",
        user_location: "Connected user's country code",
        service_name: "Your project name",
        service_description: "Your project description",
        version: 'x.x.x', // Skapi library version
        ai_agent: "AI agent instructions configured for the project",
        conf: {
            freeze_database: boolean, // The database is read-only
            prevent_signup: boolean, // Sign-up is blocked
            prevent_inquiry: boolean, // Inquiries are blocked
            prevent_anonymous: boolean // Anonymous users cannot write to the database
        }
    }
    */
   window.alert(`Connected to ${info.service_name}`);
});
</script>
```

```javascript [SPA]
import { skapi } from '../location/of/your/main.js';
skapi.getConnectionInfo().then(info => {
    console.log(info);
    /*
    Returns:
    {
        project_id: "Public ID of the connected project",
        user_ip: "Connected user's IP address",
        user_agent: "Connected user's user agent",
        user_location: "Connected user's country code",
        service_name: "Your project name",
        service_description: "Your project description",
        version: 'x.x.x', // Skapi library version
        ai_agent: "AI agent instructions configured for the project",
        conf: {
            freeze_database: boolean, // The database is read-only
            prevent_signup: boolean, // Sign-up is blocked
            prevent_inquiry: boolean, // Inquiries are blocked
            prevent_anonymous: boolean // Anonymous users cannot write to the database
        }
    }
    */
   window.alert(`Connected to ${info.service_name}`);
});
```
:::

## Advanced Settings <a id="doc-introduction-codebase-advanced-settings"></a>

You can pass additional options when initializing the `Skapi` class.

### new Skapi(...)

```ts
class Skapi {
  constructor(
    project_id: string, // Skapi project ID.
    options?: {
        autoLogin?: boolean;        // Default: true
        refetchServiceInfo?: boolean;// Default: false. Bypasses cached project information and fetches fresh information on every load.
        requestBatchSize?: number;  // Default: 50. Maximum number of requests processed per batch.
        encryption?: boolean | { // Default: false. Encrypts private record data in the browser. Can only be set during initialization. HTTPS is required.
            iterations?: number;             // Default: 600000. PBKDF2 cost. Minimum: 100000.
            minPasswordLength?: number;      // Default: 0 (off). Rejects passwords shorter than this value.
            persistDevice?: boolean;         // Default: true. Keeps encryption unlocked across page reloads on that device.
            recovery?: 'code' | 'none';      // Default: 'code'. Issues a one-time recovery code.
            trustPolicy?: 'tofu' | 'strict'; // Default: 'tofu'. Controls how a recipient's public key is trusted when sharing.
            withheld?: 'null' | 'sentinel';  // Default: 'null'. Specifies what `data` contains when a record cannot be decrypted.
            table?: string;                  // Default: '__skapi__keyring'. Reserved table for the user's keyring.
        };
        eventListener?: {
            onLogin?: (user: UserProfile | null) => void; // Fires on initial page load (after Skapi initializes), on login/logout, and when a session expires. The callback receives a UserProfile object if the user is logged in; otherwise, it receives null.
            onUserUpdate?: (user: UserProfile | null) => void; // Fires on initial page load (after Skapi initializes), on login/logout, when a session expires, and when the user's profile is updated. The callback receives a UserProfile object if the user is logged in; otherwise, it receives null.
            onBatchProcess?: (process: {
                batchToProcess: number; // Number of batches remaining
                itemsToProcess: number; // Number of items remaining
                completed: any[]; // Results completed in this batch
            }) => void;
        }
    }) {
    ...
  }
  ...
}
```

Options overview:

- `autoLogin` (boolean, default: true)
    - Automatically restores the user's session when the page loads.
    - See: [Auto Login](#doc-authentication-login-logout-auto-login)

- `requestBatchSize` (number, default: 50)
    - Maximum number of requests processed in each batch.

- `encryption` (boolean | object, default: false)
    - Encrypts the `data` of records saved with `access_group: 'private'` in the browser before the data reaches the database. The contents of files attached to those records are encrypted as well.
    - **This option takes effect only when it is set during `Skapi` initialization.** There is no method for enabling encryption later. If this option is omitted, the instance saves every record's `data` as plain text for its entire lifetime.
    - Enabling encryption later does not encrypt records that were already saved as plain text.
    - Setting it to `true` uses the default settings. Pass an object to customize them:
        - `iterations` (number, default: 600000): PBKDF2 cost for deriving the key from the user's password. Minimum: 100000.
        - `minPasswordLength` (number, default: 0, off): rejects passwords shorter than this value. Because encryption strength is limited by the user's password, setting a minimum is recommended.
        - `persistDevice` (boolean, default: true): keeps encryption unlocked across page reloads on that device.
        - `recovery` ('code' | 'none', default: 'code'): issues a one-time recovery code. This is the only way to regain access to the data after a password reset.
        - `trustPolicy` ('tofu' | 'strict', default: 'tofu'): controls how a recipient's public key is trusted when a record is shared. `'strict'` requires the key to be pinned before the first share.
        - `withheld` ('null' | 'sentinel', default: 'null'): specifies what `data` contains when a record cannot be decrypted. `'sentinel'` returns a placeholder object with the reason instead of `null`.
        - `table` (string, default: '__skapi__keyring'): the reserved table for the user's keyring.
    - See: [Encrypting Private Record Data](#doc-database-encryption)

- `eventListener` (callbacks for key events)
    - `onLogin(user: UserProfile | null)`
        - Fires after Skapi initializes on the initial page load, after login or logout, and when a session expires. The callback receives a `UserProfile` object when the user is logged in; otherwise, it receives `null`.
        - See: [Listening to Login/Logout Status](#doc-authentication-login-logout-listening-to-login-logout-status)

    - `onUserUpdate(user: UserProfile | null)`
        - Fires after login, before logout, when a session expires, and when the user's profile is updated. The callback receives a `UserProfile` object when the user is logged in; otherwise, it receives `null`.
        - See: [Listening to User Profile Updates](#doc-authentication-user-info-listening-to-user-s-profile-updates)

    - `onBatchProcess(process)`
        - Fires each time Skapi finishes processing a request batch.

Type reference: See [UserProfile](#doc-api-reference-data-types-userprofile).

# Working with HTML Forms <a id="doc-introduction-working-with-forms"></a>

Skapi can handle HTML `onsubmit` events directly by passing the `SubmitEvent` as the first argument to Skapi methods.

This makes form handling much simpler. You can process and send form data without manually reading each field.

In this example, we use [`skapi.mock()`](#doc-api-reference-connection-mock) to send a request and receive a response.

Here is an example of using a `<form>` with Skapi:

```html
<form onsubmit="skapi.mock(event).then(r => alert(r.hello)).catch(err => alert(err.message))">
  <input name="hello" placeholder="Say Hi">
  <input type="submit" value="Mock">
</form>
```

This is equivalent to writing the following code manually:

```html
<input id="hello" placeholder="Say Hi">
<button onclick="runMock()">Mock</button>
<script>
  async function runMock() {
    let helloMsg = document.getElementById("hello").value;

    try {
      let r = await skapi.mock({ hello: helloMsg });
      alert(r.hello);
    }
    catch (err) {
      alert(err.message);
    }
  }
</script>
```

When a form submit event is passed as the first argument to a Skapi method, Skapi automatically converts form input values into JavaScript key-value data. The input `name` becomes the key, and the value is resolved based on the input type.

## Nested Values and Arrays

You can create nested values and arrays by using the `[]` syntax in the `name` attribute.
The resolved data structure will depend on the input type.

If the key inside `[]` is a number, Skapi resolves the value as an array.

```html
<form onsubmit="skapi.mock(event).then(r => console.log(r))">
    <input name="user[name]" placeholder="Name">
    <input name="user[age]" type="number" placeholder="Age">
    Skills:
    <input name="user[skills]" type="radio" value="JavaScript"> JavaScript
    <input name="user[skills]" type="radio" value="Python"> Python
    IDE:
    <input name="user[ide]" type="checkbox" value="Vim">Vim
    <input name="user[ide]" type="checkbox" value="Emacs">Emacs
    Check:
    <input name="check[]" type="checkbox">
    <input name="check[]" type="checkbox">

    <input type="submit" value='Mock'>
</form>
```

The above example will resolve to the following structure:

```ts
{
  user: {
    name: string,
    age?: number,
    skills?: "JavaScript" | "Python",
    ide?: "Vim" | "Emacs" | <"Vim" | "Emacs">[]
  },
  check: boolean[]
}
```

Skapi automatically structures user input based on input type and the `name` attribute.

Number inputs are resolved as numbers, radio inputs are resolved as the selected value, and checkbox inputs are resolved as boolean values or strings if a value is specified.

When multiple inputs share the same name without `[]`, Skapi attempts to convert the values into an array.

## Using Input Elements, Textarea, and Select Elements

Skapi also works with individual input elements, including text, number, radio, checkbox, textarea, and select elements.

```html
<input name="my_message" id="message_input">
<button onclick="skapi.mock(document.getElementById('message_input'))
  .then(r => alert(r.my_message))">Mock</button>
```

In this example, we use the `id` attribute to reference the input element and pass it directly to a Skapi method.

This approach is useful when you want to handle a single field instead of a full form.

## Using the `action` Attribute in the `<form>` Element

When you set a URL in a form's `action` attribute, the user is redirected to that page after a successful request.

On the destination page, use [`skapi.getFormResponse()`](#doc-api-reference-connection-getformresponse) to retrieve the resolved data from the previous page.

The example below shows how to submit a form in `index.html` and read the resolved data in `welcome.html`.

For this example, create these two HTML files in the same directory.

```
.
├─ index.html
└─ welcome.html
```

:::code-group
```html [index.html]
<!DOCTYPE html>
<script src="https://cdn.jsdelivr.net/npm/skapi-js@2.2.5/dist/skapi.js"></script>
<script>
    // Replace 'project_id' with your project ID from your Skapi dashboard.
    const skapi = new Skapi("<Project ID>");
</script>

<form onsubmit="skapi.mock(event)" action="welcome.html">
  <input name="name" placeholder="What is your name?">
  <input name="msg" placeholder="Write message">
  <input type="submit">
</form>

```

```html [welcome.html]
<!DOCTYPE html>
<script src="https://cdn.jsdelivr.net/npm/skapi-js@2.2.5/dist/skapi.js"></script>

<h1>Welcome <span id='your_name'></span></h1>
<p>Your message: <span id='message'></span></p>

<script>
    // Replace 'project_id' with your project ID from your Skapi dashboard.
    const skapi = new Skapi("<Project ID>");

    skapi.getFormResponse()
      .then((r) => {
        // Resolved data from skapi.mock()
        your_name.innerText = r.name;
        message.innerText = r.msg;
      });
</script>
```
:::

::: tip
When building a static website, you can use the `action` attribute to redirect users to a new page after a successful request. Each page should have the Skapi library imported and initialized.

In a single-page application, redirecting to another page is often unnecessary.
:::

# Signing Up Users <a id="doc-authentication-create-account"></a>

To let users create a new account in your project, you can use the [`signup()`](#doc-api-reference-authentication-signup) method.

### Example: Signing Up Users

::: code-group

```html [Form]
<form action='login.html' onsubmit="skapi.signup(event).catch(err=>alert(err.message))">
    <h2>Sign-Up</h2>
    <hr>
    <label>
        E-Mail
        <input type="email" name="email" placeholder="user@email.com" required>
    </label>
    <label>
        Password
        <input type="password" name="password" placeholder="Your password" required>
    </label>
    <label>
        Name
        <input name="name" placeholder="Your name">
    </label>
    <input type="submit" value="Sign-Up">
</form>
```

```js [JS]
let parameters = {
  email: "user@email.com",
  password: "password", // Password must be between 6 and 60 characters.
  name: "User's name"
};

skapi.signup(parameters)
  .then(res => window.location.href = 'login.html')
  .catch(err => window.alert(err.message));
```

:::

The example above shows how to let users create their account in your project.
Once the user signup is successful, the user will be redirected to the login page.
The first argument takes the user's input (email, password, name) that will be used for signup.

::: warning
- If the user have not logged in to your project after account creation,
they will **NOT** appear on your user list in Skapi's admin page.

- If 7 days have passed since the account creation, and the user still have not logged in to your project,
user's signup will be automatically invalidated.
:::

## Login after Signup

The second argument takes additional options when creating an account.
You can also automatically login the user right after successful signup by setting `options.login` to `true` in options argument.
With `options.email_subscription` the user is subscribed to a newsletter once they confirm their e-mail: `0` or `'public'` for the public newsletter, `1` or `'authorized'` for Service Email, or the name of a named newsletter group. It requires `signup_confirmation`. See [Subscribing at signup](#doc-email-newsletters-subscribing-at-signup).

::: code-group

```html [Form]
<form onsubmit="skapi.signup(event, { login: true }).then(u=>alert('Hello ' + u.name))">
    <input type="email" name="email" placeholder="E-Mail" required>
    <input type="password" name="password" placeholder="Password" required>
    <input name="name" placeholder="Your name">
    <input type="submit" value="Create Account">
</form>
```

```js [JS]
let parameters = {
  email: "user@email.com",
  password: "password", // Password must be between 6 and 60 characters.
  name: "User's name"
};

let options = {
  login: true // If set to true, users will be automatically logged in after signup.
};

skapi.signup(parameters, options)
  .then(u => alert('Hello ' + u.name));
```

:::

When the `options.login` is set to `true`, the method will return the [UserProfile](#doc-api-reference-data-types-userprofile) object.

For more detailed information on all the parameters and options available with the [`signup()`](#doc-api-reference-authentication-signup) method,
please refer to the API Reference below:

### [`signup(params, options?): Promise<UserProfile | string>`](#doc-api-reference-authentication-signup)

`options` also takes a `template` argument, which lets a single `signup()` call use a specific
uploaded template for the signup confirmation and welcome e-mails instead of the template set for
your project. For more info, see [Overriding the template for a single call](#doc-email-email-templates-overriding-the-template-for-a-single-call).

### E-Mail and username <a id="doc-authentication-create-account-e-mail-and-username"></a>

`email` is always required. Every account needs one, because it is what signup confirmation, e-mail
verification, password reset and account recovery all work through. An account cannot be created
without it.

`username` is optional, and when you pass one the account gets **two** ways in. The username becomes
the account's permanent login ID, and once the email is verified it logs the account in as well. Leave
`username` out and the email alone is the login ID, which is what most projects want.

The username is permanent. It is fixed when the account is created and there is no API to change it.
The email is not, but changing it does not hand email login to the new address on its own. The new
address logs the account in only once the user verifies it with
[`verifyEmail()`](#doc-api-reference-user-verifyemail), and it starts working a few seconds
after the verification succeeds, not in the `verifyEmail()` response. An email an admin changes with
[`updateUserAttributes()`](#doc-api-reference-admin-updateuserattributes) is written unverified
and takes the account's email login away at once, until the user verifies the new address.

The username is unaffected either way, and so is the address the account signed up with when it was
created without a `username`: that original address is the account's own login ID, not an email login,
and it keeps working after an email change. See
[Which ID logs a user in](#doc-user-account-update-account-which-id-logs-a-user-in).

```js
skapi.signup({ email: 'user@email.com', password: 'password', username: 'my_username' })
    .then(() => {
        // The username logs the account in from the moment it is created.
        skapi.login({ username: 'my_username', password: 'password' });
        // The email logs the same account in once it is verified, and not before.
        skapi.login({ email: 'user@email.com', password: 'password' });
    });
```

The email keeps every other job it had: it is still the address verification and password-reset
e-mails go to, and it is still what `email_public` exposes.

For an account created with a `username`, email login starts once that email is **verified**, never
before. An email only proves the account owns it once one of these happens:

- the user clicks the confirmation link, when `signup_confirmation` is used,
- the user accepts the invitation, for an account made with
  [`inviteUser()`](#doc-api-reference-admin-inviteuser),
- the user calls [`verifyEmail()`](#doc-api-reference-user-verifyemail) and it succeeds, which
  is what a `signup()` without `signup_confirmation` and an account made with
  [`createAccount()`](#doc-api-reference-admin-createaccount) need. Creating the account proves
  nothing about the address, so it logs in with its username alone until the user verifies. Email
  login then arrives a few seconds later, on the account's next token, rather than in the
  `verifyEmail()` response.

Until then the email does not reach the account at all, even while the invitation or the confirmation
link is pending. A user who types their email before opening the confirmation link gets
`INCORRECT_USERNAME_OR_PASSWORD`, not `SIGNUP_CONFIRMATION_NEEDED`, and that attempt does not let
[`resendSignupConfirmation()`](#doc-api-reference-authentication-resendsignupconfirmation) send
the link again. A login attempt with the username does both.

If that email is already a login ID another account of your project was **granted**, email login is not
enabled for the new account, and the username still logs it in. `signup()` creates such an account
anyway; `createAccount()` and `inviteUser()` refuse it up front with `EXISTS` (see below).

### When a login ID is already taken <a id="doc-authentication-create-account-when-a-login-id-is-already-taken"></a>

A login ID can only ever lead to one account. So an account is refused when its own login ID is already
a login ID another account of your project was **granted**: the ID that account was created with, or an
address it has verified. The usual case is signing up without a `username` with the verified email of an
account that was created with one:

```js
// 'user@email.com' is the email of an account that was created with a username.
skapi.signup({ email: 'user@email.com', password: 'password' })
    .catch(err => {
        // err.code: 'EXISTS'
        // err.message: 'E-mail "user@email.com" is already a login ID in this service.'
    });
```

`signup()` refuses a `username` the same way, with the message
`The login ID is already used by another account in this service.`, when that `username` is an address
another account was granted as its email login. Only an e-mail address can be one, for example a
`username` of `jane@email.com` when `jane@email.com` is the verified email of an account created with a
username. A login ID that another account was itself created with (the same `username`, or the same
email both times without a `username`) is a plain duplicate and gives `The account already exists.`
instead.

`createAccount()` and `inviteUser()` run this check for every request without `openid_id`, with or
without a `username`, and they also refuse an account created with a `username` whose email is already a
login ID another account was granted, including the email that account was created with. See the
`EXISTS` errors of [`inviteUser()`](#doc-api-reference-admin-inviteuser) and
[`createAccount()`](#doc-api-reference-admin-createaccount).

:::warning Your permanent login ID is never taken away
The account's permanent login ID is fixed:
- if the account was created with a username, that username is the permanent login ID;
- if the account was created without a username, the original email is the permanent login ID.

That permanent login ID stays with the account and is never reassigned to another account.

What can be taken away is a separate email login that is not the account's permanent login ID. If another
account proves it owns the same email and verifies it, that separate email login can be reassigned to the
other account. See [Login IDs](#doc-admin-permissions-login-ids).
:::

# Signup Confirmation <a id="doc-authentication-signup-confirmation"></a>

You can require users to confirm their signup via email.
This is useful for preventing malicious users from creating fake accounts.
The account is not fully activated until the user confirms it.

## E-Mail Confirmation on Signup

When an account is created with `options.signup_confirmation` set to `true` or a URL string,
the user receives a signup confirmation email.

The user must click on the confirmation link before logging into your project.
If the `options.signup_confirmation` value is a valid URL string,
the user is redirected to that URL after successful confirmation.

You can use either a full URL or a relative path on your website.

Once the user has confirmed their signup, their profile will automatically be marked as email verified. For an account created with a `username`, that is also what gives its email address a login: from then on the username and the email both log the account in. See [E-Mail and username](#doc-authentication-create-account-e-mail-and-username).

:::code-group

```html [Form]
<form onsubmit="skapi.signup(event, { signup_confirmation: '/path/to/your/success/page' })
    .then(r=> {
        // SUCCESS: The account has been created. User's signup confirmation is required.
        console.log(r);
    })">
    <input type="email" name="email" placeholder="E-Mail" required>
    <input type="password" name="password" placeholder="Password" required>
    <input name="name" placeholder="Your name">
    <input type="submit" value="Create Account">
</form>
```

```js [JS]
let parameters = {
  email: "user@email.com",
  password: "password",
  name: "User's name"
};

let options = {
  /**
   * If set to true, the user will get a signup confirmation email with a confirmation link.
   * If set to a valid URL string, the user will be redirected to the url when the confirmation is successful.
   */
  signup_confirmation: '/path/to/your/success/page'
};

skapi.signup(parameters, options).then(res => {
    // "SUCCESS: The account has been created. User's signup confirmation is required."
    console.log(res);
});
```
:::

The example above shows how to create an account with signup confirmation enabled.

After signup succeeds, the user receives an email with a confirmation link.
After they click the link, the account is confirmed and the user is redirected to the URL you provided.

If the `signup_confirmation` value was `true`,
the user sees a blank browser tab with the message: 'Your signup has been successfully confirmed.'

:::danger
When setting the `signup_confirmation` value to a relative URL path (e.g. `/relative/path.html`), it will not work if the website is not hosted.

This happens because local files use paths like `file:///C:/Users/username/Desktop/website/index.html`, and Skapi cannot resolve folder paths on a user's local computer.

Set your redirect URL of `signup_confirmation` to be the full URL (e.g. `https://your.website.com/path/to/your/success/page`).
:::

You can also customize the signup confirmation email template.

For more info on email templates, see [Automated E-Mail](#doc-email-email-templates).

## Resending Signup Confirmation E-Mail

If you need to resend the confirmation email, use the [`resendSignupConfirmation()`](#doc-api-reference-authentication-resendsignupconfirmation) method.

:::code-group
```html [Form]
<form onsubmit="skapi.login(event)
    .then(u=>console.log('Successfully logged in.'))
    .catch(err => {
        if(err.code === 'SIGNUP_CONFIRMATION_NEEDED') {
          if(confirm('Your signup confirmation is required. Resend confirmation email?')) {
            skapi.resendSignupConfirmation().then(res=>{
              console.log(res); // 'SUCCESS: Signup confirmation e-mail has been sent.'
            });
          }
        }
        else throw err;
      }
    })">
    <input type="email" name="email" placeholder="E-Mail" required>
    <input id="password" type="password" name="password" placeholder="Password" required>
    <input type="submit" value="Login">
</form>
```

```js [JS]
skapi.login({email: 'user@email.com', password: 'password'})
    .then(u=>console.log('Successfully logged in.'))
    .catch(err=>{
        /**
         * {
         *  code: 'SIGNUP_CONFIRMATION_NEEDED',
         *  message: "User's signup confirmation is required.",
         *  name: 'SkapiError'
         * }
         */

        if(err.code === 'SIGNUP_CONFIRMATION_NEEDED') {
            let sendConfirmation = window.confirm('Your signup confirmation is required. Resend confirmation email?');

            if(sendConfirmation) {
                // now you can resend signup confirmation E-Mail to user@email.com.
                skapi.resendSignupConfirmation().then(res=>{
                console.log(res); // 'SUCCESS: Signup confirmation e-mail has been sent.'
                });
            }
        }

        else throw err;
    });
```
:::

In this example, the user tries to log in and receives a `SIGNUP_CONFIRMATION_NEEDED` error.

If needed, you can then call [`resendSignupConfirmation()`](#doc-api-reference-authentication-resendsignupconfirmation) to resend the confirmation email.

For detailed information about all parameters and options of [`resendSignupConfirmation()`](#doc-api-reference-authentication-resendsignupconfirmation),
see the API reference below:

### [`resendSignupConfirmation(): Promise<'SUCCESS: Signup confirmation e-mail has been sent.'>`](#doc-api-reference-authentication-resendsignupconfirmation)

::: warning
- To resend signup confirmation emails, the user must have at least one login attempt to your project.
- If the user does not confirm within 7 days, the signup is invalidated and they must sign up again.
:::

# Login / Logout <a id="doc-authentication-login-logout"></a>

Once a user has signed up, they can log in to your project using their email and password.

## Login

Use the [`login()`](#doc-api-reference-authentication-login) method to log a user into your project.

If the login is not successful due to invalid password, or user may not have confirm their signup etc... the [`login()`](#doc-api-reference-authentication-login) method will throw an error.

When successful, it will respond with the [`UserProfile`](#doc-api-reference-data-types-userprofile) object.

:::warning
If `signup_confirmation` option was set to `true` during [`signup()`](#doc-api-reference-authentication-signup),
users will not be able to log in until they have confirmed their account.
:::

:::info
When the user has successfully confirmed their signup and logged in, they will be sent a welcome email.
You can also customize the email template for the signup confirmation email.

For more info on email templates, see [Automated E-Mail](#doc-email-email-templates).
:::

Below is an example of a login form that uses the [`login()`](#doc-api-reference-authentication-login) method.
When the user successfully logs in, they will be redirected to the `welcome.html` page.

::: code-group

```html [Form]
<form
    action="welcome.html"
    onsubmit="skapi.login(event).catch(err=>alert(err.message))"
>
    <h2>Login</h2>
    <hr />
    <label>
        E-Mail<br />
        <input
            type="email"
            name="email"
            placeholder="user@email.com"
            required
        /> </label
    ><br /><br />
    <label>
        Password<br />
        <input
            id="password"
            type="password"
            name="password"
            placeholder="Your password"
            required
        /> </label
    ><br /><br />
    <input type="submit" value="Login" />
</form>
```

```js [JS]
let parameters = {
    email: "user@email.com",
    password: "password",
};

skapi.login(parameters).then((user) => (window.location.href = "welcome.html"));
```

:::

For more detailed information on all the parameters and options available with the [`login()`](#doc-api-reference-authentication-login) method,
please refer to the API Reference below:

### [`login(params): Promise<UserProfile>`](#doc-api-reference-authentication-login)

If the account was created with a `username`, the username logs the user in from the moment the account
is created, and the account's current email does too once that address is **verified**. Pass whichever
the user typed. Until the address is verified, by the signup confirmation link, by accepting an
invitation or by [`verifyEmail()`](#doc-api-reference-user-verifyemail), only the username reaches
the account, and the email answers `INCORRECT_USERNAME_OR_PASSWORD`. Email login then starts a few
seconds after the verification, on the account's next token.

An account created **without** a `username` logs in with the address it signed up with, which is its own
permanent login ID and keeps working even after the email is changed. See
[E-Mail and username](#doc-authentication-create-account-e-mail-and-username) for when email login
starts, and the one case where it is not enabled.

## Auto Login <a id="doc-authentication-login-logout-auto-login"></a>

By default, once user login to your website, their login session is maintained until they logout.

To ensure that users' sessions are destroyed when they leave your website, you can set options.autoLogin to false in the third argument when initializing Skapi.

```javascript
const options = {
    autoLogin: false, // session is destroyed when the user leaves; omit or set true (the default) to keep the session.
};

//Set the third argument as options
const skapi = new Skapi("<Project ID>", options);
```

## Logout

The [`logout()`](#doc-api-reference-authentication-logout) method logs the user out from the project.

:::code-group

```html [Form]
<form onsubmit="skapi.logout(event)" action="page_to_show_after_logout.html">
    <input type="submit" value="Logout" />
</form>
```

```js [JS]
skapi.logout().then((res) => {
    console.log(res); // 'SUCCESS: The user has been logged out.'
    window.location.replace("page_to_show_after_logout.html");
});
```

:::

## Global Logout

You can let the users logout and invalidate all tokens across all the users devices by setting `params.global` to `true`.

:::code-group

```html [Form]
<form onsubmit="skapi.logout(event)" action="page_to_show_after_logout.html">
    <input type="checkbox" name="global" checked />
    <input type="submit" value="Logout" />
</form>
```

```js [JS]
skapi.logout({ global: true }).then((res) => {
    console.log(res); // 'SUCCESS: The user has been logged out.'
    window.location.replace("page_to_show_after_logout.html");
});
```

:::

For more detailed information on all the parameters and options available with the [`logout()`](#doc-api-reference-authentication-logout) method,
please refer to the API Reference below:

### [`logout(params?): Promise<'SUCCESS: The user has been logged out.'>`](#doc-api-reference-authentication-logout)

## Listening to Login / Logout Status <a id="doc-authentication-login-logout-listening-to-login-logout-status"></a>

You can listen to the updates of the user's login state by setting a callback function in the `option.eventListener.onLogin` option argument of the constructor argument in Skapi.

The `onLogin` callback is triggered in the following cases:

-   Skapi initializes with the user's current authentication state.
-   User logs in or logs out.
-   User loses their session due to an expired token.

If the user is logged in, the callback receives the [UserProfile](#doc-api-reference-data-types-userprofile) object; otherwise, it receives `null`.

```js
const options = {
    eventListener: {
        onLogin: (profile) => {
            console.log(profile); // is null when user is logged out, User's information object when logged in.
        },
    },
};

const skapi = new Skapi("<Project ID>", options);
```

You can also add multiple event listeners to the `onLogin` event after the Skapi object has been initialized.

```js
skapi.onLogin = (profile) => {
    console.log(profile); // null when user is logged out, User's information object when logged in.
};
```

This handler is useful for updating the UI when the user logs in, logs out.

# User Profile <a id="doc-authentication-user-info"></a>

Once a user has logged in, they can retrieve their profile information.
The profile data is structured as an OpenID-compliant JavaScript object.

## Requesting User Information

The [`getProfile()`](#doc-api-reference-authentication-getprofile) method allows you to retrieve the user's information via promise method.
It returns the [UserProfile](#doc-api-reference-data-types-userprofile) object.

If the user is not logged in, [`getProfile()`](#doc-api-reference-authentication-getprofile) returns `null`.

This method is particularly useful for determining the user's authentication state when they first visit or reload your website.

```js
skapi.getProfile().then(profile=>{
  console.log(profile); // User's information

  if(profile === null) {
    // The user is not logged in
  }
})
```

You can also refresh the auth token and fetch the updated profile by passing `options.refreshToken` to `true`.

```js
skapi.getProfile({ refreshToken: true }).then(profile=>{
  console.log(profile); // Updated user's information

  if(profile === null) {
    // The user is not logged in
  }
})
```

This can be useful when the user needs to get their updated profile when it's updated from another device, or admin might have made change to the users profile, or you just want your users to update their token for some other security reasons.

For more detailed information on all the parameters and options available with the [`getProfile()`](#doc-api-reference-authentication-getprofile) method,
please refer to the API Reference below:

### [`getProfile(options?): Promise<UserProfile | null>`](#doc-api-reference-authentication-getprofile)

## Listening to User's Profile Updates <a id="doc-authentication-user-info-listening-to-user-s-profile-updates"></a>

You can listen to the updates of the user profiles by setting a callback function in the `option.eventListener.onUserUpdate` option argument of the constructor argument in Skapi.

The `onUserUpdate` callback function will be triggered in the following scenarios:
- Skapi initializes with the user's current authentication state.
- User logs in or logs out.
- User loses their session due to an expired token.
- User's profile information is updated.

If the user is logged in, the callback receives the [UserProfile](#doc-api-reference-data-types-userprofile) object; otherwise, it receives `null`.

```js
const options = {
  eventListener: {
    onUserUpdate: (profile) => {
      console.log(profile); // is null when user is logged out, User's information object when logged in.
    }
  }
};

const skapi = new Skapi("<Project ID>", options);
```

You can also add multiple event listeners to the `onUserUpdate` event after the Skapi object has been initialized.

```js
skapi.onUserUpdate = (profile) => {
  console.log(profile); // null when user is logged out, User's information object when logged in.
}
```

This handler is useful for updating the UI when the user logs in, logs out, or when their profile information changes.

# Forgot password <a id="doc-authentication-forgot-password"></a>

When the user forgets their password, they can request a verification code to reset their password.

:::warning
If the user's email is not verified, they will not be able to receive a verification code and may lose access to their account permanently.

It is recommended to encourage users to verify their email addresses.
For more info on email verification, see [E-Mail Verification](#doc-user-account-email-verification).
:::

## Step 1: Request Verification Code

Use the [`forgotPassword()`](#doc-api-reference-authentication-forgotpassword) method to request a verification code.

In this example, the [`forgotPassword()`](#doc-api-reference-authentication-forgotpassword) method is called with the user's email as a parameter.

The user will receive an email containing a verification code that they can use to reset their password.

:::code-group

```html [Form]
<form onsubmit="skapi.forgotPassword(event).then(res => {
    console.log(res) // SUCCESS: Verification code has been sent.
})">
    <input type="email" name="email" placeholder="E-Mail" required>
    <input type="submit" value="Request Verification Code">
</form>
```

```js [JS]
skapi.forgotPassword({email: 'someone@gmail.com'}).then(res=>{
  // User receives an e-mail with a verification code.
  // SUCCESS: Verification code has been sent.
  console.log(res);
});

```
:::

For more detailed information on all the parameters and options available with the [`forgotPassword()`](#doc-api-reference-authentication-forgotpassword) method,
please refer to the API Reference below:

### [`forgotPassword(params, options?): Promise<'SUCCESS: Verification code has been sent.'>`](#doc-api-reference-authentication-forgotpassword)

::: info
Due to security reasons, [`forgotPassword()`](#doc-api-reference-authentication-forgotpassword) will not tell the user whether the email exists.
:::

You can also customize the email template for the verification email.

For more info on email templates, see [E-Mail Templates](#doc-email-email-templates).

To use a different template for one call instead of the one set for your project, pass its
`message_id` in the optional `options.template` argument. For more info, see
[Overriding the template for a single call](#doc-email-email-templates-overriding-the-template-for-a-single-call).

## Step 2: Reset Password

The user will receive an email containing a verification code. After the user receives the verification code, they can use the [`resetPassword()`](#doc-api-reference-authentication-resetpassword) method to reset their password.

The [`resetPassword()`](#doc-api-reference-authentication-resetpassword) method is called with the user's email, the verification code received via email, and the new password.

Upon successful password reset, the user's account password will be set to the new password provided.

:::code-group

```html [Form]
<form onsubmit="skapi.resetPassword(event).then(res => {
    console.log(res); // SUCCESS: New password has been set.
})">
    <input type="email" name="email" placeholder="E-Mail" required>
    <input type="text" name="code" placeholder="Verification Code" required>
    <input type="password" name="new_password" placeholder="New Password" required>
    <input type="submit" value="Change Password">
</form>
```

```js [JS]
skapi.resetPassword({
  email: 'someone@gmail.com',
  code: '123456', // code sent to user's registered email address
  new_password: 'new_password' // The password should be at least 6 characters and 60 characters maximum.
}).then(res => {
  console.log(res);
  // SUCCESS: New password has been set.
});
```
:::

For more detailed information on all the parameters and options available with the [`resetPassword()`](#doc-api-reference-authentication-resetpassword) method,
please refer to the API Reference below:

### [`resetPassword(params): Promise<'SUCCESS: New password has been set.'>`](#doc-api-reference-authentication-resetpassword)

# OpenID Login <a id="doc-authentication-openid-login"></a>

Skapi supports OpenID authentication through configurable OpenID Loggers.

## What is OpenID?

OpenID is an authentication standard that lets users sign in with an identity provider (for example, Google) instead of creating a separate password for every app.

## Login with OpenID Profile

If you have access to an OpenID provider API, you can register an OpenID Logger in your Skapi project settings.

:::warning An OpenID Logger is a project setting
Registering, updating, deleting and listing OpenID Loggers belong to the **project owner's Skapi account**, and to Skapi staff when you ask Skapi for help. An [admin](#doc-admin-permissions-project-settings-belong-to-the-project-owner) of your project, access group `99` included, is refused with `INVALID_REQUEST` and `Only the project owner can change project settings.` The same holds for the [Secret Keys](#doc-api-bridge-client-secret-request-registering-secret-keys) a logger uses.
:::

Although providers differ in details, the overall process is:

1. You redirect the user to the provider's login page.
2. The provider authenticates the user.
3. The user is redirected back to your app.
4. Exchange the returned authorization code for an access token with [`forwardRequest()`](#doc-api-bridge-forward-request) and a stored Secret Key.
5. Call [`openIdLogin()`](#doc-api-reference-authentication-openidlogin) with the token.

## Google OAuth Example

This example shows how to implement Google OAuth authentication.

### 1. Set Up Google OAuth Service

Go to the [Google Cloud Console](https://console.cloud.google.com/) and create your OAuth app.

Follow Google's [setup instructions](https://support.google.com/cloud/answer/15549257?sjid=3416534526948669406-NC), and make sure your redirect URL points back to your web app.

### 2. Set Up Link to Google Login

After setting up OAuth in [Google Cloud Console](https://console.cloud.google.com/), create a button that sends users to Google's OAuth login URL with the parameters registered for your app.

```html
<button onclick="googleLogin()">Google Login</button>
<script>
    const GOOGLE_CLIENT_ID = '1234567890123-your.google.client.id';
    const REDIRECT_URL = window.location.origin + window.location.pathname; // URL user to redirect back to on successful login.

    function googleLogin() {
        const state = crypto.randomUUID();
        const authURL = new URL('https://accounts.google.com/o/oauth2/v2/auth');

        authURL.searchParams.set('client_id', GOOGLE_CLIENT_ID);
        authURL.searchParams.set('redirect_uri', REDIRECT_URL);
        authURL.searchParams.set('response_type', 'code');
        authURL.searchParams.set('scope', 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email');
        authURL.searchParams.set('prompt', 'consent');
        authURL.searchParams.set('state', state);
        authURL.searchParams.set('access_type', 'offline');

        window.location.href = authURL.toString();
    }
</script>
```

### 3. Register a Secret Key

After a successful Google login, the user is redirected back to your page with a `code` query parameter.

You must exchange this `code` for an access token. Because this step requires a client secret, store the secret in Skapi and request the token with [`forwardRequest()`](#doc-api-bridge-forward-request).

To register a secret key in Skapi:

1. In the project page, click on the **Secret Keys** menu.
2. Click **+ Register Secret**.
3. In the form, enter:
    - **Name:** A key identifier. For this guide, use **ggltoken**.
    - **Secret Value:** The exact client secret from your Google OAuth app.

4. Click **Register**.

For more information about registering a secret key, see [Secret Keys](#doc-api-bridge-client-secret-request-registering-secret-keys).

After registering the client secret, run the following code on the redirect page to exchange the authorization code for an access token:

```js
// Page user has redirected to

const GOOGLE_CLIENT_ID = '1234567890123-your.google.client.id';
const REDIRECT_URL = window.location.origin + window.location.pathname;
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');

const tokenResponse = await skapi.forwardRequest({
    code,
    client_id: GOOGLE_CLIENT_ID,
    client_secret: '$CLIENT_SECRET',
    redirect_uri: REDIRECT_URL,
    grant_type: 'authorization_code'
}, {
    secretName: 'ggltoken',
    url: 'https://oauth2.googleapis.com/token',
    method: 'POST',
    headers: {
        'Content-Type': 'application/json'
    }
});
const ACCESS_TOKEN = tokenResponse?.access_token;
```

### 4. Register Your OpenID Logger in Skapi

To log users into your Skapi project with the token, you must register an OpenID Logger.

The logger configuration tells Skapi how to request user profile attributes from the OAuth provider and which attribute to use as the unique account identifier.

1. Log in to [skapi.com](https://www.skapi.com) and open the project.
2. From the side menu, click **Open ID**. The page lists the loggers the project has, with their Logger ID, Username Key, Request URL and Method.

![The Open ID page of a Skapi project, listing two loggers with their Logger ID, Username Key, Request URL and Method, and a Register Logger button](https://docs.skapi.com/screenshots/openid-list.webp)

*The Open ID page. Click a row to open a logger. Tick rows and use the delete icon to remove them.*

3. Click **+ Register Logger**.
4. Fill in the form:

    - **Logger ID:** The name your app passes to [`openIdLogin()`](#doc-api-reference-authentication-openidlogin). You can use any name, and it cannot change later. For this guide, use **google**.
    - **Username Key:** The attribute of the provider's profile response that becomes the user's unique username. It cannot change later. For this example, use **email**.
    - **Request URL:** The endpoint that returns the user's profile. `$TOKEN` in the URL is replaced by the OpenID token at sign-in. For this example, use `https://www.googleapis.com/oauth2/v3/userinfo`.
    - **Method:** `GET` or `POST`, as the API requires. For this example, use `GET`.
    - **Header [JSON]:** Request headers as JSON. `$TOKEN` is replaced here too. Use:

        ```
        {
            "Authorization": "Bearer $TOKEN"
        }
        ```

    - **Get Parameters [JSON]** or **Post Body [JSON]:** Query parameters for `GET`, or the request body for `POST`, as JSON. Leave it blank for this example.
    - **Condition:** Optional. Only users whose profile attribute meets the condition can sign in through this logger. **Attribute** is the profile attribute to compare, **Condition** is the comparison, and **Value** is what it is compared with. On text, `>=` means the attribute **starts with** the value and `<=` means it **ends with** it, so `email` `<=` `@mycompany.com` lets in only that e-mail domain. Leave them blank for this example.

![The Register Logger form filled in for Google: Logger ID google, Username Key email, the userinfo Request URL, Method GET, an Authorization header in the Header JSON field, and an empty Condition section](https://docs.skapi.com/screenshots/openid-register.webp)

*The logger form filled in for this example.*

5. Click **Register**.

To change a logger later, click its row on the **Open ID** page, edit the request fields and click **Update**. The Logger ID and the Username Key are fixed once registered. **Delete** at the bottom of the form removes the logger, and users who signed in through it lose access to the project.

:::tip Let an AI agent do it
The Skapi MCP server can register, update and delete OpenID loggers for you from an AI agent, along with the secret key the token exchange uses. See [Connecting the Skapi MCP server](#doc-introduction-getting-started-connecting-the-skapi-mcp-server).
:::

Now call [`openIdLogin()`](#doc-api-reference-authentication-openidlogin) with the logger ID and the access token to sign in (or create) the user.

In this example, the logger ID is `google`.

```js
skapi.openIdLogin({ id: 'google', token: ACCESS_TOKEN }).then(user => {
    // User has logged in!
});
```

### [`openIdLogin(event?: SubmitEvent | params): Promise<{ userProfile: UserProfile; openid: { [attribute: string]: string } }>`](#doc-api-reference-authentication-openidlogin)

### Wrapping up: All in one page

This example shows the entire flow in one page. After the user signs in with Google and is redirected back to your app, use [`forwardRequest()`](#doc-api-bridge-forward-request) to exchange the authorization code for an access token.

Then call [`openIdLogin(event?: SubmitEvent | params): Promise<{ userProfile: UserProfile; openid: { [attribute: string]: string } }>`](#doc-api-reference-authentication-openidlogin) to sign the user in to your Skapi project.

```html
<button onclick="googleLogin()">Google Login</button>
<script>
    const GOOGLE_CLIENT_ID = '1234567890123-your.google.client.id';
    const REDIRECT_URL = window.location.origin + window.location.pathname;

    function googleLogin() {
        const state = crypto.randomUUID();
        const authURL = new URL('https://accounts.google.com/o/oauth2/v2/auth');

        authURL.searchParams.set('client_id', GOOGLE_CLIENT_ID);
        authURL.searchParams.set('redirect_uri', REDIRECT_URL);
        authURL.searchParams.set('response_type', 'code');
        authURL.searchParams.set('scope', 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email');
        authURL.searchParams.set('prompt', 'consent');
        authURL.searchParams.set('state', state);
        authURL.searchParams.set('access_type', 'offline');

        window.location.href = authURL.toString();
    }

    async function handleOAuthCallback() {
        const urlParams = new URLSearchParams(window.location.search);
        const code = urlParams.get('code');

        if (!code) {
            return;
        }

        try {
            const tokenResponse = await skapi.forwardRequest({
                code,
                client_id: GOOGLE_CLIENT_ID,
                client_secret: '$CLIENT_SECRET',
                redirect_uri: REDIRECT_URL,
                grant_type: 'authorization_code'
            }, {
                secretName: 'ggltoken',
                url: 'https://oauth2.googleapis.com/token',
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                }
            });

            if (!tokenResponse?.access_token) {
                throw tokenResponse;
            }

            const ACCESS_TOKEN = tokenResponse.access_token;

            await skapi.openIdLogin({ id: 'google', token: ACCESS_TOKEN });

            window.history.replaceState({}, document.title, REDIRECT_URL);

            // User has now logged in. Do whatever you want from here...
            alert('Login Success!');
        }
        catch (error) {
            console.error('Google OAuth login failed:', error);
            alert('Login failed. Please try again.');
        }
    }

    handleOAuthCallback();
</script>
```

## Merging an OpenID Account with a Previous Account <a id="doc-authentication-openid-login-merging-an-openid-account-with-a-previous-account"></a>

In some cases, you may want to merge a user's OpenID account with an existing account. Accounts created by admins are merged like any other account that matches (see below which accounts match): an account made with [`createAccount()`](#doc-api-reference-admin-createaccount), or an invited account once its invitation is accepted. A matching account that is still waiting for its signup confirmation, or whose invitation has not been accepted yet, cannot be merged: `openIdLogin()` fails with `INVALID_REQUEST` and `The account needs to be confirmed.`, with or without `merge`.

To enable merging, set your OpenID Logger ID to `by_skapi`.
Then, when calling `openIdLogin`, use the `merge` option to control what gets merged. Set `merge: true` to merge the OpenID account into the existing account (its password is replaced and no profile attributes are copied), or pass an array of OpenID attribute names, such as `["name"]`, to merge and also copy those attributes.

For example, to merge and also copy the user's "name" attribute:

```js
skapi.openIdLogin({ id: 'by_skapi', token: ACCESS_TOKEN, merge: ["name"] });
```

:::danger
After a merge, the user can no longer log in with a password. This action cannot be undone.
:::

Merge only matches the account whose original login identifier is the OpenID account's login ID, for
example an account created without a `username` using the same email. It never matches an account
through its email login: an account created with a `username` that uses the same email, or an account
whose email was changed to that address later, is not merged into.

What happens to such an account depends on whether it has **verified** that address. If it has, the
email login is one it was granted, and `openIdLogin()` fails with `EXISTS` and
`The login ID of this OpenID account is already used by another account.`, with or without `merge`. If
it has not, that email login is removed and a new OpenID account is created for the address, so two
accounts of your project then carry the same email address. See
[`openIdLogin()`](#doc-api-reference-authentication-openidlogin) and
[Login IDs](#doc-admin-permissions-login-ids).

:::tip
You can first attempt login without the `merge` parameter and only prompt the user to merge if the account already exists:

```js
skapi.openIdLogin({ id: 'by_skapi', token: ACCESS_TOKEN })
    .catch(err => {
        if (err.code === 'ACCOUNT_EXISTS') {
            if (confirm('An account already exists. Merge them now?')) {
                return skapi.openIdLogin({ id: 'by_skapi', token: ACCESS_TOKEN, merge: true });
            }
        }
        throw err; // Re-throw if not handled
    });
```
:::

# Updating User Profile <a id="doc-user-account-update-account"></a>

:::warning
You must be logged in to call this method.
:::

You can update a user's profile using [`updateProfile()`](#doc-api-reference-user-updateprofile).
If successful, the method returns the updated [UserProfile](#doc-api-reference-data-types-userprofile) object.

:::danger
-   When the email is changed, it becomes unverified.
-   Changing the email does **not** hand email login to the new address. The new address logs the
    account in only once the user verifies it with
    [`verifyEmail()`](#doc-api-reference-user-verifyemail), and then a few seconds later, on the
    account's next token rather than in the `verifyEmail()` response.
-   The old address stops logging the account in on that same next token. A `username`, if the account
    has one, is permanent and is not affected, and neither is the address a no-username account was
    created with. See [Which ID logs a user in](#doc-user-account-update-account-which-id-logs-a-user-in) below.
:::

In this example, the user's name is updated by passing a new `name` value.
If successful, the updated user profile is returned.

:::code-group

```html [Form]
<form onsubmit="skapi.updateProfile(event).then(user=>console.log(user))">
    <input type="text" name="name" placeholder="Name" required />
    <br />
    <input type="submit" value="Update Profile" />
</form>
```

```js [JS]
let params = {
    name: "New name",
    // email, // The user's login email address. The email will be unverified if it is changed.
    // address, // The user's address.
    // gender, // The user's gender. Can be "female" or "male", or other values if neither of these are applicable.
    // birthdate, // The user's birthdate in the format "YYYY-MM-DD".
    // phone_number, // The user's phone number.
    // email_public, // The user's email is public if this is set to true. The email should be verified.
    // phone_number_public, // The user's phone number is public if this is set to true. The phone number should be verified.
    // address_public, // The user's address is public if this is set to true.
    // gender_public, // The user's gender is public if this is set to true.
    // birthdate_public, // The user's birthdate is public if this is set to true.
    // picture, // URL of the profile picture.
    // profile, // URL of the profile page.
    // website, // URL of the website.
    // nickname, // Nickname of the user.
    // misc, // Additional string value for custom use. This value is visible only through skapi.getProfile().
};

skapi.updateProfile(params).then((user) => {
    console.log({ user }); // User's name is updated.
});
```

:::

For full details on parameters and options, see the API reference below:

### [`updateProfile(params): Promise<UserProfile>`](#doc-api-reference-user-updateprofile)

:::tip
If you need to upload an image to a user's profile, first upload a public image file with [`postRecord()`](#doc-api-reference-database-postrecord), then use the uploaded file URL in the profile attributes.
:::

## Public Attributes

Certain user profile attributes can be set as public or private.
When an attribute is public, other users can find it.
When an attribute is private, other users cannot find it.

The following attributes can be set to public or private:

-   `email`
-   `phone_number`
-   `address`
-   `gender`
-   `birthdate`

By default, these attributes are set to private.

Here is an example that makes the user's email public:

:::code-group

```html [Form]
<form onsubmit="skapi.updateProfile(event).then(user=>console.log(user))">
    <input type="checkbox" name="email_public" value="true" /> Make email public
    <br />
    <input type="submit" value="Update Profile" />
</form>
```

```js [JS]
let params = {
    email_public: true,
};

skapi.updateProfile(params).then((user) => {
    console.log({ user }); // User's email is now public.
});
```

:::

For full details on parameters and options, see the API reference below:

### [`updateProfile(params): Promise<UserProfile>`](#doc-api-reference-user-updateprofile)

## Which ID logs a user in <a id="doc-user-account-update-account-which-id-logs-a-user-in"></a>

An account has **one or two** ways in:

-   The **login ID it was created with**: its `username` when it was created with one, otherwise the
    email address it signed up with. It is permanent, no method changes it, and it keeps working after
    the account changes its email.
-   Its **email login**: the account's current email address, and only once that address is **verified**.

**Account created with a username.** The username is the permanent login ID and never changes. The
current email logs the user in too, once it is verified. Change the email and the account is left with
its username alone: the old address stops working on the next token, and the new one starts working a
few seconds after [`verifyEmail()`](#doc-api-reference-user-verifyemail) succeeds.

**Account created without a username.** The address the account signed up with keeps working as a login
ID forever, even after the email is changed, because that original address is what identifies the
account internally. A changed address is an email login on top of it, so it works only once it is
verified, and it stops working when the email is changed again.

So if you need an address to stop being a way in, give the account a `username` at creation. Only then
does the original address lose its hold.

An email address proves the account owns it when the user clicks the signup confirmation link, accepts
an invitation, or calls `verifyEmail()` successfully. Until then it does not reach the account at all,
and it answers `INCORRECT_USERNAME_OR_PASSWORD`. If the address is already a login ID another account of
the project was granted, email login is not enabled and the account's own login ID still works. See
[E-Mail and username](#doc-authentication-create-account-e-mail-and-username) and
[Login IDs](#doc-admin-permissions-login-ids).

:::warning An admin changing the email takes email login away at once
An email an admin sets with [`updateUserAttributes()`](#doc-api-reference-admin-updateuserattributes),
or with `updateProfile()` and another user's `user_id`, is written unverified and the account's email
login is removed inside that request, whoever sent it. The account keeps its own login ID, and gets an
email login back only after the user verifies the new address.
:::

# E-Mail Verification <a id="doc-user-account-email-verification"></a>

:::warning
User must be logged in to call this method
:::

User with verified E-Mail can:

- Log in with their email address, when the account was created with a `username`. See [Which ID logs a user in](#doc-user-account-update-account-which-id-logs-a-user-in).
- Reset their password if they've forgotten it.
- Receive newsletter from the project owner if they choose to.
- Recover their disabled account.
- Allow their email address to be public to other users if they choose.

You can verify your user's email address with [`verifyEmail()`](#doc-api-reference-user-verifyemail).

:::tip
The user's email is automatically verified if [signup confirmation](#doc-authentication-signup-confirmation) was requested in [`signup()`](#doc-api-reference-authentication-signup).
:::

The example below shows how you can verify your users email address.

1. The first method call, without any arguments, sends a verification email to the user.
2. The second call completes the verification process by passing the verification code that user retrieved from their email.

``` js
  // Send verification code to user's E-Mail
  skapi.verifyEmail().then(res=>{
     // 'SUCCESS: Verification code has been sent.'
    console.log(res);

    // Prompt user to enter the verification code
    let code = prompt('Enter the verification code sent to your E-Mail');

    // Verify E-Mail with the code
    skapi.verifyEmail({ code }).then(res=>{
      // SUCCESS: "email" is verified.
      window.alert('Your email is verified');
    });
  });
```

For more detailed information on all the parameters and options available with the [`verifyEmail()`](#doc-api-reference-user-verifyemail) method,
please refer to the API Reference below:

### [`verifyEmail(params?, options?): Promise<'SUCCESS: Verification code has been sent.' | 'SUCCESS: "email" is verified.'>`](#doc-api-reference-user-verifyemail)

:::warning
The user's email verified state will be lost if the user had changed their email address, or if an admin
changed it for them. Email login goes with it: the account logs in with its `username`, or with the
address it was created with when it has none, until the new address is verified. Verifying it grants the
email login again a few seconds later, on the account's next token. See
[Login IDs](#doc-admin-permissions-login-ids).
:::

`options` takes a `template` argument, which lets a single call use a specific uploaded template for
the verification e-mail instead of the template set for your project. It applies to the call that
issues a new code, and is ignored when you pass a code back to verify it. For more info, see
[Overriding the template for a single call](#doc-email-email-templates-overriding-the-template-for-a-single-call).

# Changing Password <a id="doc-user-account-change-password"></a>

:::warning
User must be logged in to call this method.
:::

The [`changePassword()`](#doc-api-reference-user-changepassword) method allows users who are logged-in to change their password. This method requires the user's current password and the new password as parameters. If the password change is successful, the method will return a success message.

Password should be at least 6 characters and no more than 60 characters.

:::code-group

```html [Form]
<form onsubmit="skapi.changePassword(event).then(res => alert(res))">
    <input type="password" name="current_password" placeholder="Current Password" required>
    <input type="password" name="new_password" placeholder="New Password" required>
    <input type="submit" value="Change Password">
</form>
```

``` js [JS]
let params = {
    current_password: 'current password',
    new_password: 'new password'
}

skapi.changePassword(params)
  .then(res => {
    alert(res); // SUCCESS: Password has been changed.
  });
```

:::

For more detailed information on all the parameters and options available with the [`changePassword()`](#doc-api-reference-user-changepassword) method,
please refer to the API Reference below:

### [`changePassword(params): Promise<'SUCCESS: Password has been changed.'>`](#doc-api-reference-user-changepassword)

# Disable / Recover Account <a id="doc-user-account-disable-recover-account"></a>

## Disabling account <a id="doc-user-account-disable-recover-account-disabling-account"></a>

:::warning
User must be logged in to call this method
:::

:::warning
If your project does not allow users to signup, the users will not be able to disable their account.

For more information on how to allow/disallow users to signup from your project settings page, please refer to the [Project Settings](https://docs.skapi.com/service-settings/service-settings.html#allow-signup) page.
:::

If user choose to leave your project, they can disable their account.
User's can disable their account by calling the [`disableAccount()`](#doc-api-reference-user-disableaccount) method.
**All data related to the account will be deleted after 90 days**.
User will be automatically logged out once their account has been disabled.

``` js
skapi.disableAccount().then(()=>{
  // Account is disabled and user is logged out.
});
```

For more detailed information on all the parameters and options available with the [`disableAccount()`](#doc-api-reference-user-disableaccount) method,
please refer to the API Reference below:

### [`disableAccount(): Promise<'SUCCESS: account has been disabled.'>`](#doc-api-reference-user-disableaccount)

## Recovering a Disabled Account

Disabled accounts can be reactivated **within 90 days** using the [`recoverAccount()`](#doc-api-reference-user-recoveraccount) method. This method allows users to reactivate their disabled accounts under the following conditions:

- The account email must be verified.
- The [`recoverAccount()`](#doc-api-reference-user-recoveraccount) method must be called from the `catch` block of a failed [`login()`](#doc-api-reference-authentication-login) attempt using the disabled account.

The [`recoverAccount()`](#doc-api-reference-user-recoveraccount) method sends an email to the account owner, containing a confirmation link (The same signup confirmation email) for account recovery.

Additionally, you can provide an optional `string` argument to the [`recoverAccount()`](#doc-api-reference-user-recoveraccount) method, which will redirect the user to the specified URL or relative path of your website upon successful account recovery.

:::code-group

```html [Form]
<form onsubmit="skapi.login(event)
    .then(u=>console.log('Login success.'))
    .catch(err=>{
        console.log(err.code); // USER_IS_DISABLED
        if(err.code === 'USER_IS_DISABLED') {
          // Send a recovery email to the user with a link.
          // When the user click on the link, the user will be redirected when account recovery is successful.

          let recover = confirm('Do you want to recover your account?')
          if(recover) {
            skapi.recoverAccount('/welcome/back/page').then(res=>{
              console.log(res); // SUCCESS: Recovery e-mail has been sent.
            });
          }
        }
    })">
    <input type="email" name="email" placeholder="E-Mail" required>
    <input id="password" type="password" name="password" placeholder="Password" required>
    <input type="submit" value="Login">
</form>
```

```js [JS]
// user attempt to login
skapi.login({email: 'user@email.com', password: 'password'})
    .then(u=>console.log('Login success.'))
    .catch(err=>{
        console.log(err.code); // USER_IS_DISABLED
        if(err.code === 'USER_IS_DISABLED') {
            // Send a recovery email to the user with a link.
            // When the user click on the link, the user will be redirected when account recovery is successful.

            let recover = window.confirm('Do you want to recover your account?')
            if(recover) {
            skapi.recoverAccount("/welcome/back/page").then(res=>{
                console.log(res); // SUCCESS: Recovery e-mail has been sent.
            });
            }
        }
    });
```

:::

In the example above, the [`recoverAccount()`](#doc-api-reference-user-recoveraccount) method is called from the catch block of a failed login attempt using a disabled account.

If the login attempt fails with the error code `"USER_IS_DISABLED"`, user can choose to recover their account.

The [`recoverAccount()`](#doc-api-reference-user-recoveraccount) method is called to send a recovery email to the user.
The recovery email contains a link, and when the user clicks on the link, they will be redirected to the relative path of the website URL: `/welcome/back/page` upon successful account recovery.

For more detailed information on all the parameters and options available with the [`recoverAccount()`](#doc-api-reference-user-recoveraccount) method,
please refer to the API Reference below:

### [`recoverAccount(redirect: boolean | string): Promise<'SUCCESS: Recovery e-mail has been sent.'>`](#doc-api-reference-user-recoveraccount)

:::danger
User should know their password, and have their account email verified.
Otherwise user's account cannot be recovered.
:::

# Searching Users <a id="doc-user-account-get-users"></a>

:::warning
User must be logged in to call this method
:::

Users can search, retrieve information of other users in your project using the [`getUsers()`](#doc-api-reference-user-getusers) method. By default, [`getUsers()`](#doc-api-reference-user-getusers) will return all users chronologically from the most recent sign-up.

User information retrieved from the database is returned as a list of [UserPublic](#doc-api-reference-data-types-userpublic) objects.

:::info
Any attribute that is not set to public will not be retrieved.
:::

```js
skapi.getUsers().then(u=>{
  console.log(u.list); // List of all users in your project, sorted by most recent sign-up date.
});
```

In the example above, the [`getUsers()`](#doc-api-reference-user-getusers) method is called without any parameters.
This retrieves a list of all user profiles in your project.

For more detailed information on all the parameters and options available with the [`getUsers()`](#doc-api-reference-user-getusers) method,
please refer to the API Reference below:

### [`getUsers(params?, fetchOptions?): Promise<DatabaseResponse<UserPublic>>`](#doc-api-reference-user-getusers)

## Searching users with conditions

Following examples shows how you can search users based on attributes such as name, timestamp (account created timestamp), birthdate... etc

#### Search for users whose name starts with 'Baksa'

```js
let params = {
  searchFor: 'name',
  condition: '>=', // >= means greater or equal to given value. But on string value, it works as 'starts with' condition.
  value: 'Baksa'
}

skapi.getUsers(params).then(u=>{
  console.log(u.list); // List of users whose name starts with 'Baksa'
});
```

#### Search for users who joined before 2023 Jan 1

```js
let timestampParams = {
  searchFor: 'timestamp',
  condition: '<', // Less than given value
  value: 1672498800000 //2023 Jan 1
}

skapi.getUsers(timestampParams).then(u=>{
  console.log(u.list); // List of users who joined before 2023 jan 1
});
```

#### Search for users whose birthday is between 1985 ~ 1990

```js
let birthdateParams = {
  searchFor: 'birthdate',
  value: '1985-01-01',
  range: '1990-12-31' // Queries range of value from given value to given range value.
}

skapi.getUsers(birthdateParams).then(u=>{
  console.log(u.list); // List of users whose birthday is between 1985 ~ 1990
});
```

#### Search for a single user whose `user_id` falls in a range

```js
let userIdParams = {
  searchFor: 'user_id',
  value: '00000000-0000-0000-0000-000000000000',
  range: 'ffffffff-ffff-ffff-ffff-ffffffffffff'
}

skapi.getUsers(userIdParams).then(u=>{
  console.log(u.list); // List of users whose user_id falls between the value and range
});
```

#### Fetch multiple users by their `user_id`

```js
let multipleIdParams = {
  searchFor: 'user_id',
  value: [
    '00000000-0000-0000-0000-000000000000',
    '11111111-1111-1111-1111-111111111111'
  ]
  // condition defaults to '=', and each id is looked up directly. This is not a range search.
}

skapi.getUsers(multipleIdParams).then(u=>{
  console.log(u.list); // The users matching either id, if they exist
});
```

#### Fetch multiple users by a list of e-mails <a id="doc-user-account-get-users-fetch-multiple-users-by-a-list-of-e-mails"></a>

```js
let multipleEmailParams = {
  searchFor: 'email',
  value: ['alice@example.com', 'bob@example.com']
  // condition defaults to '=', and each e-mail is looked up directly. This is not a range search.
}

skapi.getUsers(multipleEmailParams).then(u=>{
  console.log(u.list); // The users matching either e-mail, if they exist and are public
});
```

The `searchFor` parameter specifies the attribute to search for, and the value parameter specifies the search value.

:::info
- Passing a `value` array (fetching several known values at once, e.g. several `user_id`s or `email`s) only works with the default `=` condition and does not accept `range`, whichever attribute you search for. It looks up each value directly instead of running a range query.
- Users cannot search for attributes that are not set to public.
:::

#### The following attributes can be used in `searchFor` to search for users:

- `user_id`: unique user identifier, string
- `email`: user's email address, string
- `phone_number`: user's phone number, string
- `name`: user's profile name, string
- `address`: user's physical address, string
- `gender`: user's gender, string
- `birthdate`: user's birthdate in "YYYY-MM-DD" format, string
- `locale`: the user's locale, a string representing the country code (e.g "US" for United States).
- `subscribers`: number of subscribers the user has, number
- `timestamp`: timestamp of user's sign-up, number(13 digit unix time)
- `approved`: search by account approval status

#### The `condition` parameter allows you to set the search condition.

- `>`: Greater than the given value.
- `>=`: Greater or equal to the given value. When the value is `string`, it works as 'starts with' condition.
- `=`: Equal to the given value. (default)
- `<`: Lesser than the given value.
- `<=`: Lesser or equal to the given value. On `email`, `phone_number`, `name`, `address`, `gender` and `locale`, it works as 'ends with' instead.

When searching for a `string` attribute, `>` and `<` will search for strings that are higher or lower in the lexicographical order, respectively. And `>=` operator works as 'start with' condition.

#### Search for users whose e-mail ends with '@company.com'

```js
let endsWithParams = {
  searchFor: 'email',
  condition: '<=',
  value: '@company.com'
}

skapi.getUsers(endsWithParams).then(u=>{
  console.log(u.list); // List of users whose e-mail ends with '@company.com'
});
```

:::info
`email`, `phone_number`, `name`, `address`, `gender` and `locale` are the only attributes that support the 'ends with' (`<=`) condition. It is served from a separate reverse-index that skapi keeps in sync with your user data, so it is as fast as any other condition search.
:::

:::tip What an admin sees
Admins (access groups `90` ~ `99`) and the project owner get more from the same call: the `email` of every account, whether or not the user made it public, its `email_verified` state, and the `misc` field, which is otherwise visible only to the account itself. `searchFor: 'email'` searches those addresses too, so an admin can find an account by an address that is not public. Everything else follows the rules above. See [What an admin can see about a user](#doc-admin-permissions-what-an-admin-can-see-about-a-user).
:::

The `range` parameter enables searching for users based on a specific attribute value within a given range. For example, if searching by `timestamp` with a range of 1651748526 to 1651143726, only users created between the two timestamps will be returned.

:::warning
The `range` parameter cannot be used with the `condition` parameter.
:::

# Creating a Record <a id="doc-database-create"></a>

Use [`postRecord()`](#doc-api-reference-database-postrecord) to create a new record or update an existing record in the database.

This method takes two arguments:

- `data`: The content to save as key-value pairs. This can be an object literal, `null`, `undefined`, or a form `SubmitEvent`. After upload, the value is stored under the `data` key in the returned [RecordData](#doc-api-reference-data-types-recorddata).

- `config` (required): Upload configuration, including table name, access group, index values, and more.

See the example below:

:::code-group

```html [Form]
<form onsubmit="skapi.postRecord(event, { table: { name: 'my_collection', access_group: 'public' } }).then(rec => {
    console.log(rec);
    /*
    Returns:
    {
        data: { something: 'Hello World' },
        table: { name: 'my_collection', access_group: 'public' },
        ...
    }
    */
})">
    <input name="something" placeholder="Say something" value="Hello World"/>
    <input type="submit" value="Submit" />
</form>
```

```js [JS]
// Data to be saved in key:value pairs
let data = {
    something: "Hello World"
}

// Configuration for the record to be uploaded
let config = {
    table: { name: 'my_collection', access_group: 'public' }
}

skapi.postRecord(data, config).then(rec=>{
    console.log(rec);
    /*
    Returns:
    {
        data: { something: "Hello World" },
        table: { name: 'my_collection', access_group: 'public' },
        ...
    }
    */
});
```
:::

This example demonstrates using the [`postRecord()`](#doc-api-reference-database-postrecord) method to create a record in the database.
When successful, the method returns [RecordData](#doc-api-reference-data-types-recorddata).

In this example, the first argument is the data to upload.
The value of `something` is saved as part of the record and appears under the `data` key in the returned [RecordData](#doc-api-reference-data-types-recorddata).

In the second argument, the table name is set to `my_collection` and the access group is set to `public`.
`config.table` is required.

Every record must include a table name. You can think of the table name as the top-level category for your data. Later, you can fetch records by table.

`config.table.name` validation rules:

- Maximum length: 128 characters
- Must not be empty
- Must not include control characters or sentinel `􏿿`

If the access group is `public`, anyone can access the uploaded data.
To use different access restrictions, see [`Access Restrictions`](#doc-database-access-restrictions).

::: danger
Both authenticated and anonymous users can upload data to your project with [`postRecord()`](#doc-api-reference-database-postrecord).

Limitations for anonymous (unsigned) users:
1. They can only create records in the `public` access group (see [`Access Restrictions`](#doc-database-access-restrictions)).
2. They cannot edit or delete any records they create.
3. They cannot create records that use [`Subscription`](#doc-database-subscription) features.
4. They cannot create records with [`Unique ID`](#doc-database-unique-id) features.

You can prevent anonymous uploads by changing your project settings in the Skapi dashboard.
:::

::: warning
When an anonymous user uploads a record, the `user_id` value in the returned [`RecordData`](#doc-api-reference-data-types-recorddata) is temporary and should not be used for user queries.
:::

For more detailed information on all the parameters and options available with the [`postRecord()`](#doc-api-reference-database-postrecord) method,
please refer to the API Reference below:

### [`postRecord(data, config):Promise<RecordData>`](#doc-api-reference-database-postrecord)

:::tip Note
Skapi does not require you to predefine a database schema.

If the specified table does not exist, it will be automatically created when you create the record.
Conversely, if a table has no records, it will be automatically deleted.
:::

## Creating Several Records at Once <a id="doc-database-create-creating-several-records-at-once"></a>

[`postRecords()`](#doc-api-reference-database-postrecords) takes a list of `{ data, config }` items, each the two arguments of [`postRecord()`](#doc-api-reference-database-postrecord), and saves them 100 to a request. Use it when a page writes many records in one go, such as one record per imported row or per uploaded file: a thousand records are ten requests instead of a thousand.

```js
let result = await skapi.postRecords(
    rows.map(row => ({
        data: row,
        config: { table: { name: 'imports', access_group: 'public' } }
    }))
);

// result
{
    list: [RecordData],   // the records that were saved, in the order given
    failed: [             // the items that were not, each by its position in the list
        { index: 7, code: 'INVALID_PARAMETER', message: '...' }
    ]
}
```

An item that is refused does not stop the others: it is reported in `failed` with its `index`, and the same `code` and `message` `postRecord()` would have rejected with, while the items after it are still saved. `list.length + failed.length` is always the number of items given. Only a failure of a request itself (the connection, the sign-in, a server error) rejects the call, and the records saved before it stay saved.

Every item follows the rules of `postRecord()`: the same `config` keys, the same table naming rules, the same access checks. Two differences: `data` is an object (or null), not a form with files, and files are not attached here. Attach them to a saved record with [`uploadFiles()`](#doc-database-handling-files-uploading-files-to-an-existing-record), which with many files asks for its upload permits in batches as well.

### [`postRecords(items):Promise<PostRecordsResult>`](#doc-api-reference-database-postrecords)

## Large Record Data <a id="doc-database-create-large-record-data"></a>

There is no separate call for a large payload. When a record's `data` is larger than 32 KB, Skapi stores the
value as a file in your project's file storage instead of keeping it in the database, and the record keeps
only a reference to it. The value then counts toward your file storage instead of your database storage, see
[Plans and Limits](#doc-introduction-plans-large-record-data).

Reading it back needs nothing from you either. The SDK fetches the value and puts it in `record.data`, so
what you get is what you posted, whatever its size. The file it lives in never appears in the record's `bin`,
and it cannot be uploaded or deleted by name: `"__data__/__json__.json"` is a reserved file name in
[`postRecord()`](#doc-api-reference-database-postrecord) and in
[`deleteFiles()`](#doc-api-reference-database-deletefiles).

# Fetching Records <a id="doc-database-fetch"></a>

The [`getRecords()`](#doc-api-reference-database-getrecords) method allows you to fetch records from the database. It retrieves records based on the specified query parameters and returns a promise that resolves to the [DatabaseResponse](#doc-api-reference-data-types-databaseresponse) containing the [RecordData](#doc-api-reference-data-types-recorddata) object.

It takes two arguments:
- `query`: Specifies the query parameters for fetching records.
- `fetchOptions`: (optional)
    Specifies additional configuration options for fetching database records
    For more information, see [Database Fetch Options](#doc-database-fetch-database-fetch-options).

### Fetching Records from a Table

```js
let query = {
    table: { name: 'my_collection', access_group: 'public' }
}

skapi.getRecords(query).then(response=>{
    // response
    /**
     * endOfList: true,
     * list: [
     *  ...
     * ],
     * startKey: 'end',
     * ...
     */
});
```

The example above retrieve records from a table named 'my_collection' with `access_group` that are `public`
The `table` parameter in the `query` argument sets the table name you want to fetch records from.
The retrieved records are accessed through the `response.list` property.

When fetching the records with access restrictions, see [`Access Restrictions`](#doc-database-access-restrictions).

For more detailed information on all the parameters and options available with the [`getRecords()`](#doc-api-reference-database-getrecords) method,
please refer to the API Reference below:

### [`getRecords(query, fetchOptions?): Promise<DatabaseResponse<RecordData>>`](#doc-api-reference-database-getrecords)

### Fetching Record by ID

You can fetch a record by its unique ID using the [`getRecords()`](#doc-api-reference-database-getrecords) method. When fetching a record by ID, you don't need to provide any additional configuration parameters.

```js
let query = {
    record_id: 'record_id_to_fetch'
};

skapi.getRecords(query).then(response => {
    // response
    /**
     * endOfList: true,
     * list: [{
     *  ... // only 1 result
     * }],
     * startKey: 'end' // startKey is 'end' as no more records can be retrieved
     */
});
```

In this example, the `query` object includes the `record_id` property set to the ID of the record you want to fetch.
`record_id` is a unique identifier for each record in the database.

The `response.list` will contain the record data if the record exists.

## Database Fetch Options <a id="doc-database-fetch-database-fetch-options"></a>

[FetchOptions](#doc-api-reference-data-types-fetchoptions) can control the number of the record per fetch, fetching the next batch of records, fetching the records by ascending/descending order... etc.

This is used globally for all database related methods that allows optional [FetchOptions](#doc-api-reference-data-types-fetchoptions) argument.

See full list of parameters: [FetchOptions](#doc-api-reference-data-types-fetchoptions)

#### Limit Results with `fetchOptions.limit`

By default, 50 sets of the data will be fetched per call.
You can adjust the limit to your preference, allowing up to **1000 sets of data**, by using the `limit` key.

#### Fetch More Results with `fetchOptions.fetchMore`

To fetch the next batch of results, you can set the `fetchOption.fetchMore` to `true`.
When set to `false`(default), database will always return the first batch of the data.

This allows you to retrieve results in batches until the end of the list is reached.

#### Order results with `fetchOptions.ascending`

By default, the database fetch the data in ascending order.
If set to `false`, list of data can be fetched in descending order

For example, let's say there is millions of record in the database table 'my_collection'.
We can fetch the first 100 data, then paginate to the next 100 data by setting `fetchOptions.fetchMore` to `true`.

``` js
let query = {
    table: { name: 'my_collection', access_group: 'public' }
}

let fetchOptions = {
  limit: 100, // Limit each fetch to 100 data.
  fetchMore: false, // When false, database always gives you the first batch of data.
  ascending: false // Fetch in decending order.
}

skapi.getRecords(query, fetchOptions).then(res=>{
  console.log(res.list); // List of up to 100 data in the database.

  if(!res.endOfList) {
    // If there is more data to fetch, and if user chooses to, they can retrieve the next batch of 100.

    fetchOptions.fetchMore = true;
    if(confirm('Fetch more records?')) {
        skapi.getRecords(query, fetchOptions)
            .then(res=>{
                console.log(res.list); // List of the next 100 data from the database.
            }
        );
    }
  }
});
```

In this example, after the first call to the database, we see the `endOfList` value is not `true`.
This means there are more data left to fetch in the database.

To fetch more data in the database, we set `fetchOptions.fetchMore` to `true` and call the method again.
This allows to fetch the next batch of 100 data on each execution until the end of the list is reached.

:::tip
When using the `fetchMore` parameter, you must check if the response's `endOfList` value is `true` before making the next call.
The database will always return an empty list if the `fetchOptions.fetchMore` is set to `true` and it had reached the end of list and.

You can however, initialize your fetch and refetch from start by toggling `fetchOptions.fetchMore` back to `false`.
:::

:::tip
You can fetch all the data at once by recursively calling the method until the `endOfList` value is `true`.
However for efficiency, avoid trying to fetch all the data at once. Fetch only data the user needs and paginate when necessary.
:::

# Table Information <a id="doc-database-table-info"></a>

Skapi tracks all tables in your database.
Use [`getTables()`](#doc-api-reference-database-gettables) to retrieve table names, record counts, and table sizes.

You can fetch the full table list like this:

```js
skapi.getTables().then(response => {
    console.log(response); // List of all tables in the database
});
```

Called with no argument, or with an empty query object, [`getTables()`](#doc-api-reference-database-gettables) returns **every** table in the project.
That is how you list everything: give no `table`, and give no `condition`.

### Who can read table information

When the project's [Require Login](https://docs.skapi.com/service-settings/service-settings.html#require-login) setting is on, [`getTables()`](#doc-api-reference-database-gettables) is refused to a visitor who is not signed in.
The SDK throws `REQUIRE_LOGIN` before the request leaves the browser, and the backend refuses the same call with `INVALID_REQUEST`, so a direct API call is refused as well.
A project that has never set that option counts as **on**.

If a signed out page genuinely needs the listing, turn Require Login off in the project settings.

Table **names** are never filtered: the response carries every table in the project, whatever access group the records inside it live in.
The per access group record counters are filtered to the caller:

| Counter | Who receives it |
| --- | --- |
| `number_of_records_in_access_group_public` | Everyone, signed in or not. |
| `number_of_records_in_access_group_authorized`, and every numbered group up to the caller's own | A signed in user, up to and including their own access group. |
| `number_of_records_in_access_group_private`, `number_of_records_in_access_group_admin` | Admins and the project owner. |

`number_of_records` and `size` are **not** filtered.
Both are totals over every access group in the table, so `number_of_records` is normally larger than the counters you can see add up to.

### Querying tables <a id="doc-database-table-info-querying-tables"></a>

To retrieve information for a specific table, pass an existing table name:

```js
skapi.getTables({
    table: 'my_collection',
}).then(response => {
    console.log(response.list);
    // [{
    //     table: 'my_collection';
    //     number_of_records: string;
    //     size: number;
    //     number_of_records_in_access_group_public?: number;
    //     number_of_records_in_access_group_private?: number;
    //     number_of_records_in_access_group_authorized?: number;
    //     number_of_records_in_access_group_admin?: number;
    //     number_of_records_in_access_group_xx?: number; // for other access groups
    // }]
})
```

No `condition` is given here, and an omitted `condition` is an **exact** match on the table name.
This returns the table named `my_collection` and nothing else.

You can also query table names using `condition`.

```js
skapi.getTables({
    table: 'C',
    condition: '>'
}).then(response => {
    console.log(response); // Table names starting from 'C'
})
```

In this example, `condition` is set to `>` and `table` is set to `C`.
This returns table names that come after `C` in lexicographic order, such as `Cc`, `D`, `E`, and `F`.

To fetch table names that start with a prefix, set `condition` to `>=`:

```js
skapi.getTables({
    table: 'my_',
    condition: '>='
}).then(response => {
    console.log(response); // Table names starting from 'my_' (for example, 'my_collection')
})
```

While you are exploring and do not know the exact spelling, pass `>=` rather than omitting `condition`.
It is a prefix search, so it also surfaces related entries: in real data a table name very often carries a leading name shared with the rest of its data set, such as a source or dataset prefix, so `my_collection` and `my_collection_archive` are only found together by the prefix.
An exact match finds one spelling and silently misses its siblings.
When you already know the exact name, omitting `condition` is the exact match you want.

To fetch table names that **end with** a value, set `condition` to `<=`:

```js
skapi.getTables({
    table: '_archive',
    condition: '<='
}).then(response => {
    console.log(response); // Table names ending with '_archive' (for example, 'my_collection_archive')
})
```

An 'ends with' search reads each table name from its last character backwards, and the results come back in that order. `fetchOptions.ascending` still applies, but it orders by that reversed reading rather than by the table name itself, so sort the returned list yourself if you need it ordered by name.

So there are six ways the call can land:

- no argument, or an empty query object: every table in the project.
- `table` alone: an exact match on that name.
- `table` with `condition: '>='`: every table name starting with that value.
- `table` with `condition: '<='`: every table name ending with that value.
- `table` as an empty string: rejected with `"table" should not be empty.`
- `condition` with no `table`: rejected with `"table" is required for condition.`

```js
skapi.getTables({ table: '' });            // Error: "table" should not be empty.
skapi.getTables({ condition: '>=' });      // Error: "table" is required for condition.
```

Condition-based table search is useful when you need to check whether a table already exists before uploading data.

For more detailed information on all the parameters and options available with the [`getTables()`](#doc-api-reference-database-gettables) method,
please refer to the API Reference below:

### [`getTables(query, fetchOptions?): Promise<DatabaseResponse<Table>>`](#doc-api-reference-database-gettables)

# Access Restrictions <a id="doc-database-access-restrictions"></a>

Skapi database allows you to set access restrictions on records. This allows you to control who can access your records.

You can add additional settings to your `table` parameter by using an `object` instead of a `string` in your `config.table`.
This allows you to set access restrictions on records using the `access_group` parameter.

The following values can be set for `table.access_group`:

- Number 0 to 99: Integer from 0 to 99 can be set to define the access level.
- `private`: Only the uploader of the record will have access.
- `*`: Shorthand for `private`. The SDK converts it to `private` before the request is sent.
- `public`: The record will be accessible to everyone. (Equivalent to number 0)
- `authorized`: The record will only be accessible to users who are logged into your project. (Equivalent to number 1)
- `admin`: The record will only be accessible to admins (access groups `90` ~ `99`) and the project owner. (Equivalent to number 99)

If `access_group` is not set, what happens depends on **which form** the table was written in, because a table written as a plain string is not the same request as a table object with the `access_group` key left out. See [The `table` shorthand and `access_group`](#doc-database-access-restrictions-the-table-shorthand-and-access-group) below.

::: tip
Users can only access records that have an access group that is the same or a lower number than the access group defined in their user profile.

Admins (access groups `90` ~ `99`) and the project owner are the exception: they query and upload records in **every** access group, including `admin` (`99`). Fetching a single record by its `record_id` is stricter for admins in access groups `90` ~ `98`: a record in a higher access group than their own is refused with `No access to the record.` Admins in access group `99` and the project owner reach any record. Private records are off limits to all of them. See [Admin Permissions](#doc-admin-permissions-records).

The user profile's access group is set by the project owner, and by admins with [`grantAccess()`](#doc-api-reference-admin-grantaccess), up to their own access group and only on accounts below their own. See [Granting an access group](#doc-admin-permissions-granting-an-access-group).
:::

::: tip
Unless the user is referencing a private access granted record, the user cannot upload a record with `access_group` set to a higher level than their own access level. Admins and the project owner can.

You can read more about [referencing records](#doc-database-referencing).
:::

::: warning
Anonymous (unsigned) users can only create records with `access_group` set to `public`.
:::

## The `table` shorthand and `access_group` <a id="doc-database-access-restrictions-the-table-shorthand-and-access-group"></a>

Everywhere a `table` is accepted, it can be written two ways, and the two do **not** mean the same thing when no
`access_group` is given.

Writing the table as a plain **string** is shorthand that pins access group `0` (`public`):

```js
// These two calls are identical. The string form fills in access_group: 0 for you.
await skapi.getRecords({ table: 'notes' });
await skapi.getRecords({ table: { name: 'notes', access_group: 0 } });
```

That is true on `getRecords()`, on `deleteRecords()`, and on a `postRecord()` that **creates** a record (one called
without a `record_id`). It is worth being deliberate about on a delete: `deleteRecords({ table: 'notes' })` does not
empty the table, it deletes your public records in it, and the records the same table holds at any other access group
are left exactly where they are.

Writing the table as an **object** sends only the keys you actually wrote. Leaving `access_group` out of the object
sends no access group at all, and the backend decides the scope of the call:

- For a signed in user who is not the project owner, no access group resolves to group `0`, so it reads and deletes the
  same public records the shorthand would have.
- For the **master** (project owner) account, it is not restricted to one group: the call spans every access group in
  the table. That is the reason the two forms are kept apart. A dashboard or an admin tool that wants to see a whole
  table wants the object form, and a call that must stay inside the public records wants the shorthand.

```js
await skapi.getRecords({ table: 'notes' });            // public records only, for everyone
await skapi.getRecords({ table: { name: 'notes' } });  // no group sent: every group, when the caller is the master
```

A listing that spans every group includes the `private` records of your users, and those come back with their
`data` withheld. That is true of every way of listing them, so an admin tool reads the whole table without
reading anyone's private payload. See [Private Records](#doc-database-access-restrictions-private-records).

::: warning
On an **update**, neither form sends an access group. `postRecord(data, { record_id, table: 'notes' })` and
`postRecord(data, { record_id, table: { name: 'notes' } })` both leave the record in whatever access group it is
already in, and the backend fills the group back in from the stored record. This is what makes an update safe to write
without repeating the group every time. To actually **move** a record between groups, name the group you want:
`table: { name: 'notes', access_group: 'private' }`. Be aware that moving a record in or out of `'private'` is not a
metadata change, see [Encrypting Private Record Data](#doc-database-encryption).
:::

## Creating Record With Access Restrictions

Here's an example that demonstrates uploading record with `authorized` level access:

```js
let data = {
    myData: "Only for authorized users"
};

let config = {
    table: {
        name: 'ForAuthorizedUsers',
        access_group: 'authorized'
    }
};

skapi.postRecord(data, config).then(record => {
    console.log(record); // Only the logged users will have access this record.
});
```

## Fetching Records with Access Restrictions

In order to fetch records with `access_group` that is not `public`, you need to specify the `access_group` you are trying to fetch from. In this example, we are trying to fetch records from the "ForAuthorizedUsers" table with `authorized` access.

```js
let config = {
    table: {
        name: 'ForAuthorizedUsers',
        access_group: 'authorized'
    }
};

skapi.getRecords(config)
    .then(response => {
        // response
        /**
         * endOfList: true,
         * list: [
         *  {
         *      data: { myData: "Only for authorized users" },
         *      table: { name: 'ForAuthorizedUsers', access_group: 'authorized' },
         *      ...
         *  }, ...
         * ],
         * startKey: 'end',
         * ...
         */
    });
```

## Private Records <a id="doc-database-access-restrictions-private-records"></a>

Private records are only accessible to the uploader of the record.

A private record that references another record is also readable by the users with private access to that record: its uploader, and the users it is shared with. This does not depend on the referenced record's access group, so a public record opens none of the private records attached to it.

The project owner and admins read a private record's data only on the same terms as any other user. See [Private records](#doc-admin-permissions-private-records).

The example below demonstrates uploading a private record:

```js
let data = {
    myData: "My private data"
};

let config = {
    table: {
        name: 'PrivateCollection',
        access_group: 'private'
    }
};

skapi.postRecord(data, config).then(record => {
    console.log(record); // Only the uploader will be able to access this record.
});
```

Then, if someone else tries to fetch the record, they will get an error:

```js
let config = {
    record_id: 'record_id_of_the_private_record'
};

skapi.getRecords(config)
    .catch(err=>alert(err.message)); // User has no private access.
```

## Grant Private Access <a id="doc-database-access-restrictions-grant-private-access"></a>

Users can grant private access of their record to other users by using the [`grantPrivateRecordAccess(params)`](#doc-api-reference-database-grantprivateaccess) method.

```js
skapi.grantPrivateRecordAccess({
    record_id: 'record_id_of_the_private_record',
    user_id: 'user_id_to_grant_access'
})
```

When the user is granted access to the record, they will be able to fetch the record either if it's private or even if it has higher access group than the user.

Access granted users can also see all the records that is referencing this record at all access groups including private records.

You can read more about [referencing records](#doc-database-referencing).

## Remove Private Access <a id="doc-database-access-restrictions-remove-private-access"></a>

Users can remove access of their private record from other users by using the [`removePrivateRecordAccess(params)`](#doc-api-reference-database-removeprivateaccess) method.

Admins (access groups `90` ~ `99`) can remove private access from any record in the project, not only their own.
On an [encrypted](#doc-database-encryption) record this stops the user from fetching it, but only the record's uploader can re-encrypt it, so a copy the user already holds stays readable to them.

```js
skapi.removePrivateRecordAccess({
    record_id: 'record_id_of_the_private_record',
    user_id: 'user_id_to_remove_access'
})
```

## Changing the Access Group Clears Private Access <a id="doc-database-access-restrictions-changing-the-access-group-clears-private-access"></a>

Moving a record **into or out of** the `private` access group removes **every private access
grant on it**.

| Change | Grants |
| --- | --- |
| `private` to any other group | **cleared** |
| any other group to `private` | **cleared** |
| between two non-private groups | untouched |
| no access group change | untouched |

`private` is the only access group where a grant means *"this named user may read this one
record"*. In every other group a grant only widens what an already-qualifying user may do,
such as reading a record above their own access level or seeing the records referencing it,
so those grants stay meaningful and are left alone.

Leaving them in place on the way **out** of `private` would keep an access model the record
no longer uses. Leaving them in place on the way **back in** would silently re-grant users
the owner never re-approved.

```js
// shared with a colleague
await skapi.grantPrivateRecordAccess({ record_id, user_id: 'colleague_user_id' });

// this clears that grant, and so would moving it back to 'private' afterwards
await skapi.postRecord(undefined, {
    record_id,
    table: { name: 'my_collection', access_group: 'authorized' }
});
```

:::warning
The grants are gone, not suspended. If those users should still have access after the
change, grant them again.
:::

This is handled server side, so it applies to every project.

## Only the Owner Can Cross the Private Boundary <a id="doc-database-access-restrictions-only-the-owner-can-cross-the-private-boundary"></a>

A record can only be moved **into or out of** `private` by **the user who owns it**.

Nobody else can change anything on a user's private record either, subscription settings
included, or attach and delete its files. This holds for the project owner and admin accounts
too.

The two refusals below are what the project owner and admin accounts get, because they are the
only other accounts allowed to update another user's record at all (on a read-only record, admins
in access groups `90` ~ `98` get `Record is read only.` first). Any other user is refused
before either check, for a private record or not, with:

```ts
{
    code: "INVALID_REQUEST";
    message: "User has no access to update this record.";
}
```

A request from the project owner or an admin account that sets `table.access_group` of a user's
private record to any other group, moving it out of `private`, is refused with:

```ts
{
    code: "INVALID_REQUEST";
    message: "User has no access to change the private access of the record.";
}
```

From the project owner or an admin account, any other change to that record, including a
request that keeps it in `private` or changes only its subscription settings, and moving another
user's record into `private`, are refused with:

```ts
{
    code: "INVALID_REQUEST";
    message: "Only the owner of a record can move it into or out of the private access group.";
}
```

The reason is that `private` is the only access group whose contents may be
[end-to-end encrypted](#doc-database-encryption). Nobody but the owner holds the key, so a
different account moving the record across that line would either seal it under a key the
owner does not have, or publish bytes that nobody can decrypt. Both destroy the record while
reporting success, and neither is recoverable.

The rule is enforced whether or not encryption is enabled, so that a project cannot enable
it later and discover its records were already stranded.

On another user's non-private record, the project owner and admins in access group `99` can
change the data and the other settings, including moving it between any two non-private
access groups and changing its subscription settings. The project owner cannot make it
read-only. Admins in access groups `90` ~ `98` can change none of its data or settings
except its subscription settings. Files are a separate right: the project owner and every admin
(access groups `90` ~ `99`) can attach files to it and delete its files, see [Files on
Records of Other Users](#doc-database-handling-files-files-on-records-of-other-users).
On a record posted by an anonymous user, subscription settings can only be kept or turned
off. See [Admin Permissions](#doc-admin-permissions-updating-another-user-s-record) and
[Subscription](#doc-database-subscription-who-can-change-subscription-settings).

## Allowing Others to Grant Private Access to Others

By default, The owner of the record has access to grant private access of the uploaded record to others.

The owner of the record can also allow other granted users to grant private access of the uploaded record to others.

When uploading a record, if the uploader set `source.allow_granted_to_grant_others` to `true` users with private access to the record can grant access to other users as well.

```js
skapi.postRecord(null, {
    table: {
        name: 'record_can_be_granted',
        access_group: 'private'
    },
    source: {
        allow_granted_to_grant_others: true
    }
}).then(r=>{
    // now other users with an private access can also grant private access to the record (r) to others.
})
```

## Listing Private Access Grants <a id="doc-database-access-restrictions-listing-private-access-grants"></a>

You can list records or users that have been granted private access using the [`listPrivateRecordAccess(params, fetchOptions)`](#doc-api-reference-database-listprivaterecordaccess) method.

:::warning IMPORTANT
Provide either `record_id` or `user_id` (at least one is required).
:::

```js
skapi.listPrivateRecordAccess({
    // Optional: one or both of these fields
    record_id: 'record_can_be_granted',
    user_id: 'user_id_to_check_granted'
}).then(res => {
    // Response shape:
    // {
    //   list: [
    //     { user_id: 'xxxx-xxxx...', record_id: 'record_id_123' },
    //     ...
    //   ],
    //   ...
    // }
})
```

# Encrypting Private Record Data <a id="doc-database-encryption"></a>

Skapi can encrypt the `data` of your private records **in the browser**, before it reaches
the database. The keys are derived from the user's own password and never leave their device,
so the service provider running the backend cannot read the contents of a record its owner
keeps to themselves.

This is off by default, and the guarantee is narrower than "your data is encrypted". Read
this whole page before turning it on. In particular:

- Only the `data` attribute and the contents of attached files are covered. Index values,
  tags and filenames are not, and for many applications those are the sensitive part.
- An encrypted file's `url` stops being directly fetchable, so `<img src>` and plain
  download links break for those files.
- The strength is capped by the user's password, because Web Crypto offers no memory-hard KDF.
- **Sharing** a record involves a public key that the provider serves, which is a weaker
  footing than the rest of the design and needs to be described accurately to a customer.
- A forgotten password with no recovery code and no unlocked device means the data is gone.

## What is protected, and what is not

Only the **value of the `data` attribute** is encrypted, and only on records whose
`access_group` is `'private'`.

Everything the database queries on stays in plaintext, because that is the only way your
queries keep working:

| Plaintext (the provider sees this) | Encrypted |
| --- | --- |
| `record_id`, `unique_id` | `data` |
| `table.name`, `access_group` | the **contents** of files in `bin` |
| `index.name` and `index.value` | |
| `tags` | |
| `reference` and the whole record graph | |
| file **names**, sizes and upload times | |
| `uploaded` / `updated` timestamps, record sizes | |
| the list of users a record is shared with | |

Only the `data` attribute and the **bytes** of attached files are covered. A file's name is
part of its storage key, so it stays visible.

::: danger HTTPS required!
Record encryption only runs on a page served over **https**. `localhost` and `127.0.0.1`
also count, so local development works.

The reason is the browser, not skapi. `crypto.subtle` is only exposed in a *secure context*.
On plain http the page still gets `crypto.getRandomValues`, but `crypto.subtle` is
`undefined`, so no key can be derived and nothing can be encrypted or decrypted.
:::

## The security claim, stated exactly

The claim is not one sentence, it is two, because a record a user keeps to themselves and a
record they share with someone else rest on different foundations.

**For a user's own private records, never shared:**

> A service provider who dumps the database, the storage bucket, and every request log
> cannot recover the record's `data` without a successful offline guess against the user's
> password.

No key comes from the server on this path. The master key is derived in the browser from the
password, so this holds on cryptography alone.

**For records the user has shared with someone else:**

> The provider cannot recover the record's `data` by reading its own database. It *can*
> obtain that record's data key by serving a forged public key at the moment the user
> shares, which the user can detect by comparing key fingerprints, and which
> `trustPolicy: 'strict'` prevents outright.

Sharing requires fetching the other person's public key, and the provider serves that
directory. See [Key substitution](#doc-database-encryption-key-substitution-the-residual-risk-in-sharing) for the
full picture, because this is the first thing a security auditor will test.

### The ceiling on both

Both are bounded by **password entropy**, because the Web Crypto API offers only PBKDF2 (no
Argon2id, no memory-hard KDF). Skapi's default minimum password is six characters, and a
six-character password falls to an offline attack in minutes.

If you are making this promise to a customer, set `minPasswordLength` and enforce a real
password policy in your signup form. A user who keeps their [recovery
code](#doc-database-encryption-recovery-codes) is better off, since that is 128 random bits rather than a
memorable string, but the password remains the floor for anyone who does not.

## Turning it on

```js
const skapi = new Skapi("<Project ID>", { encryption: true });
```

`encryption` is an option of the Skapi class, and it only takes effect when the class is
initialized. There is no method that turns it on later: an instance built without this
option saves the `data` of every record as plain text for its entire lifetime, and enabling
it afterwards does not go back and encrypt what was already saved. It is listed with the
rest of the initialization options in
[Advanced Settings](#doc-introduction-codebase-advanced-settings).

With options:

```js
const options = {
    encryption: {
        iterations: 600000,      // PBKDF2 cost. Default 600000, minimum 100000.
        minPasswordLength: 12,   // refuse to enroll a weaker password. Default 0 (off).
        persistDevice: true,     // stay unlocked across a page reload. Default true.
        recovery: 'code',        // issue a one-time recovery code. Default 'code'.
        trustPolicy: 'tofu',     // 'tofu' (default) or 'strict'. See Sharing.
        withheld: 'null'         // 'null' (default) or 'sentinel'. See below.
    }
};

const skapi = new Skapi("<Project ID>", options);
```

Then write records exactly as you always have:

```js
await skapi.postRecord(
    { diagnosis: 'confidential' },
    { table: { name: 'notes', access_group: 'private' } }
);

const res = await skapi.getRecords({ table: { name: 'notes', access_group: 'private' } });
console.log(res.list[0].data); // { diagnosis: 'confidential' }
```

There is no key to manage and nothing for the end user to do. Logging in unlocks
encryption; a page reload stays unlocked.

## Changing the access group

If you change a record's access group to anything other than `'private'`, the data is
written back **decrypted**, as an ordinary object. A record that is no longer private is
no longer unreadable:

```js
// starts encrypted
const rec = await skapi.postRecord(
    { note: 'secret' },
    { table: { name: 'notes', access_group: 'private' } }
);

// now stored in the clear, readable by anyone
await skapi.postRecord(
    { note: 'secret' },
    { record_id: rec.record_id, table: { name: 'notes', access_group: 0 } }
);
```

The reverse also holds: moving a public record to `'private'` encrypts it on the way in.

::: warning A group change alone publishes the plaintext
You do not have to pass the data along with the group change, and that is worth knowing
before you write the call. Moving an encrypted record to a non-private group **without**
restating its data does not leave the ciphertext stranded: the SDK decrypts what is stored
and writes it back in the clear, because a record nothing will ever try to decrypt is
worse than one that is readable on purpose.

The consequence is that a bare access-group change is a publish. If the intent was only to
reorganize a table, move the record to another private group instead.

The SDK can only do this while encryption is unlocked and the caller can actually read the
record. A session that cannot decrypt it is refused rather than allowed to strand or
destroy the payload.
:::

### Attachments move with the record

Files are converted in whichever direction the record is going: sealed files come back to
plaintext when a record leaves `'private'`, and plaintext files are sealed when it arrives.
Without this, declassifying produced a public record holding files nobody could ever open
again, because the payload was decrypted while the key went with the envelope.

The order is what makes each direction safe to interrupt:

| Direction | Order | If it dies halfway |
| --- | --- | --- |
| `private` to anything | files first, then the group flip | still private, some files already plaintext. Readable, and re-running finishes it |
| anything to `private` | group flip first, then the files | private with its data sealed, some files still in the clear. Less protected than intended, nothing lost |

Neither is atomic and neither can be: a group change is one write, and every file is a
separate object. What they are is **monotonic**. No interruption destroys content, and
re-running the same update completes the job.

::: warning This costs requests
A group change on a record with attachments downloads, re-encodes and re-uploads every one
of them. An update that states a non-private group also costs one extra read, to find out
whether there are any sealed files at all. Changing the access group of a record with many
large files is not a cheap operation.
:::

### The service owner cannot change these settings for someone else

A master account can update any non-private record in its project, but **not** move someone
else's record into or out of `'private'`. That is refused in both directions, and the SDK refuses
it earlier with `ENCRYPTION_NOT_RECORD_OWNER` so the reason is clear before a round trip:

- Making **someone else's record private** would seal it under the master's key, with the
  envelope naming the master as owner while the record belongs to someone else. The binding
  check then refuses it for everyone, the real owner included. The record would be destroyed
  and the call would report success.
- **Declassifying someone else's** encrypted record cannot work either, since the master has
  no key to decrypt what it would have to write back in the clear.

Encryption operates only on records you own, whatever access level you hold. A master who
needs one of these changes has to have the owner make it.

This is enforced in the **backend**, not only in the SDK, so a direct
REST call is refused too. Declassifying someone else's private record, by setting its
`table.access_group` to any non-private group, is refused with:

> User has no access to change the private access of the record.

Making someone else's record private, and any other change to someone else's private record,
including a request that keeps it in `'private'` or changes only its subscription settings,
are refused with:

> Only the owner of a record can move it into or out of the private access group.

A master may still move someone else's record freely between any two **non-private** groups,
but cannot change anything on someone else's private record, nor attach or delete its files.
The rule is specifically about the private boundary, because `'private'` is the only
group whose contents may be encrypted, and no amount of privilege substitutes for a key that
only the owner has. See [Only the Owner Can Cross the Private
Boundary](#doc-database-access-restrictions-only-the-owner-can-cross-the-private-boundary).

On its own records a master follows the usual rules, with one exception: the project owner's
account is not a user of the project, so it cannot make its own records private or read-only,
or give them subscription settings. See [Admin Features](#doc-admin-intro-what-project-owners-cannot-do).

## Private grants are cleared at the boundary <a id="doc-database-encryption-private-grants-are-cleared-at-the-boundary"></a>

Crossing into or out of `'private'` **removes every private-access grant on the record**.

`'private'` is the only access group where a grant means *"this named user may read this one
record"*. In every other group a grant only widens what an already-qualifying user may do,
such as referencing the record, so those grants stay meaningful and are left alone.

| Change | Grants |
| --- | --- |
| `private` to anything | **cleared** |
| anything to `private` | **cleared** |
| between two non-private groups | untouched |
| no group change | untouched |

Leaving them in place on the way *out* would keep an access model the record no longer uses.
Leaving them in place on the way *back in* would silently re-grant users the owner never
re-approved, and with encryption enabled it would produce a record whose ACL says "shared"
while its key wraps say otherwise: the ACL would let those users fetch it and the crypto
would refuse them.

This runs in the record stream, so it applies **whether or not encryption is enabled**. If
the users should still have access after the change, grant them again.

::: tip State the table on updates
When you update a record without a `table`, the SDK has to fetch the record to learn its
current access group, which costs one extra request. Passing
`table: { name, access_group }` avoids it.
:::

## Sharing a record <a id="doc-database-encryption-sharing-a-record"></a>

The record's owner can give other users decrypt access. This works through the normal
grant API, with the key wrapping handled for you:

```js
await skapi.grantPrivateRecordAccess({
    record_id: rec.record_id,
    user_id: 'the-other-users-id'
});
```

Behind that call the SDK fetches the other user's public key, wraps this record's data key
to it, and writes the result onto the record **before** granting access at the ACL level,
so a failure never leaves a grant that the recipient cannot use.

The grantee then reads the record normally, with their own session. **The owner does not
need to be online for that**: the wrapped key sits on the record permanently. The owner is
only needed at the moment of granting, because only someone who can already decrypt the
record can wrap its key for someone new.

Revoking removes the wrap **and rolls the data key**, re-encrypting the record so the
revoked user cannot read future versions:

```js
await skapi.removePrivateRecordAccess({
    record_id: rec.record_id,
    user_id: 'the-other-users-id'
});
```

Revocation is **forward-only**. It cannot un-read what they already read, and if they kept
a copy of the old ciphertext and their old wrap, that copy stays readable to them forever.

::: warning A group change clears grants
Moving a record into or out of `'private'` removes every grant on it. See
[Private grants are cleared at the boundary](#doc-database-encryption-private-grants-are-cleared-at-the-boundary).
:::

### Rules for sharing

- Only the **owner** can share or revoke an encrypted record, because both mean writing to
  it. Delegated granting via `source.allow_granted_to_grant_others` does not work on
  encrypted records. An admin can still remove a user's private access to it, which stops
  that user from fetching the record, but only the owner can roll the data key; an admin
  whose session has encryption enabled gets `ENCRYPTION_NOT_RECORD_OWNER` after the access
  is removed.
- The recipient must have logged in at least once with encryption enabled, so that they
  have a published key. Granting to a user with no key throws
  `ENCRYPTION_RECIPIENT_HAS_NO_KEY` and changes nothing.
- Each recipient adds roughly 230 bytes to the record, carried on every read and write of
  the envelope. On a large record the payload itself is stored separately and is **not**
  re-uploaded when you share it: see [Large payloads](#doc-database-encryption-large-payloads).

### Key substitution: the residual risk in sharing <a id="doc-database-encryption-key-substitution-the-residual-risk-in-sharing"></a>

This is the one place where the guarantee rests on trust rather than on mathematics, so it
is worth understanding properly rather than being reassured about.

#### The attack

Alice wants to share a record with Bob. Her browser asks the backend for Bob's public key.
The backend returns a key **whose private half the provider holds**. Alice's SDK wraps the
record's data key to it, and the provider can now open that record.

It can be made invisible to both of them: having obtained the data key, the provider can
also wrap it for Bob's real key, so Bob reads the record normally and nothing looks wrong.

And because a record's data key is stable across updates, one successful substitution yields
that record's contents from then on, not only the version that existed at that moment, until
a revoke rolls the key.

#### What it does not reach

Three limits, and they are the reason this does not sink the design:

- **It is an active attack, not a passive one.** A provider who dumps the database, the
  bucket and every request log still gets nothing. This requires deliberately serving a
  forged key at the moment of a specific share. That is a different category of act: it lives
  in deploy history and source control, where it can be audited.
- **It cannot touch a user's own unshared records.** That path never asks the server for a
  key. The exposure is limited to records the user chose to share, and only to shares made
  after the attack begins.
- **It compromises no identity.** What the provider gets is one record's data key. Not the
  user's master key, not anyone's private key, not any other record.

#### Why no protocol fixes it

Every end-to-end system with a provider-hosted key directory has exactly this property:
Signal, WhatsApp, iMessage, Proton. It is a property of the trust topology, not a flaw in
this implementation.

A signature scheme is the tempting wrong answer. Bob signing his own public key with his own
identity key is self-referential, so a provider substituting both produces a signature that
verifies perfectly. Closing this needs a trust root the provider does not control.

#### The three mitigations, in increasing order of cost

**1. Fingerprint pinning.** The SDK refuses to accept a *changed* key silently. If a peer's
fingerprint differs from the one first seen, the grant throws
`ENCRYPTION_PEER_KEY_CHANGED`, because that is what a legitimate account reset looks like
and also what an attack looks like, and guessing between them is not the SDK's call:

```js
// after verifying the fingerprint with them out of band
await skapi.pinPeerKey({ user_id: 'their-id', fingerprint: 'the-verified-value' });
```

Pins are stored encrypted under the user's own master key, so the provider can destroy them
(which is loud: every peer becomes unseen again) but cannot edit them undetectably.

This is Signal's safety numbers. It means a provider cannot silently swap a key it has
already been observed serving.

**2. `trustPolicy: 'strict'`.** Requires a pin before *any* first share, so there is no
trust-on-first-use window at all. This genuinely closes the hole. The cost is that users
must verify a fingerprint before they can share with someone new, which is right for a
security-conscious tenant and too much friction for a consumer app.

**3. Key transparency (not implemented).** Publish every key the directory serves into an
append-only log with inclusion proofs, so a client can verify that the key it was handed is
the same one everyone else was handed, and that a peer's key history contains no unexplained
entries. This is what Apple's Contact Key Verification and WhatsApp's Key Transparency do.
It is the only mitigation that closes the hole without asking users to do anything, and it
is a substantial project: a verifiable log service, proof generation, and client-side
auditing.

#### What to tell a customer

Scope it honestly. An auditor will accept this:

> For your own private records, we cannot read them: the key never leaves your device. For
> records you share, we cannot read them by reading our database. The residual risk is that
> we serve you a forged public key at the moment you share, which you can detect by
> comparing fingerprints, and which strict mode prevents outright.

An auditor will not accept a flat "we cannot see your data" over a trust-on-first-use
directory, because verifying that claim is the first test they will run.

## Attached files <a id="doc-database-encryption-attached-files"></a>

Files attached to a private record are encrypted too, under a key derived from that
record's data key. Anyone who can read the record can open its files, with no extra key
distribution, and a grantee gets both at once.

Only the record's own user can attach files to a private record or delete its files. The
project owner and admins, who may do both on another user's non-private record, are refused
here. See [Files on Records of Other Users](#doc-database-handling-files-files-on-records-of-other-users).

Nothing changes in how you upload:

```js
await skapi.postRecord(
    { title: 'contract' },
    { table: { name: 'notes', access_group: 'private' } },
    [{ name: 'doc', file: myFile }]
);
```

Reading requires `getFile()`, which decrypts:

```js
const rec = (await skapi.getRecords({ record_id })).list[0];
const f = rec.bin.doc[0];

f.encrypted;    // true
f.filename;     // 'contract.pdf'  (names are NOT encrypted)
f.size;         // the PLAINTEXT byte length
f.stored_size;  // the larger, real size on storage

const blob = await f.getFile('blob');   // decrypted
await f.getFile('download');            // decrypted, then saved
```

::: danger The url is ciphertext
`record.bin[].url` serves the encrypted bytes. These stop working for an encrypted file and
there is no SDK-side fix, because the url escapes into places the SDK cannot see:

- `<img src="${file.url}">`, `<video>`, `<audio>`, inline PDF viewers
- a plain `<a href="${file.url}">` download link
- `updateProfile({ picture: file.url })`
- any dashboard or tool that renders `file.url` directly

Anything that needs the bytes must call `getFile()`. Byte-range requests are also gone, so
video seeking and resumable downloads regress for encrypted files.
:::

Everything `getFile()` returns is decrypted, including `'download'`, `'text'` and
`'base64'`. `'endpoint'` returns the raw url, and is therefore ciphertext by definition.

Files on non-private records are untouched, uploaded and served exactly as before.

## Large payloads <a id="doc-database-encryption-large-payloads"></a>

Past roughly 256KB of ciphertext the SDK stores the payload in a file of its own and keeps
only the small part of the envelope in the database:

```js
data: {
    __skapi_enc__: 1,
    iv:     "…",
    k:      { "alice": {...}, "bob": {...} },   // who can decrypt
    ct_ref: "…/__skenc_ct__/payload.bin"        // where the ciphertext lives
}
```

You do not do anything to opt into this and nothing about reading or writing changes. It
matters for one reason: **sharing a large record no longer touches the payload.**

Before, `k` and the ciphertext were one attribute, so adding a single recipient meant reading
the whole record back and writing the whole thing out again to append about 230 bytes of key
wrap. On a 1.9MB record that is a 1.9MB download and a 1.9MB upload, and a transient storage
failure could block a sharing change that never needed the payload at all. Now
`grantPrivateRecordAccess` and `removePrivateRecordAccess` rewrite a few hundred bytes and
never open the file.

Reading the record still fetches the ciphertext, of course. That is the data.

Three details worth knowing:

- **It happens on update, not on create.** A newly created record has no id yet, so there is
  nowhere to put the file. A large create is handled by the server's own offload and splits
  on its first update.
- **The spilled file is not a user file.** It never appears in `record.bin`, exactly like the
  server's offloaded `data` file.
- **The previous file is retired on the next write**, so a record never accumulates stale
  ciphertext.

## Reading a record you cannot decrypt

Decryption never throws, and never fails a whole page. A record you cannot open comes back
with `data: null` and an `encrypted` field explaining why:

```js
const res = await skapi.getRecords({ table: { name: 'notes', access_group: 'private' } });

for (const rec of res.list) {
    if (rec.encrypted?.status === 'failed') {
        console.log(rec.encrypted.reason);
    }
}
```

| `reason` | Meaning |
| --- | --- |
| `NO_SESSION_KEY` | Encryption is locked. Call `unlockEncryption({ password })`. |
| `NOT_A_RECIPIENT` | This user has no key wrap on the record. |
| `BAD_KEY` | The wrap did not open. Usually a rolled or rotated key. |
| `BINDING_MISMATCH` | The envelope does not belong to this record. |
| `CORRUPT` | The payload failed its authentication tag. |
| `UNSUPPORTED_VERSION` | Written by a newer SDK than this one. |
| `ENCRYPTION_DISABLED` | The record is encrypted but this instance has the flag off. |
| `DATA_UNAVAILABLE` | The payload is offloaded to storage and could not be fetched, so whether it is encrypted is unknown. |

`NOT_A_RECIPIENT` and `NO_SESSION_KEY` are deliberately different answers.
`NO_SESSION_KEY` means *"you are on the list, unlock and retry"*. `NOT_A_RECIPIENT`
means *"you are not on the list"*, which no amount of unlocking will change. Membership is
checked first, because it is a lookup that needs no key.

### What the withheld value looks like

By default `data` is `null`, so an ordinary `if (record.data)` check keeps working as a
"do I have data" test.

If you would rather have something explicit, opt in:

```js
const skapi = new Skapi("<Project ID>", {
    encryption: { withheld: 'sentinel' }
});
```

`data` then becomes a frozen, self-describing placeholder carrying its own reason:

```js
{
    __skapi_no_access__: true,
    reason: 'NOT_A_RECIPIENT',
    recipients: ['1111-…', '2222-…']
}
```

```js
if (skapi.isWithheld(record.data)) {
    // show a lock icon rather than an empty record
}
```

::: warning The sentinel is truthy
Every JavaScript object is truthy, so under `'sentinel'` a check like
`if (record.data) render(record.data)` will pass and render the placeholder. That is the
whole cost of this option, and it is why `'null'` is the default. Switch only when you are
ready to replace those checks with `skapi.isWithheld()`.
:::

Writing the placeholder back is refused with `ENCRYPTION_CANNOT_REWRITE_WITHHELD`, so a
read-modify-write by a session that could not decrypt cannot overwrite the record with it.

## What the service owner sees

The backend lets a master account read and delete any record in its project. What it does not
hand over is the payload of another user's private record it has no access to, and that happens
before encryption enters the picture at all: unless it holds private access to the record, or to
the record it references, the database withholds the `data` from the project owner and from admins
in access group `99`, encrypted or not, so a master never receives the envelope at all:

```js
const res = await skapi.getRecords({ record_id });
res.list[0].data;       // { __is_private__: null }
res.list[0].encrypted;  // undefined: no envelope arrived, so there is nothing to report on
```

Nothing throws, and everything else on the record stays fully visible: `record_id`,
`user_id`, index name and value, tags, reference and timestamps. The list of user_ids that
*can* decrypt it travels inside the envelope, so it is not visible either. See [Private
Records](#doc-database-access-restrictions-private-records).

This is also why `withheld: 'sentinel'` and `isWithheld()` have nothing to say here. They
describe a payload the SDK received and could not open. `{ __is_private__: null }` is the
database saying it sent no payload, and a tool that has to tell the two apart checks for the
`__is_private__` key itself.

`deleteRecords` behaves the same way. The delete still happens, and the records it returns
come back with their data withheld where the caller has no access to it. The owner of a record deleting their own gets it back
decrypted, which is the point of a delete that returns what it removed.

The `encrypted` failures on this page are for the callers that do receive an envelope: the
record's own user on a locked or re-keyed session, a user their record was
[shared](#doc-database-encryption-sharing-a-record) with, admins included, and anyone who reaches the record through a
reference but was never given its key.

::: tip Support tooling
Any dashboard or support tool that reads customer record data needs to be identified before
you enable this. It keeps working at the ACL level, and it stops receiving payloads.
:::

A record stored in plaintext has **no** `encrypted` field at all, which is how existing
records keep working unchanged after you enable the feature.

## Locking and unlocking

```js
skapi.getEncryptionStatus();
// { status: 'unlocked', user_id: '...', fingerprint: '...' }

await skapi.unlockEncryption({ password });        // for a session with no device entry
await skapi.lockEncryption();                      // drop keys from memory
await skapi.lockEncryption({ forgetDevice: true }); // and require the password next reload
```

`unlockEncryption` is rarely needed in a browser. You need it when:

- The session was restored from a token on a device that has never been unlocked.
- You are running in **Node**, which has no IndexedDB and is therefore always locked after
  a token restore.
- The user called `lockEncryption({ forgetDevice: true })`.

## Changing a password

`changePassword` re-wraps the master key for the new password before Cognito changes it, so
no record is touched and nothing is re-encrypted.

Encryption must be **unlocked** when you call it. If it is locked, the call is refused with
`ENCRYPTION_LOCKED`, because changing the login password while the keyring is still wrapped
under the old one would leave the user able to log in and never open their own data again:

```js
if (skapi.getEncryptionStatus().status !== 'unlocked') {
    await skapi.unlockEncryption({ password: currentPassword });
}
await skapi.changePassword({ current_password: currentPassword, new_password: next });
```

## The two hard trade-offs

### 1. A password reset destroys the data unless the user has their recovery code

Changing a password (with the old one in hand) is free, as above.

**Resetting** a forgotten password is different. `forgotPassword` / `resetPassword` prove
control of an email address, not knowledge of the old password, so there is nothing to
unwrap the master key with. The records stay encrypted and unreadable.

That is what the recovery code is for. See below.

This is not a gap, it is the guarantee. If an email code could recover the data, the
provider could recover it too, and the claim above would be false.

Warn the user **in your reset flow**, not afterwards.

### 2. Server-side reads stop working on encrypted records

The backend has no key and must not have one, so anything that reads the content is off for
those records:

- server-side search or filtering on `data`
- any support tool or dashboard that displays customer record data
- any future server-side processing of an attachment's bytes

The service owner and master account can still **fetch and delete** an encrypted record at
the ACL level. They receive it with `data` withheld, as described above.

This is not a gap to work around. A backend that could read these records is a backend that
could read them for anyone, and the guarantee at the top of this page would be false.

## Recovery codes <a id="doc-database-encryption-recovery-codes"></a>

At enrollment the SDK mints a one-time recovery code and shows it to you **once**. It is the
only thing standing between a forgotten password and permanent data loss.

### Collecting it

```js
const user = await skapi.login({ email, password });

const recoveryCode = skapi.takeRecoveryCode();
if (recoveryCode) {
    // Show it. Make the user confirm they saved it before continuing.
    // This is the only time it will ever exist.
}
```

`takeRecoveryCode()` returns the code and forgets it. There is deliberately no way to fetch
it again, and no "resend my code" endpoint. If either existed, the SDK (and therefore the
service provider) would be holding the key.

The code looks like `GDQ9-0MZS-X7AV-6YG5-8Z76-4CJ1-PWG0`: 128 bits of entropy in Crockford
base32, so no `I`, `L`, `O` or `U` to misread, plus two check characters. Parsing is
forgiving about case, spaces, hyphens and the classic `O`/`0` and `I`/`1` confusions, and a
single-character typo is reported as a malformed code rather than a wrong one.

### Where it comes from, and why that matters

The code is generated **in the browser**, from `crypto.getRandomValues`. It is never
transmitted. What reaches the database is only the *wrap*: your master key sealed under a key
derived from the code, which is exactly as safe to store as the password wrap sitting beside
it and useless without the code.

::: danger Never let the code reach your server
Do not email it, do not SMS it, do not log it, do not offer to store it. Anything that puts
the code in your infrastructure hands you the master key and makes the security claim on this
page false. "The server generates it and immediately forgets" does not work either, because
nobody can verify that it forgot.
:::

Because the code is high entropy, it is derived with **HKDF, not PBKDF2**. PBKDF2's 600,000
iterations exist because human passwords are guessable; 128 random bits are not, so
stretching would only make recovery slow. A practical consequence: for a user who keeps their
code, the recovery path is **stronger** than their password.

### Using it after a forgotten password

The sequencing is not obvious and you have to get it right. `forgotPassword` and
`resetPassword` are unauthenticated: there is no session during a reset, so the keyring
cannot be touched then. Recovery happens on the **next login**:

```js
// 1. the normal reset flow, unchanged
await skapi.forgotPassword({ email });
await skapi.resetPassword({ email, code, new_password });

// 2. log in with the new password. Encryption will be LOCKED, because the
//    keyring is still wrapped under the password they forgot.
await skapi.login({ email, password: new_password });
skapi.getEncryptionStatus(); // { status: 'locked', reason: 'NO_SESSION_KEY' }

// 3. the recovery code repairs it
const res = await skapi.unlockWithRecoveryCode({
    code: whatTheUserTyped,
    password: new_password        // pass it, or the next login is locked again
});

res.status;        // 'unlocked'
res.repaired;      // true: the keyring now opens with the new password
res.recoveryCode;  // a REPLACEMENT code. Show it, the old one is dead.
```

Passing `password` is what makes this self-healing. Omit it and the current session unlocks,
but the keyring is still wrapped under the forgotten password and the next login is locked
all over again.

### Rotation

A used code has been in a clipboard and possibly a screenshot, so it is retired on use and a
replacement is returned. You can also rotate on demand:

```js
const { recoveryCode } = await skapi.regenerateRecoveryCode();
```

This requires an **unlocked** session, which is the whole access-control story: the only way
to mint a code is to already be able to decrypt. There is no path here that helps someone who
is locked out, and none that lets the provider mint one.

### Opting out

```js
encryption: { recovery: 'none' }
```

No code is issued and no recovery wrap is stored. A forgotten password then means the user's
encrypted records are permanently unreadable, with no way back. Choose this only if that is
genuinely what you want.

## Other limits worth knowing

- **OpenID accounts get no protection.** Their "password" is minted by the skapi backend,
  so a key derived from it gives no confidentiality against the provider. Those sessions
  stay locked, with reason `OPENID_UNSUPPORTED`.
- **Admin-created accounts** start on a provider-issued password. That password should be
  changed before the user stores anything sensitive.
- **Plain http does not work.** `crypto.subtle` requires a secure context, so a page served
  over http (other than localhost) cannot use this feature at all.
- **`__skapi_enc__` is a reserved key** in record data, alongside `__json__` and `__data__`.
- **Size.** Encryption inflates the payload by about 33%, so the practical ceiling for a
  single-recipient record is roughly 1.5MB rather than 2MB. Exceeding it throws
  `ENCRYPTED_DATA_TOO_LARGE`, which names the real limit.
- **Partial updates are gone.** `data` is one sealed blob, so changing one field means
  read, modify, write, and requires decrypt access. A metadata-only update
  (`postRecord(null, { record_id, ... })`) is still fine and leaves the payload alone.
- **Updating a shared record needs decrypt access.** The existing key wraps are read off the
  record and carried forward, so a session that cannot decrypt it is refused rather than
  allowed to write an update that would silently un-share it.
- **Logging out clears the device key.** The next session on that browser needs the password
  again.
- **Cross-project writes are refused.** The key is derived per project; use a separate
  Skapi instance for another service.

## Where the keys live

For the record, and so an auditor does not have to reverse-engineer it:

- A random **master key** per user, wrapped under `HKDF(PBKDF2(password))` bound to
  `(service, owner, user_id)`. Stored in a reserved `__skapi__keyring` table in the user's own
  private partition, readable by nobody else.
- An **ECDH P-256 identity keypair** per user. The private half is encrypted under the
  master key; the public half is published in the same table's `authorized` partition, so
  other logged-in users can wrap keys to it. This directory is the one component a client has
  to *trust* rather than verify, which is why
  [key substitution](#doc-database-encryption-key-substitution-the-residual-risk-in-sharing) is discussed at length
  above.
- A one-time **recovery code**, 128 bits from the browser's CSPRNG, wrapping the master key
  via HKDF. Never transmitted, and stored only as that wrap.
- The master key **sealed under itself** (`self` in the keyring), so a session holding only
  the non-extractable key handle can still recover the raw bytes when it needs to mint a new
  wrap. Opening it already requires holding the key, so it grants nothing.
- A **data key per record**, wrapped once for the owner (symmetric, under the master key)
  and once per grantee (ECDH-ES). All wraps live in the record's own envelope.
- In the browser, the master key is cached in **IndexedDB as a non-extractable
  `CryptoKey`**, so a page reload stays unlocked without a password. Non-extractable means
  injected script can *use* it but cannot read or exfiltrate it. That is a reduction in
  exposure, not immunity: an XSS on your origin can still decrypt anything the user can.

# Unique ID <a id="doc-database-unique-id"></a>

When uploading a record with [`postRecord()`](#doc-api-reference-database-postrecord), you can set a unique ID for the record. This unique ID can be used to fetch the record later.
Unique ID must be a string and must be unique across all records in the table.

This feature is useful when you want to create a record with a unique identifier, such as a order ID, or any other unique identifier.

Unique ID can be used to fetch the record using the [`getRecords()`](#doc-api-reference-database-getrecords) method.

Unique ID can be also used when fetching references of a record.
More on referencing can be found in [Referencing](#doc-database-referencing).

:::warning
Anonymous (unsigned) users cannot create records using unique ID.
:::

## Creating a Record with Unique ID

```js
let data = {
    myData: "This is a record with a unique ID"
};

let config = {
    table: { name: 'my_table', access_group: 'public' },
    unique_id: 'My Unique ID %$#@'
};

skapi.postRecord(data, config).then(record => {
    console.log(record);
    /*
    Returns:
    {
        data: { myData: "This is a record with a unique ID" },
        table: { name: 'my_table', access_group: 'public' },
        unique_id: 'My Unique ID %$#@',
        ...
    }
    */
});
```

The example above demonstrates uploading a record with a unique ID.
When the request is successful, the [RecordData](#doc-api-reference-data-types-recorddata) is returned.

## Fetching a Record with Unique ID

After uploading the record, you can fetch the record using the unique ID with [`getRecords()`](#doc-api-reference-database-getrecords) method.

```js
let params = {
    unique_id: 'My Unique ID %$#@'
};

skapi.getRecords(params).then(response => {
    console.log(response.list);  // record with the unique ID
});
```

## Fetching Unique ID List <a id="doc-database-unique-id-fetching-unique-id-list"></a>

By using [`getUniqueId()`](#doc-api-reference-database-getuniqueid) method, you can fetch list of unique ID's that are registered in your database.

Below is an example where you can fetch list of unique ID that starts with **"guitar_"**

```js
let params = {
    unique_id: 'guitar_',
    condition: '>='
};

skapi.getUniqueId(params).then(response => {
    console.log(response.list);  // [{unique_id: "...", record_id: "..."}, ...]
});
```

With `condition` omitted, the unique ID is matched exactly.
`>=` fetches the unique IDs that **start with** the value, as above, and `<=` fetches the ones that **end with** it:

```js
skapi.getUniqueId({
    unique_id: '_acoustic',
    condition: '<='
}).then(response => {
    console.log(response.list);  // Unique IDs ending with "_acoustic", such as "guitar_acoustic"
});
```

An 'ends with' search reads each unique ID from its last character backwards, and the results come back in that order. `fetchOptions.ascending` still applies, but it orders by that reversed reading rather than by the unique ID itself, so sort the returned list yourself if you need it ordered by unique ID.

## Reserved Prefix: `src::`

Unique IDs that start with `src::` identify the files in your project's file storage.
A file stored at `folder/report.pdf` is identified by the unique ID `src::folder/report.pdf`.
Records that describe a part of that file can extend its ID with `#` or `::`, for example `src::folder/report.pdf#page-3`.

Deleting a file also deletes the records under its ID, so the prefix is reserved:

- The project owner and admins can set any unique ID that starts with `src::`.
- A signed in user who is not an admin can only set `src::<their user ID>/...`, which names a file in their own upload folder.
- Any other unique ID that starts with `src::` is refused with an `INVALID_REQUEST` error.

A record that already has a `src::` unique ID can be updated by anyone who is allowed to write it, as long as the unique ID is left unchanged.

When a file is deleted from the project's file storage, these records are deleted with it:

- The record whose unique ID is `src::<file path>`.
- Every record whose unique ID extends that ID with `#` or `::`.
- Every record that references one of the records above, at any depth.

:::warning
Do not use the `src::` prefix for your own identifiers. Records under a `src::` unique ID are removed when the file with that path is deleted.
:::

# Updating a Record <a id="doc-database-update-record"></a>

The [`postRecord()`](#doc-api-reference-database-postrecord) method can also be used to update an existing record. You can specify the `record_id` in the `config` object in order to do so.

[`postRecord()`](#doc-api-reference-database-postrecord) will overwrite the user's record data to a new data.

For record config parameters, you only need to include the parameters you want to update along with the `record_id` parameter.
All other fields in the record will remain unchanged unless explicitly included in the method call.

:::tip
The `record_id` parameter also accepts a [Unique ID](#doc-database-unique-id). If a record was created with a unique ID, you can pass that value as `record_id` to update the same record, without having to look up its system generated `record_id` first.

```js
let config = {
  record_id: 'my_unique_id' // a Unique ID previously assigned to the record
};

skapi.postRecord({ newData: "Updated by Unique ID." }, config).then(record => {
  console.log(record);
});
```
:::

:::warning
Users must be logged in to update a record.
:::

```js
let updatedData = {
  newData: "Overwritten with new data."
};

let config = {
  record_id: 'record_id_to_update',
  table: {
    name: 'new_table_name',
    access_group: 'private' // change access group to private
  }
};

skapi.postRecord(updatedData, config).then(record => {
  console.log(record);
});
```

Example above overwrites record data to a new data and updated to a new table name.

:::warning Moving a record into or out of `private` clears its private access grants
The examples on this page change the access group to `private`. If the record had private
access granted to other users, **those grants are removed** by the change, in either
direction. See [Changing the Access Group Clears Private
Access](#doc-database-access-restrictions-changing-the-access-group-clears-private-access).

Only the record's **owner** can move it into or out of `private`, or change anything on it
while it is private. See [Only the Owner Can Cross the Private
Boundary](#doc-database-access-restrictions-only-the-owner-can-cross-the-private-boundary)
for what a project owner or admin account can change on another user's record.
:::

:::tip
To update only the `config` of the record with `data` untouched, you can leave the first argument `data` to `undefined`.
Then, only the `config` will be updated with the previous data untouched.

```js
let new_config = {
  record_id: 'record_id_to_update',
  table: {
    name: 'new_table_name',
    access_group: 'private' // change access group to private
  }
};

skapi.postRecord(undefined, new_config).then(record => {
  console.log(record);
});
```

:::danger
Always use `undefined` if you want to update only the record configurations.
If `null` is used instead of undefined, the record data will be overwritten to value `null`.
:::

:::

:::info
Only the owner of the record can update it. The project owner and admins in access group `99` can also update other users' records, read-only ones included, and admins in access groups `90` ~ `98` can change only their [subscription settings](#doc-database-subscription-who-can-change-subscription-settings). Every admin and the project owner can also attach files to another user's record and delete its files, see [Files on Records of Other Users](#doc-database-handling-files-files-on-records-of-other-users). Nobody but the owner can update a private record or change its files. See [Updating another user's record](#doc-admin-permissions-updating-another-user-s-record).
:::

## Readonly Record <a id="doc-database-update-record-readonly-record"></a>

You can let user upload a readonly record that is immutable once it is created.
To create a readonly record, you can set the `readonly` parameter in the `config` object to `true`.

```js
let data = {
  myData: "Hello World"
};

let config = {
  table: { name: 'my_collection', access_group: 'public' },
  readonly: true
};

let read_only_record_id;
skapi.postRecord(data, config).then(record => {
  console.log(record);
  read_only_record_id = record.record_id;
});
```

When the record is created with `readonly` set to `true`, the user will not be able edit or delete the record anymore. The project owner and admins in access group `99` can still update it, and admins can delete it.

```js
skapi.postRecord({ myData: "Can this be updated?" }, { record_id: read_only_record_id }).catch(err=>{
    alert(err.message); // Record is read only.
})
```

# Handling Files <a id="doc-database-handling-files"></a>

Skapi database is integrated with Skapi's cloud storage and CDN.
This allows you to upload any size of binary files to the database without any additional setup.

## Uploading Files <a id="doc-database-handling-files-uploading-files"></a>

To upload files, you can pass the HTML form `SubmitEvent` or `FormData` that includes `FileList` object when calling the [`postRecord()`](#doc-api-reference-database-postrecord) method.

Additionally, We can log the progress of the upload by passing a [ProgressCallback](#doc-api-reference-data-types-progresscallback) in the `progress` parameter in the second argument of [`postRecord()`](#doc-api-reference-database-postrecord).
This can be useful if the user is uploading huge files, you can show a progress bar.

Here's an example demonstrating how you can upload files using Skapi:

```html
<form
    onsubmit="skapi.postRecord(event, {
    table: {
        name: 'my_photos',
        access_group: 'authorized'
    },
    progress: (p)=>console.log(p)
}).then(rec=>console.log(rec))"
>
    <input name="description" />
    <input name="picture" multiple type="file" />
    <input type="submit" value="Submit" />
</form>
```

The `name` attribute of the file input element will serve as the key name of the file data.
Regarless the file input is multi or single, the file(s) will **ALWAYS** be uploaded as an array of [BinaryFile](#doc-api-reference-data-types-binaryfile) object under the key name `picture`(the name of the file input element) in the `bin` key of the [RecordData](#doc-api-reference-data-types-recorddata) as shown below:

```js
// record data
{
    record_id: '...',
    ...,
    bin: {
        picture: [
            {
                access_group: 'authorized',
                filename: '...',
                url: 'https://...',
                path: '.../...',
                size: 1234,
                uploaded: 1234
                getFile: () => {...};
            },
            ...
        ]
    }
}
```

The `bin` data will contain array of [BinaryFile](#doc-api-reference-data-types-binaryfile) objects.
This process is handled seamlessly without any complicated file handling required.

Once the files are uploaded, Skapi serves the files using a CDN with no additional setup required.

:::info Every upload is signed for the size it declares
Skapi asks storage for a one file upload permit, and that permit is issued for **exactly** the byte size
the request declares. A request that then sends a different number of bytes is refused by storage and
nothing is stored, so the size a file is counted and billed at is always the size it really has.

The SDK declares the true size of the file it sends, [encrypted](#doc-database-encryption) files (the
size of the encrypted bytes) and empty files included, so this needs nothing from you. It matters only
if you build the upload request yourself instead of using [`postRecord()`](#doc-api-reference-database-postrecord).

A refused upload does not fail the record. [`postRecord()`](#doc-api-reference-database-postrecord)
resolves as usual and that one file is simply never stored, so fetch the record again when you need to
know which files it really holds.
:::

:::danger
If the file is uploaded in a record where the access group is not 'public', the URL value in the [BinaryFile](#doc-api-reference-data-types-binaryfile) objects can expire for security reasons.
:::

## Progress Information

When uploading files via [`postRecord()`](#doc-api-reference-database-postrecord) method, you can attach a [ProgressCallback](#doc-api-reference-data-types-progresscallback) in the `progress` parameter when uploading files.
The [ProgressCallback](#doc-api-reference-data-types-progresscallback) will trigger whenever there is a byte loaded to/from the backend.

```js
let progressCallback = (p) => {
    if (p.status === "upload" && p.currentFile) {
        console.log(`Progress: ${p.progress}%`);
        console.log("Current uploading file:" + p.currentFile.name);
    }
};
skapi.postRecord(someData, {
    table: { name: "my_photos", access_group: "authorized" },
    progress: progressCallback,
});
```

## Uploading Files to an Existing Record <a id="doc-database-handling-files-uploading-files-to-an-existing-record"></a>

[`uploadFiles()`](#doc-api-reference-database-uploadfiles) attaches files to a record that already exists, without changing anything else on it. It takes the same `FormData`, `HTMLFormElement` or `SubmitEvent` as [`postRecord()`](#doc-api-reference-database-postrecord), and the `record_id` of the record.

```js
let form = new FormData();
form.append("picture", fileA);
form.append("picture", fileB);

let result = await skapi.uploadFiles(form, { record_id: "record_id_to_attach_to" });
```

Files are uploaded one after another, and a file storage does not accept does not stop the rest. The result says what happened to each one:

```js
// result
{
    completed: [File],        // stored
    failed: [File],           // not stored
    errors: [                 // why, one entry per file in `failed`, in the same order
        {
            file: File,       // the same File object as in `failed`
            name: "b.jpg",
            error: SkapiError // error.code and error.message say why, see below
        }
    ],
    bin_endpoints: [string]   // the endpoint of every file, stored or not
}
```

`error.code` is the storage code when storage refused the file (`AccessDenied`, `EntityTooLarge` ...), or `NETWORK_ERROR` (the connection failed), `TIMEOUT` (see below), `ABORTED` (the progress callback's `abort()` was called) or `TOO_MANY_REQUESTS`. `error.cause` holds the raw response, when there was one. `failed` lists the file alone, as it always has; `errors` is where the reason is.

### Many files <a id="doc-database-handling-files-many-files"></a>

With five files or more, the upload permits are requested 100 to a request instead of one request per file, so a folder of a thousand files costs ten permit requests and a thousand uploads rather than two thousand requests. Nothing changes in how you call it or in the result; the files still go up one after another. A file the server refuses a permit for (the record already holds 100 files, the project is over its storage, a reserved name) is listed in `failed` and `errors` with the server's `code`, the others are still uploaded, and its slot in `bin_endpoints` is an empty string. Where the bulk permit route is not available yet, the permits are requested one per file as before, automatically.

A file whose upload keeps being answered with `429` and a `Retry-After` is retried up to 6 times and then counted as failed with the code `TOO_MANY_REQUESTS`; it is never retried without end.

### Time limit per file

By default a file's upload has no time limit: a connection that stalls holds the call for as long as the browser keeps it open. `timeout`, in milliseconds, ends that. A file that is not finished within the time is aborted and counted as failed with the code `TIMEOUT`, and the next file is uploaded. Every file gets the full time.

```js
await skapi.uploadFiles(form, {
    record_id: "record_id_to_attach_to",
    timeout: 120000, // two minutes per file
});
```

### Failing at the first refused file

`throwOnFileError: true` makes the call reject at the first file storage does not accept, with that file's error, instead of resolving with it listed. The files uploaded before it stay stored, and the files after it are not sent.

```js
try {
    await skapi.uploadFiles(form, { record_id: "record_id_to_attach_to", throwOnFileError: true });
} catch (err) {
    console.log(err.code, err.message); // 'EntityTooLarge' 'Your proposed upload exceeds the maximum allowed size'
}
```

Neither option changes [`postRecord()`](#doc-api-reference-database-postrecord): a file refused there is left out of the record without an error, as described under [Uploading Files](#doc-database-handling-files-uploading-files).

## Downloading Files

To download files from the record, you can use the `getFile()` method on the [BinaryFile](#doc-api-reference-data-types-binaryfile) object in the record.

Below is an example of how you can download a file from a record:

```js
skapi.getRecords({ record_id: "record_id_with_file" }).then((rec) => {
    let record = rec.list[0]; // record with files attached.

    /*
    // record
    {
        table: {
            name: 'my_photos',
            access_group: 'authorized'
        },
        record_id: '...',
        ...,
        bin: {
            picture: [
                {
                    access_group: 'authorized',
                    filename: '...',
                    url: 'https://...',
                    path: '.../...',
                    size: 1234,
                    uploaded: 1234
                    getFile: () => {...};
                },
                ...
            ]
        }
    }
    */

    let fileToDownload = record.bin.picture[0]; // get the file object from the record

    fileToDownload.getFile(); // browser will download the file.
});
```

:::info
Uploaded files follow the access restrictions of the record.
User must have access to the record in order to download the file.
:::

`getFile()` allows you to download the file in various ways:

-   `blob`: Downloads the file as a [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) object.
-   `base64`: Downloads the file as a base64 string.
-   `endpoint`: If the file access requires authentication or needs token update, you can request an updated endpoint of the file.
-   `download` (or omitted): Triggers file download from the web browser.
-   `text`: Downloads the file as text string.
-   `info`: Returns file information.

The `getFile()` method on the [BinaryFile](#doc-api-reference-data-types-binaryfile) object takes two arguments:

-   `dataType`: Type of download - `blob`, `base64`, `endpoint`, `text`, `info` or `download`. Defaults to `download`.
-   `progress`: Optional progress callback function. Useful when downloading large files as blob to show progress bar. (Will not work with `endpoint` or `download` types.)

Alternatively, you can call the standalone [`skapi.getFile()`](#doc-api-reference-database-getfile) method with the file's endpoint URL: `skapi.getFile(url, config?)`. Here `url` is the file's endpoint URL and `config` is an optional object that holds `dataType` (same values as above), `progress` (the progress callback), `expires` (use a URL that expires in the given number of seconds; useful for private files), and `browserCache` / `refresh` (see [Caching Expiring Files](#doc-database-handling-files-caching-expiring-files) below).

If the file has private access restriction, you must use the `endpoint` type to get the file endpoint URL.
The endpoint URL will be a signed URL that can expire after a certain amount of time.

If the file is an image or a video, you can use the url on img tag or video tag to display the file.

:::warning Encrypted files are different
None of the above applies to a file attached to a record whose data is
[encrypted](#doc-database-encryption). Its URL serves **ciphertext**, so `endpoint` returns
something no `img` or `video` tag can display, and a plain link downloads unreadable bytes.

Use `getFile()` with `blob`, `base64`, `text` or `download` instead: those decrypt. You can
tell the two apart without fetching anything, because the bin entry says so:

```js
if (file.encrypted) {
    const blob = await file.getFile('blob');   // decrypted
    imgEl.src = URL.createObjectURL(blob);     // instead of file.url
}
```
:::

Below is an example of how you can get the endpoint URL of the access restricted private file (The user must have private access granted.):

```js
fileToDownload.getFile("endpoint").then((url) => {
    console.log(url); // endpoint of the file. https://...
});
```

Below is an example of how you can download a file as a blob, base64 with progress callback:

```js
let progressInfo = (p) => {
    console.log(p); // Download progress information
};

fileToDownload.getFile("blob", progressInfo).then((b) => {
    console.log(b); // Blob object of the file.
});

fileToDownload.getFile("base64", progressInfo).then((b) => {
    console.log(b); // base64 string
});
```

## Caching Expiring Files <a id="doc-database-handling-files-caching-expiring-files"></a>

Files in a record whose access group is not `public` are **cached for one week automatically**.
Reading the same private file twice used to download it twice, because the URL a private file is served under changes on every read and browsers cache by URL.
Now the first read downloads it and every read after that is served from the browser's own cache, with no network request at all, for a week.

You do not have to do anything for this. It applies to every `getFile()` call on a `record.bin[...]` object:

```js
skapi.getRecords({ record_id: "record_id_with_file" }).then((rec) => {
    let file = rec.list[0].bin.picture[0]; // a private file

    file.getFile("blob"); // downloads it
    file.getFile("blob"); // served from the browser cache, no network
});
```

To display a private file, take the URL from `getFile("endpoint")` rather than reading the `url` property:

```js
let src = await file.getFile("endpoint"); // cached for a week
imgElement.src = src;
```

::: info
The `url` property on a bin object is deliberately **not** the cached URL.
It stays the record's own file URL, because that is the string you pass back to `remove_bin` and `deleteFiles`, the string the dashboards render, and the one that is safe to store: a cached URL is signed for one user and stops working once it expires.
Reading `file.url` directly works too, it is just not the cached path.
:::

::: info
Files reached through a **granted private access key** (someone else's restricted file that was shared with you) are not cached, because the URL for those cannot be minted in a cacheable form.
They keep working exactly as before.
:::

The rest of this section is for files you fetch by URL yourself with `skapi.getFile()`.

When you request a file with `expires`, Skapi mints a **signed URL**, and a signed URL is different every time you ask for one.
Browsers cache by URL, so a new URL is always a cache miss: the same unchanged file is downloaded again on every page load.
For a chat window or a gallery that shows the same private images repeatedly, that is the whole page's worth of traffic, every time.

`browserCache` fixes it from the other end. It caches the **request that mints the URL**, so the same URL comes back and the copy the browser already downloaded stays usable.

```js
skapi.getFile(url, {
    dataType: "endpoint",
    expires: 1200, // the signed URL is valid for 20 minutes
    browserCache: 86400, // reuse it, and the downloaded file, for a day
});
```

The two numbers do different jobs, and it is normal for `browserCache` to be much larger than `expires`:

-   `expires` is how long the **URL** works. Keep it short, so a URL that leaks is useless quickly.
-   `browserCache` is how long the **file stays available locally**. The browser serves it from its own cache without checking the URL again.

Once the browser drops the file from its cache, the next load uses a URL that has since expired and fails.
Call `getFile()` again with `refresh: true` to mint a working URL:

```js
skapi.getFile(url, {
    dataType: "endpoint",
    expires: 1200,
    browserCache: 86400,
    refresh: true, // ignore the cached URL, mint a new one
});
```

::: warning
If you overwrite a file at the same path, the browser keeps serving the copy it already has until `browserCache` runs out.
Use `refresh: true` after replacing a file so the new version is picked up.
:::

::: info
`browserCache` only does something when `expires` is also set, and the server caps it at 1 week.
It is ignored for public CDN URLs, which are already cacheable without it.
:::

## Removing Files <a id="doc-database-handling-files-removing-files"></a>

To remove files, use the `remove_bin` parameter in the `config` argument of the [`postRecord()`](#doc-api-reference-database-postrecord) method.
When updating a record, you can remove files by passing the `remove_bin` parameter as an array of [BinaryFile](#doc-api-reference-data-types-binaryfile) objects or the endpoint URLs of the files that need to be removed from the record.

Here's an example demonstrating how you can remove files from a record:

```js
...
let fileToDelete = record.bin.picture[0]; // file object retrieved from the record.
skapi.postRecord(undefined, { record_id: 'record_id_with_file', remove_bin: [fileToDelete] });
```

If you have the endpoint URL of the file, you can also pass the URL as a string in the `remove_bin` parameter:

```js
skapi.postRecord(undefined, {
    record_id: "record_id_with_file",
    remove_bin: ["https://..."],
});
```

If you want to remove all files from the record, you can pass the `remove_bin` parameter as `null`:

```js
skapi.postRecord(undefined, {
    record_id: "record_id_with_file",
    remove_bin: null,
}); // removes all files from the record.
```

:::warning
The file that is targeted for removal should be in the record that you are updating.
:::

::: tip
If you remove the record that is holding the files, all files that the deleted record was holding will also be completely removed from the database.
:::

:::info Files attached by another account
A file that the project owner or an admin attaches to another user's record is stored under the **record's user**, the same way as a file that user attached. The `uploader` in its [file information](#doc-database-handling-files-get-file-information) names the record's user, not the account that attached it. The record's user can delete it by its URL, it moves with the record when the record changes access group, and it is deleted with the record.
:::

:::warning Files attached by another account earlier
Files that the project owner or an admin attached to another user's record before files were stored under the record's user keep the old behaviour, and are not moved. They stay under the account that attached them, and their `uploader` names that account, so you can tell one apart by an `uploader` that is not the record's `user_id`. The record's user cannot remove such a file by its URL (only `remove_bin: null` takes it off the record), it does not move when the record changes access group, and it is not removed from storage when the record is deleted.
:::

### Deleting Files by URL <a id="doc-database-handling-files-deleting-files-by-url"></a>

[`deleteFiles()`](#doc-api-reference-database-deletefiles) deletes files by their endpoint URLs without updating anything else on the record. It takes one URL or a list of up to 1000, which may belong to different records, and resolves to the records the files were removed from.

```js
let fileToDelete = record.bin.picture[0]; // file object retrieved from the record.
skapi.deleteFiles({ endpoints: [fileToDelete.url] }).then(records => {
    console.log(records); // updated records
});
```

The whole list is checked before anything is deleted, so if any file in it is refused, no file is deleted.

A user who is not an admin can delete only the files of their own records, except [files attached by another account earlier](#doc-database-handling-files-removing-files), and is refused otherwise with:

```ts
{
    code: "INVALID_REQUEST";
    message: "The record should be owned by the user.";
}
```

## Files on Records of Other Users <a id="doc-database-handling-files-files-on-records-of-other-users"></a>

Files are an admin right of their own, separate from a record's data and settings. The project owner and every admin (access groups `90` ~ `99`) can attach files to another user's record and delete its files, as long as the record is not private. This includes admins in access groups `90` ~ `98`, who otherwise can change only a record's [subscription settings](#doc-database-subscription-who-can-change-subscription-settings).

-   **Attach** by passing the files to [`postRecord()`](#doc-api-reference-database-postrecord) with the record's `record_id`. The `postRecord()` call itself follows the update rules, so for admins in access groups `90` ~ `98` it must change nothing else: leave `data` as `undefined` and send no other setting that differs from the stored record. A read-only record refuses them with `Record is read only.`
-   **Delete** with [`deleteFiles()`](#doc-database-handling-files-deleting-files-by-url). Admins in access groups `90` ~ `98` cannot remove a file from another user's record with `remove_bin`, because that is a change to the record itself: `postRecord()` refuses it with `Admins can only change the subscription settings of another user's record (remove_bin differs).` The project owner and admins in access group `99` can use either.

```js
// An admin attaches a file to another user's record, changing nothing else on it.
skapi.postRecord(undefined, { record_id: "record_id_of_another_user" }, [
    { name: "picture", file: someFile },
]);

// ...and deletes one of its files.
skapi.deleteFiles({ endpoints: ["https://..."] });
```

A file attached this way is stored under the record's user, so that user can delete it, it moves with the record and it is deleted with the record. Files attached this way earlier are the exception, see [Files attached by another account](#doc-database-handling-files-removing-files) above.

Files on a **private** record are locked to its user, for everyone, the project owner and admins included, because `private` is the only access group whose files may be [encrypted](#doc-database-encryption-attached-files). Attaching with `postRecord()` is refused like any other change to another user's private record:

```ts
{
    code: "INVALID_REQUEST";
    message: "Only the owner of a record can move it into or out of the private access group.";
}
```

and `deleteFiles()` is refused with:

```ts
{
    code: "INVALID_REQUEST";
    message: "Only the owner of a private record can delete its files.";
}
```

## Get File Information <a id="doc-database-handling-files-get-file-information"></a>

You can use [`getFile()`](#doc-api-reference-database-getfile) method to get the file information just from the endpoint URL of the file.

Below is an example of how you can get the file information from the endpoint URL:

```js
let fileUrl = "https://...";
skapi.getFile(fileUrl, { dataType: "info" }).then((fileInfo) => {
    console.log(fileInfo);
    /*
    {
        url: string,
        filename: string,
        access_group: number | 'private' | 'public' | 'authorized',
        filesize: number,   // for an encrypted file this is the PLAINTEXT length
        record_id: string,
        uploader: string,   // user ID the file is stored under: the record's user, apart from files attached by another account earlier
        uploaded: number,
        fileKey: string
    }
    */
});
```

# Deleting Records <a id="doc-database-delete-records"></a>

:::warning
User must be logged in to call this method
:::

The [`deleteRecords()`](#doc-api-reference-database-deleterecords) method allows users to delete records that they own.
When the record is deleted, all the files that were uploaded to the record will be deleted as well.

The `params` object accepts similar parameters as the [`getRecords()`](#doc-api-reference-database-getrecords) method.

If the `record_id` is provided, it will delete the record with the given `record_id`.

## Deleting a Record by Record ID

Here's an example that demonstrates how to delete a record using its record ID:

```js
let query = {
    record_id: "record_a_record_id",
};

skapi.deleteRecords(query).then((response) => {
    // 'SUCCESS: records are being deleted. please give some time to finish the process.'
    console.log(response);
});
```

## Deleting a Record by Unique ID

Here's an example that demonstrates how to delete a record using its unique ID:

```js
let query = {
    unique_id: "unique id of the record",
};

skapi.deleteRecords(query).then((response) => {
    // 'SUCCESS: records are being deleted. please give some time to finish the process.'
    console.log(response);
});
```

## Deleting User's Records with Database Query

Here's an example of deleting all user's records uploaded in the "A" table with a public access group.

```js
let query = {
    table: {
        name: "A",
        access_group: "public",
    },
};

skapi.deleteRecords(query).then((response) => {
    // 'SUCCESS: records are being deleted. please give some time to finish the process.'
    console.log(response);
});
```

You can use the database query however you like to let users delete bulk of records that they uploaded. (e.g. by access group, by table name, index, tag, reference, etc.)

:::tip
When deleting multiple records, the promise will return success immediately, but it may take some time for the deleted records to be reflected in the database.
:::

:::danger
`deleteRecords()` builds the same index query as [`getRecords()`](#doc-api-reference-database-getrecords), so `condition: '<='` on a `string` index value now deletes every record whose index value **ends with** that value. It used to delete records that were lesser or equal in lexicographical order. If you have a `deleteRecords()` call that uses `'<='` on a string index, run the same query through [`getRecords()`](#doc-api-reference-database-getrecords) first and check the list before deleting. See [Indexing](#doc-database-indexing-ends-with-string-values).
:::

:::warning
When deleting records by database query, user will not delete records that they do not own, or records that are uploaded as read-only.

However, if the user is an admin, they can delete any records in the database, private and read-only records included. So be cafeful when admin is using this method.

Read more about [admin access to deleting records](#doc-admin-permissions-deleting-records).
:::

For more detailed information on all the parameters and options available with the [`deleteRecords()`](#doc-api-reference-database-deleterecords) method,
please refer to the API Reference below:

### [`deleteRecords(params): Promise<string | DatabaseResponse<RecordData>>`](#doc-api-reference-database-deleterecords)

# Indexing <a id="doc-database-indexing"></a>

When uploading records, you can set additional indexing options in the `index` property.
Indexing lets you categorize records and search for specific values using conditions or value ranges.
Indexed values are also tracked by their sum, average, and the number of records for each index name.
The `index` object consists of the index's `name`, used for indexing, and its corresponding `value`, which is searchable.

## Configuring Indexing for Records

For example, let's consider a table of music albums. You can create an index for the `name` "year" and its corresponding `value` as the release year. This can be set when uploading/updating a record.

This enables searching for music albums by release year when quering records.

```js
let album = {
    title: "Getz/Gilberto",
    artist: "Stan Getz, João Gilberto",
    tracks: 10
};

let config = {
    table: {name: "Albums", access_group: "public"},
    index: {
        name: "year",
        value: 1964
    }
};

skapi.postRecord(album, config);
```

## Querying with Index

Once indexed record is uploaded, you can fetch records based on the "year" in the "Albums" table.

```js
skapi.getRecords({
    table: {name: "Albums", access_group: "public"},
    index: {
        name: "year",
        value: 1964
    }
}).then(response => {
    console.log(response.list); // List of albums released on year 1964.
});
```

## Querying Index with Conditions

You can broaden your search by using the `condition` parameter within the `index` parameter.

```js
skapi.getRecords({
    table: {name: "Albums", access_group: "public"},
    index: {
        name: "year",
        value: 1960,
        condition: '>' // Greater than given value
    }
}).then(response => {
    console.log(response.list); // List of albums released after the year 1960.
});
```

The index value can be of type `number`, `string`, or `boolean`.

When the index value type is `number` or `boolean`, conditions work as they do with numbers. `<=` on a `number` or `boolean` value is still a plain 'lesser or equal' comparison; only `string` values get the 'ends with' behavior described below.

When the index value type is `string`, `>` and `<` will search for strings that are higher or lower in the lexicographical order, respectively.
`>=` (more than or equal to) acts as a 'starts with' operation when searching for string values.
`<=` (less than or equal to) acts as an 'ends with' operation when searching for string values.

The `condition` parameter takes the following string values:

- `>`: Greater than the given value.
- `>=`: Greater or equal to the given value. When the value is `string`, it works as 'starts with' condition.
- `=`: Equal to the given value. (default)
- `<`: Lesser than the given value.
- `<=`: Lesser or equal to the given value. When the value is `string`, it works as 'ends with' condition.

### Ends with (string values) <a id="doc-database-indexing-ends-with-string-values"></a>

When the index value is a `string`, use `condition: '<='` to fetch every record whose index value **ends with** the given value:

```js
// posted with index { name: 'title', value: 'Getz/Gilberto' }
skapi.getRecords({
    table: {name: 'Albums', access_group: 'public'},
    index: {
        name: 'title',
        value: 'Gilberto',
        condition: '<=' // Ends with "Gilberto"
    }
}).then(response => {
    console.log(response.list); // Albums whose "title" index value ends with 'Gilberto'
});
```

To end-with-match the leaf value of a compound (dot-delimited) index name, see [Ends with on the value of a compound index](#doc-database-indexing-ends-with-on-the-value-of-a-compound-index).

An 'ends with' search reads the index value from its last character backwards, and the results come back in that order. `fetchOptions.ascending` still applies, but it orders by that reversed reading rather than by the index value itself, so sort the returned list yourself if you need it ordered by value.

:::tip
'ends with' is served by a separate suffix index, which is written whenever a record's index value is written. A record whose index value was written before that suffix index existed is matched once its index value, table, tags, or reference changes. The `=`, `>`, `>=`, and `<` conditions are not affected.
:::

:::warning
When querying an index with conditions, it will only return records with the same value type.

ex) '2' and 2 are different values.
:::

:::danger
`index.name` (custom) rules:

- Maximum length: 128 characters
- Must not be empty
- Must not start with `$` (reserved namespace)
- Must not include control characters or sentinel `􏿿`

Reserved names are limited to: `$uploaded`, `$updated`, `$referenced_count`, `$user_id`.

`index.value` (string) rules:

- Maximum length: 256 characters
- Empty string is allowed
- Control characters and sentinel `􏿿` are not allowed
:::

## Query Index with Range

In addition to conditions, you can also retrieve records based on a range of values in the index.
To do so, specify the `range` parameter in the `index` object within the [`getRecords()`](#doc-api-reference-database-getrecords) method.

For example, consider the following scenario:

```js
skapi.getRecords({
    table: {name: "Albums", access_group: "public"},
    index: {
        name: "year",
        value: 1960,
        range: 1970
    }
}).then(response => {
    console.log(response.list); // List of albums released from 1960 to 1970.
});
```

In the example above, the [`getRecords()`](#doc-api-reference-database-getrecords) method will retrieve all records in the "Albums" table that have a "year" index value between 1960 and 1970 (inclusive).

:::warning
- When using the `range` parameter, the `value` and `range` parameter values should be same type of data.
- The `range` and `condition` parameter cannot be used simultaneously.
:::

## Query Index with Reserved Keywords

Skapi has reserved a few keywords to help with querying your records.
The reserved keywords are:

- `$uploaded`: Fetches the timestamp(13 digits millisecond format) at which the record was created.

- `$updated`: Fetches the timestamp(13 digits millisecond format) at which the record was last updated.

- `$referenced_count`: Fetch by the number of records that are [referencing](#doc-database-referencing) the record. This can be useful if you need a query like: 'Post that has the most comments'

- `$user_id`: Fetches list of record uploaded by given user ID.

With the exception of `$user_id`, all of these reserved keywords can be queried with `condition` and `range` just like any other index values. `$user_id` cannot be queried with condition or range.

## Querying Index with Reserved Keywords
For example, let's query records created after 2021:

```js
skapi.getRecords({
    table: {name: "Albums", access_group: "public"},
    index: {
        name: '$uploaded',
        value: 1609459200000, // this timestamp is 2021 January 1,
        condition: '>'
    }
}).then(response => {
    console.log(response.list); // List of albums uploaded after 2021.
});

```

## Compound Index Names <a id="doc-database-indexing-compound-index-names"></a>

When posting records, you can use compound index names to have more control over querying the records.
This makes it more flexible to search and retrieve records.

### Uploading a Record with a compound index name

In the example below, we are uploading a record with a compound index name:

```js
let album_data = {
    title: "Dukkha",
    tracks: 7
};

skapi.postRecord(album_data, {
    table: {name: 'Albums', access_group: 'public'},
    index: {
        name: 'Band.AsianSpiceHouse.year',
        value: 2023
    }
})
```

In this example, we have created a compound index name by joining the artist type, artist name, and "year" with a period.
The `value` of this index can only be searched when using the full index name (Band.AsianSpiceHouse.year).

### Querying child level compound index names

When compound index name is used, you can also query records by artist type(Band), artist name(AsianSpiceHouse), or release year(2023).

For example, you can query all albums performed by a **'Band'** using the following code:

```js
skapi.getRecords({
    table: {name: 'Albums', access_group: 'public'},
    index: {
        name: 'Band.',
        value: '',
        condition: '>' // More than
    }
}).then(response=>{
    console.log(response.list); // All albums by "Band" type artists.
})
```

Notice `index.name` value includes a period at the end: **'Band.'**.

This allows you to query the child index name of the compound index name as a `string` value.
Since the `index.value` is an empty string and the condition is set to "more than",
this will retrieve all records where the index name begins with **'Band.'**.

The next example shows how you can query albums by artist name.

```js
skapi.getRecords({
    table: {name: 'Albums', access_group: 'public'},
    index: {
        name: 'Band.',
        value: 'Asian',
        condition: '>=' // Starts with
    }
}).then(response=>{
    console.log(response.list); // All albums by "Band" with band name starting with 'Asian'
})
```

In this example, the `value` of the index is set to "Asian" and the `condition` is set to "more than or equal".
This allows you to query all artist names starting with "Asian" where the index `name` begins with "Band."

Just as `>=` matches the child name from the **start**, `<=` matches it from the **end**. Use `condition: '<='` to query all albums whose band name **ends with** a value:

```js
skapi.getRecords({
    table: {name: 'Albums', access_group: 'public'},
    index: {
        name: 'Band.',
        value: 'House',
        condition: '<=' // Ends with
    }
}).then(response=>{
    console.log(response.list); // All albums by "Band" with band name ending with 'House'
})
```

Here the index `name` begins with "Band." and the `value` is "House", so this returns every band whose name ends with "House" (for example, "AsianSpiceHouse" or "SpiceHouse"), but not "HouseOfPain". Only the name segment immediately after "Band." is matched.

The same availability note applies here as for a plain string index. See [Ends with (string values)](#doc-database-indexing-ends-with-string-values).

### Querying with full compound index name

Finally, you can query the band Asian Spice House albums by release year as follows:

```js
skapi.getRecords({
    table: {name: 'Albums', access_group: 'public'},
    index: {
        name: 'Band.AsianSpiceHouse.year',
        value: 2010,
        condition: '>'
    }
}).then(response=>{
    console.log(response.list); // All albums by Asian Spice House released after 2010.
})
```

:::warning
When querying the child index names from the compound index, you need to specify the index respecting the hierarchy of the compound index name.

From the example above, you cannot simply use **'Band.year'** as an index name to query by the year values.
You must provide the full **'Band.AsianSpiceHouse.year'** as an index name if you want to query the actual value of the index.
:::

### Ends with on the value of a compound index <a id="doc-database-indexing-ends-with-on-the-value-of-a-compound-index"></a>

The `Band.` query above ends-with-matches the child **name** segment. To instead end-with-match the leaf **value** under a compound index, pass the **full** compound name (no trailing period) together with a string value and `condition: '<='`:

```js
// posted with index { name: 'Band.AsianSpiceHouse.title', value: 'Dukkha (Live)' }
skapi.getRecords({
    table: {name: 'Albums', access_group: 'public'},
    index: {
        name: 'Band.AsianSpiceHouse.title', // full compound name (no trailing period)
        value: '(Live)',                     // ends-with matches the leaf value
        condition: '<=' // Ends with
    }
}).then(response=>{
    console.log(response.list); // Albums whose title value ends with '(Live)'
})
```

So with a compound index name, the position of the period decides what `<=` matches:

- a **trailing** period (`Band.`) ends-with-matches the next **name** segment, whatever the type of the leaf value;
- the **full** name (`Band.AsianSpiceHouse.title`) ends-with-matches the leaf **value**, and only when that value is a `string`. A compound index whose leaf value is a `number` or `boolean` still compares with a plain 'lesser or equal'.

## Fetching Index Information

Skapi tracks the index information in each table. You can fetch the index information using the [`getIndexes()`](#doc-api-reference-database-getindex) method.

Index information is useful when you want to list all index names used in a table or find out the total number of the records indexed to that index name or average/total value of the index values.

The index information includes:

- `average_number`: The average of the number type values.
- `total_number`: The total sum of the number values.
- `number_count`: The total number of records with number as the index value.
- `average_bool`: The rate of true values for booleans.
- `total_bool`: The total number of true values for booleans.
- `boolean_count`: The total number of records with boolean as the index value.
- `string_count`: The total number of records with string as the index value.
- `index`: The name of the index. (`index_name` is only valid as an `order.by` key.)

For example, let say we have a table called "VoteBoard" that lets user upload records with a compound index name such as "Vote.Beer".
The index value will be boolean.
```js
skapi.postRecord(null, {
    table: {name: 'VoteBoard', access_group: 'public'},
    index: {
        name: 'Vote.Beer',
        value: true // or false
    }
})
```

Then you can fetch information about the **"Vote.Beer"** index in the **"VoteBoard"** table to find out the rating of **"Vote.Beer"**.

```js
skapi.getIndexes({
    table: 'VoteBoard', // Table name is required
    index: 'Vote.Beer' // index name
}).then(response => {
    console.log(response.list[0]); // index information of "Vote.Beer"
});
```

The `index` parameter decides how much of the table comes back:

- `index` omitted: a prefix listing of **every** index in the table.
- `index` with no trailing period, as in the example above: an **exact** match on that index name.
- `index` ending in a period: a prefix listing of the children of that compound index. `'Vote.'` lists `Vote.Beer`, `Vote.Wine`, and so on.

So to list everything, give the table and nothing else:

```js
skapi.getIndexes({
    table: 'VoteBoard'
}).then(response => {
    console.log(response.list); // Every index in the "VoteBoard" table
});
```

`table` is **required**, so unlike [`getTables()`](#doc-api-reference-database-gettables) and [`getTags()`](#doc-api-reference-database-gettags) there is no no-argument form:
a call without `table` is rejected with `"table" is required.`

```js
skapi.getIndexes({ index: 'Vote.Beer' }); // Error: "table" is required.
```

[`getIndexes()`](#doc-api-reference-database-getindex) has no top level `condition`.
The only condition it accepts is `order.condition`, described in [Querying index value](#doc-database-indexing-querying-index-value) below.

### Who can read index information

When the project's [Require Login](https://docs.skapi.com/service-settings/service-settings.html#require-login) setting is on, [`getIndexes()`](#doc-api-reference-database-getindex) is refused to a visitor who is not signed in.
The SDK throws `REQUIRE_LOGIN` before the request leaves the browser, and the backend refuses the same call with `INVALID_REQUEST`, so a direct API call is refused as well.
A project that has never set that option counts as **on**.

:::warning
The index listing is **not** filtered by access group, and cannot be.
An index is stored as one row keyed by the table and index name, with the access group left out of the key, so `number_of_records`, `total_number`, `average_number`, `total_bool` and the rest are aggregates over **every** access group in that table, including the records a given caller could never read.
Any caller allowed to read the listing reads the whole project's index vocabulary.

The records themselves stay gated, but an index name, and any average or total computed from private values, is visible to every signed in user. An index whose very existence is sensitive does not belong in a table those users can list.
:::

For more detailed information on all the parameters and options available with the [`getIndexes()`](#doc-api-reference-database-getindex) method,
please refer to the API Reference below:

### [`getIndexes(query, fetchOptions?): Promise<DatabaseResponse<Index>>`](#doc-api-reference-database-getindex)

### Querying index value <a id="doc-database-indexing-querying-index-value"></a>

Suppose you want to list all the indexes in a table and order them in a specific order.
Maybe there is multiple indexes such as "Vote.Beer", "Vote.Wine", "Vote.Coffee" and so on.
And you may want to know the rating of each index and order them by the rating.
In that case, you can use the `order.by` parameter in the `query`.

Example below lists all indexes under the compound index `Vote.` in the **"VoteBoard"** table ordered by `average_bool` from highest value:

:::warning
`order.by` only works on the values under the index name.
If your index name is a [compound index name](#doc-database-indexing-compound-index-names),
You should declare the parent index of your child compound index name.

For example, if you expect to get the index `Vote.Beer` from ordering `average_bool`, you should declare the parent index `Vote.` in the `index` parameter.
:::

```js
let config = {
    ascending: false
};

let query = {
    table: 'VoteBoard',
    index: 'Vote.',
    order: {
        by: 'average_bool'
    }
};

skapi.getIndexes(query, config).then(response => {
    console.log(response.list); // List of indexes ordered from high votes.
});
```

Note that in the `config` object, the `ascending` value is set to `false`.

So the list will be ordered in *descending* order from highest votes to lower votes.

For example, to list all indexes under "Vote." that has higher votes then 50% and order them by `average_bool`, you can do the following:

```js
let config = {
    ascending: false
};

let query = {
    table: 'VoteBoard',
    index: 'Vote.',
    order: {
        by: 'average_bool',
        value: 0.5,
        condition: '>'
    }
};

skapi.getIndexes(query, config).then(response => {
    console.log(response.list); // List of votes that rates higher then 50%, ordered from high votes.
});
```

`order.condition` **requires** `order.value`: a query that sets `order.condition` without `order.value` is rejected.
When `order.value` is given and `order.condition` is omitted, the value is matched **exactly**.
With `order.by` alone and no `order.value`, nothing is matched at all: the whole partition comes back, ordered by that attribute, as in the `average_bool` example above.

### Searching index names <a id="doc-database-indexing-searching-index-names"></a>

When you are looking for an index by name rather than listing every one, order by `index_name`.
`order.value` is then a piece of the name, and `order.condition` decides how it is matched:

| `order.condition` | Result |
| --- | --- |
| Omitted, with an `order.value` | The index whose name is exactly the value. |
| `>=` | Names that **start with** the value. |
| `<=` | Names that **end with** the value. |
| `>` | Names that come after the value, in character order. |
| `<` | Names that come before the value, in character order. |
| Omitted, with no `order.value` | Every index name, ordered by name. |

The search covers the whole table, or the children of a compound index when you give its parent in `index`.

Pass `>=` instead of a bare value to search from the start of the name:

```js
skapi.getIndexes({
    table: 'VoteBoard',
    order: {
        by: 'index_name',
        value: 'Vote',
        condition: '>=' // Starts with
    }
}).then(response => {
    console.log(response.list); // Indexes of "VoteBoard" whose name starts with 'Vote'
});
```

A bare `order.value` is an exact match on the name, and in real data an index name very often carries a leading name shared with the rest of its data set, such as a source or dataset prefix or a series name.
The exact match finds one spelling and silently misses its siblings, while the prefix finds the whole family.

Pass `<=` to search from the end of the name:

```js
skapi.getIndexes({
    table: 'VoteBoard',
    index: 'Vote.',
    order: {
        by: 'index_name',
        value: 'er',
        condition: '<=' // Ends with
    }
}).then(response => {
    console.log(response.list); // "Vote.Beer", "Vote.Water", and so on
});
```

'Ends with' matches one segment of a [compound index name](#doc-database-indexing-compound-index-names): the one right under `index`, or the first segment of the name when `index` is omitted.
Here `index` is "Vote." and the value is "er", so "Vote.Beer" and "Vote.Water" are returned and "Vote.Wine" is not.
Names nested under a match come back with it: "Vote.Beer.Lager" is returned as well, the same way a `>=` search for "Vote.B" returns it.

An 'ends with' search reads each name segment from its last character backwards, and the results come back in that order. `fetchOptions.ascending` still applies, but it orders by that reversed reading rather than by the name itself, so sort the returned list yourself if you need it ordered by name.

# Tags <a id="doc-database-tags"></a>

Tags are additional metadata that can be associated with a record. They provide extra search criteria for more detailed queries, either on their own or in combination with indexes. Unlike indexes, tags cannot be queried with conditional operators.
The number of records is also tracked for each tag name.

To add tags to a record, you can use the `config.tags` parameter in the [`postRecord()`](#doc-api-reference-database-postrecord) method.
This parameter accepts a string or an array of strings or string with comma separated values, allowing you to add multiple tags to a single record.

## Adding Tags to a Record

Here's an example of how to add tags to a record:

```js
let record = {
    title: "Dukkha",
    artist: "Asian Spice House",
    tracks: 7
};

let config = {
    table: {name: 'Albums', access_group: 'public'},
    index: {
        name: "year",
        value: 2023
    },
    tags: ['Indie', 'Experimental']
}

skapi.postRecord(record, config);
```

:::danger
Tag validation rules:

- Maximum length: 64 characters per tag
- Must not be empty
- Must not include control characters or sentinel `􏿿`
:::

## Querying Records by Tag

You can also utilize tags in your queries.

Example below lists albums released after 2020, that have the tag 'Experimental'.

```js
skapi.getRecords({
    table: {name: 'Albums', access_group: 'public'},
    index: {
        name: "year",
        value: 2020,
        condition: '>'
    },
    tag: 'Experimental'
}).then(response=>{
    // List of albums released after 2020, that have the tag 'Experimental'.
    console.log(response.list);
});
```

The `tag` in a record query is matched **exactly**, and no condition widens it: the prefix search described below belongs to [`getTags()`](#doc-api-reference-database-gettags), not to [`getRecords()`](#doc-api-reference-database-getrecords).
When one thing has been tagged more than one way, `Experimental` and `Experimental Rock` for example, a query on one of those spellings returns only that spelling's records and reports its count as though it were the whole set.
List the spellings first with `getTags({ table, tag, condition: '>=' })`, then query each one and merge the results yourself.

:::tip
To query multiple tags simultaneously, you can make multiple API calls and await them all at once in Javascript Promise.all().
Then you may sort the data as you wish.

Following Example below shows fetching albums with the tag 'Experimental' OR 'Indie'
:::

```js
let experimental = skapi.getRecords({
    table: {name: 'Albums', access_group: 'public'},
    tag: 'Experimental'
})

let indie = skapi.getRecords({
    table: {name: 'Albums', access_group: 'public'},
    tag: 'Indie'
});

Promise.all([experimental, indie]).then(res=>{
    let experimental = res[0].list;
    let indie = res[1].list;

    let or_list = {};
    for(let r of experimental) or_list[r.record_id] = r;
    for(let r of indie) or_list[r.record_id] = r;

    // 'Experimental' OR 'Indie' in { [record_id]: record } format.
    console.log(or_list);
})
```

## Fetching Tag Information

You can fetch all tags used in a table with [`getTags()`](#doc-api-reference-database-gettags).

```js
skapi.getTags({
    table: 'MyTable'
}).then(response=>{
    console.log(response); // List of all tags in table named 'MyTable'
})
```

This call gives no `condition`, and that is what makes it a listing.
When only `table` is given, the omitted `condition` defaults to `>=`, a prefix search on the tag name, and with no `tag` to prefix it matches every tag in that table.

When **both** `table` and `tag` are given and `condition` is omitted, the default is an **exact** match on the tag instead.

Calling `getTags()` with no argument at all, or with an empty query object, returns every tag in the project, ordered by record count, descending.
That is the widest listing there is:

```js
skapi.getTags().then(response=>{
    console.log(response); // Every tag in the project, most used tag first
})
```

Given only a `tag` and no `table`, the same `>=` default runs across the whole project, so the call finds that tag in every table that uses it:

```js
skapi.getTags({
    tag: 'Experimental'
}).then(response=>{
    console.log(response); // The tag 'Experimental', in every table it appears in
})
```

So the combinations are:

- neither `table` nor `tag`: every tag in the project, ordered by record count, descending.
- `table` alone: every tag in that table.
- `tag` alone: that tag across all tables.
- `table` and `tag`: an exact match on that tag in that table.
- `table`, `tag` and `condition: '>='`: every tag in that table whose name starts with the value.
- `table`, `tag` and `condition: '<='`: every tag in that table whose name ends with the value.
- `tag` and `condition: '<='`, with no `table`: every tag whose name ends with the value, in every table.
- `condition` with neither `table` nor `tag`: rejected with `"table" or "tag" is required for condition.`

```js
skapi.getTags({ condition: '>=' }); // Error: "table" or "tag" is required for condition.
```

### Who can read tag information

When the project's [Require Login](https://docs.skapi.com/service-settings/service-settings.html#require-login) setting is on, [`getTags()`](#doc-api-reference-database-gettags) is refused to a visitor who is not signed in.
The SDK throws `REQUIRE_LOGIN` before the request leaves the browser, and the backend refuses the same call with `INVALID_REQUEST`, so a direct API call is refused as well.
A project that has never set that option counts as **on**.

:::warning
The tag listing is **not** filtered by access group, and cannot be.
A tag is stored as one row keyed by the tag and the table name, with the access group left out of the key, so `number_of_records` counts the records in every access group together.
Any caller allowed to read the listing reads the whole project's tag vocabulary, including tags carried only by `private` or `admin` records, and their cross group counts.

The records themselves stay gated: a tag tells a signed in user that records exist, not what is in them. Still, treat a tag name as visible to every signed in user, and do not encode anything sensitive in one.
:::

For more detailed information on all the parameters and options available with the [`getTags()`](#doc-api-reference-database-gettags) method,
please refer to the API Reference below:

### [`getTags(query, fetchOptions?): Promise<DatabaseResponse<Tag>>`](#doc-api-reference-database-gettags)

### Querying tags <a id="doc-database-tags-querying-tags"></a>

You can also query tags that meets the `condition`.

```js
skapi.getTags({
    table: 'MyTable',
    tag: 'A',
    condition: '>'
}).then(response=>{
    console.log(response); // List of all tags starting from 'A' in table named 'MyTable'
})
```

In this example, the condition property is set to `>`, and `tag` is set to `A`.  This query will return the tag names that come after tag 'A' in lexicographic order, such as 'Ab', 'B', 'C', 'D' and so on.

To list only the tags whose name **starts with** 'A', set `condition` to `>=`, which is a prefix search.

While you are exploring and do not know the exact spelling, pass `>=`.
Because it is a prefix search it surfaces related entries: in real data a tag very often carries a leading name shared with the rest of its data set, a series name for example, or an entity recorded once plainly and once with a parenthesised alias, such as a tag `Asian Spice House` and a tag `Asian Spice House (alias)`.
An exact match finds one spelling and silently misses its siblings, while the prefix finds the whole family.
When you already know the exact name, give both `table` and `tag` and omit `condition`: that is the exact match you want.

To list the tags whose name **ends with** a value, set `condition` to `<=`:

```js
skapi.getTags({
    table: 'MyTable',
    tag: 'Rock',
    condition: '<='
}).then(response=>{
    console.log(response); // Tags of 'MyTable' ending with 'Rock', such as 'Rock', 'PunkRock' and 'IndieRock'
})
```

Leave `table` out to search every table at once: the call then returns the tags that end with the value, in each table they appear in.

An 'ends with' search reads each tag from its last character backwards, and the results come back in that order. `fetchOptions.ascending` still applies, but it orders by that reversed reading rather than by the tag itself, so sort the returned list yourself if you need it ordered by tag.

# Referencing <a id="doc-database-referencing"></a>

Users can reference another record when uploading a new record.

When records are referenced, users can retrieve records based on the one being referenced.

This feature is useful for building a discussion board similar to Reddit, comment section, rating board, tracking purchased items, like buttons etc.

To reference a record, you'll need to specify the `record_id` or `unique_id` of the record you want to reference in the `reference` parameter in the `config` object.

## Uploading a Record to be Referenced

First lets upload a record to be referenced.

```js
let data = {
    post: "What do you think of this post?"
};

let config = {
    unique_id: 'unique id of the post',
    table: { name: 'Posts', access_group: 'public' }
};

let referenced_record_id;

skapi.postRecord(data, config).then(response => {
    // The original post has been uploaded. Now, users can upload another record that references it.
    referenced_record_id = response.record_id;  // Record ID of the record to be referenced.
});
```

## Creating a Referencing Record

Note that we have uploaded a record to be referenced,
we can use the uploaded record's ID in `reference` when uploading a comment record.

```js
let commentRecord = {
    comment: "I like it!"
};

let commentConfig = {
    table: { name: 'Comments', access_group: 'public' },
    reference: referenced_record_id
};

skapi.postRecord(commentRecord, commentConfig);
```

## Fetching References

Now you can query all the records that references the original record by passing the record ID in the `reference` parameter in [`getRecords()`](#doc-api-reference-database-getrecords) method.:

```js
skapi.getRecords({
    table: { name: 'Comments', access_group: 'public' },
    reference: referenced_record_id,
}).then(response => {
    console.log(response.list);  // Array of comments of the reference record.
});
```

:::tip
A user cannot reference a record with a higher access group or 'private' access.
However, if the uploader has granted the private access of the record to the user, the user will be able to reference it.
:::

:::danger
Users who have private access granted to a record will also have access to all other private/higher access group records that is referenced.
To avoid unintended sharing of private records, do not permit users to upload a private record that references another record.
:::

## Creating a Referencing Record with Unique ID

When uploading a record, you can also reference the record using the unique ID.

```js
skapi.postRecord({
    comment: "I like it!"
}, {
    table: { name: 'Comments', access_group: 'public' },
    reference: 'unique id of the post'
});
```

## Fetching References with Unique ID

If the reference record has a unique ID setup, you can also fetch records based on the unique ID of the record being referenced.

```js
skapi.getRecords({
    table: { name: 'Comments', access_group: 'public' },
    reference: 'unique id of the post'
}).then(response => {
    console.log(response.list);  // Array of records in 'Comments' table referencing the record with the unique ID.
});
```

More on unique ID can be found in [Unique ID](#doc-database-unique-id).

## Using reference to fetch certain user's post

You can also query all the records posted by certain user giving a `user_id` as a value of `reference` parameter.

```js
skapi.getRecords({
    table: { name: 'Comments', access_group: 'public' },
    reference: 'user-id-whose-post-you-want'
}).then(response => {
    console.log(response.list);  // Array of records in 'Comments' table posted by a certain user
});
```

## Removing a reference

To remove a reference, set the the `reference` parameter to `null` when updating the record.

```js
skapi.postRecord(undefined, {
    record_id: 'record_id_of_the_comment_to_remove',
    reference: null
}).then(record => {
    console.log(record);  // The reference has been removed.
});
```

## Reference source settings in [`postRecord()`](#doc-api-reference-database-postrecord) <a id="doc-database-referencing-reference-source-settings-in-postrecord"></a>

When uploading record via [`postRecord()`](#doc-api-reference-database-postrecord), you can set restrictions on referencing from other records using additional parameters in `source`.

- `source.referencing_limit`: Allowed maximum number of times that other records can reference the uploading record.
  This is useful if you are building ticketing system where only a certain number of people purchase a ticket.
  If this parameter is set to `null`, the number of references is unlimited. The default value is `null`.
  Set `referencing_limit` to `0` to prevent others to reference the uploading record.

- `source.prevent_multiple_referencing`: If set to `true`, single user will be allowed to reference this record only once.
  This is useful for building a voting system where each users are allowed to vote only once.

- `source.can_remove_referencing_records`: If set to `true`, the owner of the record has an authority to remove the referencing records.
  When the owner removes the record, all the referenceing records will be removed.
  This can be useful when you want to build a discussion board where the owner can remove the comments.
  The default value is `false`.

- `source.only_granted_can_reference`: When set to `true`, only the user who has granted private access to the record can reference this record.

- `source.referencing_index_restrictions`: You can set list of restrictions on the index values of the referencing record.
  This is useful when you want to restrict the referencing record to have certain index names and values.
  This only affects referencing records that has an index.

## Referencing Index Restrictions <a id="doc-database-referencing-referencing-index-restrictions"></a>

You can set restrictions on the index values of the referencing record.
This is useful when you want to restrict the referencing record to have certain index values.
This only affects referencing records that has an index.

For example, you can set the index value range so the total rating value does not exceed a certain value.

Example below shows how you can build a review board where only **10** people are allowed to rate **1** to **5** and each user can only rate once.

It is important to set restrictions on index values for cases like rating systems where you want to prevent users from posting malicious data to mess up your index informations.

```js
let pollPost = skapi.postRecord({
    title: `How would you rate Stan Getz, João Gilberto's album "Getz/Gilberto"?`,
    description: "Only 10 people are allowed to vote"
}, {
    unique_id: 'Review board of GetzGilberto',
    table: { name: 'ReviewBoard', access_group: 'public' },
    source: {
        prevent_multiple_referencing: true,
        referencing_limit: 10,
        referencing_index_restrictions: [
            {
                name: 'Review.Album.GetzGilberto',
                value: 1,
                range: 5
            }
        ]
    }
})
```

As shown in the example above, the `referencing_index_restrictions` parameter is an array of objects that contains the following properties:

- `name`: The name of the index value. (required)
- `value`: The value of the index. (optional)
- `range`: The range of the index value. (optional)
- `condition`: The condition of the index value. (optional)

Validation rules for `source.referencing_index_restrictions` fields:

- `name`: 1..128 characters, cannot be empty, cannot start with `$`, and must not include control characters or sentinel `􏿿`.
- `value` (string): 0..256 characters (empty string allowed), blocks only control characters and sentinel `􏿿`.
- `range` (string): 0..256 characters (empty string allowed), blocks only control characters and sentinel `􏿿`.

You can set many index restrictions by adding more objects to the array.

When the index restriction is set, the referencing record must have the same index name with same typed value and within the specified range.

If all optional parameters not set, the referencing record must have the same index name.

If `value` is set, the referencing record must have the same index name with the same value.

If `range` is set, the referencing record must have the same index name with the value within the specified range.

If `condition` is set, the referencing record must have the same index name with the index value that satisfies the condition to the `value`.
For example, if you want your referencing record's index value to be less than **5**, you can set the parameter as shown below:

```js
let pollPost = skapi.postRecord({
    title: `How would you rate Stan Getz, João Gilberto's album "Getz/Gilberto"?`,
    description: "Only 10 people are allowed to vote"
}, {
    unique_id: 'Review board of GetzGilberto',
    table: { name: 'ReviewBoard', access_group: 'public' },
    source: {
        prevent_multiple_referencing: true,
        referencing_limit: 10,
        referencing_index_restrictions: [
            {
                name: 'Review.Album.GetzGilberto',
                value: 5,
                condition: '<'
            }
        ]
    }
})
```

`condition` can be one of the following: `=`, `!=`, `>`, `<`, `>=`, `<=`, `eq`, `ne`, `gt`, `gte`, `lt`, `lte`.

`condition` cannot be used with `range`.

## Creating a Poll with Restricted Referencing

Now people can post a review by referencing the **pollPost**:

```js
// Now only 10 people can post references for this record
skapi.postRecord({
    comment: "This rocks! I'd give 4.5 out of 5!"
}, {
    table: { name: 'ReviewBoard', access_group: 'public' },
    reference: 'Review board of GetzGilberto',
    index: {
        name: 'Review.Album.GetzGilberto',
        value: 4.5
    }
});
```

Note that the "Review.Album.GetzGilberto" `index` uses a `value` of type `number` so you can later retrieve the average rating and total sum of the values.

## Powerful Ways to Use Reference Records

1. Discussion board

   The owner of the reference record has all access to the referencing records.

   Meaning, the user who uploaded the source of the reference record can have authority to delete the referencing records owned by other users, have access to all access levels referencing records including private records.

   This is useful when you want to build a discussion board where the owner can remove the comments, let users post private comments to reference owner.

2. Rating/voting system

   You can set restrictions on the number of times a record can be referenced, prevent multiple referencing for each users, and restrict the index values of the referencing record.

   While Skapi database works as schema-less in nature, you can use this characteristic to have more control over how your users can post data to your project.

3. Sharing private data between limited users

   Private referencing records are readable only by their uploaders and by the users with private access to the reference record: its uploader, and the users it is shared with. This holds whatever the reference record's access group, so a private record attached to a public one stays private.

# Subscription <a id="doc-database-subscription"></a>

## Posting Feeds

Skapi database includes a subscription feature.

This feature lets users subscribe to other users and view feed records from those subscriptions.

Users can also block specific users from accessing their subscription-level records.

You can let users upload records to the subscription table by setting `table.subscription.is_subscription_record` to `true` in [`postRecord()`](#doc-api-reference-database-postrecord) parameters.

When `table.subscription.upload_to_feed` is set to `true`, subscribed users can fetch feed records from all subscribed users at once using [`getFeed()`](#doc-api-reference-database-getfeed).

### Subscription options overview <a id="doc-database-subscription-subscription-options-overview"></a>

The `table.subscription` object controls how a record behaves for subscribers.

-   `is_subscription_record`: Marks the record as subscription-scoped. Subscribed users can retrieve these records with [`getRecords()`](#doc-api-reference-database-getrecords) (using `table.subscription`).
-   `upload_to_feed`: Publishes the record to subscriber feeds so it can appear in [`getFeed()`](#doc-api-reference-database-getfeed). Off unless set to `true`.
-   `notify_subscribers`: When the record is created, sends a push notification to the uploader's subscribers who subscribed with `get_notified: true`. See [Notifications](#doc-database-subscription-notifications).
-   `feed_referencing_records`: Adds records that reference this record to the feed of this record's uploader.
-   `notify_referencing_records`: Every time a new record references this record, sends a push notification to this record's uploader's subscribers who subscribed with `get_notified: true`. See [Notifications](#doc-database-subscription-notifications).

You can enable these options independently or combine them depending on your product behavior.

For example:

-   Use `is_subscription_record: true` without `upload_to_feed` when records should be accessible to subscribers but not appear in feed timelines.
-   Use `upload_to_feed: true` for timeline-style content.
-   Use `notify_subscribers: true` to tell subscribers about a new post as it happens, and `notify_referencing_records: true` to tell them about new comments on it.

This is useful for social apps where users follow each other, consume feed content, and track subscriber counts.

Assume **user A** uploads a record in table `Posts` as a subscription record.

```js
// User A uploads a subscription record.
skapi.postRecord(null, {
    table: {
        name: "Posts",
        access_group: "authorized",
        subscription: {
            is_subscription_record: true
        },
    },
});
```

You can also set every option at upload time:

```js
skapi.postRecord({
    title: "New post",
    body: "Hello subscribers!"
}, {
    table: {
        name: "Posts",
        access_group: "authorized",
        subscription: {
            is_subscription_record: true,
            upload_to_feed: true,
            notify_subscribers: true,
            feed_referencing_records: false,
            notify_referencing_records: false
        }
    }
});
```

To allow other users to access records that require a subscription, they must first subscribe to the user the record belongs to using the [`subscribe()`](#doc-api-reference-database-subscribe) method:

:::warning
Anonymous (unsigned) users cannot create subscription records.
:::

### Who can change subscription settings <a id="doc-database-subscription-who-can-change-subscription-settings"></a>

Every signed-in user of the project can set the subscription settings of their own records.

The project owner and admins (access groups `90` ~ `99`) can also change the subscription settings of an existing record that belongs to another user of the project. The record stays that user's, so it is that user's subscribers who get access to it. Admins in access groups `90` ~ `98` can change nothing else in the record's data or settings, and cannot change a read-only one. Files are a separate right: they can attach files to such a record by passing them to [`postRecord()`](#doc-api-reference-database-postrecord) with nothing else changed, except to a read-only record, which refuses them with `Record is read only.`, and they can delete its files with [`deleteFiles()`](#doc-api-reference-database-deletefiles), read-only or not. The project owner and access group `99` can attach files to read-only records too. A file attached this way is stored under the record's user. See [Admin Permissions](#doc-admin-permissions-subscription-settings) and [Files on Records of Other Users](#doc-database-handling-files-files-on-records-of-other-users).

Nobody but the record's own user can change anything on a private record, subscription settings included, or attach and delete its files.

The project owner's own records can never have subscription settings, whoever sends the request. A request that adds or turns one on is refused, while settings an older record already has can be kept as they are or turned off:

```ts
{
    code: "INVALID_REQUEST";
    message: "Records of the project owner cannot have subscription settings.";
}
```

Records posted by anonymous (signed-out) users belong to no user of the project, so the project owner and admins cannot give them subscription settings either. A request that adds or turns one on is refused, while settings such a record already has can be kept as they are or turned off:

```ts
{
    code: "INVALID_REQUEST";
    message: "Anonymous records cannot have subscription settings.";
}
```

On an update, options left out of `table.subscription` keep their stored values, and `table.subscription: null` turns every subscription setting off.

## Subscribing <a id="doc-database-subscription-subscribing"></a>

:::warning
User must be logged in to call this method.
:::

Assume **user B** wants to access **user A**'s subscription records. **User B** must first subscribe to **user A**.

```js
// User B subscribes to user A.
skapi.subscribe({
    user_id: "user_id_of_user_A",
    get_feed: true, // Required to use getFeed() later.
});
```

:::tip
To use the [`getFeed()`](#doc-database-subscription-getting-feed) method later, be sure to include `get_feed: true` in [`subscribe()`](#doc-api-reference-database-subscribe).

`table.subscription.upload_to_feed` must be set to `true` for records to appear in subscriber feeds.
:::

A new subscription starts with every option off: `get_feed` and `get_notified` are only on when you set them. Calling [`subscribe()`](#doc-api-reference-database-subscribe) again for the same user changes only the options you pass and keeps the others, so a subscriber can turn notifications on or off later without losing their feed:

```js
// User B turns notifications on. get_feed stays as it is.
skapi.subscribe({
    user_id: "user_id_of_user_A",
    get_notified: true,
});
```

:::warning
Subscribers will not get feeds that are posted prior to the subscription.
:::

Once **user B** subscribes to **user A**, **user B** can access records in that subscription table.

```js
// User B can now retrieve records that require subscription to user A.
skapi
    .getRecords({
        table: {
            name: "Posts",
            access_group: "authorized",
            subscription: "user_id_of_user_A",
        },
    })
    .then((response) => {
        console.log(response.list); // All of user A's subscription records in the Posts table.
    });
```

:::tip
Subscriber counts are tracked in the [UserPublic](#doc-api-reference-data-types-userpublic) object,
which you can retrieve with [`getUsers()`](#doc-api-reference-user-getusers).
:::

### [`subscribe(option): Promise<Subscription>`](#doc-api-reference-database-subscribe)

## Unsubscribing

:::warning
User must be logged in to call this method.
:::

Users can unsubscribe using [`unsubscribe()`](#doc-api-reference-database-unsubscribe).

```js
// User 'B'
skapi.unsubscribe({
    user_id: "user_id_of_user_A",
});
```

For full parameter and option details, see the API reference below:

### [`unsubscribe(option): Promise<string>`](#doc-api-reference-database-unsubscribe)

:::warning
After unsubscribing, subscription information may need some time to update (usually almost immediate).
:::

## Blocking and Unblocking Subscribers

:::warning
User must be logged in to call this method.
:::

One benefit of the subscription feature is that users can block specific users from subscription-level records.
However, subscriptions are not a full security boundary.

Even when a user is blocked, they may still access files attached to records unless those files are private or use a higher access group.

Blocked users cannot access subscription-level record data.

To block a subscriber, call [`blockSubscriber()`](#doc-api-reference-database-blocksubscriber):

### Blocking a Subscriber

```js
// User A blocks user B from subscription records.
skapi
    .blockSubscriber({
        user_id: "user_id_of_user_B",
    })
    .then((res) => {
        // User B no longer has access to user A's subscription records.
    });
```

For full parameter and option details, see the API reference below:

### [`blockSubscriber(option): Promise<string>`](#doc-api-reference-database-blocksubscriber)

### Unblocking a Subscriber

```js
// User A unblocks user B.
skapi
    .unblockSubscriber({
        user_id: "user_id_of_user_B",
    })
    .then((res) => {
        // User B can access user A's subscription records again.
    });
```

An unblocked subscriber gets their subscription back with the options they had. Unblocking a user who was blocked without ever subscribing does not subscribe them.

For full parameter and option details, see the API reference below:

### [`unblockSubscriber(option): Promise<string>`](#doc-api-reference-database-unblocksubscriber)

## Notifications <a id="doc-database-subscription-notifications"></a>

A user's subscribers can get a push notification when the user posts a record, and when someone references one of the user's records (for example, a comment on a post). Both are turned on per record by the uploader, and each subscriber decides whether they want them.

### What the subscriber needs

1. Subscribe with `get_notified: true`:

    ```js
    // User B wants notifications from user A.
    skapi.subscribe({
        user_id: "user_id_of_user_A",
        get_notified: true,
    });
    ```

2. Register the device for push notifications with [`subscribeNotification()`](#doc-api-reference-realtime-subscribenotification). Notifications are delivered to every device the subscriber registered. See [Notifications](#doc-notification-send-notifications) for the service worker and the registration steps.

To stop the notifications, subscribe again with `get_notified: false`. Their other options stay as they are.

### Notifying subscribers of a new record <a id="doc-database-subscription-notifying-subscribers-of-a-new-record"></a>

Set `table.subscription.notify_subscribers` to `true` when you create the record. Pass `notification` to choose the text the subscribers see:

```js
// User A posts, and user A's subscribers are told.
skapi.postRecord({
    title: "New post",
    body: "Hello subscribers!"
}, {
    table: {
        name: "Posts",
        access_group: "authorized",
        subscription: {
            notify_subscribers: true
        }
    },
    notification: {
        title: "User A posted",
        body: "Hello subscribers!"
    }
});
```

`notification.title` and `notification.body` are both required, and together they must fit 3072 bytes. Without `notification`, subscribers see the title `New post` and the body `A user you subscribed to posted in "Posts".`

Only creating the record sends the notification. Updating a record, even to turn `notify_subscribers` on, does not.

### Notifying subscribers of a new reference

Set `table.subscription.notify_referencing_records` to `true` on a record. From then on, every new record that references it notifies the record uploader's subscribers:

```js
// User A's post: new comments on it notify user A's subscribers.
skapi.postRecord({ title: "New post" }, {
    table: {
        name: "Posts",
        access_group: "authorized",
        subscription: {
            notify_referencing_records: true
        }
    }
});

// User C comments. User A's subscribers who set get_notified are told.
skapi.postRecord({ comment: "Nice!" }, {
    table: {
        name: "Comments",
        access_group: "authorized"
    },
    reference: "record_id_of_user_A_post"
});
```

These notifications always show the title `New reference` and the body `A post by a user you subscribed to was referenced in "Comments".` (with the referencing record's table name). The user who references cannot choose the text, because it goes to someone else's subscribers.

A record notifies only when it gains the reference, when it is created or updated to reference a different record. Saving it again with the same reference does not notify again.

### Who is notified

-   Subscribers who set `get_notified: true` and are not blocked.
-   Only subscribers who can read the record: every subscriber for a `public` or `authorized` record, and for a higher access group, only subscribers whose own access group is at least that high. A reference is checked against the higher access group of the two records.
-   Never for a `private` record, on either side of a reference.
-   Never the user who posted the record, even if they subscribe to the uploader.
-   Only references made by signed-in users notify.

### What the service worker receives

The push message is a JSON object. Besides `title` and `body`, it says which record it is about, so your service worker can open the right page:

```ts
{
    title: string;
    body: string;
    type: "record" | "reference"; // "record": a new record. "reference": a new record referencing one of the user's records.
    record_id: string; // The new record.
    table: string; // Table name of the new record.
    user_id: string; // User who posted the new record.
    reference?: string; // Only for "reference": the record that was referenced.
}
```

See [Notifications](#doc-notification-send-notifications-notifications-from-records) for a service worker that opens the record when the notification is clicked.

## Listing Subscriptions

The [`getSubscriptions()`](#doc-api-reference-database-getsubscriptions) method retrieves subscription information from the database.

### Parameters

-   `subscriber`: User ID of the subscriber.
-   `subscription`: User ID of the user being subscribed to.
-   `blocked`: Set to `true` to return only blocked subscriptions.

Provide at least one of `params.subscriber` or `params.subscription`.

### Examples

```js
/**
 * Retrieve all subscriptions where user B is the subscriber.
 */
skapi
    .getSubscriptions({
        subscriber: "userB_user_id",
    })
    .then((response) => {
        console.log(response.list);
    });

/**
 * Retrieve all subscriptions where user A is being subscribed to.
 */
skapi
    .getSubscriptions({
        subscription: "userA_user_id",
    })
    .then((response) => {
        console.log(response.list);
    });

/**
 * Check whether user B is subscribed to user A.
 */
skapi
    .getSubscriptions({
        subscriber: "userB_user_id",
        subscription: "userA_user_id",
    })
    .then((response) => {
        console.log(response.list?.[0]);
    });
```

For full parameter and option details, see the API reference below:

### [`getSubscriptions(params, fetchOptions?): Promise<DatabaseResponse<Subscription>>`](#doc-api-reference-database-getsubscriptions)

## Getting Feed <a id="doc-database-subscription-getting-feed"></a>

[`getFeed()`](#doc-api-reference-database-getfeed) retrieves the current user's feed.

It returns feed records from users the current user has subscribed to.

You can use this method to build a feed page for the user.

### Examples

First, user A must upload a record to the feed.

```js
// User A uploads a record to the feed.
skapi.postRecord(null, {
    table: {
        name: 'Posts',
        access_group: 'authorized',
        subscription: {
            upload_to_feed: true
        }
    }
});
```

Then user B, who is subscribed to user A, can fetch feed records from subscribed users.

```js
/**
 * User B retrieves all feed records with access_group: "authorized".
 */
skapi.getFeed({ access_group: 'authorized' }).then((response) => {
    console.log(response.list); // Feed records from subscribed users in the 'authorized' access group.
});
```

:::danger
If the record does NOT have `table.subscription.upload_to_feed` set to `true`, it will not show up in the feed. Saving a record without the flag leaves it as stored, and a record that never set it stays out of the feed.
:::

:::danger
When a user unsubscribes from another user, all past records from that user will no longer show in their feed.
:::

:::warning
Users only see feed records from the time they subscribed onward.
:::

### [`getFeed(params?, fetchOptions?): Promise<DatabaseResponse<RecordData>>`](#doc-api-reference-database-getfeed)

# Using Third-Party APIs <a id="doc-api-bridge-client-secret-request"></a>

You can connect Skapi to third-party APIs (projects outside your app), such as AI service, map service, payment service, or your own external APIs.

If the API requires a client secret, use [`forwardRequest()`](#doc-api-bridge-forward-request) to send the request from Skapi's servers with the secret substituted in.

Because client secrets must never be exposed in frontend code, register each secret key securely in Skapi.

## Registering Secret Keys <a id="doc-api-bridge-client-secret-request-registering-secret-keys"></a>

Open your project in the dashboard and click **Secret Keys** in the project menu.

![The Secret Keys page: the Register Secret button, a search box, and a table of keys with their name, masked secret, a lock icon and their destinations](https://docs.skapi.com/screenshots/secrets-list.webp)

*The Secret Keys page. The Secret column shows the first two characters and hides the rest, the eye icon reveals a value to the project owner, and the lock says whether the key is locked to signed-in users.*

Click **+ Register Secret** and fill in the form:

![The Register Secret form: Name, Secret Value hidden behind bullets with an eye icon, a Locked checkbox and a Destinations field, with Cancel and Register buttons](https://docs.skapi.com/screenshots/secrets-register.webp)

*Registering a key. The value is typed hidden and can be revealed while you check it.*

- **Name**: the label your client passes as `secretName` in [`forwardRequest()`](#doc-api-reference-api-bridge-forwardrequest). Up to 16 characters.
- **Secret Value**: the key itself. It is kept on the server and your client never receives it. Skapi puts it in place of `$CLIENT_SECRET` in the `data`, `params`, `headers` or `url` of a forwarded request.
- **Locked**: when ticked, only signed-in users can call `forwardRequest()` with this key. Unticked, any visitor of your app can.
- **Destinations**: optional. Comma-separated URLs this key may be sent to, such as `https://api.example.com/v1`. A request to any other URL is refused. Leave it empty to allow any URL. See [Restricting Where a Key Can Be Sent](#doc-api-bridge-client-secret-request-restricting-where-a-key-can-be-sent).

Click **Register**. Click a row to change a key's value, lock or destinations, or to delete it, and tick rows to delete several at once. Requests that use a deleted key start failing.

An AI agent can do this for you. The Skapi MCP server saves, lists and deletes secret keys from a prompt, and the list it reads is masked, so the values never reach the agent. See [Connecting the Skapi MCP server](#doc-introduction-getting-started-connecting-the-skapi-mcp-server).

:::warning Secret keys are a project setting
Registering, listing, replacing and deleting them belong to the **project owner's Skapi account**, and to Skapi staff when you ask Skapi for help. Listing them answers with the stored secret values, so it is owner only as well. An [admin](#doc-admin-permissions-project-settings-belong-to-the-project-owner) of your project, access group `99` included, is refused with `INVALID_REQUEST` and `Only the project owner can change project settings.`
Using a saved key with [`forwardRequest()`](#doc-api-reference-api-bridge-forwardrequest) is unchanged for every user of your project.
:::

## Sending Requests to Third-Party APIs

After you register your secret key, use [`forwardRequest(form, options)`](#doc-api-reference-api-bridge-forwardrequest) to send secure requests to third-party APIs.

The example below sends a `POST` request to a third-party API using a key saved as `YourSecretKeyName`. It places `$CLIENT_SECRET` in the `Authorization` header.

```js
skapi.forwardRequest(null, {
    secretName: 'YourSecretKeyName',
    url: 'https://third.party.com/api',
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer $CLIENT_SECRET'
    }
})
```

The first argument is the request body, a form or a plain object. This request sends none, so it passes `null`. The `options` object supports these fields, among others:

- `secretName`: Name of the secret key registered in your Skapi project. Optional: a request that names none is forwarded with only what you supplied, and nothing is substituted.
- `url`: Third-party API endpoint URL.
- `method`: HTTP method (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, or `HEAD`). Defaults to `POST`.
- `headers`: Request headers as key-value pairs. A name starting with `x-skapi-` is refused.
- `data`: Request body as key-value pairs, merged over the first argument (used when `method` is not `GET`, `DELETE` or `HEAD`).
- `params`: Query parameters as key-value pairs, merged over the first argument (used when `method` is `GET`, `DELETE` or `HEAD`).
- `multipart`: Relay the form's own bytes, files included, instead of flattening it to fields.
- `skapiHeaders`: Tell the destination who is calling, from the verified identity of the request.
- `poll`: Polling interval in milliseconds. See [Polling for the Result](#doc-api-bridge-client-secret-request-polling-for-the-result) below. Must be a non-negative number.
- `expires`: Expiration time in seconds for the request record. After this period the record is removed and any poll returns an error.
- `queue`: Optional queue name. Requests sharing the same `url`, `method`, and `queue` value are processed sequentially on the server side. Useful for rate-limited APIs or operations that must not run in parallel. When omitted, requests are processed in parallel.
- `onResponse`: Callback called with the final API response. For non-queued requests it is called immediately alongside the returned promise. For queued requests it is called when polling resolves.
- `onError`: Callback called when the request or polling fails.

:::warning
When you name a `secretName`, include the `$CLIENT_SECRET` placeholder in at least one of these values: `data`, `params`, `headers`, or `url`. A request that names no secret needs no placeholder.
:::

For full parameter details, see the API reference below:

### [`forwardRequest(form, options): Promise<any>`](#doc-api-reference-api-bridge-forwardrequest)

## Restricting Where a Key Can Be Sent <a id="doc-api-bridge-client-secret-request-restricting-where-a-key-can-be-sent"></a>

`forwardRequest()` takes its `url` from your frontend code, so anyone who can call it can also choose where the request goes. For a key that is not **Locked**, that includes visitors who are not signed in. Without a restriction, a key can be sent to any address, including one that records it.

Set **Destinations** on the key to close that. When a key has at least one destination, Skapi only sends it to a URL that matches one of them, and refuses every other request before anything leaves the server.

Each destination is a full URL, and a request URL matches when:

- **The scheme is the same.** `https://` and `http://` are different destinations.
- **The host is the same**, ignoring upper and lower case. `https://*.example.com` matches every subdomain of `example.com`, such as `api.example.com` or `eu.api.example.com`, but not `example.com` itself.
- **The port is the same.** When no port is written, `443` is used for `https://` and `80` for `http://`.
- **The path starts with the destination's path**, at a `/` boundary. `https://api.openai.com/v1` matches `https://api.openai.com/v1` and `https://api.openai.com/v1/chat/completions`, but not `https://api.openai.com/v10` or `https://api.openai.com/dashboard`. A destination without a path matches every path on that host.

The query string is never compared, so `$CLIENT_SECRET` can still go in a query parameter.

```js
// Key "my_secret" registered with Destinations: https://api.example.com/v1

skapi.forwardRequest(null, {
    secretName: 'my_secret',
    url: 'https://api.example.com/v1/chat/completions', // allowed
    method: 'POST',
    headers: { Authorization: 'Bearer $CLIENT_SECRET' }
});

skapi.forwardRequest(null, {
    secretName: 'my_secret',
    url: 'https://example.net/collect', // refused
    method: 'POST',
    headers: { Authorization: 'Bearer $CLIENT_SECRET' }
}).catch((err) => {
    console.log(err.code); // 'INVALID_REQUEST'
    console.log(err.message); // 'Destination is not allowed for this client secret.'
});
```

A key that has destinations is also sent with redirects turned off. If an allowed destination answers with a redirect, you receive that redirect response instead of Skapi following it to another address.

Queued requests are checked twice: when you make the request, and again right before it is sent. If you narrow a key's destinations while requests are waiting in a queue, the waiting requests that no longer match fail instead of being sent.

On a key that has destinations, a request URL is refused whatever those destinations say when it contains user info (`https://name@host`), spaces, backslashes, a placeholder such as `$CLIENT_SECRET` before the path, or `.` and `..` path segments. Each of those can make the address a server reads differ from the one that was compared, so none of them is matched, it is simply refused.

:::tip
Add destinations to every key that can reach a paid or private API. It costs nothing when your frontend only calls the addresses you list, and it means a copy of your frontend code cannot turn your key into someone else's.
:::

## Polling for the Result <a id="doc-api-bridge-client-secret-request-polling-for-the-result"></a>

Some third-party APIs are slow, or must be rate-limited so requests do not run in parallel. For these, run the request through a **queue** and **poll** for the result instead of waiting on a single response.

- Set `poll` to a polling interval in milliseconds (a non-negative number). When `poll > 0`, the request is queued, the promise resolves immediately with a status object (`id`, `status`, `queue_name`, `in_queue`), and the final result is delivered to your `onResponse` (or `onError`) callback once it is ready.
- When `poll` is `0` or omitted, the returned status object also carries a `poll()` method you can call to start polling manually.
- Add a `queue` name so requests sharing the same `url`, `method`, and `queue` are processed one at a time on the server.

```js
skapi.forwardRequest(null, {
    secretName: 'YourSecretKeyName',
    url: 'https://third.party.com/api',
    method: 'POST',
    queue: 'my-queue',
    poll: 1000, // check every second
    headers: { Authorization: 'Bearer $CLIENT_SECRET' },
    onResponse: (res) => console.log('final result', res),
    onError: (err) => console.error(err)
});
```

To stop watching a poll without cancelling the running request, use [`stopForwardRequestPolling()`](#doc-api-reference-api-bridge-stopforwardrequestpolling); pick the result back up later by polling again. A stopped poll resolves with `{ status: 'stopped' }`, which [`isPollStopped()`](#doc-api-reference-api-bridge-ispollstopped) detects.

## Request History

[`forwardRequestHistory()`](#doc-api-reference-api-bridge-forwardrequesthistory) returns the past requests for a given `url` and `method` as a paginated list of [`RequestHistory`](#doc-api-reference-data-types-requesthistory) items. Each item includes the `request_body`, the `response_body`, the `status`, and two timestamps in milliseconds: `created` (when the request was made) and `updated` (the most recent status change, i.e. when the response arrived for a settled request).

```js
skapi.forwardRequest(null, {
    secretName: 'YourSecretKeyName',
    url: 'https://third.party.com/api',
    method: 'POST',
    headers: { Authorization: 'Bearer $CLIENT_SECRET' }
}).then(() => skapi.forwardRequestHistory({
    url: 'https://third.party.com/api',
    method: 'POST'
})).then((history) => {
    for (const req of history.list) {
        console.log(req.created, req.updated, req.status);
    }
});
```

## Related Methods

- [`forwardRequestHistory(params, fetchOptions)`](#doc-api-reference-api-bridge-forwardrequesthistory): list past requests (see above).
- [`cancelForwardRequest(params)`](#doc-api-reference-api-bridge-cancelforwardrequest): cancel a queued or running request and remove it from the client-side queue.
- [`stopForwardRequestPolling(params?)`](#doc-api-reference-api-bridge-stopforwardrequestpolling): stop polling locally without cancelling the request.
- [`isPollStopped(res)`](#doc-api-reference-api-bridge-ispollstopped): tell a stopped-poll result apart from a real API response.
- [`forwardRequestQueueCount(params)`](#doc-api-reference-api-bridge-forwardrequestqueuecount): how many requests are waiting in a named queue.

# Forwarding Requests <a id="doc-api-bridge-forward-request"></a>

[`forwardRequest(form, options)`](#doc-api-reference-api-bridge-forwardrequest) relays a request to a destination of your choosing **from Skapi's servers** instead of from the browser. It is the method to reach for whenever the call should not leave the browser as it stands: because it carries a key the page must not hold, because it has to be queued or rate limited, because its answer has to outlive the page that asked for it, or because you want the response streamed as it arrives.

## Sending a request

```js
skapi.forwardRequest({ report: 'quarterly' }, {
    url: 'https://api.example.com/v1/report',
    method: 'POST',
    headers: { 'Content-Type': 'application/json' }
}).then(res => console.log(res));
```

The first argument is the request body, here a plain object. Pass `null` when there is nothing to send.

To send a stored secret with the request, name it and put `$CLIENT_SECRET` where the value goes:

```js
skapi.forwardRequest({ report: 'quarterly' }, {
    secretName: 'my_secret',
    url: 'https://api.example.com/v1/report',
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer $CLIENT_SECRET'
    }
});
```

The substitution happens on the server, so the secret never reaches the browser. Registering secrets, and restricting where each one may be sent, is on the [Secret Keys](#doc-api-bridge-client-secret-request) page.

## The form is the first argument <a id="doc-api-bridge-forward-request-the-form-is-the-first-argument"></a>

The first argument is a **submit event**, a **form element**, a **`FormData`**, a **plain object**, or **`null`**. A submit event uses its target form, and the SDK calls `preventDefault()` for you, so `onsubmit="skapi.forwardRequest(event, { ... })"` works with nothing else to write.

By default the form is **flattened** into a plain key-value object and merged into the request:

- into **`params`** when the method is `GET`, `DELETE` or `HEAD`,
- into **`data`** for every other method.

Repeated field names collapse to an array, and bracketed names such as `name="filter[status]"` build nested objects, exactly as everywhere else in this SDK. Where the form and your options carry the same key, **the option wins**: you typed it at the call site, while the form's value was collected from a page.

::: code-group

```html [Form]
<form onsubmit="skapi.forwardRequest(event, {
        secretName: 'my_secret',
        url: 'https://api.example.com/v1/report',
        method: 'POST',
        headers: { Authorization: 'Bearer $CLIENT_SECRET' }
    }).then(res => console.log(res))">
    <input name="title" placeholder="Title" required>
    <textarea name="notes"></textarea>
    <input type="submit" value="Send">
</form>
```

```js [JS]
skapi.forwardRequest({ title: 'Quarterly numbers', notes: '' }, {
    secretName: 'my_secret',
    url: 'https://api.example.com/v1/report',
    method: 'POST',
    headers: { Authorization: 'Bearer $CLIENT_SECRET' }
}).then(res => console.log(res));
```

:::

Both calls send `{ title, notes }` as the request body.

:::warning Files are dropped
The flattened object is merged into `data` or `params` and travels as **JSON**, where a file has no representation. A file input in the form is therefore **dropped**, silently, along with a `File` or `Blob` value in a plain object. To send the file itself, use `multipart: true` below.
:::

## Sending the raw form body with multipart <a id="doc-api-bridge-forward-request-sending-the-raw-form-body-with-multipart"></a>

`multipart: true` sends the body **byte for byte the way a browser posting that form would send it, files included**. Skapi builds the body with the platform's own `FormData` serialization, so the boundary, the part headers and the file bytes are exactly what `fetch()` would have written, and relays it to the destination with that same content type.

```js
skapi.forwardRequest(formElement, {
    secretName: 'my_secret',
    url: 'https://api.example.com/v1/upload',
    method: 'POST',
    headers: { Authorization: 'Bearer $CLIENT_SECRET' },
    multipart: true
});
```

:::warning Keep a multipart body under about 1.5 MB
A raw body rides the **ordinary request**, not a separate upload, so it shares that request's payload budget. Skapi caps a request at 2 MB, and the encoded body is capped at **2 MB minus 64 KB (2,031,616 characters)**, the headroom being for the url, the headers and the query string that share the same budget.

The cap is measured on the **base64 text**, and base64 costs four characters per three bytes, so the file itself has to be about a quarter smaller again: roughly **1.5 MB**. Past it the call throws `INVALID_PARAMETER` with the measured size in the message.

More than that, and the file belongs somewhere else: [upload it](#doc-database-handling-files) first and send its url.
:::

`data` is **refused** alongside `multipart: true`, in the SDK and on the server both. There is no sane merge of a raw body with a key-value object, and either choice of which to drop would be a quiet surprise. `headers` and `params` are unaffected; your `Content-Type`, if you set one, is replaced by the multipart type the body actually has.

`multipart: true` needs a form: called with `null` it throws `INVALID_PARAMETER`. In an environment with no `FormData` or `Response`, it throws `NOT_SUPPORTED`.

## Naming a secret key is optional

`secretName` names one of your project's [Secret Keys](#doc-api-bridge-client-secret-request-registering-secret-keys). It is **optional**.

**Named.** `$CLIENT_SECRET` is substituted server side in the `url` and in the values of `headers`, `data` and `params`, at any depth, such as `data: { auth: { key: '$CLIENT_SECRET' } }`; the key's access group authorizes the caller; and the key's [Destinations](#doc-api-bridge-client-secret-request-restricting-where-a-key-can-be-sent) are enforced before anything is sent. Nothing is substituted inside a [`multipart`](#doc-api-bridge-forward-request-sending-the-raw-form-body-with-multipart) body, which is sent byte for byte. When `$CLIENT_SECRET` appears nowhere, there is nothing for the key to fill, so the SDK leaves `secretName` out and sends the request as written, the same as a request that names no key.

**Absent.** Nothing is resolved and no placeholder is required. The request is forwarded with only what you supplied. A literal `$CLIENT_SECRET` in a request that names no secret is just text you typed, and is sent as such.

:::info Signing in is not required
A signed-out visitor can call this, and the key's own **Locked** setting decides whether they may use it: a locked key needs a signed-in caller whose access group reaches the key's, an unlocked one is open to anyone.
:::

## Telling the destination who is calling

`skapiHeaders` is **off by default**: the destination is told nothing about the caller.

| value | sent |
|---|---|
| `true` | `x-skapi-user` and `x-skapi-service` |
| `{ user: true }` | `x-skapi-user` |
| `{ service: true }` | `x-skapi-service` |

- **`x-skapi-user`** is the signed-in caller's **user id**. A signed-out caller has none, so the header is **left out entirely** rather than sent empty, which lets a destination read its presence as "a signed-in caller".
- **`x-skapi-service`** is **the service this request runs against**, which is not necessarily the caller's own connection service.

Both values are written on the server from the **verified identity of the request**, never from anything typed at the call site.

:::warning The `x-skapi-` prefix is reserved
A header of your own whose name starts with `x-skapi-` is **refused** with `INVALID_PARAMETER`, whatever `skapiHeaders` says, matched without regard to case. Both the SDK and the server refuse it. That refusal is the whole value of the prefix: a caller who could set one of these headers could claim to be any user of any service, so a destination would have no reason to trust them.
:::

## Methods

`GET`, `POST`, `PUT`, `PATCH`, `DELETE` and `HEAD`. `POST` is the default, which is what a form with no method attribute of its own would have meant and what all but a read is.

`GET`, `DELETE` and `HEAD` carry the form's fields in the **query string** (`params`); everything else carries them in the **body** (`data`).

## Reading the answer

`responseType` shapes what the promise resolves with:

- `'text'` hands back a string, stringifying a value that is not one already.
- `'json'` parses a string, and hands the string back unchanged when it does not parse.
- Left out, the answer arrives exactly as the server stored it.

A Skapi status object, the envelope a queued request resolves with, is handed back **untouched** either way: reshaping it would corrupt the very fields it is read for.

The destination is called **server side** and its body is relayed, so there is no live `Response` object to hand back. The destination's own status code is the `status_code` of the row [`forwardRequestHistory()`](#doc-api-bridge-request-history) lists.

`signal` takes an `AbortSignal`, and aborting it stops **this client's poll**. The request is already on the server and may already be running at the destination, so it is deliberately not cancelled: [`cancelForwardRequest()`](#doc-api-bridge-polling-request-cancelling-a-request) is what removes it. A signal that is already aborted throws before anything is sent, and on the direct non-queued path there is no poll, so a signal does nothing there.

## Queueing, polling and streaming

Each of these has a page of its own:

- [Polling Requests](#doc-api-bridge-polling-request): `poll`, `queue`, the `poll()` handle on the reply, stopping a poll, cancelling a request, and the queue count.
- [Streaming the Response](#doc-api-bridge-streaming-request): `stream`, `realtime`, `onStream`, reading a request you did not start, and finalizing what to keep.
- [Request History](#doc-api-bridge-request-history): listing past requests, and their timestamps.

```js
const res = await skapi.forwardRequest({ report: 'quarterly' }, {
    secretName: 'my_secret',
    url: 'https://api.example.com/v1/report',
    method: 'POST',
    headers: { Authorization: 'Bearer $CLIENT_SECRET' },
    queue: 'reports',
    poll: 1000,
    onResponse: (result) => console.log('final result', result),
    onError: (err) => console.error(err)
});
```

:::warning A streaming form must have no `action`
When a form carries an `action` attribute, the SDK stores the resolved value and navigates to that url once the request settles, which throws a stream away. Leave `action` off a form you stream from.
:::

## Errors

| code | when |
|---|---|
| `INVALID_PARAMETER` | a bad `method`, `multipart` with `data`, `multipart` with no form, a header in the `x-skapi-` namespace, a multipart body over the size cap, a negative `poll` |
| `INVALID_REQUEST` | an unknown `secretName`, no access to the named secret, a destination the secret is not allowed to be sent to, a `signal` that was already aborted |
| `NOT_SUPPORTED` | `multipart: true` in an environment with no `FormData` or `Response` |

A destination that answers with an error of its own is not one of these: that answer is relayed and stored like any other, and is read from the request's status and history.

### [`forwardRequest(form, options): Promise<any>`](#doc-api-reference-api-bridge-forwardrequest)

# Polling for the Result <a id="doc-api-bridge-polling-request"></a>

Because third-party APIs can take time to respond, `forwardRequest()` uses a queue-and-poll model. Every call is queued on the server and you can poll for the result.

## Automatic polling with `poll`

Pass a `poll` interval (in milliseconds) together with `onResponse` and `onError` callbacks. With `poll` greater than `0`, Skapi auto-generates a queue and starts polling automatically; the final result is delivered to `onResponse` (and errors to `onError`). Note that when `onResponse` is provided, the awaited return value of `forwardRequest()` is the callback's return value, so don't rely on the returned promise for the status object in the auto-poll case. The immediate status object with a `poll()` method (`{ id, status, ... }`) is only returned when `poll` is omitted or `0` and `onResponse` is not supplied.

```js
skapi.forwardRequest({ model: 'gpt-image-1.5', prompt: 'A cute baby sea otter', n: 1, size: '1024x1024' }, {
    secretName: 'openai',
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer $CLIENT_SECRET'
    },
    poll: 2000,              // poll every 2 seconds
    onResponse(result) {
        console.log('Done:', result);
    },
    onError(err) {
        console.error('Failed:', err);
    }
});
```

## Manual polling with `poll()`

When `poll` is omitted or `0`, the promise resolves with the status object plus a `poll()` method. Call it whenever you are ready to start polling:

```js
const res = await skapi.forwardRequest({ model: 'gpt-image-1.5', prompt: 'A cute baby sea otter', n: 1, size: '1024x1024' }, {
    secretName: 'openai',
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer $CLIENT_SECRET'
    },
    queue: 'image-queue'
});

// res = { id, status: 'running', queue_name, in_queue, poll }
res.poll({
    latency: 2000, // start polling at 2-second intervals
    onResponse(result) {
        console.log('Done:', result);
    },
    onError(err) {
        console.error('Failed:', err);
    }
});
```

:::warning
Only requests with status `running` or `pending` can be polled.
A request enters `running` or `pending` only when either `params.queue` (queue name) or `params.poll` (poll interval) is provided.
:::

## How long the call actually took

`onResponse` receives a second argument, `meta`, carrying facts about the **request** rather than about the response. A settled poll resolves with the destination's own answer, and that answer belongs to the destination: there is nowhere in it to put a value of skapi's without changing what you asked for. So request-level facts arrive beside it instead.

`meta.executed` is when the worker **began running** the request, in milliseconds. Subtract it from the request's `updated` to time the call on its own:

```js
res.poll({
    latency: 2000,
    onResponse(result, meta) {
        if (meta?.executed) {
            console.log('the call began at', new Date(meta.executed));
        }
        console.log('Done:', result);
    }
});
```

Use `executed` rather than `created` when you are timing the destination. `created` is when the request was **queued**, and a queued request can sit there for a long time before a worker picks it up, so `updated - created` measures your own queue depth plus the call while `updated - executed` measures the call.

`meta.executed` is optional and can legitimately be absent, so branch on it rather than assuming it. The value rides on the status envelope that a **running** poll tick returns, so a request that began and finished between two ticks never showed one. When it is missing, show nothing: substituting `created` reports a queue backlog as though the destination had been slow. The value is on the request's own row either way, so a later [`forwardRequestHistory()`](#doc-api-bridge-request-history) will still report it as `executed`.

A poll started from a `forwardRequestHistory()` item is the exception: it takes the execution start from the listing that produced it, so it can hand you `meta.executed` even when the request settles on the poll's very first read.

A callback that declares one parameter is unaffected, and `onResponse` on a direct, non-queued `forwardRequest()` receives no `meta`, since nothing ever recorded an execution start for a request that was never queued.

## Stopping Polling <a id="doc-api-bridge-polling-request-stopping-polling"></a>

Polling and the request itself are separate things. `stopForwardRequestPolling()` stops *watching* a
request; the server keeps working on it, and you can pick the result up later.

This matters because polling is real network traffic. A long-running request polled every second keeps
issuing calls for as long as it takes, whether or not anyone is looking at the result. Stop polling when
the user navigates away or the tab is hidden, and start again when they come back.

```js
// Stop watching one request. It keeps running on the server.
skapi.stopForwardRequestPolling({
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST',
    id: 'stamp:entropy'   // the id from the forwardRequest response
});

// Stop every poll on a queue.
skapi.stopForwardRequestPolling({ queue: 'image-queue' });

// Stop everything this client is polling.
skapi.stopForwardRequestPolling();
```

Each call returns how many polls it stopped.

### Handling a stopped poll

A stopped poll **resolves** with `{ id, status: 'stopped' }` rather than rejecting, so `await` sites do
not need a `catch`. Its `onResponse` and `onError` callbacks are **not** called, because a stop is not a
result. Use `isPollStopped()` to tell the two apart:

```js
const res = await skapi.forwardRequest(null, { /* ... */ });

const result = await res.poll({ latency: 2000 });

if (skapi.isPollStopped(result)) {
    // We stopped watching. Nothing failed, and the request may still be running.
    return;
}

console.log('Done:', result);
```

### Resuming

There is nothing to resume, as such: just poll again. Fetch the request from
[`forwardRequestHistory()`](#doc-api-reference-api-bridge-forwardrequesthistory) and call
`poll()` on it. If it finished while you were not watching, the history entry already carries the result.

```js
document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
        skapi.stopForwardRequestPolling({ queue: 'image-queue' });
    }
});
```

### [`stopForwardRequestPolling(params?): number`](#doc-api-reference-api-bridge-stopforwardrequestpolling)

### [`isPollStopped(res): boolean`](#doc-api-reference-api-bridge-ispollstopped)

:::tip
Requests sharing a queue are polled **one at a time**. A request that never settles therefore holds up
every poll queued behind it, so stopping it also unblocks the rest. Stopping a request that has not
started yet removes it from the queue entirely, freeing the slot immediately.
:::

## Cancelling a Request <a id="doc-api-bridge-polling-request-cancelling-a-request"></a>

To cancel a pending request before it is processed, call [`cancelForwardRequest()`](#doc-api-reference-api-bridge-cancelforwardrequest):

```js
const result = await skapi.cancelForwardRequest({
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST',
    id: 'stamp:entropy',  // the id from the forwardRequest response
    queue: 'image-jobs'   // required if the request was submitted with a queue name
});

console.log(result.removed); // true if successfully removed
```

Provide `queue` when the original request was submitted with a queue name. This removes the pending job from the client-side queue in addition to cancelling it on the server.

:::info
`cancelForwardRequest()` cancels the **request**. [`stopForwardRequestPolling()`](#doc-api-bridge-polling-request-stopping-polling)
only stops **watching** it: the request carries on, and its result stays available. Use cancel when the
work is no longer wanted, and stop-polling when only the traffic is.
:::

### [`cancelForwardRequest(params): Promise<{ removed: boolean; message: string }>`](#doc-api-reference-api-bridge-cancelforwardrequest)

## Checking Queue Size

To check how many requests are currently waiting in a named queue, use [`forwardRequestQueueCount()`](#doc-api-reference-api-bridge-forwardrequestqueuecount):

```js
const info = await skapi.forwardRequestQueueCount({ queue: 'image-jobs' });
console.log(info.in_queue); // number of requests waiting
```

### [`forwardRequestQueueCount(params): Promise<{ queue_name: string; in_queue: number }>`](#doc-api-reference-api-bridge-forwardrequestqueuecount)

# Streaming the Response <a id="doc-api-bridge-streaming-request"></a>

By default, a queued [`forwardRequest()`](#doc-api-reference-api-bridge-forwardrequest) waits for the destination to finish, downloads the whole response, and stores it on the request. Pass `stream: true` and the server reads the response **incrementally** instead, relaying the raw bytes as they arrive. Your `onStream` callback receives them in order, so you can act on the beginning of a response while the destination is still producing the end of it.

Anything that answers incrementally works: a Server-Sent Events endpoint, an NDJSON export, a log tail, a progress feed, a long report a vendor writes row by row. Skapi relays the bytes and reads none of them, so the format is entirely between you and the destination.

## What streaming gives you, and what it does not

Streaming does not make the destination faster. A request that takes 30 seconds to answer still takes 30 seconds. What changes is when you can use it: the first piece arrives after the first piece is produced, instead of after the last one.

Three things are worth being blunt about before you write any code:

1. **Skapi does not parse the stream.** It relays bytes and never inspects them, so decoding whatever framing your destination uses is your code's job. That is deliberate: it is what makes this work against any destination rather than a list of blessed ones.
2. **A streamed request settles with a status and no body.** The relayed bytes live in a chunk store, not on the request, so its history entry stays empty until you say what should be kept with [`forwardRequestFinalize()`](#doc-api-bridge-streaming-request-finalizing-what-to-keep).
3. **Your destination has to be streaming too.** `stream: true` only says how Skapi should READ the response. Asking the destination to produce one incrementally is part of your own request, so whatever that API requires (most spell it `"stream": true`) goes in the request body, the first argument, as usual. Set only Skapi's half and you get one ordinary document relayed in pieces, with no error to tell you so. Set only the destination's and Skapi buffers the whole event transcript into the stored response, where a reader expecting that API's normal document finds a wall of `data: {...}` lines. Neither half-set case raises: your request body is yours and is never inspected.

## Streaming a request

`stream` needs a queue, because the relayed text is appended to a polling row and only a queued request has one. The SDK mints a queue name for you when you do not give one, so in practice you just set the flag. (The endpoint refuses a streamed request with no queue rather than silently handing back a buffered answer. You are unlikely to see it through the SDK, which mints a queue name before it validates.)

The example below uses a Server-Sent Events endpoint, because SSE is the most common incremental format and OpenAI's chat completions API is a recognisable one to read; the mechanism is the same for any other. Note that `stream: true` is set alongside the `stream` this particular destination takes in its own body, and that the SSE decoding is entirely the caller's business:

```html
<pre id="output"></pre>
<script src="https://cdn.jsdelivr.net/npm/skapi-js@2.2.5/dist/skapi.js"></script>
<script>
const skapi = new Skapi("<Project ID>");

let output = document.getElementById('output');
let buffer = '';   // holds a frame that arrived cut in half
let answer = '';   // the text as we have assembled it

// Your format, your parser. Skapi handed us raw bytes and knows nothing about SSE.
function readEvents(chunk) {
    buffer += chunk;
    let frames = buffer.split('\n\n');
    buffer = frames.pop();  // the last piece may be half written, keep it for next time

    for (let frame of frames) {
        for (let line of frame.split('\n')) {
            if (!line.startsWith('data: ')) continue;
            let payload = line.slice(6);
            if (payload === '[DONE]') return;
            let delta = JSON.parse(payload).choices[0].delta.content || '';
            answer += delta;
            output.textContent = answer;
        }
    }
}

skapi.forwardRequest({
    model: 'gpt-4.1',
    // This "stream" is OpenAI's own field, asking IT to answer in pieces.
    // Skapi passes it through without looking at it.
    stream: true,
    messages: [{ role: 'user', content: 'Explain HTTP polling in two paragraphs.' }]
}, {
    secretName: 'openai',
    url: 'https://api.openai.com/v1/chat/completions',
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer $CLIENT_SECRET'
    },
    // And this one is Skapi's, telling it to relay that answer as it arrives.
    stream: true,
    poll: 1000,
    onStream: (chunk, seq) => readEvents(chunk)
}).then(res => {
    // A streamed request settles with a status and NO body: the text was the stream.
    console.log(res.status); // "resolved"

    // Keep what we assembled as this request's history, and release the chunks.
    return skapi.forwardRequestFinalize(res.id, answer, {
        url: 'https://api.openai.com/v1/chat/completions',
        method: 'POST'
    });
});
</script>
```

Three parameters are added to `forwardRequest()` for this:

- `stream`: relay the destination's response incrementally instead of buffering it. Requires a queue (one is minted if you do not name it). Never sent to the destination.
- `realtime`: also push each relayed piece over skapi's websocket, so it arrives as it is relayed instead of on the next poll tick. Optional accelerator, covered under [Faster delivery over the websocket](#doc-api-bridge-streaming-request-faster-delivery-over-the-websocket).
- `onStream`: `(chunk: string, seq: number, via?: 'socket' | 'poll') => void`. Called with each relayed piece, in order, with raw text. `via` names the transport that carried it, and is covered in that same section.

Chunk boundaries are set by how fast the destination writes, not by your format, so a chunk can end in the middle of a frame. Buffer until your own framing says a unit is complete, as the example does. Multi-byte characters are never split across chunks.

The presence of `onStream` is what makes the poll loop fetch chunks at all. A poll without it sends no cursor and gets exactly the response it got before streaming existed, which is what lets a second reader watch the same request for its outcome only, paying nothing for text it does not want.

When you omit `poll`, the promise resolves with the status object and its `poll()` method instead, and you pass the callback there:

```js
const res = await skapi.forwardRequest({ model: 'gpt-4.1', stream: true, messages: [/* ... */] }, {
    secretName: 'openai',
    url: 'https://api.openai.com/v1/chat/completions',
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer $CLIENT_SECRET'
    },
    stream: true
});

// res = { id, status: 'pending', queue_name, in_queue, poll }
res.poll({ latency: 1000, onStream: (chunk) => readEvents(chunk) });
```

:::warning
The relayed bytes are decoded as **UTF-8, and only UTF-8**. A `charset` in the destination's response headers is ignored, because the streaming shape almost never declares an honest one. A destination that answers a streamed request in some other encoding gets `U+FFFD` where its non-ASCII characters were, and should be called without `stream`.
:::

## Reading a request you did not start <a id="doc-api-bridge-streaming-request-reading-a-request-you-did-not-start"></a>

`onStream` on `forwardRequest()` only reaches the caller that started the request. The page was reloaded, a second tab is watching, or an old request is being opened from history and was never finalized: for all of those, use `forwardRequestStream()`.

Give it the request id and it does the right thing for the state the request is in:

- **Still running:** polls and delivers text through `onStream` until the request settles, then resolves with its terminal status.
- **Finished, not finalized:** fetches the whole stored text at once (paging internally until there is none left), delivers it through `onStream` in order, and resolves. This is what makes an unfinalized request re-readable.
- **Already finalized:** resolves with the stored body itself, exactly the value you kept. Nothing streams, because the chunks were released when that version was stored.

```js
// Started somewhere else, id kept in storage.
localStorage.setItem('pending_turn', res.id);

// ...after a reload, or in another tab
let requestId = localStorage.getItem('pending_turn');

const restored = await skapi.forwardRequestStream(requestId, {
    url: 'https://api.openai.com/v1/chat/completions',
    method: 'POST',
    poll: 1000,                     // interval while it is still running, default 1000
    onStream: (chunk) => readEvents(chunk)
});
```

```ts
forwardRequestStream(
    requestId: string, // "stamp:entropy" as returned by the request, or an already composed full id.
    options: {
        url?: string; // The url the request was sent to. Required unless requestId is a full id.
        method?: 'GET' | 'POST' | 'DELETE' | 'PUT'; // Required unless requestId is a full id.
        onStream?: (chunk: string, seq: number, via?: 'socket' | 'poll') => void; // Each relayed piece, in order. Raw text.
        realtimeGroup?: string; // The realtime_group the dispatching call handed back. See Faster delivery below.
        since?: number; // Start after this sequence number instead of from the beginning.
        poll?: number; // Polling interval in ms while the request is still running. Default 1000.
        onResponse?: (res: any) => void; // Called once with whatever this resolves with.
        onError?: (err: any) => void; // Called if the read itself fails.
    }
): Promise<any> & { stop: () => void }
```

`since` is there so a reader that already holds part of a response does not receive it twice. The returned promise carries a `stop()` that ends the read without touching the request, and it is registered like any other poll, so [`stopForwardRequestPolling()`](#doc-api-reference-api-bridge-stopforwardrequestpolling) reaches it too when called with the request's `url`, `method` and `id`, or with no arguments at all.

Telling the two resolutions apart takes all three fields: a status envelope always carries `status`, `id` and `in_queue` **together**, and anything that does not is a finalized body. Do not test `status` alone, because the body you finalized may carry a `status` field of its own, and most destinations' responses do. This is exactly the test the SDK uses internally.

:::tip
Only the caller who made a request can read it back. The identity half of a request id is taken from the request context on the server and never travels, so an id that is not yours composes a key that does not exist. For a signed-in user that identity is the account; for a visitor who is not signed in it is the IP and user agent pair, so a request made while signed out is not readable from another browser or another network.
:::

## Faster delivery over the websocket <a id="doc-api-bridge-streaming-request-faster-delivery-over-the-websocket"></a>

The poll decides how quickly relayed text reaches you: at `poll: 1000` a piece written just after a tick waits nearly a second for the next one. Pass `realtime: true` and the server ALSO pushes each piece over skapi's websocket as it relays it, so it arrives as soon as it exists.

```js
const res = await skapi.forwardRequest({ report: 'quarterly', stream: true }, {
    secretName: 'my_secret',
    url: 'https://api.example.com/v1/report',
    method: 'POST',
    stream: true,
    realtime: true,
    poll: 1000,
    onStream: (chunk, seq, via) => { output.textContent += chunk; }
});
```

**It is an accelerator, never the source of truth.** The poll keeps running and keeps reading the same chunk store it always did. Everything it implies follows from that:

- No websocket in this environment, no session to open one with, a connection that will not open, a connection that drops mid-response: none of them are errors and none of them are reported. The read continues at poll speed, which is the behaviour with no websocket at all.
- Nothing is delivered twice and nothing arrives out of order, whichever transport carried it. A piece the websocket delivers is skipped by the poll, and a piece that arrives ahead of one still missing is held until the gap is filled rather than handed over early.
- Every piece is in the chunk store either way, so a response is re-readable in full with `forwardRequestStream()` regardless of how it was delivered live.

Because both transports hand their text to the same `onStream`, a response delivered perfectly over the websocket and one polled the whole way look identical from the outside. The third argument is the difference: `via` is `'socket'` when the websocket got there first, `'poll'` when the poll did. It is there to be observed, not acted on, and a callback that ignores it reads exactly as it did before.

```js
onStream: (chunk, seq, via) => {
    if (via === 'socket') console.log('live', seq);
    output.textContent += chunk;
}
```

`realtime: true` only makes a difference alongside `stream: true` and an `onStream` callback: without somewhere to put the text there is nothing to deliver faster, and skapi will not open a connection your application did not ask for.

### What it does to your own websocket

Skapi's realtime connection is shared, and this borrows it:

- A connection your application already opened is **not** replaced, its callback is not changed, and it is not closed. These pieces are read off the socket directly, so your own realtime callback never sees them.
- A connection skapi opens for a read is closed by that read, and only while it is still the one skapi opened.
- Joining a room **replaces** the room the connection was in, and the room is left when the read settles rather than restored, because the SDK cannot read which room you were in. If your application uses realtime rooms of its own, re-join yours after the request settles.
- One connection is in one room at a time, so two responses streaming at once cannot both listen. The first takes the room and the second reads at poll speed rather than evicting a reader mid-response.

An application that never calls `connectRealtime()` has none of this to think about.

### Re-attaching later

The reply to a `realtime: true` request carries `realtime_group`. It cannot be rebuilt from a request ID, so keep it beside the ID if you may want to re-attach:

```js
localStorage.setItem('pending', JSON.stringify({ id: res.id, group: res.realtime_group }));

// ...after a reload
const saved = JSON.parse(localStorage.getItem('pending'));
await skapi.forwardRequestStream(saved.id, {
    url: 'https://api.example.com/v1/report',
    method: 'POST',
    realtimeGroup: saved.group,
    onStream: (chunk, seq, via) => { output.textContent += chunk; }
});
```

Everything above holds for this read too. Omit `realtimeGroup` and it works exactly as it always has, at poll speed.

## Finalizing what to keep <a id="doc-api-bridge-streaming-request-finalizing-what-to-keep"></a>

A streamed request settles with a status and no body. Skapi never decides what those chunks add up to, because it never read them. `forwardRequestFinalize()` is where you say:

```js
await skapi.forwardRequestFinalize(requestId, answer, {
    url: 'https://api.openai.com/v1/chat/completions',
    method: 'POST'
});
// { finalized: true, message: 'Finalized.' }
```

```ts
forwardRequestFinalize(
    requestId: string, // "stamp:entropy", or an already composed full id.
    data?: any, // The version to keep. Sent verbatim and stored as given.
    options?: {
        url?: string; // Required unless requestId is a full id.
        method?: 'GET' | 'POST' | 'DELETE' | 'PUT'; // Required unless requestId is a full id.
    }
): Promise<{ finalized: boolean; message: string }>
```

What you send becomes that request's stored result: the value [`forwardRequestHistory()`](#doc-api-reference-api-bridge-forwardrequesthistory) lists as `response_body`, and the value a later poll of the same request hands back. The content is entirely yours. Skapi does not validate it, parse it, or interpret it: text you assembled from the chunks, a rebuilt response object, a summary, a single word, anything at all. It is stored as given and returned as given. Calling it with **no** `data` is not a no-op: it keeps `null` as the request's result and still releases the chunks, which destroys the relayed text. Pass what you actually want kept.

Storing that version is also what **releases the chunks**, which are deleted once the result lands.

:::warning
Chunks are kept **indefinitely** until you finalize. Nothing expires them on a timer. Until then, opening that request cold has to fetch and re-parse all of its chunks, so a request nobody finalized gets slower and more expensive to open, forever. (A reader that already holds a cursor only fetches past it, which is what `since` is for.) Finalize as soon as you have what you want: it is what turns a pile of relayed bytes into an ordinary history entry.

The one exception is `expires`. A request sent with it is removed when its history expires, and the removal takes its chunks with it. Cancelling a **live** streamed request with [`cancelForwardRequest()`](#doc-api-reference-api-bridge-cancelforwardrequest) also discards them, so finalize first if you want to keep the part that arrived. Cancel only reaches a request that is still `pending` or `running`: a settled one is refused with `{ removed: false }`, so for a finished request finalize (or `expires`) is the only release.

:::

Finalizing twice simply replaces the kept version, so a call lost to the network can be repeated. `finalized: false` comes back with a message rather than an error in these cases:

| message | meaning |
|---|---|
| `Request not found.` | No such request for this caller. An id belonging to somebody else reads the same way, deliberately. |
| `The request has not finished yet.` | Only a settled request can be finalized. Finalizing a live one would race the worker's own settle. |
| `The request was not sent with stream, so its response is already stored.` | A buffered request's stored result **is** the destination's own answer, and that answer is not yours to overwrite. |
| `The request changed while it was being finalized. Try again.` | The row moved between the read and the write. Read it again and decide afresh. |

## What a poll returns

For a streamed request polled **with** `onStream` (which is what sends the cursor), a poll answers with the status object plus the chunks you do not have yet:

```js
{
    id: 'stamp:entropy',
    status: 'running',       // 'pending' | 'running' | 'resolved' | 'failed' | 'cancelled'
    queue_name: 'my-queue',
    in_queue: 1,
    stream: true,
    chunks: [ { seq: 41, txt: 'data: ...' }, { seq: 42, txt: 'data: ...' } ],
    last_seq: 42,            // send this back as the next cursor
    more: false
}
```

- While the request is **running or pending**, that is what every tick returns, with `chunks` carrying whatever arrived since the last one.
- Once it is **`resolved` or `failed` and not finalized**, the same shape comes back with the terminal `status`, and the chunks stay readable. This is the state `forwardRequestStream()` replays. A **`cancelled`** request is the exception: the cancel releases its chunks, so there is nothing left to replay.
- When it **failed**, the same shape also carries an `error` field. See below.
- Once it is **finalized**, the poll returns your stored body verbatim instead, with none of the envelope fields. The signal is the absence of `status`, `id` and `in_queue` together, not the absence of `status` on its own.

`more: true` means that read was capped, not that the request is unfinished: there is more text already written and waiting. The one exception is `more: true` with an empty `chunks` and an unchanged `last_seq`: that is the server saying the chunk read itself failed, not that a page is waiting. Treat it as "nothing known this tick" and poll again rather than as a truncation. The SDK distinguishes the two: on a real cap it goes straight back round for the next page rather than spending a whole interval per page, which is how a finished request replays in one pass, while on a degraded read it waits out the interval and, after three consecutive degraded reads on a settled request, gives up and resolves with what it has. **That last case is the one to guard**: a body assembled after a degraded read can be short, so check that your own parser saw the end of the response before you finalize, or you will store a truncation and release the chunks holding the rest. `last_seq` is the cursor to send back, and it stays at the value you sent when nothing new arrived.

A poll sent **without** `onStream` sends no cursor, so it returns the pre-streaming response key for key: no `chunks`, `last_seq`, `more`, `stream` or `error`. Those five are attached only when a cursor is present.

## When things fail

A failed request does not throw and does not reach `onError`, which is reserved for the poll itself failing. Read **with** `onStream`, it resolves with the status envelope carrying `status: 'failed'`, so check the status. Read without one, it resolves with the error payload on its own, exactly as a buffered failure always has.

**The destination answered with an error.** A response of 400 or above is never relayed into chunks. It is small, it is the destination's own error message, and it is stored on the request where every error reader already looks. The poll returns `status: 'failed'` and an `error` of `status_code`, `body` and `truncated`.

**The destination ran past the body budget.** A streamed read is bounded by the invocation's own clock, so a destination still sending at the end of it settles the request `failed` with the chunks that did arrive and a message saying it was still sending. A healthy answer is nowhere near that ceiling; a destination that trickles for many minutes will meet it.

**The stream died mid body.** The request goes terminal as `failed`, and **the part that arrived stays exactly where it is**. A request that died at 80% has 80% of a response sitting there, and the poll hands you the terminal status, the error, and those chunks in one response:

```js
{
    id: 'stamp:entropy',
    status: 'failed',
    queue_name: 'my-queue',
    in_queue: 0,
    stream: true,
    chunks: [ /* everything that did arrive */ ],
    last_seq: 118,
    more: false,
    error: {
        message: 'streamed response ended before completion: ...',
        status_code: 200,
        chunks: 118
    }
}
```

Read without `onStream`, a failed request answers with that error payload on its own rather than the status envelope, exactly as a buffered one always has.

You can read that partial answer back later with `forwardRequestStream()`, and you can finalize it: a failed request is still finalizable, so keeping the 80% that arrived is a normal thing to do.

## Streaming from your own backend

Your own backend is simply another destination, and streaming from it works exactly as streaming
from anywhere else does: the response is relayed into a chunk store, read by polling, survives a
reload through [`forwardRequestStream()`](#doc-api-bridge-streaming-request-reading-a-request-you-did-not-start), and stays stored until
you finalize it.

Store your backend's key on the [Secret Keys](#doc-api-bridge-client-secret-request-registering-secret-keys) page, name it with `secretName`, and put `$CLIENT_SECRET` where the key goes. Set `skapiHeaders` when your backend needs to know who is calling: it is off by default, and `x-skapi-user` carries the caller's **user id**.

```js
skapi.forwardRequest({ question: 'What were last quarter\'s numbers?', stream: true }, {
    secretName: 'my_backend_key',
    url: 'https://api.yourbackend.com/chat',
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'x-api-key': '$CLIENT_SECRET' },
    skapiHeaders: true,
    stream: true,
    poll: 1000,
    onStream: (chunk) => { output.textContent += chunk; }
});
```

An `AbortSignal` in `signal` stops **this client** receiving the rest, and leaves the request
running. What removes the request itself is
[`cancelForwardRequest()`](#doc-api-bridge-polling-request-cancelling-a-request).

## A destination that is not SSE

Nothing above depends on Server-Sent Events. A destination that writes newline-delimited JSON needs no SSE parsing at all, just a buffer and a split, because the chunk boundaries Skapi hands you have nothing to do with the destination's own record boundaries:

```js
let carry = '';
const rows = [];

await skapi.forwardRequest({ report: 'quarterly', format: 'ndjson' }, {
    secretName: 'vendor',
    url: 'https://api.vendor.example/v1/export',
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer $CLIENT_SECRET'
    },
    stream: true,
    onStream(chunk) {
        carry += chunk;
        const lines = carry.split('\n');
        carry = lines.pop();          // the last piece may be half a line
        for (const line of lines) {
            if (line.trim()) rows.push(JSON.parse(line));
        }
    }
});
```

The rule is the same whatever the format: a chunk is an arbitrary slice of the byte stream, so hold the incomplete tail and carry it into the next one. Skapi guarantees only two things about the boundaries, that the pieces arrive in order and that a multi-byte UTF-8 character is never split across two of them.

# Fetching Request History <a id="doc-api-bridge-request-history"></a>

Every call made through `forwardRequest()` is stored in your project so the result can be retrieved later, even if the original call returned before the third-party API responded, or if the page was reloaded mid-request.

Use [`forwardRequestHistory(params, fetchOptions)`](#doc-api-reference-api-bridge-forwardrequesthistory) to list past requests for a given `url` and `method`:

```js
const history = await skapi.forwardRequestHistory({
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST'
});

console.log(history.list); // RequestHistory[]
```

## Timestamps <a id="doc-api-bridge-request-history-timestamps"></a>

Every [`RequestHistory`](#doc-api-reference-data-types-requesthistory) item carries these timestamps, all in milliseconds:

- `created`: when the request was made. It is set once and never changes, so it is the value to sort or display a request list by.
- `updated`: when the request status last changed. For a request that has settled (`resolved` or `failed`) this is when its response arrived, so `updated - created` is how long the third-party API took.
- `responded`: when the third-party API's response finished arriving. It is recorded once and never changes. Use it, rather than `updated`, to show when an answer came back for a [streamed request](#doc-api-bridge-streaming-request): finalizing a streamed request stores its response afterwards and moves `updated` to that moment, while `responded` stays where it was. It is absent on a request that has not settled, on one that failed before any response arrived, and on requests made before this value was recorded, so fall back to `updated` when it is missing.

```js
const history = await skapi.forwardRequestHistory({
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST'
});

for (const item of history.list) {
    const answered = item.responded ?? item.updated;
    console.log(new Date(item.created), item.status, answered - item.created + 'ms');
}
```

## Polling History Items

Items with `status: 'running'` or `status: 'pending'` include a `poll()` method. Call it to start polling that specific item:

```js
const history = await skapi.forwardRequestHistory({
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST'
});

for (const item of history.list) {
    if (item.status === 'running' || item.status === 'pending') {
        item.poll({
            latency: 2000,
            onResponse(result) { console.log('Resolved:', result); },
            onError(err) { console.error('Failed:', err); }
        });
    }
}
```

## Filtering by Status

Pass a `status` filter to return only requests in a specific state:

```js
skapi.forwardRequestHistory({
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST',
    status: 'pending'
});
```

## Filtering by Queue

If the original request used a `queue` name, pass the same `queue` to return only requests in that queue:

```js
skapi.forwardRequestHistory({
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST',
    queue: 'image-jobs'
});
```

::: warning Queue names match as a prefix
A bare `queue` lookup is a prefix range: `queue: 'image'` also returns requests queued as
`'image-jobs'`, `'image-retry'`, and every other queue starting with `'image'`. Pass
`queue_exact: true` when you want only the named queue.
:::

## Exact Queue Matching

`queue_exact: true` restricts the listing to exactly the named queue:

```js
skapi.forwardRequestHistory({
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST',
    queue: 'image',
    queue_exact: true // 'image' only; 'image-jobs' etc. are excluded
});
```

The inverse also exists: `queue_exclude: '<name>'` drops one queue's rows from a listing,
which is how you fetch everything *except* a background queue.

Both filters are applied server-side after the range read, so a page can come back short
(or even empty) while more matches remain. Keep paging by `startKey`/`endOfList` as usual;
never treat a short page as the end of the list.

## Compact Listings

Request and response bodies can be large (file contents, long AI conversations). When you
only need to *list* requests (render a history view, label rows, check whether something
finished), pass `compact: true`:

```js
const history = await skapi.forwardRequestHistory({
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST',
    compact: true
});

for (const item of history.list) {
    // item.request_body / item.response_body are omitted. Instead:
    console.log(item.request_text);             // first text of the request's last user message, truncated
    console.log(item.response_text);            // the head of the response text, truncated
    console.log(item.response_complete_marker); // whether the response carried the completion marker
    console.log(item.compact);                  // true: bodies were deliberately omitted, not empty
}
```

Each item carries lightweight stubs **instead of** the full `request_body` and
`response_body`; the bodies never leave the server, so a page of heavy requests costs a
fraction of the bandwidth. `request_text` can be missing when a request body's shape has
no recognizable user message. When you do need a full body, re-fetch that listing without
`compact`, or `poll()` a still-running item, which always resolves with the full result.

For full parameter details, see the API reference:

### [`forwardRequestHistory(params, fetchOptions): Promise<DatabaseResponse<RequestHistory[]>>`](#doc-api-reference-api-bridge-forwardrequesthistory)

# OpenAI Images API <a id="doc-api-bridge-example"></a>

In this example, you will use Skapi to call the [OpenAI Images API](https://developers.openai.com/api/reference/resources/images/methods/generate) securely.

First, we review the OpenAI API request format. Then we build the same request with `forwardRequest()`.

#### Prerequisites

1. Create an OpenAI account and get your API secret key from [platform.openai.com](https://platform.openai.com/).
2. Save your OpenAI API secret key on the **Secret Keys** page in Skapi.

  For this example, save the key with the name `openai`.

  You will use this name as `secretName` in the request.

#### Understanding the API call

According to the API documentation, the endpoint is:

```
POST https://api.openai.com/v1/images/generations
```

This means the request uses `POST` to `https://api.openai.com/v1/images/generations`.

The curl example looks like this:

``` bash
curl https://api.openai.com/v1/images/generations \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-image-1.5",
    "prompt": "A cute baby sea otter",
    "n": 1,
    "size": "1024x1024"
  }'
```

From this example, the request needs these headers:

- `Content-Type: application/json`
- `Authorization: Bearer $OPENAI_API_KEY`

The request body should include fields such as `model`, `prompt`, `n`, and `size`.

`$OPENAI_API_KEY` is a placeholder for your OpenAI secret key.

Because secrets must not be exposed in frontend code, use [`forwardRequest()`](#doc-api-reference-api-bridge-forwardrequest) and pass `Bearer $CLIENT_SECRET` instead.

The first argument is the **form**, so the form's own fields become the request body. The destination, the method and the headers go in the second argument:
::: code-group

```html [Form]
<form onsubmit="skapi.forwardRequest(event, {
      secretName: 'openai',
      url: 'https://api.openai.com/v1/images/generations',
      method: 'POST',
      headers: {
          'Content-Type': 'application/json',
          Authorization: 'Bearer $CLIENT_SECRET'
      }
  }).then(r=>console.log(r))">
  <input name="model" hidden value="gpt-image-1.5">
  <input name="n" hidden type='number' value="1">
  <input name="size" hidden value="1024x1024">
  <textarea name='prompt' required>A cute baby sea otter</textarea>
  <input type="submit" value="Generate">
</form>
```

```js [JS]
skapi.forwardRequest({
    model: "gpt-image-1.5",
    "prompt": "A cute baby sea otter",
    n: 1,
    size: "1024x1024"
}, {
    secretName: 'openai',
    url: 'https://api.openai.com/v1/images/generations',
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer $CLIENT_SECRET'
    },
    onResponse(result) {
        console.log(result);
    }
})
```
:::
The example above shows how to build request headers and body data for a secure OpenAI API call.
Use `$CLIENT_SECRET` in the `Authorization` header and set `secretName` to `openai`, which is the key name saved in your Skapi dashboard.

The two calls send the same request. In the form version the fields `model`, `n`, `size` and `prompt` are flattened and merged into the request body, because the method is `POST`; in the JavaScript version they are written out as `data`. Passing `null` as the first argument is how you say "no form, everything is in the options".

:::warning
The form is flattened to plain fields, and a **file input would be dropped**. This request has none. See [multipart](#doc-api-bridge-forward-request-sending-the-raw-form-body-with-multipart) for sending a form's files.
:::

When the request runs, Skapi replaces `$CLIENT_SECRET` with your stored secret key and returns the response from the OpenAI API via the `onResponse` callback.

# Tickets <a id="doc-tickets-introduction"></a>

A ticket is an HTTP endpoint that you register once as the project owner, and that anyone can call afterwards: a webhook sender such as your payment provider, a browser, or a signed-in user of your project. Each call is a **consumption**.

On every consumption Skapi:

1. checks the service and the ticket itself (service active, ticket exists, not expired, count left, per-user limit, issuer, token belongs to this project),
2. evaluates the ticket's **condition** against the incoming request and captures **placeholders** from it,
3. runs the ticket's **actions** in order (an action chain), each with its own **error chain**,
4. writes a consumption log row you can inspect from the dashboard,
5. answers with `{ tkid, hash }` on success or a standardized JSON error.

A ticket is how a request from outside your frontend reaches your database without a server of your own. A payment your payment provider reports becomes an `orders` record and lifts the buyer's access group. A coupon link writes one record per visit. A signed-in user unlocks a private record by calling one URL.

There is no rollback: actions that already ran stay applied when a later one fails. Design tickets so that a repeated call is harmless. See [Actions](#doc-tickets-actions-how-a-chain-runs).

## Registering a Ticket

Tickets are registered from the dashboard. Open your project, click **Tickets** in the project menu, then **+ Register Ticket**.

![The Tickets page of a project: the Register Ticket button, a search box, and a table of registered tickets with their id, description, remaining count, per-user limit, expiry and actions](https://docs.skapi.com/screenshots/tickets-list.webp)

*The Tickets page. Every registered ticket is one row. Click a row to open it, search by id or description, and tick rows to delete several at once.*

An AI agent can do this for you. The Skapi MCP server registers, updates and deletes tickets and reads their logs from a prompt, so a webhook endpoint is one sentence away. See [Connecting the Skapi MCP server](#doc-introduction-getting-started-connecting-the-skapi-mcp-server).

Only the project owner can register, update or delete a ticket. An admin of your project (access group `99`) is refused with `INVALID_REQUEST: Only the project owner can register tickets.`, and the SDK has no public register method.

The detail view has these sections:

- **Ticket**: the identity and the limits of the ticket, listed below.
- **Endpoints**: the URLs the ticket answers on, with copy buttons. They appear as soon as you type an id.
- **Condition**: what an incoming request must look like. See [Conditions and Placeholders](#doc-tickets-conditions).
- **Actions**: what happens once the condition passes. See [Actions](#doc-tickets-actions).
- **Log**: on an existing ticket, every consumption. See [Reading the Log in the Dashboard](#doc-tickets-errors-reading-the-log-in-the-dashboard).

![A ticket opened in the dashboard: the Ticket and Log tabs, the Edit as JSON toggle, the Ticket fields, and the Endpoints section with the POST, GET, signed-in and dry run URLs](https://docs.skapi.com/screenshots/tickets-register.webp)

*A ticket opened from the list. The same form registers a new ticket, and its Endpoints appear as soon as you type an id.*

### Ticket fields

- **Ticket ID**: the last segment of the endpoint URL. Must match `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`; `#`, `!` and `/` are reserved. It cannot be changed after creation.
- **Description**: up to 500 characters, shown in the list.
- **Remaining** (`count`): every successful consumption takes one. At `0` the ticket answers `TICKET_EXHAUSTED`. Blank keeps the current value when you update a ticket, and means unlimited on a new one. `0` exhausts the ticket. Tick **Unlimited** to remove the count from an existing ticket.
- **Limit per user** (`limit_per_user`): how many times one user may consume the ticket. `1` means once. Blank means unlimited. Only signed-in consumptions are counted, so it has no effect on the anonymous endpoints.
- **Expires** (`time_to_live`): a date and time after which the ticket answers `TICKET_EXPIRED`, entered in your local time. Blank means never. When set it must be in the future.
- **Always 200** (`return200`): answer HTTP 200 even when the consumption fails, for webhooks that retry on errors.
- **Method**: Any, GET or POST. Any accepts both; with GET or POST, a request with the other method fails with `METHOD_NOT_ALLOWED`. See [`return200` and `method`](#doc-tickets-conditions-return200-and-method).

Updating a ticket replaces its whole definition, but the per-user usage counters survive an update, so editing the count does not hand every user a fresh allowance. Deleting a ticket deletes the counters and keeps the log.

### Edit as JSON

The **Edit as JSON** toggle at the top of the form shows the same ticket as one JSON document, which is the form the examples in this guide use:

![The same ticket in JSON mode: the Edit as JSON toggle is on, the Ticket JSON editor holds the whole document, and an Apply button sits under it](https://docs.skapi.com/screenshots/tickets-json.webp)

*JSON mode. Edit or paste the whole ticket, click Apply to load it into the builder, then Register or Update to save.*

```ts
type Ticket = {
  ticket_id: string;                 // letters, digits, _ and -, up to 64 characters
  description?: string;              // up to 500 characters
  count?: number | null;             // uses left; null = unlimited
  limit_per_user?: number | boolean | null; // per signed-in user; true = 1; false, 0 or null = no limit
  time_to_live?: number | null;      // expiry, ms since the epoch; null = never
  condition?: {
    return200?: boolean;               // answer 200 even when it fails
    method?: "GET" | "POST";           // absent = both
    signature?: {
      secretName: string;                // a Secret Key's name
      header: string;                    // the header holding the signature
      algorithm?: "sha256" | "sha1" | "sha512";
      encoding?: "hex" | "base64";
      separator?: string;
      parts?: string[];
      signed?: string;
      timestamp?: string;
      tolerance?: number;
      secret_encoding?: "raw" | "base64" | "hex";
      secret_prefix?: string;
    };
    ip?: { operator: "=" | "!=" | ">" | ">=" | "<" | "<="; value: string | string[] };
    user_agent?: { operator: "=" | "!=" | ">" | ">=" | "<" | "<="; value: string | string[] };
    headers?: Matcher[];                   // request headers
    body?: Matcher[];                      // POST body
    params?: Matcher[];                    // query string
    user?: Matcher[];                      // signed requests only
    record_access?: string;            // signed requests only
  };
  actions: Action[];                 // required; [] = no actions; up to 50 in total
};

type Matcher = {
  key: string;                       // "keyname" or a path, such as "param[keyname][0]"
  operator?: "=" | "!=" | ">" | ">=" | "<" | "<="; // absent = capture only
  value?: string | number | boolean | null | (string | number | boolean | null)[];
  setValueWhenMatch?: any;           // the placeholder holds this instead of the field's value
  placeholder?: string;              // captures the field's value as ${placeholder[NAME]}
};

type Action =
  | { act: "pstr"; exe: PostRecord; err?: Action[]; retry?: boolean } // post a record
  | { act: "acsg"; exe: { group: number | "admin"; user_id?: string } & Then; err?: Action[]; retry?: boolean } // set the access group of a user
  | { act: "acsr"; exe: { record_id: string; user_id?: string | string[] } & Then; err?: Action[]; retry?: boolean } // grant private access to a record
  | { act: "req"; exe: HttpRequest; err?: Action[]; retry?: boolean } // send an HTTP request and run a nested chain on its response
  | { act: "mail"; exe: { template: string; to: string; placeholders?: { [name: string]: string | number | boolean } } & Then; err?: Action[]; retry?: boolean } // send a custom e-mail
  | { act: "nlsd"; exe: { group: "public" | "authorized" | number | string; newsletter: string } & Then; err?: Action[]; retry?: boolean } // send a stored newsletter
  | { act: "resp"; exe: Respond; err?: Action[] }    // answer the caller now, then stop or go on later
  | { act: "cond"; exe: CheckAnswer; err?: Action[] }; // a check on the enclosing action's answer, inside a Then chain only

type Then = {
  actions?: Action[];                // the Then chain, reading the answer as ${response}; a Check answer in it checks the answer
};

type Respond = {
  status?: number | string;          // 100 to 599, default 200; or a reference
  body?: any;                        // any JSON, templated; absent = the receipt { tkid, hash }
  resume?: "stop" | "0m" | string | number; // "stop" (default), "0m" (at once, in the background), "10m" | "2h" | "3d" (after a delay), or a time in ms
};

type CheckAnswer = {
  response: Matcher[];               // rows on the enclosing action's answer: key = a path in it ("body[status]", "record_id"), "" the whole answer as text
};

type PostRecord = {
  table: {
    name: string;
    access_group: "public" | "authorized" | "admin" | "private" | number; // required in JSON mode
    subscription?: {                 // needs user_id: the project owner's records cannot have one
      is_subscription_record?: boolean;     // only the uploader's subscribers can read it
      upload_to_feed?: boolean;             // shows in subscribers' getFeed()
      notify_subscribers?: boolean;         // pushes to subscribers when created
      feed_referencing_records?: boolean;
      notify_referencing_records?: boolean;
    } | null;                        // null clears every setting
  };
  data?: any;
  index?: { name: string; value: any };
  tags?: string[];
  unique_id?: string;
  record_id?: string;                // update this record instead of creating one
  reference?: string;
  readonly?: boolean;
  source?: object;
  user_id?: string;                  // post as this user; absent = the project owner
} & Then;                            // actions: the Then chain, reading the record as ${response}

type HttpRequest = {
  url: string;
  method?: "GET" | "POST" | "PUT" | "DELETE"; // absent = GET
  secretName?: string;               // fills ${CLIENT_SECRET}
  headers?: { [name: string]: string };
  data?: any;                        // body, POST and PUT only
  params?: { [key: string]: string };
  actions?: Action[];                // read the response as ${response}: status, headers, body; a Check answer in it checks the response
};
```

Every string in `exe` may hold references such as `${body[order][id]}` (a Check answer's parts are literal); see [Conditions](#doc-tickets-conditions) and [Actions](#doc-tickets-actions) for what each part does. Paste a document of this shape, click **Apply**, and the builder fills in from it. **Apply** refuses a document that leaves out a required parameter and names it: `actions` (write `[]` for a ticket with no actions) and the `access_group` of every `pstr` table, as in `"table": { "name": "orders", "access_group": "public" }`.

JSON mode is the quickest way to register a ticket from an example on these pages, or to copy a ticket from one project to another: switch the toggle on, paste the document, **Apply**, then **Register** or **Update**. The toggle refuses to switch while a field holds something the ticket cannot be saved with, and marks that field, so fix it first. Switching back to the builder with edits you have not applied asks whether to discard them.

Each action card in the builder also has **Show other fields (JSON)**. It holds the keys of that action the card has no control for, or could not load, and they are merged into the action when you save. When a control above fills the same key, the control wins.

## Endpoints <a id="doc-tickets-introduction-endpoints"></a>

Every ticket answers on a regional host, `https://<reg>.skapi.dev`, where `<reg>` is the first 4 characters of your project id. The dashboard shows the exact URLs in the **Endpoints** section of the ticket.

| route | auth | notes |
|---|---|---|
| `POST /tp/<service_id>/<ticket_id>` | none | body = JSON (or raw text) |
| `GET  /tg/<service_id>/<ticket_id>` | none | query string = data |
| `POST /tpa/<service_id>/<ticket_id>` | Cognito id token in `Authorization` | consumer = the user. POST only |
| `GET/POST /publ/check/<service_id>/<owner_id>/<ticket_id>` | none | dry run: service and ticket checks + condition only, no actions, no count change; log row with `:CHK` |

Signed-in consumption is POST only. The signed-in endpoint is what [`consumeTicket()`](#doc-api-reference-tickets-consumeticket) calls with `auth: true`; the SDK sends the token, you never handle it.

On the anonymous endpoints the consumer is identified by IP address and user agent. On the signed-in endpoint the consumer is the user, and the token must belong to this project: a token from another Skapi project is refused with `AUTH_REQUIRED`.

### What the request carries <a id="doc-tickets-introduction-what-the-request-carries"></a>

- **The body.** A POST body is parsed as JSON. A body that is not JSON is kept as its text, so no row key finds anything in it, but an action can still read the whole text as `${body}`. A GET has no body, and reads it as `{}`. `body` rows read the body, and actions read it as `${body}` or `${body[key]}`.
- **The query string**, on both methods. Each value is JSON-parsed on its own when it parses (`1` becomes the number `1`, `true` becomes `true`, `{"a":1}` becomes an object), otherwise it stays a string: `?code=LAUNCH24&qty=2` is read as `{ "code": "LAUNCH24", "qty": 2 }`. A request without a query string reads it as `{}`. `params` rows read the query string, and actions read it as `${params}` or `${params[key]}`.
- **The caller**: its headers, IP address, user agent and method, and on the signed-in endpoint the user. See [References](#doc-tickets-conditions-references).

A GET ticket's data is its query string: use `params` rows and `${params[...]}` there.

## Consuming From a Browser

Use [`consumeTicket()`](#doc-api-reference-tickets-consumeticket). `method` picks the endpoint, `auth` picks the signed-in one, and `data` is the JSON body of a POST or the query string of a GET.

```js
// anonymous GET: data becomes the query string
let coupon = await skapi.consumeTicket({
    ticket_id: 'launch-coupon',
    method: 'GET',
    data: { code: 'LAUNCH24' }
});

// signed-in POST: the consumer is the logged-in user
let unlocked = await skapi.consumeTicket({
    ticket_id: 'unlock-guide',
    method: 'POST',
    auth: true
});

console.log(unlocked);
/*
{
    ticket_id: 'unlock-guide',
    consume_id: 'UwdAhf6k3Qp',
    user_id: 'f2b6c9e1-3a4d-4c8b-9e0f-1a2b3c4d5e6f',
    is_test: false,
    timestamp: 1757721600000,
    hash: '...'
}
*/
```

`auth: true` with `method: 'GET'` throws `INVALID_PARAMETER: Signed-in consumption is POST only.` before anything is sent.

The object above is the **receipt**. A ticket with a [Respond](#doc-tickets-actions-resp-answer-the-caller) action answers what that action composed instead, and `consumeTicket()` resolves with that body as sent, whatever its status: `consumeTicket<{ ok: boolean }>(...)` names its type.

A failed consumption rejects with a `SkapiError` whose `code` is the ticket error code and whose `cause` is the whole error body. See [What consumeTicket() Rejects With](#doc-tickets-errors-what-consumeticket-rejects-with).

:::warning
A browser sends whatever the page tells it to, so the request data of a `consumeTicket()` call is not something a condition can trust on its own. Decide who may run your actions with a [signature](#doc-tickets-conditions-signature), or with the signed-in endpoint and rows on the consumer's account.
:::

## Consuming From a Webhook

A webhook sender only needs the URL. Paste the POST endpoint into the sender's settings, or call it with plain HTTP:

```sh
curl -X POST https://eu73.skapi.dev/tp/eu73kXm2PqA9vLb4/order-paid \
  -H 'content-type: application/json' \
  -d '{ "type": "payment.completed", "payment": { "id": "pay_a1B2c3" } }'
```

Success answers `200` with `application/json`, the receipt:

```json
{ "tkid": "#order-paid#UwdAhf6k3Qp#203.0.113.7(curl/8.5.0)", "hash": "..." }
```

A ticket with a [Respond](#doc-tickets-actions-resp-answer-the-caller) action answers that action's `status` and `body` instead, at the moment it runs; the rest of the chain can go on in the background.

A failure answers `400`, or `200` when the ticket has **Always answer 200** on, with a flat JSON error:

```json
{
  "code": "CONDITION_FAILED",
  "message": "The \"data\" condition did not match.",
  "stage": "condition",
  "detail": {
    "field": "body",
    "keys": [
      "type"
    ]
  },
  "ticket_id": "order-paid"
}
```

Every error body has `stage`; a success body never does. See [Errors and Logs](#doc-tickets-errors).

## Dry Run <a id="doc-tickets-introduction-dry-run"></a>

`/publ/check/<service_id>/<owner_id>/<ticket_id>` runs the service and ticket checks and the condition, and stops there: no action runs and the count does not change. It accepts GET and POST, so both kinds of ticket can be tried. The dashboard shows the URL with your owner id filled in.

```sh
# a GET ticket: the query string is the data
curl 'https://eu73.skapi.dev/publ/check/eu73kXm2PqA9vLb4/f2b6c9e1-3a4d-4c8b-9e0f-1a2b3c4d5e6f/launch-coupon?code=LAUNCH24'

# a POST ticket: the body is the data
curl -X POST https://eu73.skapi.dev/publ/check/eu73kXm2PqA9vLb4/f2b6c9e1-3a4d-4c8b-9e0f-1a2b3c4d5e6f/order-paid \
  -H 'content-type: application/json' \
  -d '{ "type": "payment.completed", "payment": { "id": "pay_a1B2c3" } }'
```

A passing dry run answers the JSON string `"SUCCESS: Ticket check passed. No action taken."`. A failing one answers the same error body a real consumption would. Once the ticket has been read, a dry run is always logged, whatever fails after that: a passing one shows as `check` in the Log tab, with the captured placeholders in its details before an action ever runs, and a failing one shows as `failed: <code>` with `"check": true` in its details, an expired ticket or a signature that does not verify included. Only a dry run that never reaches the ticket (`INVALID_SERVICE`, `SERVICE_DISABLED`, `TICKET_NOT_FOUND`) leaves no row.

:::tip
The dry run evaluates the whole condition, a signature included. To try a signed webhook, point the sender's test mode, or its "send test event" feature, at the check URL and read the Log tab. You can also sign a request yourself. For a ticket with the signature of the [payment webhook example](#doc-tickets-examples-a-payment-webhook):

```sh
body='{ "type": "payment.completed", "payment": { "id": "pay_a1B2c3" } }'
ts=$(date +%s)
sig=$(printf '%s.%s' "$ts" "$body" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" | sed 's/^.* //')
curl -X POST https://eu73.skapi.dev/publ/check/eu73kXm2PqA9vLb4/f2b6c9e1-3a4d-4c8b-9e0f-1a2b3c4d5e6f/order-paid \
  -H 'content-type: application/json' \
  -H "x-signature: t=$ts,v1=$sig" \
  --data-raw "$body"
```

A row marked `failed: CONDITION_FAILED` whose details say `"field": "signature"` means the secret name, the secret itself, or the way the signature fields describe the header is wrong; a `check` row means the signature verified and the placeholders were captured.
:::

## Next

- [Conditions and Placeholders](#doc-tickets-conditions): what a request must look like, and how values are read out of it.
- [Actions](#doc-tickets-actions): what a ticket does, chaining and error chains.
- [Errors and Logs](#doc-tickets-errors): every error code, and the consumption log.
- [Examples](#doc-tickets-examples): a payment webhook, a coupon link, a signed-in unlock, and a call to an API with your Secret Key.

# Conditions and Placeholders <a id="doc-tickets-conditions"></a>

A ticket's **condition** says what an incoming request must look like. When it fails, the consumption stops before any action runs, and the answer names the first thing that did not match. While it runs, it can also **capture** values from the request into **placeholders** for the actions to use.

The condition is one object. Every key is optional, and an empty condition passes every request:

```ts
{
    return200?: boolean,                 // answer 200 even when the consumption fails (webhook friendly)
    method?: "GET" | "POST",             // absent = both allowed
    signature?:  { secretName, header, algorithm?, encoding?, separator?, parts?, signed?, timestamp?, tolerance?, secret_encoding?, secret_prefix? }, // an HMAC of the request
    ip?:         { operator, value: string | string[] },
    user_agent?: { operator, value: string | string[] },
    headers?: [ { key, operator, value } ],                                  // key = header name, matched case-insensitively
    data?:    [ { key, operator?, value?, setValueWhenMatch?, placeholder? } ], // rows against the POST body; key = a path in it
    params?:  [ { key, operator?, value?, setValueWhenMatch?, placeholder? } ], // rows against the query string, on GET and POST
    user?:    [ { key, operator, value } ],                                  // signed requests only: the consumer's attributes
    record_access?: string               // signed requests only: a record the consumer must own or have been granted
}
```

## The Condition in the Dashboard

Open a ticket on the **Tickets** page and scroll to **Condition**. Every key of the object above is one part of the form, and a part you leave empty is not checked.

![The Condition section of a ticket in the dashboard: Signature with its secret key, header, algorithm and encoding, the Allowed IPs and User agent matchers, a Headers row, three Body rows with capture only and placeholder fields, and the Query string, User and Record access parts](https://docs.skapi.com/screenshots/tickets-conditions.webp)

*The Condition section of a payment webhook ticket. The Body rows check the event type, capture the order id and the buyer as placeholders, and require a positive amount.*

- **Signature**: pick the **Secret key** that holds the sender's signing secret (a key from the Secret Keys page; **None** turns the check off), type the **Signature header** the sender writes, and pick the **Algorithm** and the **Signature encoding**. **[Show advanced]** opens the header separator, header parts, signed payload template, timestamp template, tolerance, secret encoding and secret prefix. See [Signature](#doc-tickets-conditions-signature).
- **Allowed IPs** and **User agent**: one operator and a list of values. **[+ Add another]** adds a value, and the part passes when any value matches.
- **Headers**: one row per header name, with an operator, a value type and a value. Every header listed must match.
- **Body** and **Query string**: the `body` and `params` rows. A row is a key path, an operator, a value type and a value. Choosing **capture only** as the operator makes a [capture-only row](#doc-tickets-conditions-match-rows-and-capture-rows); a match row can also carry **replace with** (`setValueWhenMatch`) and a **placeholder** name. **[+ Add row]** adds a row. On a GET ticket the Body part collapses, because a GET has no body.
- **User**: rows against the signed-in consumer's attributes, such as `access_group`.
- **Record access**: the id of a record the signed-in consumer must own or be granted.

The operator list reads differently with the value type: on text, `>=` is **starts with** and `<=` is **ends with**; on a number they are **at least** and **at most**. A `${ }` typed into a row value is compared as text, never filled in, and the form says so under the row.

## How Each Part Decides <a id="doc-tickets-conditions-how-each-part-decides"></a>

The parts are evaluated in this order: `method`, `signature`, `ip`, `user_agent`, `headers`, `body`, `params`, `user`, `record_access`. The **first part that fails** stops the consumption and is the one reported; the parts after it are not evaluated. A part that is empty or absent is not checked.

| part | passes when | empty or absent |
|---|---|---|
| `method` | the request uses that method | both methods pass |
| `signature` | the HMAC verifies | no signature check |
| `ip` | the caller's IP address matches **any** listed value | every address passes |
| `user_agent` | the caller's user agent matches **any** listed value | not checked |
| `headers` | **every** header listed is sent and matches | not checked |
| `body` | **every** key listed in the POST body passes | not checked |
| `params` | **every** key listed in the query string passes | not checked |
| `user` | **every** key listed passes, read from the signed-in consumer's account | not checked |
| `record_access` | the signed-in consumer owns the record or holds a grant on it | not checked |

`headers`, `body`, `params` and `user` share one rule for their rows, described below: rows on the same key are alternatives, every different key must pass, and a missing field is a mismatch.

`return200` never decides whether a ticket fails. It only changes the HTTP status a failure is answered with. See [`return200` and `method`](#doc-tickets-conditions-return200-and-method).

:::warning `user` and `record_access` work only for signed requests
They read the account of the user who consumes the ticket, so they only pass for an app user of this project calling [`consumeTicket()`](#doc-api-reference-tickets-consumeticket) with `auth: true`, which uses the signed-in endpoint. A third-party webhook has no session: a ticket that uses either of them **always fails for webhooks**, with `AUTH_REQUIRED`.
:::

## Operators and Rows

`operator` is one of `=`, `!=`, `>`, `>=`, `<`, `<=`. The word forms `eq`, `ne`, `gt`, `gte`, `lt`, `lte` are accepted and stored as the symbols.

| operator | on two strings | on two numbers | anything else |
|---|---|---|---|
| `=` | the same text | the same number | the same value, compared strictly |
| `!=` | different text | a different number | a different value, compared strictly |
| `>=` | **starts with** | greater than or equal | never passes |
| `<=` | **ends with** | less than or equal | never passes |
| `>` | after, in character order | greater than | never passes |
| `<` | before, in character order | less than | never passes |

- `=` and `!=` compare strictly, as JavaScript's `===` does: the number `1` is not the text `"1"`, and `true` is not `1`.
- The ordering operators compare two strings or two numbers. A string against a number, a boolean, `null` or a missing field never passes, and is never an error.
- A field the request does not carry fails every operator, `!=` included, unless the row compares with `null` or `undefined` (see [below](#doc-tickets-conditions-null-and-undefined)).
- `value` may be a list. With `=` and the ordering operators the row passes when **any** member matches. With `!=` it passes when the field is **none** of them. See [Lists, objects and arrays](#doc-tickets-conditions-lists-objects-and-arrays).

```json
"ip": { "operator": "!=", "value": ["203.0.113.7", "198.51.100.23"] }
```

This one lets every address through except those two.

### Rows on the same key <a id="doc-tickets-conditions-rows-on-the-same-key"></a>

Rows in `headers`, `body`, `params` and `user` combine by key. Rows with the **same** `key` are alternatives: one of them matching satisfies that key. Rows with **different** keys must all be satisfied.

```json
"body": [
  {
    "key": "type",
    "operator": "=",
    "value": "payment.completed"
  },
  {
    "key": "type",
    "operator": "=",
    "value": "payment.captured"
  },
  {
    "key": "amount",
    "operator": ">",
    "value": 100
  }
]
```

Here `type` may be either event, **and** `amount` must be above `100` as well. A body without `amount` fails on `amount`: a field that is not there does not match.

When a key is not satisfied, the answer is `CONDITION_FAILED` with the part in `detail.field` and every key that failed in `detail.keys`, such as `{ "field": "body", "keys": ["amount"] }`.

**Rows run in order, and every one of them is read.** On one key, the first match row that matches wins: its `setValueWhenMatch` replaces the value, in the data itself where the field exists, and its `placeholder` captures the result. The remaining match rows on that key are **skipped without being evaluated**, so nothing is re-checked against the replaced value; the log marks them `skipped`. A later row on another key whose path runs through the replaced field reads the replacement, and so do the actions that follow. A capture-only row (a `placeholder` and no `operator`) is never skipped: it fills its placeholder whenever its path exists, and never fails. At the ticket's condition, keys and values are literal: `${...}` inside a row is plain text, never a reference. In a [Check answer action](#doc-tickets-actions-cond-check-answer) the key stays literal, but `value` and `setValueWhenMatch` may reference the answer and the pool.

### Lists, objects and arrays <a id="doc-tickets-conditions-lists-objects-and-arrays"></a>

A row's `value` can be any JSON value. On the dashboard, pick the **JSON** type to enter one. How it is compared depends on its shape.

**A list at the top of `value` is a set of alternatives, not a value to compare.** The field is compared with each member on its own:

| row | passes when the field is |
|---|---|
| `status = ["paid", "complete"]` | `"paid"` or `"complete"` |
| `status != ["paid", "complete"]` | neither of them |
| `code >= ["ch_", "pi_"]` | text starting with `ch_` or with `pi_` |

**An object is compared whole.** `=` passes only when the field is an object with exactly the same keys, and every value is the same, compared strictly and all the way down. A field with an extra key, a missing key, or `1` where the row has `"1"` does not match. `!=` passes for any other value, and fails on a missing field like every operator. The ordering operators never pass on an object.

```json
"body": [
  {
    "key": "metadata",
    "operator": "=",
    "value": {
      "plan": "pro",
      "seats": 5
    }
  }
]
```

This passes for `{ "plan": "pro", "seats": 5 }` and for nothing else, not `{ "plan": "pro", "seats": "5" }` and not `{ "plan": "pro", "seats": 5, "trial": true }`. To check one value inside an object, write a row for its path instead, such as `metadata[plan] = "pro"`.

**To compare with a whole array, put it inside a list.** A top-level list is always read as alternatives, so `[1, 2]` means "`1` or `2`". Wrap the array to make it the one alternative: `[[1, 2]]` passes only when the field is the array `[1, 2]`, with the same members in the same order. `[[1, 2], [2, 1]]` accepts either order.

```json
"body": [
  {
    "key": "tags",
    "operator": "=",
    "value": [
      [
        "a",
        "b"
      ]
    ]
  }
]
```

**A list and rows on the same key.** For `=` and the ordering operators, one row with a list passes for the same requests as one row per member, since [rows on the same key](#doc-tickets-conditions-rows-on-the-same-key) are alternatives too. They differ in two ways:

- **`!=`**: `status != ["paid", "complete"]` means none of them. Two rows, `status != "paid"` and `status != "complete"`, are alternatives, so every status passes one of them, and `"paid"` gets through. To exclude several values, use one row with a list.
- **Replacements and placeholders**: `setValueWhenMatch` and `placeholder` belong to a row. A list row gives every member the same replacement. Separate rows give each value its own, since the first row that matches sets its replacement, which makes a lookup table:

```json
"body": [
  {
    "key": "price",
    "operator": "=",
    "value": "price_basic",
    "setValueWhenMatch": 2,
    "placeholder": "GROUP"
  },
  {
    "key": "price",
    "operator": "=",
    "value": "price_pro",
    "setValueWhenMatch": 3,
    "placeholder": "GROUP"
  }
]
```

`${placeholder[GROUP]}` is `2` for the basic price and `3` for the pro price, and any other price fails the condition.

A list may hold `null` as a member, such as `[null, "none"]`. It cannot hold `undefined`, which JSON has no way to write: a row that checks for a missing field has no `value` key (see [Null and undefined](#doc-tickets-conditions-null-and-undefined)). `ip` and `user_agent` lists must hold text only, or the ticket is refused when you register. A header's value is always text, so in a `headers` row only text members can match.

### Null and undefined <a id="doc-tickets-conditions-null-and-undefined"></a>

Rows of `body` and `params`, and the `response` rows of a [Check answer in a Then chain](#doc-tickets-actions-then-on-every-action), can also compare with `null` and `undefined`, with their strict JavaScript meaning. A field that is not in the request is `undefined`, never `null`.

| row | passes when |
|---|---|
| `key = null` | the field is there and is `null` |
| `key != null` | anything else, a missing field included |
| `key = undefined` | the field is missing: "must be absent" |
| `key != undefined` | the field is there, `null` included: "must exist" |
| `>`, `>=`, `<`, `<=` with `null` or `undefined` | never |

In JSON, `null` is written as `"value": null`, and may also be a member of a value list, such as `[null, "none"]`. JSON has no `undefined`: a row with an `operator` and **no `value` key** compares with `undefined`, which is exactly what a JavaScript object with `value: undefined` becomes when it is sent. An `undefined` row takes a single value, never a list.

```json
"body": [
  {
    "key": "coupon",
    "operator": "="
  },
  {
    "key": "customer[email]",
    "operator": "!="
  }
]
```

The first row passes only when the body has no `coupon`. The second passes only when `customer[email]` is there. On the dashboard every value has an explicit type (text, number, true/false, null, undefined), so an `undefined` row never comes from a field left blank.

`= undefined` and `!= null` are the only rows a missing field can pass, because that is what they ask for. Against any other value a missing field is a mismatch, `!=` included.

`headers` and `user` rows always compare with a value: a row with `null`, or with no `value`, is refused when you register. `ip` and `user_agent` are not rows: a `null` or missing `value` there means no check, exactly like an empty list (see [IP, User Agent and Headers](#doc-tickets-conditions-ip-user-agent-and-headers)).

## `return200` and `method` <a id="doc-tickets-conditions-return200-and-method"></a>

`return200: true` answers HTTP 200 even when the consumption fails. The body still carries the error, so a caller that reads it knows what happened, but a webhook sender that retries on non-2xx answers stops retrying. It covers every failure raised once the ticket has been read, `TICKET_EXPIRED`, `TICKET_EXHAUSTED`, `USER_LIMIT_REACHED` and `ISSUER_CANNOT_CONSUME` included. Only `INTERNAL_ERROR` is still answered with 500. See [`return200`](#doc-tickets-errors-return200).

`method` restricts the ticket to GET or POST. A request with the other method is refused with `METHOD_NOT_ALLOWED` and `detail: { expected, received }`. When absent, both are allowed.

## Signature <a id="doc-tickets-conditions-signature"></a>

`signature` verifies an HMAC that the sender computed over the request with a secret it shares with you. It is checked before anything else, over the **raw body as received**, never over a parsed or re-serialized copy. Every field is plain data, so any sender that signs with HMAC-SHA256, HMAC-SHA1 or HMAC-SHA512 can be described.

```json
"signature": {
  "secretName": "webhook_secret",
  "header": "x-signature",
  "separator": ",",
  "parts": [
    "t=${timestamp}",
    "v1=${signature}"
  ],
  "signed": "${timestamp}.${body}",
  "timestamp": "${timestamp}"
}
```

This one reads a header such as `x-signature: t=1757721600,v1=<hex>`, signs `<timestamp>.<raw body>` with HMAC-SHA256, and refuses a timestamp more than 300 seconds away from now.

On the dashboard, pick the key under **Secret key** and type the **Signature header**; `separator`, `parts` (one pattern per line), `signed`, `timestamp`, `tolerance`, `secret_encoding` and `secret_prefix` are under **Show advanced**. Leave Secret key at **None** and the header blank for no signature check.

| field | default | meaning |
|---|---|---|
| `secretName` | required | The **name** of a Secret Key of your project, never the secret itself. Save the sender's signing secret on the dashboard's **Secret Keys** page, the same store [`forwardRequest()`](#doc-api-bridge-client-secret-request-registering-secret-keys) resolves `$CLIENT_SECRET` from, and write its name here. |
| `header` | required | The request header that carries the signature, matched case-insensitively. Up to 256 characters. |
| `algorithm` | `"sha256"` | The HMAC hash: `"sha256"`, `"sha1"` or `"sha512"`. |
| `encoding` | `"hex"` | How the signature in the header is written: `"hex"` or `"base64"`. |
| `separator` | none | Splits the header value into items, and each item is trimmed. 1 to 8 characters; a single space is allowed. Without it, the whole header value is one item. |
| `parts` | `["${signature}"]` | The patterns each item is matched against. See [Header parts](#doc-tickets-conditions-header-parts). Up to 10 patterns of up to 256 characters each. |
| `signed` | `"${body}"` | A template of the bytes the sender signed. See [Tokens](#doc-tickets-conditions-tokens). Up to 512 characters. |
| `timestamp` | none | A template that resolves to a unix time: seconds, or milliseconds when the number is above 10^12. Without it, no time check is made. Up to 512 characters. |
| `tolerance` | `300` | How many seconds the timestamp may be away from now, 1 to 86400. Only used with `timestamp`. |
| `secret_encoding` | `"raw"` | How the stored secret becomes the HMAC key: `"raw"` uses its text as is, `"base64"` and `"hex"` decode it. |
| `secret_prefix` | none | Text removed from the start of the stored secret before it is decoded. Up to 64 characters. |

### Header parts <a id="doc-tickets-conditions-header-parts"></a>

Each pattern in `parts` is literal text with at most **one** capture, `${name}`, where the name uses letters, digits and `_`. Each header item is tried against the patterns in order, and the first pattern whose literal text before and after the capture fits the item captures the rest of it. Literal text is compared exactly, case included. An item that matches no pattern is ignored.

- `${signature}` captures a signature to compare. Several items may carry one, as happens while a sender rotates its secret; the request passes when any one of them matches.
- Any other name, such as `${timestamp}` or `${id}`, becomes a token for `signed` and `timestamp`. When several items capture the same name, the first one wins. `body` and `method` are built-in tokens and cannot be capture names.
- At least one pattern must capture `${signature}`. A request whose header yields no signature fails.

With `"separator": ","` and `"parts": ["t=${timestamp}", "v1=${signature}"]`, the header `t=1757721600,v1=5f2b,v1=9c0d` yields the token `timestamp` = `1757721600` and the two signature candidates `5f2b` and `9c0d`.

### Tokens <a id="doc-tickets-conditions-tokens"></a>

`signed` and `timestamp` are literal text in which these tokens are replaced:

| token | value |
|---|---|
| `${body}` | the raw request body, byte for byte as received |
| `${method}` | the HTTP method, upper case |
| `${header:Name}` | the value of the request header `Name`, matched case-insensitively. An absent header fails the verification |
| `${name}` | a capture from `parts` (not `${signature}`) |

Everything else is copied as is. A token that is none of these is refused when you register. These tokens belong to `signed` and `timestamp` only: they are not the [references](#doc-tickets-conditions-templating) that actions use.

### How verification works

1. When `timestamp` is set, it is resolved and must be a whole number within `tolerance` seconds of now.
2. `signed` is resolved into the bytes to sign.
3. The key is the stored secret with `secret_prefix` removed, decoded according to `secret_encoding`.
4. The HMAC of the signed bytes is computed with `algorithm` and compared, in constant time, with every `${signature}` candidate decoded according to `encoding`. One match passes.

Anything that goes wrong on the way is a plain mismatch, never a server error: a missing header, no signature candidate, a timestamp that is not a number or is out of tolerance, a secret that does not decode, a candidate that is not valid hex or base64, or a `secretName` that names no key. They all fail the same way: `CONDITION_FAILED` with `detail: { "field": "signature" }`.

The key must exist when you register: a `secretName` that names no Secret Key is refused with `INVALID_PARAMETER`. If the key is deleted later, the condition fails on every consumption instead.

### Describing a sender <a id="doc-tickets-conditions-describing-a-sender"></a>

Look up three things in the sender's documentation: which header carries the signature and how its value is laid out, which bytes are signed, and how the signing secret is given to you. Each answer maps to the fields above. Some common shapes:

A header carrying `t=<timestamp>,v1=<hex>` pairs over `<timestamp>.<body>`:

```json
{
  "secretName": "webhook_secret",
  "header": "x-signature",
  "separator": ",",
  "parts": [
    "t=${timestamp}",
    "v1=${signature}"
  ],
  "signed": "${timestamp}.${body}",
  "timestamp": "${timestamp}"
}
```

The hex HMAC of the body, after a `sha256=` prefix:

```json
{ "secretName": "webhook_secret", "header": "x-signature-256", "parts": ["sha256=${signature}"] }
```

The base64 HMAC of the body, alone in the header:

```json
{ "secretName": "webhook_secret", "header": "x-hmac-sha256", "encoding": "base64" }
```

`v0=<hex>` over `v0:<timestamp>:<body>`, with the timestamp in a header of its own:

```json
{
  "secretName": "webhook_secret",
  "header": "x-request-signature",
  "parts": [
    "v0=${signature}"
  ],
  "signed": "v0:${header:X-Request-Timestamp}:${body}",
  "timestamp": "${header:X-Request-Timestamp}"
}
```

Space-separated `v1,<base64>` items over `<id>.<timestamp>.<body>`, with the id and the timestamp in headers of their own and a base64 secret that starts with a fixed prefix:

```json
{
  "secretName": "webhook_secret",
  "header": "x-signature",
  "separator": " ",
  "parts": [
    "v1,${signature}"
  ],
  "signed": "${header:X-Id}.${header:X-Timestamp}.${body}",
  "timestamp": "${header:X-Timestamp}",
  "encoding": "base64",
  "secret_encoding": "base64",
  "secret_prefix": "key_"
}
```

Public-key signatures (RSA, ECDSA, Ed25519) are not supported: this condition only verifies an HMAC made with a shared secret.

### Where a signature secret may be sent

Nowhere. The verifier reads the signature secret on the server and nothing puts it into a request, so its **Destinations** do not restrict what the ticket calls. Each call a ticket makes is held only to the Destinations of the Secret Key that call carries itself, named by a `req` action's `secretName`. See [Sending a Secret Key](#doc-tickets-actions-sending-a-secret-key).

:::warning
`headers`, `ip` and `user_agent` rows are **not authentication**. Anyone can send any header, and addresses and user agents are trivially forged or shared. Use them to filter noise. Use `signature`, or the signed-in endpoint with `user` rows, to decide who gets to run your actions.
:::

## IP, User Agent and Headers <a id="doc-tickets-conditions-ip-user-agent-and-headers"></a>

`ip` and `user_agent` each take one `operator` and a `value` or list of values, all text. The part passes when the caller matches **any** listed value, and with `!=` when it is **none** of them. A `value` that is an empty list, `""`, `null` or missing is no check: every caller passes, and registration drops the part. With `>=` meaning starts with and `<=` meaning ends with, an address range or a user-agent family is one row:

```json
"ip": {
  "operator": ">=",
  "value": [
    "203.0.113.",
    "198.51.100."
  ]
},
"user_agent": {
  "operator": ">=",
  "value": "MyService/"
}
```

`headers` rows match request headers. `key` is the header name, compared case-insensitively, so `X-Source` and `x-source` rows are the same key. **Every** header listed must be sent and match; rows on the same header are alternatives, and a header the request does not carry fails its key.

```json
"headers": [ { "key": "x-webhook-source", "operator": "=", "value": "inventory" } ]
```

Header rows always compare with a value, and capture nothing. An action that needs a header's value reads it as `${headers[<name>]}` (see [References](#doc-tickets-conditions-references)).

A mismatch is `CONDITION_FAILED` with `field` set to `"ip"`, `"user_agent"` or `"headers"`; for headers, `detail.keys` lists the header names that did not match.

## Body and Params <a id="doc-tickets-conditions-body-and-params"></a>

`body` rows read the POST body and `params` rows read the query string. The query string is read on **both** methods, so a POST ticket can check a token or an id in its URL as well as its body.

A GET request has no body. Registering `body` rows on a ticket whose `method` is `GET` is refused with `"condition.body": a GET request has no body. Use "params" for the query string.`, and on a ticket that allows both methods, the body of a GET request is `{}`, so every `data` key fails for it.

A row's `key` is a **path** into that data, written without `${ }`: `id` is the body's `id`, and `order[id]` the body's `order.id` (see [Row Keys](#doc-tickets-conditions-row-keys)). It is never templated, and neither is its `value`, which is a literal. A key that is not a path is refused when you register.

### Match rows and capture rows <a id="doc-tickets-conditions-match-rows-and-capture-rows"></a>

A row with an `operator` is a **match row**. It compares the field at `key` with its `value`, or with `undefined` when it has no `value` (see [Null and undefined](#doc-tickets-conditions-null-and-undefined)). Match rows follow [the key rule](#doc-tickets-conditions-rows-on-the-same-key): every key that has one must be satisfied, and a path that does not exist in the request is a mismatch for its key. A key that is not satisfied raises `CONDITION_FAILED` with `field: "body"` (or `"params"`) and that key in `detail.keys`.

A row with a `placeholder` and no `operator` is a **capture-only row**. It never passes or fails anything, and captures the field whenever it is there. When the path is missing, the placeholder simply stays unset.

Two more keys turn a match row into more than a check:

- `setValueWhenMatch`: when the row matches, the field at `key` is **replaced** by this value in the request before anything else reads it, so an action reading `${body[...]}` sees the replacement. `null` replaces nothing.
- `placeholder`: a name. When the row matches, the field (after any replacement) is stored under that name for the actions to read. A match row that does **not** match captures nothing.

### How the rows are read

Every row is read, in order, before the part decides, so every capture is filled:

1. For each key, the **first** match row that matches wins. Its `setValueWhenMatch` applies and its `placeholder` captures. Later match rows on that key are skipped.
2. A match row that does not match captures nothing.
3. A capture-only row captures the field as it is at that point, after any replacement an earlier row made.

Then every key that has match rows and no matching one fails the part.

```json
"body": [
  {
    "key": "type",
    "operator": "=",
    "value": "payment.completed"
  },
  {
    "key": "payment[metadata][user_id]",
    "placeholder": "BUYER"
  },
  {
    "key": "payment[method]",
    "operator": "=",
    "value": "card",
    "setValueWhenMatch": "Card payment"
  }
]
```

The first row is a match row: `type` must be `payment.completed`. The second captures the buyer's user id as `BUYER` and never fails. The third is a match row too, so `payment[method]` must be `card`, and when it is, it is rewritten to `"Card payment"`, which is what `${body[payment][method]}` then reads.

### Lookup tables <a id="doc-tickets-conditions-lookup-tables"></a>

Because the first matching row of a key wins, and a row that does not match captures nothing, several rows on one key can map a value to another:

```json
"body": [
  {
    "key": "plan",
    "operator": "=",
    "value": "basic",
    "setValueWhenMatch": 2,
    "placeholder": "GROUP"
  },
  {
    "key": "plan",
    "operator": "=",
    "value": [
      "pro",
      "team"
    ],
    "setValueWhenMatch": 3,
    "placeholder": "GROUP"
  },
  {
    "key": "plan",
    "operator": "!=",
    "value": null,
    "setValueWhenMatch": 1,
    "placeholder": "GROUP"
  }
]
```

`basic` captures `GROUP` = `2`, and `pro` or `team` capture `3`. The last row is the catch-all: every other plan, and a body without one, captures `1`, so this key never fails the ticket (only a `plan` that is `null` would). Leave the catch-all out, and a plan you did not list fails the ticket with `CONDITION_FAILED` and `keys: ["plan"]`.

An action reads the result as `${placeholder[GROUP]}`, for instance `{ "act": "acsg", "exe": { "group": "${placeholder[GROUP]}", "user_id": "${placeholder[BUYER]}" } }`. The replacement also rewrites `plan` in the request, so `${body[plan]}` reads the number as well.

## User <a id="doc-tickets-conditions-user"></a>

:::warning Signed requests only
`user` rows read the account of the signed-in consumer, so they only pass for an app user of this project calling [`consumeTicket()`](#doc-api-reference-tickets-consumeticket) with `auth: true`. A third-party webhook has no session: a ticket with `user` rows always fails for webhooks, with `AUTH_REQUIRED`.
:::

```json
"user": [
  {
    "key": "email_verified",
    "operator": "=",
    "value": true
  },
  {
    "key": "access_group",
    "operator": ">=",
    "value": 2
  }
]
```

The keys are the consumer's [`UserProfile`](#doc-api-reference-data-types-userprofile) attributes (`user_id`, `email`, `name`, `locale`, `email_verified`, `email_public` and so on; the verified and public flags are booleans), plus `access_group` as a number and `timestamp` (the approval time in milliseconds, taken from the account's `approved` attribute). Two more, `records` (how many records the user uploaded) and `subscribers`, are looked up only when a row names them.

`user` rows follow [the key rule](#doc-tickets-conditions-rows-on-the-same-key), and an attribute the account does not have is a mismatch. They always compare with a value, and capture nothing: an action reads an attribute as `${user[<key>]}`, and all of them as `${user}`.

## Record Access <a id="doc-tickets-conditions-record-access"></a>

:::warning Signed requests only
`record_access` checks the signed-in consumer, so it only passes for an app user of this project calling [`consumeTicket()`](#doc-api-reference-tickets-consumeticket) with `auth: true`. A third-party webhook has no session: a ticket with `record_access` always fails for webhooks, with `AUTH_REQUIRED`.
:::

`record_access` names a record id. The consumer must be the record's uploader, or have been granted private access to it with [`grantPrivateRecordAccess()`](#doc-database-access-restrictions-grant-private-access). A consumer who is neither fails with `CONDITION_FAILED` and `field: "record_access"`.

```json
"record_access": "9x2K4mQ1pL8vB3nR6tW5yZ0cA7dF"
```

The id is a literal, never templated: a `${...}` written there is plain text, not a reference, so the record id must be exactly that text; the dashboard warns when the field holds one. An action reads the id as `${record_access}`.

## Row Keys <a id="doc-tickets-conditions-row-keys"></a>

A condition row's `key` says where the row looks, **relative to its own list**, and is written without `${ }`. It is never templated.

| list | `key` | reads |
|---|---|---|
| `body` | `type` | the request body's `type` |
| `body` | `payment[metadata][user_id]` | the body's `payment`, then its `metadata`, then its `user_id` |
| `body` | `items[0][price]` | the `price` of the first element of the body's `items` list |
| `params` | `code` | the query string's `code` |
| `headers` | `x-webhook-source` | that request header, whatever its case |
| `user` | `email_verified` | that attribute of the signed-in consumer |
| `response` of a [Check answer in a Then chain](#doc-tickets-actions-then-on-every-action) | `body[status]` | the enclosing action's answer, here a request's response: its body's `status`. `status` and `headers[content-type]` read the rest of a response; `""` is the whole answer as text |

A key of a `body` or `params` row is a root segment followed by zero or more bracketed segments. Segments are literal keys, with no escaping and no reserved names: a `body` row keyed `ticket[id]` or `placeholder[x]` reads the body's own `ticket` or `placeholder` key. A segment that is all digits indexes a list when the value is a list, and is a key when the value is an object. The root segment may hold letters, digits, `_`, `.` and `-` (`[A-Za-z0-9_][A-Za-z0-9_.\-]*`); a key that does not fit is refused when you register. A `headers` key is the header name, and a `user` key the attribute name.

Only action values use `${ }` [references](#doc-tickets-conditions-templating). A row key never does, and neither does a row's `value` or `setValueWhenMatch`: both are literals.

## Placeholders <a id="doc-tickets-conditions-placeholders"></a>

The **placeholder pool** is where captures go. It starts empty, every capture-only row and every matching row with a `placeholder` adds to it (the rows of a Check answer included), and every action can read it as `${placeholder[NAME]}`. A name must match `^[A-Za-z_][A-Za-z0-9_]*$`. Keys after the name read into a captured object or list: `${placeholder[ORDER][id]}`.

Reading a name that was never captured raises `PLACEHOLDER_MISSING` with `detail: { "placeholder": "NAME" }`. A capture-only row whose path was missing, or a match row that did not match, leaves the name unset, so the error surfaces at the action that needs the value, not at the row.

An action can read the request itself with `${body[...]}`, so a placeholder is for three things:

- a value a row **chose or rewrote**, such as the result of a [lookup table](#doc-tickets-conditions-lookup-tables);
- a value from a `req` action's **response** that an action outside that `req` needs: `${response}` exists only in the `req`'s own nested actions, while the pool is shared by the whole consumption;
- **readability**: `${placeholder[BUYER]}` says more than `${body[payment][metadata][user_id]}` when the same value is used in several actions.

At the end of a consumption the pool is written to the log row, so the Log tab shows what was captured.

## Templating <a id="doc-tickets-conditions-templating"></a>

Action values are templated right before the action runs. **Only text inside `${ }` is a reference**, and everything outside it is used exactly as written, whatever it looks like: `orders`, `order[id]` and a URL with brackets are all plain text. That is what keeps ordinary text from being read as a reference by accident, and it is why every reference starts with its root: a key of the request body can never be hidden behind a reference name.

On the dashboard you rarely type one yourself: the **`${ }`** button next to an action field opens a picker. Choose what to read, type a key when it takes one, and **Insert** writes the reference.

### References <a id="doc-tickets-conditions-references"></a>

| reference | reads |
|---|---|
| `${body}` | the whole request body: the parsed JSON, or the text of a body that is not JSON. `{}` on a GET, which has no body |
| `${body[key]}`, `${body[k1][k2]}` | a key of the request body: `${body[id]}` is the body's `id`, `${body[payment][id]}` its `payment.id` |
| `${params}`, `${params[key]}` | the query string, whole or one key, on GET and POST |
| `${headers[name]}` | a request header, the name matched case-insensitively. `authorization` and `cookie` read `<redacted>` |
| `${placeholder[NAME]}` | a value a condition row [captured](#doc-tickets-conditions-placeholders) |
| `${user}`, `${user[key]}` | the signed-in consumer's attributes, the ones [`user` rows](#doc-tickets-conditions-user) read. Signed requests only |
| `${ip}`, `${user_agent}`, `${method}` | the caller's IP address, its user agent, and the HTTP method |
| `${record_access}` | the record id the condition's [`record_access`](#doc-tickets-conditions-record-access) names. Signed requests only |
| `${response}`, `${response[key]}` | the answer of the enclosing action: the record a `pstr` posted, the response of a `req` (`${response[status]}`, `${response[headers][content-type]}`, `${response[body][key]}`), the SUCCESS text of a grant or a send. Only in that action's Then chain (`actions`). See [Then on every action](#doc-tickets-actions-then-on-every-action) |
| `${error}`, `${error[key]}` | in an `err` chain, the failure it handles: `code`, `message`, `detail`, `action`, `path`. See [Error chains](#doc-tickets-actions-error-chains-with-error) |
| `${ticket}`, `${ticket[key]}` | this consumption: `id`, `service`, `owner`, `consume_id`, `timestamp` and `hash` |
| `${CLIENT_SECRET}` | reserved: the value of a Secret Key, only in the `headers`, `data` and `params` values of a `req` action that names a `secretName`. See [Sending a Secret Key](#doc-tickets-actions-sending-a-secret-key) |

`${body}` is always the request the ticket received, at every depth: in a `req` action's nested actions and in `err` chains as well. The answer of a `req` is `${response}`.

The root always comes first. A body with a `data` key of its own, as many webhook events have (`{ "data": { "object": { "id": "evt_1" } } }`), reads as `${body[data][object][id]}`: the first `data` is the root, the second is the body's key.

### How a value is filled in

1. A string that is **exactly one** `${...}` becomes the value **with its type kept**: a number, a boolean, an object, a list or `null`.
2. Inside longer text, each `${...}` is replaced by the value as text: a string as it is, and `null`, `true`, `false`, numbers, objects and lists as compact JSON.
3. `$${...}` writes a literal `${...}`. Spaces just inside the braces are ignored, so `${ data[id] }` is `${body[id]}`.
4. Templating runs once. A value that arrives in the request is never read again, so a body field holding the text `${CLIENT_SECRET}` stays that text.
5. Only values are templated, never object keys.

| action value | request body | becomes |
|---|---|---|
| `"${body[payment][amount_total]}"` | `{ "payment": { "amount_total": 4200 } }` | `4200`, a number |
| `"${body[type]}"` | `{ "type": "payment.completed" }` | `"payment.completed"` |
| `"order-${body[payment][id]}"` | `{ "payment": { "id": "pay_a1B2c3" } }` | `"order-pay_a1B2c3"` |
| `"items: ${body[items]}"` | `{ "items": [1, 2] }` | `"items: [1,2]"` |
| `"${body}"` | `{ "a": 1 }` | `{ "a": 1 }`, the whole body |
| `"orders"` | anything | `"orders"`: text outside `${ }` is literal |
| `"data[type]"` | anything | `"data[type]"`: without `${ }` it is not a reference |
| `"$${price}"` | anything | `"${price}"` |

Which keys of each action are templated is listed under [What is Templated](#doc-tickets-actions-what-is-templated).

### What registration refuses

Inside `${ }`, only the forms in the table above are accepted. Registering anything else is refused with `INVALID_PARAMETER`, a message that says where it was found, and the list of valid forms:

- a root that does not exist, such as `${id}` or `${code}`: write `${body[id]}` or `${params[code]}`;
- keys under a root that takes none, such as `${ip[x]}`;
- a root that is only read with a key, `${placeholder}` or `${headers}`, and `${headers[a][b]}`;
- a bracketed root such as `${[ip]}`, broken brackets such as `${body[id}`, and an empty `${}`;
- a placeholder name that is not a name, such as `${placeholder[1x]}`.

```
"actions[0].exe.data.order": "${id}" is not a valid reference. Use one of: ${body}, ${body[key]}, ${params}, ${params[key]}, ${headers[name]}, ${placeholder[NAME]}, ${user}, ${user[key]}, ${ip}, ${user_agent}, ${method}, ${record_access}, ${response}, ${response[key]}, ${error}, ${error[key]}, ${ticket}, ${ticket[key]} and ${CLIENT_SECRET}.
```

A reference written where it can never resolve is refused as well: `${response}` outside an action's Then chain, `${error}` outside an `err` chain, and `${record_access}` when the condition names no record. So is `${CLIENT_SECRET}` anywhere but where [Sending a Secret Key](#doc-tickets-actions-sending-a-secret-key) allows it.

### When a reference does not resolve

A reference is resolved when its action runs, and one that does not resolve fails that action **before it does anything**:

| case | code | detail |
|---|---|---|
| the key is not there, such as `${body[coupon]}` on a body without `coupon`, or `${response[body][id]}` on a response whose body has no `id` | `PATH_NOT_FOUND` | `{ "path": "data[coupon]" }`, the reference without `${ }` |
| a placeholder that was never captured | `PLACEHOLDER_MISSING` | `{ "placeholder": "NAME" }` |
| `${user}` or `${user[key]}` on a request that is not signed in | `AUTH_REQUIRED` | |

The failure is the action's: its `err` chain runs, the consumption stops, and it is answered with `stage: "action"` (400, or 200 with `return200`) and logged. See [How a Chain Runs](#doc-tickets-actions-how-a-chain-runs).

# Actions <a id="doc-tickets-actions"></a>

Actions are what a ticket does once its condition passes. A ticket holds an ordered list of them, the **action chain**, and each action can carry its own **error chain** that runs when it fails.

```ts
Action = {
  act: "acsg" | "acsr" | "pstr" | "req" | "mail" | "nlsd" | "resp" | "cond",
  exe: { ... },            // parameters, templated
  err?: Action[],          // error chain
  retry?: boolean          // try again on a failure (every action but resp and cond)
}
```

There are eight actions:

| `act` | does | answers |
|---|---|---|
| `acsg` | set the access group of a user | the SUCCESS text of the grant |
| `acsr` | grant private access to a record | the SUCCESS text of the grant |
| `pstr` | post a record | the record |
| `req` | send an HTTP request | the response: `status`, `headers`, `body` |
| `mail` | send a custom e-mail to one address | the SUCCESS text of the send |
| `nlsd` | send a stored newsletter to a group | the SUCCESS text of the send |
| `resp` | answer the caller now, then stop or go on later | |
| `cond` | Check answer: rows on the enclosing action's answer | |

Every action that answers something can run a **Then chain** on that answer, and a Check answer placed in it checks the answer; see [Then on every action](#doc-tickets-actions-then-on-every-action).

## Actions in the Dashboard

The **Actions** section of a ticket is the chain as cards, one per action, in the order they run.

![The Actions section of a ticket: a Post record card with its fields, and its open Then chain holding a Check answer card whose Answer row captures the posted record's id as ORDER_RECORD, and a Set access group card with Retry under it](https://docs.skapi.com/screenshots/tickets-actions.webp)

*The first action of a payment webhook's chain. It posts the order; in its Then chain a Check answer reads the record posted and captures its id, and a Set access group lifts the buyer's access group.*

- The select at the top of a card picks the action: **Post record** (`pstr`), **HTTP request** (`req`), **Set access group** (`acsg`), **Grant record access** (`acsr`), **Send e-mail** (`mail`), **Send newsletter** (`nlsd`), **Respond** (`resp`) or **Check answer** (`cond`). The arrows reorder cards, the cross removes one, and **+ Add action** appends one. The label on the card, such as `actions[0]`, is the path the log and the error body use for it.
- Each field is one key of `exe`. The **${ }** button next to a field writes a reference for you, and **[Show every reference]** at the top of the section lists every root and what it reads.
- **[Show advanced (JSON)]** holds the keys a card groups together, such as a `pstr`'s reference, readonly, source and subscription. **[Show other fields (JSON)]** holds keys the card has no control for; they are merged into the action on save.
- **[Show then]** on every card but a Respond's and a Check answer's holds the card's Then chain, which reads the answer as `${response[...]}`. To check the answer, add a **Check answer** card inside it: one list of **Answer** rows, keys paths in the answer.
- **Retry** on those six cards is `retry: true`: the action is tried again on a failure.
- **[Show on error]** opens the card's error chain, labelled **On error** with its level. It is a chain like any other, and its cards read the failure as `${error[...]}`.
- The line under the section title counts the actions used of the fifty a ticket may hold, and nesting stops at eight levels.

## How a Chain Runs <a id="doc-tickets-actions-how-a-chain-runs"></a>

Actions run in order. Each action but `resp` and `cond` produces an **answer**, read inside that action's own Then chain as `${response}` and `${response[...]}`, or captured into a placeholder by a Check answer placed there ([Then on every action](#doc-tickets-actions-then-on-every-action)). When the chain ends, the consumer is answered the receipt, unless a [Respond](#doc-tickets-actions-resp-answer-the-caller) action answered earlier.

When an action raises, its `err` chain runs (with `${error[...]}` set, and `${body}` still the request the ticket received), and then the whole consumption **stops** and that error is answered. An `err` action that itself raises runs its own `err` chain and stops the error chain; the error originally reported is unchanged. Nothing is rolled back; an action with `retry` is [tried again](#doc-tickets-actions-retrying-an-action) first.

On a failure the count is not consumed, the per-user counter is not incremented, and a log row with `fail: true` is written. Actions that ran before the failure stay applied. The one exception is a failure answered by a Respond in an error chain: the consumer got an answer, so the count is consumed and the per-user counter moves, and the failure is still in the log.

:::warning No rollback
There is no rollback. When the second action fails, the record the first one posted stays posted, and the sender may retry the whole request. Make every ticket safe to repeat:

- Give a `pstr` action a `unique_id` built from something unique in the request, such as `order-${body[payment][id]}`. A retried webhook then **updates** the same record instead of creating a second one.
- Turn on `return200` for webhooks that retry on non-2xx answers, so a consumption that was refused on purpose (an event type you do not handle, say) is not retried for days.

Together these keep a retried webhook idempotent.
:::

### Limits and the time budget <a id="doc-tickets-actions-limits-and-the-time-budget"></a>

- At most **50 actions** in the whole tree of a ticket, error chains and nested chains included, nested at most **8** deep. The dashboard enforces both while you edit.
- The whole consumption must finish within **25 seconds** of the request. Before each action and each HTTP call the engine checks what is left; when the budget is gone, the consumption fails with `TIMEOUT` for the action about to run, that action's `err` chain is skipped, and the failure is logged.
- Each HTTP call of a `req` action has a **10 second** timeout, answered as `REQUEST_FAILED` with `reason: "timeout"`.

## What is Templated <a id="doc-tickets-actions-what-is-templated"></a>

An action's parameters are templated right before it runs, by the rules in [Templating](#doc-tickets-conditions-templating): only text inside `${ }` is a reference, such as `${body[payment][id]}` or `${placeholder[BUYER]}`, and everything else is used as written. Which keys are templated:

| action | templated keys |
|---|---|
| `req` | `url`, `headers`, `data`, `params` |
| `acsg`, `acsr`, `pstr` | every key of `exe` but `condition` and `actions` |
| `resp` | `status`, `body`, `resume` |
| `cond` | nothing: its parts are rows, compared as written |

A `req` action's `method` is one of the four methods and `secretName` is the literal name of a Secret Key; neither holds a reference. The `actions` of a Then chain are templated when each of them runs, where `${response}` reads the answer, and the rows of a Check answer are literal wherever it sits. Two things apply to a `req` only: a value interpolated into its `url` is [percent-encoded](#doc-tickets-actions-url-and-address-rules), and `${CLIENT_SECRET}` resolves in its `headers`, `data` and `params` values when it names a Secret Key (see [Sending a Secret Key](#doc-tickets-actions-sending-a-secret-key)).

Templating only touches string **values**, never object keys. `"amount": "${body[payment][amount_total]}"` becomes the number the request carried; `"table": "orders"` stays the text `orders`.

## `acsg`: set the access group of a user

```ts
exe: { group: number (1..99) | "admin", user_id?: string }
```

The target is `user_id`, or the consumer when `user_id` is absent (on an anonymous endpoint that raises `AUTH_REQUIRED`). `"admin"` is access group `99`.

The same guards apply as to [`grantAccess()`](#doc-api-reference-admin-grantaccess): the target must be a user of this project, confirmed and not suspended, and the project owner cannot be a target. A refusal is `ACTION_FAILED`, with the operation's own `code` and `message` in `detail`.

Answers the text the grant returns, `SUCCESS: Access has been granted to the user.`, which a Then chain reads as `${response}`, and a Check answer in it with the empty key.

```json
{ "act": "acsg", "exe": { "group": 2, "user_id": "${placeholder[BUYER]}" } }
```

`group` may be a reference too, such as `${placeholder[GROUP]}` filled by a [lookup table](#doc-tickets-conditions-lookup-tables).

## `acsr`: grant private access to a record

```ts
exe: { record_id: string, user_id?: string | string[] }
```

The targets are `user_id` (one id or a list; on the dashboard, ids separated by commas), or the consumer. `record_id` must be a record id, not a unique id.

The grant is made on behalf of the record's uploader, so the rules of [`grantPrivateRecordAccess()`](#doc-api-reference-database-grantprivateaccess) apply: a grantee must be an approved user or an invitation, and the project owner cannot be a grantee. The uploader must be a user of the project or the project owner; a record uploaded anonymously fails with `ACTION_FAILED` and `Record uploader is not a user.`. When none of the targets could be granted, the action fails with `No eligible user.`.

Answers the text the grant returns, such as `SUCCESS: Granted 1 user to private access of record: <record id>`.

```json
{ "act": "acsr", "exe": { "record_id": "9x2K4mQ1pL8vB3nR6tW5yZ0cA7dF" } }
```

## `pstr`: post a record

Posts one record to the project's database, the way [`postRecord()`](#doc-api-reference-database-postrecord) does from the SDK: the same keys, limits and errors. Two keys are the ticket's own: `user_id`, which decides who posts the record ([Who posts the record](#doc-tickets-actions-who-posts-the-record)), and `actions`, the Then chain every action has ([Then on every action](#doc-tickets-actions-then-on-every-action)). The full type is the `pstr` member of [`TicketAction`](#doc-api-reference-tickets-ticketaction).

```ts
{
  act: "pstr",
  exe: {
    table: {                                  // required; a plain name such as "orders" also works
      name: string;                           // required
      access_group?: "public" | "authorized" | "admin" | "private" | number; // 0 to 99
      subscription?: {
        is_subscription_record?: boolean;
        upload_to_feed?: boolean;
        notify_subscribers?: boolean;
        feed_referencing_records?: boolean;
        notify_referencing_records?: boolean;
      } | null;
    } | string;
    data?: any;                               // the record's data: any JSON
    index?: { name: string; value: string | number | boolean };
    tags?: string[];
    unique_id?: string;
    record_id?: string;                       // update this record instead of creating one
    reference?: string | null;                // a record ID or unique ID
    readonly?: boolean;
    source?: {
      referencing_limit?: number | null;
      prevent_multiple_referencing?: boolean;
      can_remove_referencing_records?: boolean;
      only_granted_can_reference?: boolean;
      allow_granted_to_grant_others?: boolean;
      referencing_index_restrictions?: {
        name: string;
        value?: string | number | boolean;
        range?: string | number | boolean;
        condition?: "=" | "!=" | ">" | ">=" | "<" | "<=";
      }[] | null;
    };
    user_id?: string;                         // a user ID: post as this user. Absent: the project owner
    actions?: TicketAction[];                 // the Then chain: reads the record as ${response}
  };
  err?: TicketAction[];                       // error chain
  retry?: boolean;                            // try again on a failure
}
```

The record is described the way `postRecord()` takes it:

- **Where it goes.** `table` is the table, as `{ "name": "orders", "access_group": "admin" }` or a plain name such as `"orders"`, which is read as `{ "name": "orders" }`. `access_group` is who can read the record: `"public"`, `"authorized"`, `"admin"`, `"private"` or a number from 0 to 99; left out, the record is public on a create and unchanged on an update, and `"private"` needs `user_id`. `table.subscription` holds the subscription flags, needs `user_id`, and `null` clears them on an update. See [Creating Records](#doc-database-create), [Access Restrictions](#doc-database-access-restrictions) and [Subscription options](#doc-database-subscription-subscription-options-overview).
- **What it holds.** `data` is the record's data, any JSON. `index` is one `{ "name", "value" }` pair to query the record by, the value text, a number or a boolean. `tags` is a list of tags. See [Indexing](#doc-database-indexing) and [Tags](#doc-database-tags).
- **Which record.** `unique_id` is a name of your own for the record: posting the same unique ID again updates that record, so a retried webhook does not duplicate it. `record_id` updates that record instead of creating one. See [Unique ID](#doc-database-unique-id) and [Updating Records](#doc-database-update-record).
- **Relations and flags.** `reference` is the record ID or unique ID of a record this one refers to, `source` the rules for the records that reference this one, and `readonly: true` makes the record read-only, which needs `user_id`. See [Referencing](#doc-database-referencing), [Reference source settings](#doc-database-referencing-reference-source-settings-in-postrecord) and [Readonly Record](#doc-database-update-record-readonly-record).
- **Who posts it.** `user_id`, a user ID or a reference that gives one; see [Who posts the record](#doc-tickets-actions-who-posts-the-record).
- **Then.** `actions`, a chain run after the post, reading the record as `${response}`; a Check answer in it reads the record's fields, such as `record_id`.

On the dashboard the card has a field for each of these: **Table**, **Access group** (with **Access group #** under Authorized), **Data**, **Index name** and **Index value**, **Tags**, **Unique ID**, **Record ID** and **Post as user**; `reference`, `readonly`, `source` and `subscription` sit under **Show advanced (JSON)**. See [On the dashboard](#doc-tickets-actions-on-the-dashboard).

Every value may hold references, such as `${body[order][id]}` or `${placeholder[BUYER]}`, filled in right before the action runs ([What is Templated](#doc-tickets-actions-what-is-templated)). A value that is one whole reference keeps its type, so `"value": "${placeholder[AMOUNT]}"` can put a number in the index.

Any other key is refused when the ticket is registered, with `Unknown key "<key>" in "actions[0].exe".`. That includes the `postRecord()` options a ticket does not take: `notification`, `remove_bin`, `reference_private_key` and `progress`. A ticket cannot attach files to a record.

### Example

A payment webhook saving the order as a record of the buyer, under a unique ID built from the payment's own id:

```json
{
  "act": "pstr",
  "exe": {
    "table": {
      "name": "orders",
      "access_group": "admin"
    },
    "unique_id": "order-${body[payment][id]}",
    "index": {
      "name": "buyer",
      "value": "${placeholder[BUYER]}"
    },
    "data": {
      "payment": "${body[payment][id]}",
      "amount": "${body[payment][amount_total]}"
    },
    "user_id": "${placeholder[BUYER]}"
  }
}
```

:::tip
A `unique_id` makes a retried webhook **update** the same record instead of duplicating it. Build it from the sender's own id for the event or the object, as above. See [Unique ID](#doc-database-unique-id).
:::

### Who posts the record <a id="doc-tickets-actions-who-posts-the-record"></a>

- **Without `user_id`**, the project owner posts it. The project owner is not a user of the project, so a record it creates cannot be private, read-only or carry subscription settings (see [What Project Owners Cannot Do](#doc-admin-intro-what-project-owners-cannot-do)); the post is refused and the action fails with `ACTION_FAILED`. The exception is an update of another user's record (`record_id`), which the project owner may make read-only or whose subscription settings it may change.
- **With `user_id`**, that user posts it and the record is theirs. Write `${user[user_id]}` for the signed-in consumer ([signed requests](#doc-tickets-conditions-user) only), or a placeholder a condition row captured, such as `${placeholder[BUYER]}`. Once its references are filled in, the value must be:
  - a user ID, which is a UUID such as `8f3a2c1e-4b5d-4e6f-9a7b-1c2d3e4f5a6b`. Anything else, an empty value included, fails with `ACTION_FAILED`, code `INVALID_PARAMETER` and `Post as user ("user_id") is not a valid user ID (UUID).`
  - not the project owner's own ID, which fails with `Post as user cannot be the project owner. Leave it empty to post as the project owner.`
  - a user of this project who is active, otherwise the action fails with `User does not exist.` or `User is not active.`

Any other refusal of the post fails the action with `ACTION_FAILED`, with the operation's `code` and `message` in `detail`.

### On the dashboard <a id="doc-tickets-actions-on-the-dashboard"></a>

- **Access group** is **Public**, **Authorized**, **Private** or **Admin**, as on the Database page; under **Authorized** an **Access group #** from 1 to 99 narrows the record to that group (blank is 1). It starts at **Public** and is always saved with the action. An action saved without one opens as **Public**.
- **Show advanced (JSON)** takes `reference`, `readonly`, `source` and `subscription` only: the first three go into the post, `subscription` into the table. Saving refuses any other key, a value of the wrong type and a misspelt flag. A key the card has its own field for, such as `table` or `data`, is refused with the field to use.
- Saving refuses an action that creates a private or read-only record, or one with subscription settings, while **Post as user** is empty, and marks the field that asks for it. An action with a `record_id` is checked when it runs instead, since the project owner may keep another user's record private or make it read-only.

### What `${response}` holds <a id="doc-tickets-actions-what-response-holds"></a>

The record, as [`postRecord()`](#doc-api-reference-database-postrecord) resolves with it: the [`RecordData`](#doc-api-reference-data-types-recorddata) type. Records posted by a ticket are written on the server, so they are never client-side [encrypted](#doc-database-encryption), even into `private`.

For the example above, the Then chain reads this as `${response}`, and a Check answer in it reads the same fields by key (the ids are made up):

```json
{
  "record_id": "Xp7kQ2m9Lr4Aab3cdk12",
  "unique_id": "order-pi_3Ns8Qw2eZvKYlo2C",
  "user_id": "5b0f9c2e-7d31-4a8e-9f6b-2c4d1e8a7b30",
  "table": {
    "name": "orders",
    "access_group": "admin",
    "subscription": {
      "is_subscription_record": false,
      "upload_to_feed": false,
      "notify_subscribers": false,
      "feed_referencing_records": false,
      "notify_referencing_records": false
    }
  },
  "index": { "name": "buyer", "value": "5b0f9c2e-7d31-4a8e-9f6b-2c4d1e8a7b30" },
  "data": { "payment": "pi_3Ns8Qw2eZvKYlo2C", "amount": 14890 },
  "uploaded": 1791532099306,
  "updated": 1791532099306,
  "readonly": false,
  "referenced_count": 0,
  "source": {
    "referencing_limit": null,
    "prevent_multiple_referencing": false,
    "can_remove_referencing_records": false,
    "only_granted_can_reference": false
  },
  "ip": "203.0.113.10",
  "bin": {}
}
```

`${response[record_id]}` is the record's id, `${response[data][amount]}` the amount it holds and `${response[table][name]}` its table; a Check answer row with the key `record_id` captures the id into a placeholder. `tags` and `reference` appear when the record has them.

## `req`: HTTP request with its own condition and chain <a id="doc-tickets-actions-req-http-request-with-its-own-condition-and-chain"></a>

Sends one HTTP request from the server and can run more actions on its answer. The full type is the `req` member of [`TicketAction`](#doc-api-reference-tickets-ticketaction).

```ts
{
  act: "req",
  exe: {
    url: string;                                     // required: http:// or https:// and a hostname; references in it are percent-encoded
    method?: "GET" | "POST" | "PUT" | "DELETE";      // default "GET"; written out, never a reference
    secretName?: string;                             // a Secret Key of the project, written out; its value is ${CLIENT_SECRET} in headers, data and params
    headers?: { [name: string]: string };            // request headers, values templated; Host and X-Skapi-Ticket cannot be set
    data?: any;                                      // the request body, POST and PUT only: JSON with a content-type: application/json header, form encoded otherwise
    params?: { [key: string]: any };                 // the query string, on every method; a value that is not text is sent as compact JSON
    actions?: TicketAction[];                        // the Then chain, run on an answer below 300: ${response} is the response
  };
  err?: TicketAction[];                              // error chain
  retry?: boolean;                                   // try again on a failure
}
```

`url`, `headers`, `data` and `params` are templated right before the call ([What is Templated](#doc-tickets-actions-what-is-templated)); `method` and `secretName` are used as written, and each nested action is templated when it runs. On the dashboard the card has **URL**, **Method**, **Secret key**, **Headers** and **Query** (JSON objects) and **Data**, the request body, shown for POST and PUT. Any other key is refused when the ticket is registered, with `Unknown key "<key>" in "actions[0].exe".`.

Every request also carries `X-Skapi-Ticket: <service_id>/<ticket_id>`, so your server can tell which ticket is calling.

### Headers

`headers` are sent as written, their values templated. Two headers are the engine's own, and a ticket cannot set either: registration refuses them whatever their case or surrounding spaces.

- **`Host`** is always the host of the `url`: `"actions[0].exe.headers.host": the "Host" header cannot be set: it is always the host of the URL.`
- **`X-Skapi-Ticket`** is added to every call: `"actions[0].exe.headers.X-Skapi-Ticket": the "X-Skapi-Ticket" header cannot be set: Skapi adds it to every request a ticket sends.` A copy in a ticket saved before this rule is dropped from the call.

A header name must be ASCII, with no `:`, line break or NUL. A header value cannot hold a line break, NUL or a character outside Latin-1. What you write is checked when you register, so a header that could never be sent is refused before it is saved:

- a name: `"actions[0].exe.headers.X-Näme": the "X-Näme" header cannot be sent: a header name is ASCII, without ":", line breaks or NUL.`
- the text of a value outside its `${...}` references (a `$${...}` counts as the `${...}` it writes): `"actions[0].exe.headers.X-Name": the "X-Name" header cannot be sent: a header value cannot hold line breaks, NUL or characters outside Latin-1 (send such values in the body).` The message never quotes the value.

What a reference fills in is known only when the call is made, and a value read from the request easily breaks the rule (a customer name in Korean, say). Such a call fails with `REQUEST_FAILED` and `detail: { "reason": "invalid_header", "header": "<name>" }` before anything is sent, with the same rule in its message: `The "X-Name" header of the request to "api.example.com" cannot be sent: a header value cannot hold line breaks, NUL or characters outside Latin-1 (send such values in the body).` Send such values in the body.

### Failures and the answer <a id="doc-tickets-actions-failures-and-the-answer"></a>

A status of 300 or above, a refused address, a connection error or a timeout raises `REQUEST_FAILED`, with `detail: { status, body }` or `detail: { reason }`. `body` is the parsed answer, or, when its text is longer than 4 KB, the first 4 KB of that text (compact JSON for a JSON answer). Redirects are never followed: a 3xx is a failure. The Then chain runs only on an answer below 300.

Answers the **whole response**: `status`, `headers` (names in lowercase) and the parsed `body`. The Then chain reads it as `${response}`:

```json
{
  "status": 200,
  "headers": { "content-type": "application/json", "x-request-id": "7f3a2c1e" },
  "body": { "status": "ok", "shipment": { "id": "shp_9x4c" }, "carrier": "dhl" }
}
```

`${response[body][carrier]}` reads `dhl`, `${response[status]}` the status and `${response[headers][content-type]}` a header. A body that is not JSON is its text under `body`. A ticket saved before 2026-10-09 keeps answering the body alone until it is saved again; see [A request's answer was its body](#doc-deprecated-deprecated-a-request-s-answer-was-its-body).

```json
{
  "act": "req",
  "exe": {
    "url": "https://api.example.com/fulfil",
    "method": "POST",
    "headers": {
      "content-type": "application/json"
    },
    "data": {
      "user_id": "${placeholder[BUYER]}",
      "payment": "${body[payment][id]}"
    },
    "actions": [
      {
        "act": "cond",
        "exe": {
          "response": [
            {
              "key": "status",
              "operator": "=",
              "value": "ok"
            }
          ]
        }
      },
      {
        "act": "pstr",
        "exe": {
          "table": {
            "name": "shipments",
            "access_group": "admin"
          },
          "data": {
            "id": "${response[body][shipment][id]}",
            "carrier": "${response[body][carrier]}",
            "payment": "${body[payment][id]}"
          }
        }
      }
    ]
  }
}
```

The Check answer's row reads the response: its key `body[status]` is the body's `status`. The `pstr` after it runs only when the row passes; inside it, `${response[body][shipment][id]}` and `${response[body][carrier]}` read the body, while `${body[payment][id]}` still reads the request the ticket received.

### Checking the response <a id="doc-tickets-actions-checking-the-response"></a>

The response is checked by a [Check answer](#doc-tickets-actions-cond-check-answer) placed in the `req`'s Then chain. Its rows read the whole response, whatever its shape:

- a key is a path in the response: `status` the HTTP status, `headers[content-type]` a header (names in lowercase), `body[status]` or `body[shipment][id]` a field of the parsed body, and `""` the whole response as text. Rows follow [the key rule](#doc-tickets-conditions-rows-on-the-same-key) of the ticket's own `body` rows: every key listed must pass, rows on the same key are alternatives, and a missing field is a mismatch.
- [`null` and `undefined`](#doc-tickets-conditions-null-and-undefined), captures, `setValueWhenMatch` and [lookup tables](#doc-tickets-conditions-lookup-tables) work the same way. A replacement lands in the response, so the actions after the Check answer read it as `${response[...]}`, and captures land in the shared placeholder pool.

The rest of the chain runs after it, reading the answer as `${response}`. It may hold any action, another `req` with a Secret Key of its own included, whose own Then chain reads its answer as `${response}`. A failure anywhere inside, in the HTTP call or in the Then chain, is the failure of this `req` action: its `err` chain runs, and `${error[path]}` names the innermost action that failed. It is answered with `stage: "action"` and, in `action`, the innermost action that failed (this `req` for its HTTP call, the Check answer for a response that does not pass, the nested action for anything else), never as a bare condition failure, and is always logged.

### Sending a Secret Key <a id="doc-tickets-actions-sending-a-secret-key"></a>

A `req` can send one of your project's [Secret Keys](#doc-api-bridge-client-secret-request-registering-secret-keys), such as the API key of the service it calls, without the key ever being written into the ticket. Name the key in `secretName` (on the dashboard, the **Secret key** select of an HTTP request action), and write `${CLIENT_SECRET}` where its value goes:

```json
{
  "act": "req",
  "exe": {
    "url": "https://api.example.com/v1/orders/${body[order][id]}",
    "method": "GET",
    "secretName": "orders_api_key",
    "headers": {
      "Authorization": "Bearer ${CLIENT_SECRET}"
    }
  }
}
```

- **`secretName`** is the literal name of a Secret Key, never templated. It must exist when you register; otherwise registration is refused with `"actions[0].exe.secretName": no secret key named "orders_api_key" in Secret Keys.`
- **`${CLIENT_SECRET}`** is replaced by that key's value, in the **values** of this same `req`'s `headers`, `data` and `params`, and nowhere else. Write it with braces: the bare `$CLIENT_SECRET` of `forwardRequest()` is plain text in a ticket.
- **Where it is refused.** Registration refuses `${CLIENT_SECRET}` in the `url`, in a header name or any other key, in a `req` without `secretName`, in every other action, and in condition rows. Each refusal says where it was found and why.
- **A written-out host.** With `secretName` set, the URL's scheme and host must be written out, with no reference before the path, so a request can never choose where the key goes: `https://api.example.com/v1/orders/${body[order][id]}` is accepted, `https://${placeholder[HOST]}/v1/orders` is refused.
- **Nothing in the request can reach it.** The key is read on the server when the action runs. A request that sends a field called `CLIENT_SECRET`, or the text `${CLIENT_SECRET}` in a value that ends up in the call, sends exactly that text: templating runs once and never expands a value it has just filled in.
- **Never shown back.** Before the response is checked, handed to the nested actions, written to the log, or put into a `REQUEST_FAILED` error, the key's value is replaced by the text `${CLIENT_SECRET}` wherever it is found: in the body, in the response headers, and in the text of a connection error. A copy is found as sent, and escaped up to three times over. Each time it is escaped, any of these notations may be used, mixed character by character: percent-encoding (hex in either case, and `%uXXXX`), backslash escapes (`\uXXXX`, `\u{...}`, `\xXX`, `\/` and the other short escapes), and HTML character references (`&#x2F;`, `&#47;`, `&sol;`). So a key percent-encoded twice inside a redirect link, or written with `&#37;2F` for its `/`, is still found. There are two limits. A copy escaped four times over is not found. And a copy is not guaranteed to be found when one round of escaping escapes only part of an escape the round before it wrote, and the part it leaves as written still reads as an escape on its own: that part is then decoded one round too early, as another character. For a character outside ASCII, the escape is the whole run of `%XX` bytes that writes it. For example, `%&#68;0%BA` is the `%D0%BA` of `к` with only its first `D` escaped, so `%BA` is read alone; `&#3&#x32;&#X3B;` is the `&#32;` of a space with only `2;` escaped, so `&#3` is read alone, since a reference without its `;` is accepted. Percent-encoding, backslash escaping and HTML escaping, each applied to the whole text, never split an escape this way. A space and a `+` count as the same character. When the text right before a copy runs into it as the start of an escape (`5%` before a copy that begins with `ab`), the replacement starts where that escape starts. All of this happens before a long error `body` is cut to 4 KB, and a cut that would end partway into a copy of the key, or partway into an escape, drops that part. The call itself is never logged.
- **Long answers.** An answer of any size is searched whole and passed on whole: nothing is cut, so a JSON body of any size still parses, and a Check answer, `${response}` and the log read all of it. A body longer than 131,072 characters is searched that many characters at a time, and a copy that runs from one stretch into the next is found as if the body were read at once. While it searches, the call checks the [25 second budget](#doc-tickets-actions-limits-and-the-time-budget) about every 131,072 characters. When the budget runs out while the answer is being searched, the action fails with `REQUEST_FAILED`, `detail: { "reason": "timeout" }` and the message `The request to "<host>" timed out.`, and nothing of the answer is passed on. Only the answer to a `req` that names a `secretName` is searched, so no other call can fail this way.
- **Missing key.** When the key no longer exists in Secret Keys, the action fails with `REQUEST_FAILED` and `detail: { "reason": "secret_missing", "secretName": "orders_api_key" }`, and nothing is sent.
- **Locked** does not apply. A ticket reads the key on the server, on your behalf, so the key's Locked setting, which is about which users may call `forwardRequest()`, plays no part.

Each call is held to the **Destinations** of the key it carries, the per-key allowlist described under [Restricting Where a Key Can Be Sent](#doc-api-bridge-client-secret-request-restricting-where-a-key-can-be-sent). A key with no destinations may be sent anywhere a ticket can call. A call that carries no key is not restricted by any key. A refused call fails with `REQUEST_FAILED` and `detail: { "reason": "refused_address" }` before the address is resolved or dialled. The list is read with the key itself, from a lookup held for at most 60 seconds, so narrowing a key's Destinations takes effect within a minute.

:::tip
Give every key a ticket sends Destinations that cover only the API it is for, such as `https://api.example.com/v1/orders`. Then no edit to the ticket, and nothing a request carries, can send it anywhere else.
:::

### URL and address rules <a id="doc-tickets-actions-url-and-address-rules"></a>

Registration validates the shape of the URL; consumption vets the address on every call.

- The URL must start with `http://` or `https://` written out, followed by a hostname: no IP literal, no userinfo (`user@` or `user:password@` before the host). A URL that is one whole reference, such as `${placeholder[URL]}`, is refused.
- A value interpolated into the URL with `${...}` is **percent-encoded** as one piece, `/`, `?`, `#`, `&` and `=` included, so a value from the request cannot add path segments or query parameters. A value that is `.` or `..` is refused with `REQUEST_FAILED` and `reason: "refused_address"`.
- The URL is sent the way a browser sends it. A tab or line break anywhere in it is removed, not encoded: `/a<tab>b` goes out as `/ab`. So are spaces and control characters up to U+001F before the scheme. A hostname outside ASCII is IDNA-encoded: `https://bücher.example/` calls `xn--bcher-kva.example`. In the path and query, a space, any other control character and every character outside ASCII is percent-encoded as UTF-8: `/a b/é` goes out as `/a%20b/%C3%A9`. Text that is already percent-encoded is sent as written. Every rule below reads the encoded hostname, so a name that only looks different, such as one written in fullwidth letters, is judged as the host it reaches.
- A Secret Key's Destinations are compared with the URL as sent, so list what is sent: a hostname outside ASCII in its encoded form (`https://xn--bcher-kva.example`), and a path outside ASCII percent-encoded with uppercase hex, as the engine writes it (`https://api.example.com/caf%C3%A9`, not `/café`, which can never match). A path your URL already writes percent-encoded is compared as you wrote it.
- Registration refuses a hostname that cannot be encoded, such as `bü..x`, or that holds a character standing for `/`, `?`, `#`, `@` or `:` once normalized, such as the fullwidth `／` of `ex／ample.com`, with `"actions[0].exe.url": URL has an invalid hostname.` It refuses a hostname that is an IP address once encoded, such as `１２７.０.０.１` in fullwidth digits or `8.8.8.8.` with a trailing dot, and any `[` or `]` in the host, with `"actions[0].exe.url": URL must use a hostname, not an IP address.` A call made from a stored URL is refused the same way, with `REQUEST_FAILED` and `reason: "refused_address"`.
- The engine resolves the host and refuses loopback, link-local (`169.254/16`, `fe80::/10`), private (`10/8`, `172.16/12`, `192.168/16`, `fc00::/7`), unspecified and IPv4-mapped forms of those.
- Any host under the API domain (`*.skapi.dev`) and any `*.execute-api.*.amazonaws.com` host is refused. A consume request that arrives carrying an `X-Skapi-Ticket` header is refused as well, with `CONDITION_FAILED` and `field: "loop"`, so a ticket cannot call tickets. That refusal is checked on every consumption, before the condition and even when the ticket has none. It is never logged on a real consumption; a [dry run](#doc-tickets-introduction-dry-run) logs it like any other failure.
- The connection is pinned to the vetted address. Redirects are never followed; a 3xx is `REQUEST_FAILED` with its status.
- A call that carries a Secret Key must be on that key's Destinations, when it has any. See [Sending a Secret Key](#doc-tickets-actions-sending-a-secret-key).
- Every request carries `X-Skapi-Ticket: <service_id>/<ticket_id>`.

A refused address is `REQUEST_FAILED` with `reason: "refused_address"`.

## `mail`: send an e-mail <a id="doc-tickets-actions-mail-send-an-e-mail"></a>

```ts
exe: {
  template: string;                                  // the template ID of a custom e-mail template, written out
  to: string;                                        // one e-mail address, templated: "${user[email]}", "${body[email]}"
  placeholders?: { [name: string]: string | number | boolean }; // values for the template's own ${name} placeholders, templated
  actions?: TicketAction[];                          // the Then chain, reading the SUCCESS text as ${response}
}
```

Sends one e-mail from the project's email alias: the custom template `template` names, with its subject, to the address `to` resolves to. Custom templates are the **Custom** tab of the `Automated Emails` page: a template sent to that tab's address like any other, identified by its **template ID** rather than set in use. See [Custom e-mails](#doc-email-email-templates-custom-e-mails).

- **`template`** is the template ID, written out, never a reference, and the template must be on the Custom tab when the ticket is registered; otherwise registration is refused with `"actions[0].exe.template": no custom e-mail template with this ID. Send the template to the Custom address on the Automated Emails page first; its ID is in the reply and on the Custom tab.` A template deleted after registration fails the action with `ACTION_FAILED` when it runs.
- **`to`** is templated and must be one e-mail address once filled in; anything else fails the action with `ACTION_FAILED`.
- **`placeholders`** fills the template's own placeholders: a key is a placeholder's name as written in the template without `${ }`, and its value is templated. `${service_name}` and `${email}`, the recipient, are filled in by default, and a key of the same name overrides them. Up to 30, names of letters, digits and `_`, values text, numbers or booleans. A value is inserted as text, never as markup.
- Counts as **one e-mail send** of the project's month, like a newsletter to one subscriber: the plan's monthly sends, the Trial owner cap and the complaint shutoff apply, and a refusal fails the action with `QUOTA_EXCEEDED` (`detail: { "field": "emlsd" }`), its `err` chain running. See [Sending limits](#doc-email-newsletters-sending-limits).
- Once the e-mail is queued the action is done: should counting the send fail afterwards, the action still answers SUCCESS, its log row carries a `warning`, and Skapi is told. A retry never sends an e-mail twice.

Answers the text `SUCCESS: E-mail sent to <address>.`, which a Then chain reads as `${response}`.

```json
{
  "act": "mail",
  "exe": {
    "template": "4f1c9e2b7a8d3c6e5b0f1a2d3e4c5b6a",
    "to": "${user[email]}",
    "placeholders": { "name": "${user[name]}", "order": "${body[order][id]}" }
  }
}
```

![The Actions section of a ticket: a Send e-mail card with a Template ID field, a To field reading ${user[email]} and placeholder rows; under it a Send newsletter card with a Group select and a Newsletter ID field](https://docs.skapi.com/screenshots/tickets-mail-actions.webp)

*A Send e-mail card names a template of the Custom tab by its ID and the recipient with a reference; a Send newsletter card picks a group and names one of its stored newsletters by ID.*

On the dashboard the **Send e-mail** card has **Template ID**, copied from the Template ID column of the Custom tab, **To**, and **Placeholders** as name and value rows.

## `nlsd`: send a newsletter <a id="doc-tickets-actions-nlsd-send-a-newsletter"></a>

```ts
exe: {
  group: "public" | "authorized" | number | string;  // the list: public, authorized, 2 to 99, or a named group; written out
  newsletter: string;                                // the newsletter ID of a newsletter stored under that group; written out
  actions?: TicketAction[];                          // the Then chain, reading the SUCCESS text as ${response}
}
```

Sends a **stored** newsletter of the group to every subscriber of the group, the way a newsletter e-mailed to the group's address goes out: one copy each, from the project's email alias, with the unsubscribe link at the bottom. The newsletter may be one that went out before, or one saved with the group's **Save without sending** address; see [Saving a newsletter without sending it](#doc-email-newsletters-saving-a-newsletter-without-sending-it).

- **`group`** and **`newsletter`** are written out, never references. The newsletter must be stored under that group when the ticket is registered; otherwise registration is refused with `"actions[0].exe.newsletter": no newsletter with this ID is stored under this group. Its ID is on the Newsletters page.` A newsletter deleted after registration fails the action with `ACTION_FAILED` when it runs.
- Counts **one e-mail send per subscriber**, under the same limits as an e-mailed newsletter; past them the action fails with `QUOTA_EXCEEDED` (`detail: { "field": "emlsd" }`) before anything is sent. The 15 minute wait between e-mailed newsletters does not apply.
- The newsletter's row is marked sent: its **Not sent** mark goes, and [`getNewsletters()`](#doc-api-reference-email-getnewsletters) returns the time in `sent`. Once the newsletter is queued the action is done: should marking the row fail afterwards, the action still answers SUCCESS, its log row carries a `warning` and the row keeps its mark until the next send, and Skapi is told. A retry never sends a newsletter twice.

Answers the text `SUCCESS: Newsletter sent to the subscribers of <group>.`, where the group is `public`, `authorized`, `group <n>` or the group's name.

```json
{ "act": "nlsd", "exe": { "group": "promo", "newsletter": "msg-1" } }
```

On the dashboard the **Send newsletter** card has a **Group** select and a **Newsletter ID** field. Copy the ID from the **Newsletter ID** column of the `Newsletters` page, under the group's tab; a group may hold hundreds of newsletters, so there is no list to pick from.

## `resp`: answer the caller <a id="doc-tickets-actions-resp-answer-the-caller"></a>

```ts
exe: {
  status?: number | string;   // 100 to 599, default 200; or a reference
  body?: any;                 // any JSON, templated; absent: the receipt { tkid, hash }
  resume?: "stop" | "0m" | string | number  // what happens after the answer; default "stop"
}
```

A Respond answers the consumer **now**, with `status` and `body`, instead of the receipt the consumption would answer at its end. [`consumeTicket()`](#doc-api-reference-tickets-consumeticket) resolves with that body as sent, and a webhook sender reads it with the status you chose. An error status such as `404` is an answer like any other: the consumption is not a failure, the count is consumed, and the log shows `answered 404`. A `body` cannot carry a `stage` key, since that is how a consumer tells an error from an answer; registration refuses it, and so does the action when a reference puts one there.

**One Respond per run.** A consumption is answered once, so registration refuses a second Respond on a path a first one already answered on: `"actions[2]": a Respond already answered on every path to this one; a consumption is answered once. A second Respond may only sit in an error chain of an action that runs before the first.` That one place is allowed because when such an action fails, the first Respond never runs. A Respond that finds the consumer already answered when it runs fails with `ALREADY_RESPONDED`.

**In an error chain.** A Respond in an `err` chain answers the consumer, say a `200` with a message of your own, while the failure it handles is still logged with its code: the Log tab shows `answered 200 (on error: ACTION_FAILED)`, the consumer got an answer, so the count is consumed and the per-user counter moves. With `resume`, only error chains go on afterwards; the chain the failed action sat in is over.

### After the answer: `resume`

| value | what happens |
|---|---|
| absent, or `"stop"` | the rest of the chain does not run |
| `"0m"` | the rest of the chain goes on at once, in the background |
| `"10m"`, `"2h"`, `"3d"` | it goes on after that delay, to the minute: ten minutes means ten minutes plus up to a minute |
| a number | a time in milliseconds since the epoch: it goes on at that time, to the second. A literal time in the past is refused at registration; a time a reference fills in that is already past goes on at once |

Anything else is refused: `"actions[0].exe.resume" should be "stop", "0m", a delay such as "10m", "2h" or "3d", or a time in milliseconds since the epoch.` Every run that goes on is a **queued run**, counted against the plan's monthly figure; see [Going on later](#doc-tickets-actions-going-on-later-queued-runs).

```json
{
  "act": "resp",
  "exe": {
    "status": 202,
    "body": { "ok": true, "order": "${body[order][id]}" },
    "resume": "0m"
  }
}
```

On the dashboard the **Respond** card has **Status**, **Body** (JSON) and **After answering**: Stop, Go on at once in the background, Go on after a delay, Go on at a time (local time, to the minute), or As a reference says.

## `cond`: Check answer <a id="doc-tickets-actions-cond-check-answer"></a>

```ts
exe: {
  response: Row[];   // rows on the answer of the action whose Then chain this is; key "" is the whole answer as text
}
```

A Check answer is a plain **answer checker**. It sits in the Then chain of an action, and its rows read that action's answer, whatever its shape. A key is a path in the answer: `record_id` or `table[name]` in a posted record; `status`, `headers[content-type]` or `body[shipment][id]` in a request's response; and `""` the whole answer as text, which is how the SUCCESS text of a grant or a send is checked: `{ "key": "", "operator": ">=", "value": "SUCCESS" }`. The rows work as the ticket's own `body` rows do ([Conditions and Placeholders](#doc-tickets-conditions)): the same operators, the [key rule](#doc-tickets-conditions-rows-on-the-same-key), [`null` and `undefined`](#doc-tickets-conditions-null-and-undefined), captures into the shared placeholder pool, `setValueWhenMatch` and [lookup tables](#doc-tickets-conditions-lookup-tables). The request, the caller and the pool are checked by the ticket's own condition, not by a Check answer action.

Every key listed must pass: rows on the same key are alternatives, the first that matches wins and the rest of that key are skipped unread, so nothing is re-checked against a replaced value; a row that only captures never fails.

The key is a literal path. `value` and `setValueWhenMatch` may hold `${response}`, `${response[key]}` and `${placeholder[NAME]}`, resolved right before the comparison, so a row can compare a field of the answer with a value captured earlier (`body[order_id]` equals `${placeholder[ORDER]}`) or with another field of the answer, and a replacement can be built from both; a value that is one whole reference keeps its type. Any other reference is refused when the ticket is registered: `"actions[0].exe.actions[0].exe.response[0].value": only ${response}, ${response[key]} and ${placeholder[NAME]} may be used in a Check answer row's value and setValueWhenMatch.` A row's placeholder captures the replaced value when the row has one, else the field as found. A key no row matches fails the chain **here**, with `CONDITION_FAILED`, `stage: "action"`, this action's path and `detail: { "field": "response", "keys": [...] }`; the `err` chain of the action the Then chain belongs to runs like any other failure, and the consumption stops with that error unless a Respond in the `err` chain answers. A Check answer answers nothing and produces nothing, so it has no Then chain of its own. Registration refuses a Check answer outside a Then chain (`"actions[0].exe": a Check answer only runs inside the Then chain ("actions") of an action, where its rows read that action's answer.`), one with any key but `response`, and one with no row.

```json
{ "act": "cond", "exe": { "response": [{ "key": "body[status]", "operator": "=", "value": "paid" }, { "key": "body[id]", "placeholder": "ORDER" }] } }
```

On the dashboard the **Check answer** card is one list of **Answer** rows, and the card is offered inside Then chains only.

## What each action answers

What a Then chain reads as `${response}`, and what a Check answer in it checks, is the SDK's own answer for the operation: what [`postRecord()`](#doc-api-reference-database-postrecord), [`grantAccess()`](#doc-api-reference-admin-grantaccess) and [`grantPrivateRecordAccess()`](#doc-api-reference-database-grantprivateaccess) resolve with, and the whole response of a request.

| action | `${response}` is | a key reads |
|---|---|---|
| `pstr` | the record, a [`RecordData`](#doc-api-reference-data-types-recorddata) | `record_id`, `table[name]`, `data[amount]` |
| `req` | the response: `{ status, headers, body }` | `status`, `headers[content-type]`, `body[shipment][id]` |
| `acsg` | the text `SUCCESS: Access has been granted to the user.` | the empty key: the whole text |
| `acsr` | the text `SUCCESS: Granted <n> user to private access of record: <record id>` | the empty key |
| `mail` | the text `SUCCESS: E-mail sent to <address>.` | the empty key |
| `nlsd` | the text `SUCCESS: Newsletter sent to the subscribers of <group>.` | the empty key |
| `resp`, `cond` | nothing: `${response}` inside their chains is still the enclosing action's answer | |

**A posted record** (`pstr`): the fields `postRecord()` returns, with `data` as the record holds it; see the example under [What `${response}` holds](#doc-tickets-actions-what-response-holds).

```json
{ "record_id": "Xp7kQ2m9Lr4Aab3cdk12", "unique_id": "order-pi_3Ns8Qw2eZvKYlo2C", "user_id": "5b0f9c2e-7d31-4a8e-9f6b-2c4d1e8a7b30", "table": { "name": "orders", "access_group": "admin", "subscription": { "...": "..." } }, "index": { "name": "buyer", "value": "5b0f9c2e-7d31-4a8e-9f6b-2c4d1e8a7b30" }, "data": { "payment": "pi_3Ns8Qw2eZvKYlo2C", "amount": 14890 }, "uploaded": 1791532099306, "updated": 1791532099306, "readonly": false, "referenced_count": 0, "source": { "...": "..." }, "ip": "203.0.113.10", "bin": {} }
```

**A response** (`req`): the status, the headers with their names in lowercase, and the body, parsed when it is JSON, its text otherwise.

```json
{ "status": 200, "headers": { "content-type": "application/json" }, "body": { "status": "ok", "shipment": { "id": "shp_9x4c" } } }
```

**A grant or a send** (`acsg`, `acsr`, `mail`, `nlsd`): one line of text, so `${response}` is that text and a Check answer row reads it with the empty key, as in `{ "key": "", "operator": ">=", "value": "SUCCESS" }`.

```
SUCCESS: Access has been granted to the user.
SUCCESS: Granted 1 user to private access of record: Xp7kQ2m9Lr4Aab3cdk12
SUCCESS: E-mail sent to ana@example.com.
SUCCESS: Newsletter sent to the subscribers of promo.
```

## Then on every action <a id="doc-tickets-actions-then-on-every-action"></a>

Every action that answers something takes one more key in `exe`: **`actions`**, its **Then chain**, run after the action. Inside it `${response}` and `${response[key]}` read the answer, `${body}` is still the request the ticket received, and the pool holds everything captured so far. A failure anywhere inside is the failure of the action the chain belongs to, with the inner action's path in `action.path`; it is never retried.

To **check** the answer, put a [Check answer](#doc-tickets-actions-cond-check-answer) in the chain. Its rows read the answer under the rules of the ticket's own `body` rows, their keys paths in the answer, and a key of `""` the whole answer as text, which is how the SUCCESS text of a grant or a send is checked: `{ "key": "", "operator": ">=", "value": "SUCCESS" }`. Whatever shape the answer has, a record, a response or a line of text, the same rows read it. A row that captures lands in the shared placeholder pool, so a value needed further down the ticket is captured here, and a `setValueWhenMatch` lands in the answer, so the actions after it read the replacement as `${response[...]}`. A row that does not pass fails the chain there, with `CONDITION_FAILED` at the Check answer's path, and the `err` chain of the action the chain belongs to runs.

```json
{
  "act": "pstr",
  "exe": {
    "table": { "name": "orders", "access_group": "admin" },
    "unique_id": "order-${body[payment][id]}",
    "data": { "amount": "${body[payment][amount_total]}" },
    "actions": [
      {
        "act": "cond",
        "exe": {
          "response": [{ "key": "record_id", "placeholder": "ORDER_RECORD" }]
        }
      },
      {
        "act": "req",
        "exe": {
          "url": "https://api.example.com/notify",
          "method": "POST",
          "headers": { "content-type": "application/json" },
          "data": { "record": "${response[record_id]}", "table": "${response[table][name]}" }
        }
      }
    ]
  }
}
```

The Check answer captures the id of the record the `pstr` posted, and the `req` reads the record as `${response}`; `${placeholder[ORDER_RECORD]}` reads the same id anywhere later in the ticket, in another chain included. On the dashboard every card but a Respond's and a Check answer's has **[Show then]**, and a Check answer card is one list of **Answer** rows.

## Retrying an action <a id="doc-tickets-actions-retrying-an-action"></a>

`retry: true` on an `acsg`, `acsr`, `pstr`, `req`, `mail` or `nlsd` action tries it again when it fails: up to **three more times**, with pauses of 1, 2 and 4 seconds, as long as the [time budget](#doc-tickets-actions-limits-and-the-time-budget) still holds the pause plus a second. What is retried is the action itself; a failure inside its Then chain, and a `TIMEOUT`, are never retried, and neither is a refusal that waiting cannot change: an address that is not an e-mail address, a template or a newsletter that no longer exists, or the month's e-mail sends used up (`QUOTA_EXCEEDED`). Those fail at once. The last failure is the one reported and the one the `err` chain runs for, once. The log row of the action carries `attempts` and `retried`, the earlier failures, so a call that succeeded on its second try reads `ok after 2 tries` in the dashboard.

A Respond or a Check answer cannot be retried; registration refuses `retry: true` on either with `"actions[0].retry": a Respond cannot be retried.` On the dashboard the six cards have a **Retry** checkbox.

## Going on later: queued runs <a id="doc-tickets-actions-going-on-later-queued-runs"></a>

A Respond with `resume` other than `"stop"` ends the consumption for the caller and leaves the rest of the chain to a **queued run**: the actions still to run, the placeholder pool, the request and the consumer are stored, and the run starts at once (`"0m"`), after the delay, or at the time. It runs with a fresh 25 second budget, by the ticket as it is stored when it starts, and writes a log row of its own: the Log tab shows the original consumption as `answered 202` with a **queued** mark, and the run as **resumed**, naming the consumption it continues and how long it waited. The run's own action rows sit under it. A run cannot answer the consumer again, since the consumer is gone: registration keeps a second Respond off that path.

What a queued run costs and what can stop it:

- Every run that goes on counts toward the plan's **queued ticket runs** for the UTC month: Trial 100, Standard 10,000, Premium 100,000 and $1.00 per 1,000 past that. On Trial and Standard a Respond that would queue a run past the month's figure fails with `QUOTA_EXCEEDED` (`Queued ticket runs for this month are used up. Consider upgrading your plan.`, `detail: { field: "tkq", limit, used, month }`) before it answers; its `err` chain runs, and a Respond that stops is never affected. The **Plan & Usage** card shows the month's figure, and the Tickets page says so when it is used up. See [Plans and Limits](#doc-introduction-plans-queued-ticket-runs).
- A delay is counted to the minute: a run queued for `"10m"` starts between ten and eleven minutes later. A fixed time starts on the second.
- Deleting the ticket drops its runs that are still waiting, when they come due; so does deleting the project. A run that cannot be started after three tries is written to the log as a failed row with `skipped`.

## Error Chains With `${error}` <a id="doc-tickets-actions-error-chains-with-error"></a>

An `err` chain is a normal chain that runs only when its action fails. Inside it, `${error}` and its keys describe the failure:

| reference | value |
|---|---|
| `${error[code]}` | the error code, such as `ACTION_FAILED` |
| `${error[message]}` | its one-sentence message |
| `${error[detail]}` | the code-specific detail object |
| `${error[action]}` | the `act` that failed |
| `${error[path]}` | its chain path, such as `actions[2].actions[0]` |

`${body}` is still the request the ticket received, and the placeholder pool holds everything captured so far.

```json
{
  "act": "pstr",
  "exe": {
    "table": {
      "name": "orders",
      "access_group": "admin"
    },
    "data": {
      "payment": "${body[payment][id]}"
    }
  },
  "err": [
    {
      "act": "req",
      "exe": {
        "url": "https://hooks.example.com/alert",
        "method": "POST",
        "headers": {
          "content-type": "application/json"
        },
        "data": {
          "text": "order record failed: ${error[message]}",
          "payment": "${body[payment][id]}"
        }
      }
    }
  ]
}
```

After the alert is sent, the consumption stops and answers the `pstr` failure, not the alert's outcome. An error chain is for telling someone, or for writing a record of the failure; it cannot make the consumption succeed. It can answer the caller, though: a [Respond](#doc-tickets-actions-resp-answer-the-caller) in an error chain sends a status and body of your own while the failure stays in the log.

# Errors and Logs <a id="doc-tickets-errors"></a>

A consumption either succeeds, and answers `{ tkid, hash }` (or what a [Respond](#doc-tickets-actions-resp-answer-the-caller) action composed), or fails at one of three stages: `ticket` (the service and the ticket itself), `condition`, or `action`. Every failure is answered with the same flat JSON body, and most failures are written to the ticket's log.

A ticket the **Tickets** page marks **previous rules** answers differently until it is saved again: see [Tickets saved before this release](#doc-deprecated-deprecated-tickets-saved-before-this-release).

## The Error Body <a id="doc-tickets-errors-the-error-body"></a>

```json
{
  "code": "<CODE>",
  "message": "<human readable, one sentence>",
  "stage": "ticket" | "condition" | "action",
  "action": { "act": "req", "path": "actions[1].err[0]" },   // only when stage == "action"
  "detail": { ... },                                          // optional, code specific, JSON safe
  "ticket_id": "<ticket_id>"
}
```

A success body never has `stage`; an error body always does. That is how [`consumeTicket()`](#doc-api-reference-tickets-consumeticket) tells them apart, whatever the HTTP status.

The status is `400`, or `200` when the ticket's `return200` is on. `INTERNAL_ERROR` is always `500`; `return200` never downgrades it.

`action.path` names the action that failed, counted from the ticket's `actions` list: `actions[1].err[0]` is the first action of the error chain of the second action, and `actions[2].actions[0]` is the first nested action of the third, a `req`.

## Error Codes <a id="doc-tickets-errors-error-codes"></a>

| code | stage | when | detail |
|---|---|---|---|
| `INVALID_SERVICE` | ticket | region prefix does not match the host, or service not found | |
| `SERVICE_DISABLED` | ticket | the service's `active` is below 1, checked right after `INVALID_SERVICE` on every route, before the ticket is read | |
| `TICKET_NOT_FOUND` | ticket | no ticket with that id | |
| `TICKET_EXPIRED` | ticket | `time_to_live < now` | `{ "expired_at": <time_to_live> }` |
| `TICKET_EXHAUSTED` | ticket | `count <= 0` | |
| `USER_LIMIT_REACHED` | ticket | per-user limit hit | `{ "limit": <limit_per_user>, "used": <consumptions by this user> }` |
| `ISSUER_CANNOT_CONSUME` | ticket | consumer is the ticket issuer | |
| `AUTH_REQUIRED` | ticket, condition or action | a user is needed and the route is anonymous (a `user` or `record_access` condition, which a webhook can never pass, an action with no `user_id`, or a `${user}` reference), or the token is from another project | |
| `METHOD_NOT_ALLOWED` | condition | `condition.method` mismatch | `{ "expected": "POST", "received": "GET" }` |
| `CONDITION_FAILED` | condition, or action for a Check answer | a part of the condition did not pass, or a Check answer row did not match | `{ "field": "signature"\|"ip"\|"user_agent"\|"headers"\|"body"\|"params"\|"user"\|"record_access"\|"loop"\|"response", "keys": [ ...keys that failed ] }` (`keys` empty for signature/ip/user_agent/record_access/loop). See [below](#doc-tickets-errors-condition-failed-fields) |
| `PATH_NOT_FOUND` | action | a [reference](#doc-tickets-conditions-references) in an action value could not be resolved | `{ "path": "data[payment][id]" }`, the reference without `${ }` |
| `PLACEHOLDER_MISSING` | action | `${placeholder[NAME]}` referenced but never captured | `{ "placeholder": "NAME" }` |
| `REQUEST_FAILED` | action (`req`) | HTTP status >= 300, refused address, connection error, per-call timeout, a header that cannot be sent, or a Secret Key that no longer exists | `{ "status": 502, "body": <the parsed answer, or the first 4 KB of its text when longer> }`, `{ "reason": "timeout" \| "refused_address" \| "connection" }`, `{ "reason": "invalid_header", "header": "<name>" }` (see [the req action](#doc-tickets-actions-req-http-request-with-its-own-condition-and-chain)), or `{ "reason": "secret_missing", "secretName": "<name>" }` |
| `TIMEOUT` | action | the 25 s consumption budget ran out before an action (or its HTTP call) could start; its `err` chain is skipped | `{ "elapsed_ms": n }` |
| `ACTION_FAILED` | action | the underlying Skapi operation refused (record post, access grant, group update) | `{ "code": "<the operation's code>", "message": "<its message>" }` |
| `ACTION_FORBIDDEN` | action | the action is not available to this project | |
| `QUOTA_EXCEEDED` | action (`resp`, `mail`, `nlsd`) | a Respond would queue a run past the month's queued ticket runs, on a plan that stops there (Trial, Standard): `{ "field": "tkq", "limit": n, "used": n, "month": "YYYYMM" }`, see [Going on later](#doc-tickets-actions-going-on-later-queued-runs). A Send e-mail or a Send newsletter past the month's e-mail sends, the Trial owner cap or the complaint shutoff: `{ "field": "emlsd" }`, see [Sending limits](#doc-email-newsletters-sending-limits) | |
| `ALREADY_RESPONDED` | action (`resp`) | a Respond ran after the consumer was already answered | `{ "path": "actions[2]" }` |
| `INTERNAL_ERROR` | any | unexpected exception (reported to Skapi) | |

The ticket-stage codes are the checks of [step 1 of a consumption](#doc-tickets-introduction), the condition-stage codes come from [Conditions and Placeholders](#doc-tickets-conditions), and the action-stage codes from [Actions](#doc-tickets-actions).

A code raised inside a `req` action, by its HTTP call or its Then chain, is reported with `stage: "action"` and, in `action`, the innermost action that failed: the `req` itself when its HTTP call failed, the nested action when its Then chain failed. A `CONDITION_FAILED` from a Check answer therefore carries `action: { "act": "cond", "path": "actions[2].actions[0]" }`, as a record post failing in the same chain carries `action: { "act": "pstr", "path": "actions[2].actions[1]" }`.

### `CONDITION_FAILED` fields <a id="doc-tickets-errors-condition-failed-fields"></a>

`detail.field` names the part of the condition that failed, and `detail.keys` the keys of that part that did not pass: header names for `headers`, paths for `body` and `params`, attribute names for `user`. A path the request does not carry is one of those keys; it is never a separate error.

One value is not a part you write: `"loop"`. The request carried an `X-Skapi-Ticket` header, so it was sent by a ticket, and a ticket cannot consume a ticket. The message is `Requests sent by a ticket cannot consume a ticket.` See [URL and address rules](#doc-tickets-actions-url-and-address-rules).

## What `consumeTicket()` Rejects With <a id="doc-tickets-errors-what-consumeticket-rejects-with"></a>

[`consumeTicket()`](#doc-api-reference-tickets-consumeticket) reads the body whatever the HTTP status. A body with `stage` is an error, and the promise rejects with a `SkapiError` whose `message` and `code` come from the body and whose `cause` is the whole body:

```js
try {
    await skapi.consumeTicket({ ticket_id: 'launch-coupon', method: 'GET', data: { code: 'WRONG' } });
}
catch (err) {
    console.log(err.code);          // 'CONDITION_FAILED'
    console.log(err.message);       // one sentence
    console.log(err.cause.stage);   // 'condition'
    console.log(err.cause.detail);  // { field: 'params', keys: ['code'] }
    console.log(err.cause.action);  // { act: 'req', path: 'actions[2]' } on an action failure, else undefined
    console.log(err.cause.ticket_id);
}
```

`err.cause` is a [`TicketError`](#doc-api-reference-tickets-ticketerror).

Errors raised before the request is sent, such as a missing `ticket_id` or `auth: true` with `method: 'GET'`, are plain `INVALID_PARAMETER` errors with no `cause`.

## `return200` <a id="doc-tickets-errors-return200"></a>

Webhook senders retry when an endpoint answers anything but 2xx. Most of the time a failed consumption is a request you chose not to handle, such as an event type the condition filters out, and a retry would fail the same way, so the retries only fill the log. `return200: true` answers `200` in those cases. The body is unchanged, so a caller that reads it still sees the error, the Log tab still shows the failure, and `consumeTicket()` still rejects, because it discriminates on `stage` and not on the status.

`return200` covers every failure raised once the ticket has been read, `TICKET_EXPIRED`, `TICKET_EXHAUSTED`, `USER_LIMIT_REACHED` and `ISSUER_CANNOT_CONSUME` included. `INVALID_SERVICE`, `SERVICE_DISABLED`, `TICKET_NOT_FOUND` and the `AUTH_REQUIRED` for a token from another project are raised before the ticket is read, so they are always `400`.

`INTERNAL_ERROR` is the exception: it is always answered with `500`, so the sender does retry, and by then the fault may be gone.

## What Gets Logged <a id="doc-tickets-errors-what-gets-logged"></a>

Every successful consumption is logged, and so is every dry run once the ticket has been read, whatever fails after that. Failures of a real consumption are logged when they are worth reading:

- every action-stage failure,
- every condition failure on the signed-in endpoint,
- on an anonymous endpoint, a condition failure only when the request passed `method`, `signature`, `ip`, `user_agent` and `headers` and failed on a later part: `body`, `params`, `user` or `record_access` (except the `X-Skapi-Ticket` loop-guard refusal, which is never logged on a real consumption). Such a request came from a caller that already looks like the intended sender.

Ticket-stage failures of a real consumption (`TICKET_NOT_FOUND`, `TICKET_EXPIRED`, `TICKET_EXHAUSTED` and the rest) and failures of `method`, `signature`, `ip`, `user_agent` and `headers` on an anonymous endpoint are not logged, so a scanner hitting your endpoints does not fill the log.

A failed consumption does not take from the count, and does not count toward the per-user limit. The one exception is a failure a Respond in an error chain answered: the consumer got an answer, so the count is consumed and the per-user counter moves, and the row shows `answered <status> (on error: <code>)`.

A [queued run](#doc-tickets-actions-going-on-later-queued-runs) writes a row of its own when it runs, marked **resumed** and naming the consumption it continues, with its own action rows under it.

## The Log Row <a id="doc-tickets-errors-the-log-row"></a>

A log row's `description` is a JSON string of this shape:

```json
{
  "data": <query string (raw strings) or POST body as received>,
  "query": { ... },                   // POST only, when the URL had a query string (raw strings)
  "method": "post" | "get",
  "headers": { ... },                 // as received, lowercase names; authorization and cookie replaced by "<redacted>"
  "user_agent": "...", "ip": "...", "timestamp": <ms>,
  "note": "...",                      // the consumer's `description` string, when sent
  "outcome": {
    "ok": true | false,
    "check": true,                    // dry run only
    "placeholders": { NAME: value },  // the pool at the end (values truncated to 1 KB of JSON each)
    "actions": [ { "path": "actions[0]", "act": "req", "ok": true, "result": <truncated 1 KB>, "attempts": 2, "retried": [ { code, message, ... } ] }
               | { "path": "actions[1]", "act": "pstr", "ok": false, "error": { code, message, detail } } ],
    "responded": { "status": 202, "path": "actions[1]", "at": <ms>, "body": <truncated 1 KB>, "from_error": true },  // what a Respond answered
    "queued": { "due": <ms>, "run": "<id>", "kind": "now" | "delay" | "at" },  // the run that goes on later
    "error": { code, message, stage, action?, detail? }   // when ok == false
  }
}
```

`outcome.actions` lists every action that ran, in order, with its answer or its error, so you can see how far a chain got; `attempts` and `retried` appear on an action that was [tried again](#doc-tickets-actions-retrying-an-action), and `warning` on a Send e-mail or Send newsletter that went out but could not be counted or marked sent. `outcome.responded` is what a [Respond](#doc-tickets-actions-resp-answer-the-caller) answered, and `outcome.queued` the queued run it left, when there is one. `outcome.placeholders` is the pool at the end. The whole string is capped at 60 KB: `data` is truncated first, then the action results.

The row of a resumed run has, beside `outcome`, `continues` (the consume id of the consumption it goes on with), `queued_at`, `due`, `started`, `ended`, `waited` (ms), `kind` and `run` (its attempt number); a queued run that could not be started after three tries has `skipped` and a failed `outcome`.

### The action rows <a id="doc-tickets-errors-the-action-rows"></a>

Beside the consumption row, every action that ran has a row of its own, keyed `@<ticket_id>#<consume_id>#<path>`, so a consumption's actions can be read one by one and a long chain's detail does not swell the consumption row:

```json
{
  "from": { "ticket_id": "order-paid", "consume_id": "UwdAhf6k3Qp" },
  "path": "actions[0]", "act": "req", "ok": true,
  "started": <ms>, "ended": <ms>, "attempts": 1,
  "request": { ... },               // what the action sent, its references filled in (a Secret Key value never); for a Check answer, its rows with what each found
  "response": <the answer, up to 200 KB; for a req { status, headers, body }>,
  "retried": [ ... ],               // the earlier failures of a retried action
  "warning": "...",                 // a send that went out but could not be counted or marked sent
  "error": { code, message, detail },
  "responded": { "status": 202, "resume": "delay" },   // on a Respond
  "placeholders": { NAME: value }   // the pool after the action
}
```

The project owner lists them with [`getTickets()`](#doc-api-reference-tickets-gettickets) and `ticket_id: '@<ticket_id>#<consume_id>#'`, and the dashboard shows them under the consumption in its Details dialog. A row is capped at 300 KB; past it the request and the response are cut to 4 KB each and the pool is dropped.

`note` is the `description` key of the request data (the POST body, or the query string on a GET), when it is a string, cut to 500 characters. It is kept for you to read and replaces nothing.

`headers` never holds a credential: `authorization` and `cookie` are stored as `"<redacted>"`, and `${headers[...]}` reads the same redacted values. Nor does an action: in the result or the error of a `req` that sends a Secret Key, every copy of the key's value, as sent or escaped up to three times over, is replaced by the text `${CLIENT_SECRET}`. See [Sending a Secret Key](#doc-tickets-actions-sending-a-secret-key).

`data` holds the query string values of a GET as the raw strings they arrived as, before the JSON parsing described in [What the request carries](#doc-tickets-introduction-what-the-request-carries). On a POST, `data` is the body, and the query string, which `params` rows read, is in `query` the same way.

## Reading the Log in the Dashboard <a id="doc-tickets-errors-reading-the-log-in-the-dashboard"></a>

Open the ticket on the **Tickets** page and switch to the **Log** tab.

![The Log tab of a ticket: From and To fields and a Clear log link above five consumptions with their time, consumer and result: a resumed run, a consumption answered 202 and marked queued, two failures with their error code in red, and a dry run marked check, each with a Details link](https://docs.skapi.com/screenshots/tickets-logs.webp)

*The Log tab, newest first. A consumption a Respond answered reads answered 202 with a queued mark, the run that went on later reads resumed, a failed consumption names its error code, and a dry run reads check. From and To narrow the range; Clear log deletes rows before a time.*

Each row shows the **Time**, the **Consumer** (the user id on the signed-in endpoint, otherwise the caller's IP address; the user agent is in the details) and the **Result**: `ok`, `failed: <code>`, `check` for a dry run, `answered <status>` when a Respond answered, or `resumed` for a queued run; a **queued** mark says the consumption's chain goes on later, a **resumed** mark that the row is such a run. Hovering a failed result shows what its code means. **From** and **To** narrow the list to a time range. **[Details]** opens the full log row described above, with the `note` under its header when the request carried one, and lists the [action rows](#doc-tickets-errors-the-action-rows) under it, each opening to its own JSON. **Load more** fetches the next page, the refresh icon reloads the log, and **[Clear log]** deletes rows before a time ([Clearing the log](#doc-tickets-errors-clearing-the-log)).

![The Consumption dialog for an answered row: the row key with the ticket id, consume id and caller, a line saying when the rest of the chain goes on, the JSON log, and under it the Actions list with one row per action, the first opened to its own JSON with what it sent, the record it got back and the placeholder pool](https://docs.skapi.com/screenshots/tickets-log-details.webp)

*Details of an answered consumption. The outcome lists every action that ran, and the Actions list under it opens each action's own row: the Post record was tried twice and posted the order, and the Check answer in its Then chain captured the record id.*

Only the project owner sees the log, in the dashboard or by calling [`getTickets()`](#doc-tickets-errors-who-may-call-gettickets) with `ticket_id: '#<id>#'`.

## Clearing the log <a id="doc-tickets-errors-clearing-the-log"></a>

**[Clear log]** on the Log tab, or [`clearTicketLog({ ticket_id, before })`](#doc-api-reference-tickets-clearticketlog) from the SDK, deletes the ticket's log rows (consumptions and their action rows) before a time, which is now unless you pick an earlier one. The rows go in the background over the next minutes. The ticket itself, its remaining count and its per-user limits are unchanged; nothing that was consumed is handed back.

Deleting a ticket deletes all of its logs with it, and drops its queued runs that are still waiting when they come due; the dialog says so before it does.

## How long the log is kept

A ticket's log rows count toward the project's database storage, shown as their own part of the Database figure on the **Plan & Usage** card, and they are kept for the plan's retention: 30 days on Trial, 90 days on Standard, and for good on Premium. A row past its retention expires on its own, and expiring hands nothing back to the count. See [Plans and Limits](#doc-introduction-plans-queued-ticket-runs).

## `getConsumedTickets()`

A signed-in user can list their own consumptions with [`getConsumedTickets()`](#doc-api-reference-tickets-getconsumedtickets). Only consumptions made on the signed-in endpoint are attributed to a user, so anonymous ones never appear here. `ticket_id` narrows the list to one ticket.

```js
let consumed = await skapi.getConsumedTickets({ ticket_id: 'unlock-guide' });

for (let row of consumed.list) {
    let log = JSON.parse(row.description);   // the log row above
    console.log(row.ticket_id, row.consume_id, new Date(row.timestamp), log.outcome.ok);
}
```

## Who May Call `getTickets()` <a id="doc-tickets-errors-who-may-call-gettickets"></a>

[`getTickets()`](#doc-api-reference-tickets-gettickets) lists the tickets of the project. Any signed-in user may call it, and sees the public part of each ticket: `ticket_id`, `description`, `count`, `time_to_live` and `timestamp`. Conditions, actions and the log are only returned to the project owner: in the dashboard, or when the owner calls `getTickets()` from the SDK. The owner's rows carry `limit_per_user`, `updated`, `condition` and `actions` as well, and the owner may pass `ticket_id: '#<id>#'` to read that ticket's log rows, the way the dashboard's Log tab does, or `'@<id>#<consume_id>#'` for the [action rows](#doc-tickets-errors-the-action-rows) of one consumption, with `from` and `to` (milliseconds) for a time range. Every other signed-in user, an admin of the project included, is refused a `ticket_id` containing `#` or `@` with `INVALID_REQUEST: Invalid ticket id.`.

```js
let tickets = await skapi.getTickets({});
console.log(tickets.list);
/*
[
    { ticket_id: 'launch-coupon', description: 'Launch coupon', count: 87, timestamp: 1757721600000 },
    { ticket_id: 'unlock-guide', description: 'Unlock the members guide', timestamp: 1757808000000 }
]
*/
```

# Examples <a id="doc-tickets-examples"></a>

Four tickets, each explained line by line. Paste any of them into the **Edit as JSON** view of the dashboard's Tickets page, click **Apply**, and register.

## A Payment Webhook <a id="doc-tickets-examples-a-payment-webhook"></a>

Your payment provider reports a completed payment. The ticket records the order, lifts the buyer's access group, and asks your fulfilment API to ship. The whole ticket:

```json
{
  "ticket_id": "order-paid",
  "description": "Payment completed",
  "condition": {
    "return200": true,
    "method": "POST",
    "signature": {
      "secretName": "payment_webhook",
      "header": "x-signature",
      "separator": ",",
      "parts": [
        "t=${timestamp}",
        "v1=${signature}"
      ],
      "signed": "${timestamp}.${body}",
      "timestamp": "${timestamp}"
    },
    "body": [
      {
        "key": "type",
        "operator": "=",
        "value": "payment.completed"
      },
      {
        "key": "payment[metadata][user_id]",
        "placeholder": "BUYER"
      }
    ]
  },
  "actions": [
    {
      "act": "pstr",
      "exe": {
        "table": {
          "name": "orders",
          "access_group": "admin"
        },
        "unique_id": "order-${body[payment][id]}",
        "index": {
          "name": "buyer",
          "value": "${placeholder[BUYER]}"
        },
        "data": {
          "payment": "${body[payment][id]}",
          "amount": "${body[payment][amount_total]}",
          "country": "${body[payment][customer][country]}",
          "note": "paid via ${body[payment][payment_methods][0]}"
        }
      },
      "err": [
        {
          "act": "req",
          "exe": {
            "url": "https://hooks.example.com/alert",
            "method": "POST",
            "headers": {
              "content-type": "application/json"
            },
            "data": {
              "text": "order record failed: ${error[message]}"
            }
          }
        }
      ]
    },
    {
      "act": "acsg",
      "exe": {
        "group": 2,
        "user_id": "${placeholder[BUYER]}"
      }
    },
    {
      "act": "req",
      "exe": {
        "url": "https://api.example.com/fulfil",
        "method": "POST",
        "headers": {
          "content-type": "application/json"
        },
        "data": {
          "user_id": "${placeholder[BUYER]}",
          "payment": "${body[payment][id]}"
        },
        "actions": [
          {
            "act": "cond",
            "exe": {
              "response": [
                {
                  "key": "status",
                  "operator": "=",
                  "value": "ok"
                }
              ]
            }
          },
          {
            "act": "pstr",
            "exe": {
              "table": {
                "name": "shipments",
                "access_group": "admin"
              },
              "data": {
                "id": "${response[body][shipment][id]}",
                "carrier": "${response[body][carrier]}",
                "payment": "${body[payment][id]}"
              }
            }
          }
        ]
      }
    }
  ]
}
```

The event this ticket expects looks like this, with the signature in the `x-signature` header:

```json
{
  "type": "payment.completed",
  "payment": {
    "id": "pay_a1B2c3",
    "amount_total": 4200,
    "payment_methods": [
      "card"
    ],
    "metadata": {
      "user_id": "<the Skapi user id>"
    },
    "customer": {
      "country": "KR"
    }
  }
}
```

Every value the actions fill in is a reference inside `${ }`, and everything outside `${ }` is sent as written. `${body[...]}` reads this event, `${placeholder[BUYER]}` the value a condition row captured, `${result[...]}` the previous action's result, `${response[...]}` the fulfilment API's answer, and `${error[...]}` the failure an `err` chain handles. See [References](#doc-tickets-conditions-references).

### The ticket

`"ticket_id": "order-paid"` is the last segment of the endpoint. The webhook URL to paste into your payment provider's webhook settings is `https://<reg>.skapi.dev/tp/<service_id>/order-paid`, copied from the **Endpoints** section of the ticket.

`"description"` is what the list shows. There is no `count`, `limit_per_user` or `time_to_live`: the ticket is unlimited and never expires. A per-user limit would not apply anyway, because the provider calls the anonymous endpoint.

### The condition <a id="doc-tickets-examples-the-condition"></a>

`"return200": true`. Webhook senders usually retry an endpoint that answers anything but 2xx. An event the condition turns away would fail the same way on every retry, so failures are answered with 200. The error body still says what failed, and the Log tab still records it.

`"method": "POST"`. A GET is answered `METHOD_NOT_ALLOWED`.

`"signature"`. The provider signs every event with a signing secret it gives you when you add the endpoint. This example assumes it sends `t=<timestamp>,v1=<hex>` in the `x-signature` header, where the hex is the HMAC-SHA256 of `<timestamp>.<raw body>`:

- `"separator": ","` splits the header into its `t=...` and `v1=...` items.
- `"parts"` captures the timestamp from the `t=` item and the signature from every `v1=` item.
- `"signed": "${timestamp}.${body}"` rebuilds the text the provider signed.
- `"timestamp": "${timestamp}"` refuses an event more than 300 seconds (the default `tolerance`) away from now, so a captured request cannot be replayed later.

These `${timestamp}`, `${signature}` and `${body}` are the signature's own tokens, not references. `algorithm` and `encoding` are left at their defaults, SHA-256 and hex. If your provider lays the header out differently or signs other bytes, change these fields to match its documentation; [Describing a sender](#doc-tickets-conditions-describing-a-sender) shows other common shapes. Save the signing secret as a [Secret Key](#doc-api-bridge-client-secret-request-registering-secret-keys) named `payment_webhook`; `"secretName": "payment_webhook"` is that **name**. Skapi recomputes the HMAC over the raw body and compares in constant time. Without this condition, anyone who knows the URL could post a fake event and run your actions.

`{ "key": "type", "operator": "=", "value": "payment.completed" }`. `type` is a top-level key of the event. Any other event type fails here with `CONDITION_FAILED` and `detail: { field: "body", keys: ["type"] }`, answered 200 because of `return200`, and logged, because it carried a valid signature and so came from your provider.

`{ "key": "payment[metadata][user_id]", "placeholder": "BUYER" }`. A capture-only row: no operator, no value. The key is a path in the event body, written without `${ }`: `payment`, then its `metadata`, then `user_id`, which you set when you create the payment with the provider (`metadata: { user_id: <the Skapi user id> }`). The value is remembered as `BUYER`, a shorter name for a value two actions use. A payment without it does not fail here; the first action that reads `${placeholder[BUYER]}` raises `PLACEHOLDER_MISSING` instead.

### The actions

**`actions[0]`: post the order record.**

`"table": { "name": "orders", "access_group": "admin" }`. The record goes into `orders`, readable by admins only. The name has no `${ }`, so it is the text `orders`.

`"unique_id": "order-${body[payment][id]}"`. A reference inside text: the payment id `pay_a1B2c3` becomes the unique id `order-pay_a1B2c3`. When the provider retries the event, the same unique id makes this an update of the same record instead of a second record.

`"index": { "name": "buyer", "value": "${placeholder[BUYER]}" }`. A value that is one whole reference keeps its type, so the index value is the captured user id, and `getRecords({ table: { name: 'orders', access_group: 'admin' }, index: { name: 'buyer', value: userId } })` finds one buyer's orders.

`"data"`. Four keys, each read from the event. `payment` and `amount` are whole references and keep their types, so `amount` is the number the provider sent (`4200`, in the smallest unit of the currency). `country` reads a nested key. `note` puts a list element into text and becomes `paid via card`.

`"err"`. When the post fails, this chain runs. It is one `req` action that POSTs `{ "text": "order record failed: <the message>" }` to an alert hook, `${error[message]}` being the failed action's message. After it the consumption stops and answers the `pstr` failure; the alert cannot make it succeed.

**`actions[1]`: lift the buyer's access group.**

`{ "act": "acsg", "exe": { "group": 2, "user_id": "${placeholder[BUYER]}" } }` sets the buyer to access group `2`, so records posted with `access_group: 2` become readable to them. It answers the SUCCESS text the grant returns.

**`actions[2]`: call your fulfilment API.**

`"data": { "user_id": "${placeholder[BUYER]}", "payment": "${body[payment][id]}" }`. `${placeholder[BUYER]}` reads the buyer's id the condition captured, at every depth of the chain. `${body[payment][id]}` reads the provider's event.

`"actions"` is the Then chain, run on the response. Its first action is a Check answer whose row key `body[status]` is a path in the response: the body's `status` must be `ok`, else the chain fails there with `CONDITION_FAILED` and `field: "response"`, answered with `stage: "action"` and `action: { act: "cond", path: "actions[2].actions[0]" }` because the failure happened inside the action.

The `pstr` after it reads the response as `${response[...]}`: a `shipments` record is posted with the body's `shipment.id` and `carrier` (`${response[body][shipment][id]}`, `${response[body][carrier]}`), and `${body[payment][id]}` still reads the provider's event, as it does at every depth.

### What you see afterwards

Consuming the ticket writes a log row whose `outcome.actions` lists the actions with their results, and whose `outcome.placeholders` holds `BUYER`. The `orders` table has one record per payment, indexed by buyer. The buyer is in access group `2`.

## A Coupon Link

A link you hand out at launch. The first hundred visits with the right code each write a `coupons` record.

```json
{
  "ticket_id": "launch-coupon",
  "description": "Launch coupon",
  "count": 100,
  "limit_per_user": 1,
  "condition": {
    "method": "GET",
    "params": [
      {
        "key": "code",
        "operator": "=",
        "value": "LAUNCH24"
      }
    ]
  },
  "actions": [
    {
      "act": "pstr",
      "exe": {
        "table": {
          "name": "coupons",
          "access_group": "public"
        },
        "data": {
          "code": "${params[code]}",
          "ip": "${ip}"
        }
      }
    }
  ]
}
```

`"count": 100`. The first hundred successful consumptions take one each; the next answers `TICKET_EXHAUSTED`. Failed calls do not count.

`"limit_per_user": 1`. Once per signed-in user. This ticket is GET, and signed-in consumption is POST only, so on this link the limit never applies and the count alone caps it. To make it once per user, drop `"method": "GET"`, move the row from `params` to `data`, write `${body[code]}` in the action, and have the page call `consumeTicket({ ticket_id: 'launch-coupon', method: 'POST', auth: true, data: { code } })`.

`"method": "GET"` and `"params"`. The data is the query string, and `params` rows read it. `code` is a top-level key of that query, so the link is:

```
https://eu73.skapi.dev/tg/eu73kXm2PqA9vLb4/launch-coupon?code=LAUNCH24
```

or, from a page:

```js
let coupon = await skapi.consumeTicket({
    ticket_id: 'launch-coupon',
    method: 'GET',
    data: { code: 'LAUNCH24' }
});
```

A wrong code answers `400` with `CONDITION_FAILED` and `detail: { field: "params", keys: ["code"] }`, and is logged. A link without `code` fails the same way: a field that is not there does not match.

`"data": { "code": "${params[code]}", "ip": "${ip}" }`. `${params[code]}` reads `code` from the query string, the string `LAUNCH24`, and `${ip}` is the caller's address. Without the `${ }`, `params[code]` would be stored as that very text: only text inside `${ }` is a reference.

The record is posted as the project owner into the public `coupons` table.

## A Signed-In Unlock

A private record, the members guide, that a signed-in user with a verified e-mail can unlock once by calling one endpoint.

```json
{
  "ticket_id": "unlock-guide",
  "description": "Unlock the members guide",
  "limit_per_user": 1,
  "condition": {
    "method": "POST",
    "user": [
      {
        "key": "email_verified",
        "operator": "=",
        "value": true
      }
    ]
  },
  "actions": [
    {
      "act": "acsr",
      "exe": {
        "record_id": "9x2K4mQ1pL8vB3nR6tW5yZ0cA7dF"
      }
    }
  ]
}
```

`"limit_per_user": 1`. Once per user. A second call from the same user answers `USER_LIMIT_REACHED` with `detail: { limit: 1, used: 1 }`. Signed-in consumption is the only kind that is counted per user, and this ticket only makes sense signed in.

`"method": "POST"`. Signed-in consumption is POST only, so this is the only method that could work.

`"user"`. The consumer's `email_verified` must be `true`. `user` rows work only for signed requests, which is what this ticket is for: on an anonymous POST this row raises `AUTH_REQUIRED` before the action runs (an anonymous GET already fails on `method`), so nobody can reach the action without an account. For the same reason a `user` row never belongs on a webhook ticket, which it would fail every time. An action can read the same attributes, such as `${user[email]}`.

`{ "act": "acsr", "exe": { "record_id": "9x2K4mQ1pL8vB3nR6tW5yZ0cA7dF" } }`. `record_id` is the guide's record id (not its unique id). With no `user_id`, the grantee is the consumer. The grant is made on behalf of the record's uploader, so the guide must have been uploaded by a user of your project, an admin account for instance, or by you as the owner.

The page calls it like this:

```js
await skapi.consumeTicket({
    ticket_id: 'unlock-guide',
    method: 'POST',
    auth: true
});

// the guide is now readable to this user
let guide = await skapi.getRecords({ record_id: '9x2K4mQ1pL8vB3nR6tW5yZ0cA7dF' });
```

The user can see the consumption afterwards with `getConsumedTickets({ ticket_id: 'unlock-guide' })`, and you can see it in the ticket's Log tab with the user id as the consumer.

:::tip
To hand the unlock out with a purchase instead of to everyone, add a `body` row on a code the page sends, or name a receipt record the user already holds in `record_access`. Both are evaluated before the action runs.
:::

## Calling an API With Your Secret Key

A service you use sends a webhook when an order changes, but the event is only a notice: it carries the order id and little else. This ticket fetches the order from that service's API with your API key, checks the answer, maps the order's plan to an access group, and sets the buyer's group. Because it reads the order's current state every time, a repeated or late event applies the same result.

```json
{
  "ticket_id": "order-changed",
  "description": "Order changed: fetch it and set the buyer's plan",
  "condition": {
    "return200": true,
    "method": "POST",
    "signature": {
      "secretName": "orders_webhook",
      "header": "x-signature",
      "separator": ",",
      "parts": [
        "t=${timestamp}",
        "v1=${signature}"
      ],
      "signed": "${timestamp}.${body}",
      "timestamp": "${timestamp}"
    },
    "body": [
      {
        "key": "type",
        "operator": "=",
        "value": [
          "order.created",
          "order.updated"
        ]
      }
    ]
  },
  "actions": [
    {
      "act": "req",
      "exe": {
        "url": "https://api.example.com/v1/orders/${body[order][id]}",
        "method": "GET",
        "secretName": "orders_api_key",
        "headers": {
          "Authorization": "Bearer ${CLIENT_SECRET}"
        },
        "actions": [
          {
            "act": "cond",
            "exe": {
              "response": [
                {
                  "key": "status",
                  "operator": "=",
                  "value": "paid"
                },
                {
                  "key": "body[plan]",
                  "operator": "=",
                  "value": "basic",
                  "setValueWhenMatch": 2,
                  "placeholder": "GROUP"
                },
                {
                  "key": "body[plan]",
                  "operator": "=",
                  "value": [
                    "pro",
                    "team"
                  ],
                  "setValueWhenMatch": 3,
                  "placeholder": "GROUP"
                },
                {
                  "key": "body[plan]",
                  "operator": "!=",
                  "value": null,
                  "setValueWhenMatch": 1,
                  "placeholder": "GROUP"
                }
              ]
            }
          },
          {
            "act": "acsg",
            "exe": {
              "group": "${placeholder[GROUP]}",
              "user_id": "${response[body][metadata][user_id]}"
            }
          }
        ]
      }
    }
  ]
}
```

The webhook event looks like this, signed like the one in [A Payment Webhook](#doc-tickets-examples-a-payment-webhook):

```json
{ "type": "order.updated", "order": { "id": "ord_8Kq2" } }
```

and the service's API answers `GET https://api.example.com/v1/orders/ord_8Kq2` with the order:

```json
{
  "id": "ord_8Kq2",
  "status": "paid",
  "plan": "pro",
  "metadata": {
    "user_id": "<the Skapi user id>"
  }
}
```

### Before you register

Save two [Secret Keys](#doc-api-bridge-client-secret-request-registering-secret-keys): the service's webhook signing secret as `orders_webhook`, and your API key for the service as `orders_api_key`. Both must exist when you register, or registration names the one that is missing.

Give `orders_api_key` the **Destinations** `https://api.example.com/v1/orders`. The `req` that carries it may then only call that API, and a call anywhere else fails with `REQUEST_FAILED` and `detail: { "reason": "refused_address" }` before anything is sent. The signing secret needs none: it is only read to verify the signature and is never sent.

### The condition

`"return200": true`. Every failure is answered with 200, so the service does not retry an order that is not paid. The Log tab still shows each failure. If you would rather have a failed call to the API retried, leave `return200` off: then every failure, an unpaid order included, answers 400 and the service sends the event again later.

`"signature"` is read exactly as in [A Payment Webhook](#doc-tickets-examples-the-condition), with this ticket's own signing secret.

`{ "key": "type", "operator": "=", "value": ["order.created", "order.updated"] }`. A value list: the row passes for either event type.

### The request

`"url": "https://api.example.com/v1/orders/${body[order][id]}"`. `${body[order][id]}` reads the order id from the event. The scheme and host are written out, which a `req` with a `secretName` requires, so no event can send your key to another host. The order id is percent-encoded as it goes in, so an id such as `../customers` stays one path segment.

`"secretName": "orders_api_key"` and `"Authorization": "Bearer ${CLIENT_SECRET}"`. The key's value goes into this header of this call, and nowhere else. Wherever the answer repeats it, as sent or escaped up to three times over, the Log tab shows the text `${CLIENT_SECRET}` instead. See [Sending a Secret Key](#doc-tickets-actions-sending-a-secret-key).

A failed call (an answer of 300 or above, a timeout, a refused address) fails the action with `REQUEST_FAILED`, and the rest of the `req` is skipped.

### Checking the answer

The first action of the `req`'s Then chain is a Check answer whose rows read the response, under the [same rules](#doc-tickets-actions-checking-the-response) as the ticket's own rows. Their keys are paths in the response, the order the API returned being its `body`:

- `body[status]` must be `paid`. Any other status fails the Check answer with `CONDITION_FAILED`, `detail: { field: "response", keys: ["body[status]"] }` and `action: { act: "cond", path: "actions[0].actions[0]" }`, and nothing else runs.
- The three `body[plan]` rows are a [lookup table](#doc-tickets-conditions-lookup-tables). The first row that matches wins, so `basic` captures `GROUP` = `2`, `pro` and `team` capture `3`, and the last row, the catch-all, captures `1` for any other plan, so an unknown plan does not fail the ticket.

### The nested action

`{ "act": "acsg", "exe": { "group": "${placeholder[GROUP]}", "user_id": "${response[body][metadata][user_id]}" } }`, the action after the Check answer, sets the group the lookup table chose for the Skapi user id you stored on the order when it was created, read from the response body with `${response[body][...]}`. For the order above, it moves the buyer to access group `3`.

### What you see afterwards

The log row's `outcome.placeholders` holds `GROUP`, and `outcome.actions` lists the `req` at `actions[0]`, with the order as its result, the Check answer at `actions[0].actions[0]`, and the access group change at `actions[0].actions[1]`. The buyer is in the access group of their current plan, however many times the service sends the event.

# Connecting to Realtime <a id="doc-realtime-connecting"></a>

Skapi's realtime connection let's you transfer JSON data between users in realtime.
This is useful for creating chat applications, notifications, etc.

## Creating Connection

:::warning
User must be logged in to call this method
:::

Before you start sending realtime data, you must create a realtime connection.
You can create a realtime connection by calling [`connectRealtime()`](#doc-api-reference-realtime-connectrealtime) method.

For more detailed information on all the parameters and options available with the [`connectRealtime()`](#doc-api-reference-realtime-connectrealtime) method,
please refer to the API Reference below:

### [`connectRealtime(RealtimeCallback): Promise<WebSocket | void>`](#doc-api-reference-realtime-connectrealtime)

Once the connection is established, you can start receiving realtime data from the [RealtimeCallback](#doc-api-reference-data-types-realtimecallback).

On the initial connection, the returned promise resolves to `undefined` rather than the socket. The `WebSocket` is delivered through the [RealtimeCallback](#doc-api-reference-data-types-realtimecallback).

```js
let RealtimeCallback = (rt) => {
    // Callback executed when there is data transfer between the users.
    /**
    rt = {
      type: 'message' | 'error' | 'success' | 'close' | 'notice' | 'private' | 'reconnect' | 'rtc:incoming' | 'rtc:closed';
      message?: any;
      connectRTC?: (params: RTCReceiverParams, callback: (e: RTCEvent) => void) => Promise<RTCResolved>; // Accept incoming RTC calls.
      hangup?: () => void; // Reject incoming RTC connection.
      sender?: string; // user_id of the sender
      sender_cid?: string; // scid of the sender
      sender_rid?: string; // group of the sender
      code?: 'USER_LEFT' | 'USER_DISCONNECTED' | 'USER_JOINED' | null; // code for notice messages
    }
    */
    console.log(rt);
};

skapi.connectRealtime(RealtimeCallback);
```

In the example above, the [RealtimeCallback](#doc-api-reference-data-types-realtimecallback) function is executed whenever data is transferred between users.

When the callback runs, it receives a message object with the following properties:

-   `type` Shows the type of the received message as below:

    -   "message": When there is data broadcasted from the realtime group.

    -   "error": When there is an error. Usually websocket connection has an error and will be disconnected.

    -   "success": When the connection is established. This may fire on initial connection, or when the reconnection attempt is successful.

    -   "close": When the connection is intentionally closed by the user.

    -   "notice": When there is a notice. Usually notice users when the user in a realtime group has joined, left, or being disconnected.

    -   "private": When there is private data transfer between the users.

    -   "reconnect": When there is reconnection attempt. This happens after user leaves the browser tab or device screen is lock for certain amount of time, and the user comes back to the application. The websocket can disconnect when the application is left unfocus for some period of time.

    -   "rtc:incoming": When there is incoming WebRTC call.
    -   "rtc:closed": When the WebRTC connection is closed.

-   `message` is the data passed from the server. It can be any JSON data.
-   `sender` is the user ID of the message sender. It is available when `type` is "message", "private", or "notice". For "notice" events it carries the user ID of the user who joined, left, or was disconnected.
-   `sender_cid` is the connection ID of the message sender. It can be used to track the sender's connected device.
-   `sender_rid` is the group name of the received message.
-   `code` is a string identifier for notice messages.
-   `connectRTC`: Function to accept an incoming RTC call. Pass a callback to receive `RTCEvent` updates.
-   `hangup`: Function to reject incoming RTC connection.

## Closing Connection

You can close the realtime connection by calling [`closeRealtime()`](#doc-api-reference-realtime-closerealtime) method.

```js
skapi.closeRealtime();
```

When the connection is successfully closed, [RealtimeCallback](#doc-api-reference-data-types-realtimecallback) will trigger with following callback data:

```ts
{
  type: 'close',
  message: 'WebSocket connection closed.'
}
```

For more detailed information on all the parameters and options available with the [`closeRealtime()`](#doc-api-reference-realtime-closerealtime) method,
please refer to the API Reference below:

### [`closeRealtime(): Promise<void>`](#doc-api-reference-realtime-closerealtime)

:::tip
When the user closes the tab or refresh the browser, the connection will be closed automatically.
And when the connection is closed user will be removed from the group.
:::

# Sending Realtime Data <a id="doc-realtime-post"></a>

Once the realtime connection is established, users can start sending realtime data to another user.

## Sending Data to a User

User can send any JSON data over to any users by using [`postRealtime()`](#doc-api-reference-realtime-postrealtime) method.

For more detailed information on all the parameters and options available with the [`postRealtime()`](#doc-api-reference-realtime-postrealtime) method,
please refer to the API Reference below:

### [`postRealtime(message, recipient, notification?): Promise<{ type: 'success', message: string }>`](#doc-api-reference-realtime-postrealtime)

:::warning
The receiver must also be connected to the realtime connection to receive the data.
:::

::: code-group

```html [Form]
<form
    onsubmit="skapi.postRealtime(event, 'recipient_user_id').then(u=>console.log(u))"
>
    <input name="msg" required /><input type="submit" value="Send" />
</form>
```

```js [JS]
skapi
    .postRealtime({ msg: "Hello World!" }, "recipient_user_id")
    .then((res) => console.log(res));
```

:::

Example above shows how to send realtime data to a user with an id: 'recipient_user_id' (Should be the user_id of user's profile).

[`postRealtime()`](#doc-api-reference-realtime-postrealtime) method can be used directly from the form element as shown in the example above.
[`postRealtime()`](#doc-api-reference-realtime-postrealtime) takes two arguments:

-   `message`: The data to be sent to the recipient. It can be any JSON parsable data, or a SubmitEvent object.
-   `recipient`: The user ID of the recipient or the name of the group the user have joined.
-   `notification`: Notification to send with the realtime message, or notification to use when user is not connected to realtime. See [`Sending Notifications`](#doc-notification-send-notifications-sending-notifications)

When the message is sent successfully, the method will return the following object:

```ts
{
  type: 'success',
  message: 'Message sent.'
}
```

## Receiving Data from a User

On the receiver’s side, the message is delivered to the [RealtimeCallback](#doc-api-reference-data-types-realtimecallback) defined when creating the realtime connection with [`connectRealtime()`](#doc-api-reference-realtime-connectrealtime). The callback receives an object whose `type` property is set to `"private"`.

Below is an example of how the other user receives the sent data from the RealtimeCallback function:

```js
let RealtimeCallback = (rt) => {
    /**
    {
        "type": "private",
        "message": {
            "msg": "Hello World!",
        },
        "sender": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "sender_cid": "cid:xx-xxxxxxxxxxxx=",
        "sender_rid": null,
        "code": null
    }
    */
};

skapi.connectRealtime(RealtimeCallback);
```

# Realtime Groups <a id="doc-realtime-group"></a>

Realtime groups are used to send realtime data to multiple users at once.
This can be used to create group chats, group notifications, etc.

## Joining a Group

Users can join a group by calling [`joinRealtime()`](#doc-api-reference-realtime-joinrealtime) method.

params argument takes the following parameters:

-   `group`: The name of the group to join.

Group name can be anything you want.
If the group does not exist, it will be created automatically, otherwise the user will be joined to the existing group.

```js
skapi.joinRealtime({ group: "HelloWorld" }).then((res) => {
    console.log(res.message); // Joined realtime message group: "HelloWorld".
});
```

When the user is joined to the group successfully, the method will return the following object:

```ts
{
  type: 'success',
  message: 'Joined realtime message group: "HelloWorld".'.
}
```

For more detailed information on all the parameters and options available with the [`joinRealtime()`](#doc-api-reference-realtime-joinrealtime) method,
please refer to the API Reference below:

### [`joinRealtime(params): Promise<{ type: 'success', message: string }>`](#doc-api-reference-realtime-joinrealtime)

:::tip
Even if the user has joined the group, they can still receive realtime data sent individually to them.
:::

## Sending Data to a Group

Once the user has joined the group, they can send any JSON data to it using the [`postRealtime()`](#doc-api-reference-realtime-postrealtime) method. The message (JSON data) will be broadcast to all users in the group.

The example below shows how to send realtime data to a group named "HelloWorld":

::: code-group

```html [Form]
<form
    onsubmit="skapi.postRealtime(event, 'HelloWorld').then(u=>console.log(u))"
>
    <input name="msg" required /><input type="submit" value="Send" />
</form>
```

```js [JS]
skapi
    .postRealtime({ msg: "Hello World!" }, "HelloWorld")
    .then((res) => console.log(res));
```

:::

For more detailed information on all the parameters and options available with the [`postRealtime()`](#doc-api-reference-realtime-postrealtime) method,
please refer to the API Reference below:

## Receiving Data from the Group

When a message (JSON data) is broadcast in the group, every user receives it as an argument to the RealtimeCallback defined when creating the realtime connection with [`connectRealtime()`](#doc-api-reference-realtime-connectrealtime). The callback receives an object whose `type` property is set to `"message"`.

The example below shows how other users receive the broadcasted data from the group named "HelloWorld" via [RealtimeCallback](#doc-api-reference-data-types-realtimecallback) function:

```js
let RealtimeCallback = (rt) => {
    /**
    {
        "type": "message",
        "message": {
            "msg": "Hello World!",
        },
        "sender": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "sender_cid": "cid:xx-xxxxxxxxxxxx=",
        "sender_rid": "HelloWorld",
        "code": null
    }
    */
};

skapi.connectRealtime(RealtimeCallback);
```

### [`postRealtime(message, recipient, notification?): Promise<{ type: 'success', message: string }>`](#doc-api-reference-realtime-postrealtime)

:::warning
The user must be joined to the group to send data to the group.
:::

## Leaving a Group

Also, if you want to leave the group, you can call [`joinRealtime(params)`](#doc-api-reference-realtime-joinrealtime) method with a `params.group` value as empty `string` or `null`.

```js
skapi.joinRealtime({ group: null });
```

## Changing the Group

Users can join only one group at a time.
If you want your user to change groups, you can call [`joinRealtime(params)`](#doc-api-reference-realtime-joinrealtime) method with a different `params.group` value.

```js
skapi.joinRealtime({ group: "Another Group Name" });
```

## Listing Groups

Users can get a list of realtime groups by calling [`getRealtimeGroups()`](#doc-api-reference-realtime-getrealtimegroups) method.

Example below shows listing all groups existing in your service:

```js
skapi.getRealtimeGroups().then((res) => {
    console.log(res.list); // [{ group: 'HelloWorld', number_of_users: 1 }, ...]
});
```

You can search groups by the name.
Below example shows how to search group names that start with "Hello":

```js
skapi
    .getRealtimeGroups({ searchFor: "group", value: "Hello", condition: ">=" })
    .then((res) => {
        console.log(res.list); // [{ group: 'HelloWorld', number_of_users: 1 }, ...]
    });
```

You can also list groups that have more than 10 users:

```js
skapi
    .getRealtimeGroups({
        searchFor: "number_of_users",
        value: 10,
        condition: ">=",
    })
    .then((res) => {
        console.log(res.list); // [{ group: 'HelloUniverse', number_of_users: 11 }, ...]
    });
```

For more detailed information on all the parameters and options available with the [`getRealtimeGroups()`](#doc-api-reference-realtime-getrealtimegroups) method,
please refer to the API Reference below:

### [`getRealtimeGroups(params?, fetchOptions?): Promise<DatabaseResponse<{ group: string; number_of_users: number; }>>`](#doc-api-reference-realtime-getrealtimegroups)

## Listing Users in a Group

Users can get a list of users in a group by calling [`getRealtimeUsers()`](#doc-api-reference-realtime-getrealtimeusers) method.

Example below shows listing all users in the "HelloWorld" group:

```js
skapi.getRealtimeUsers({ group: "HelloWorld" }).then((res) => {
    console.log(res.list); // [{user_id: 'user_a', cid: 'user_cid'}, ...]
});
```

You can also search users in the group by their user ID.
This is useful if you want to check if the user is in the group.

```js
skapi
    .getRealtimeUsers({ group: "HelloWorld", user_id: "user_a" })
    .then((res) => {
        console.log(res.list); // [{user_id: 'user_a', cid: 'user_cid'}]
    });
```

For more detailed information on all the parameters and options available with the [`getRealtimeUsers()`](#doc-api-reference-realtime-getrealtimeusers) method,
please refer to the API Reference below:

### [`getRealtimeUsers(params?, fetchOptions?): Promise<DatabaseResponse<{ user_id:string; cid:string; }[]>>`](#doc-api-reference-realtime-getrealtimeusers)

# WebRTC <a id="doc-realtime-webrtc"></a>

Skapi makes WebRTC integration easy, allowing developers to quickly add real-time communication features to their applications.

WebRTC (Web Real-Time Communication) is a technology that enables peer-to-peer media and data streaming between two parties.
This can be used for video calls, voice calls, and data exchange, making it a versatile tool for various real-time communication needs.

:::danger HTTPS REQUIRED.
WebRTC only works in an HTTPS environment.
You need to set up an HTTPS environment when developing WebRTC features for your web application.

You can host your application on skapi.com or on your own servers.
:::

## Creating RTC Connection

To create an RTC connection, both parties must be online and connected to Realtime.

One user initiates the call with the other user's `cid` via [`connectRTC()`](#doc-api-reference-realtime-connectrtc), and the other user receives an `rtc:incoming` event.

If media streaming is enabled, users must grant camera/microphone permission.

## Overall Flow

Before reading the full code, here is the call flow in plain language:

1. **Both users connect to Realtime**
    Each browser starts a Realtime connection with [`connectRealtime()`](#doc-api-reference-realtime-connectrealtime) and joins the same room with [`joinRealtime()`](#doc-api-reference-realtime-joinrealtime).

2. **Users discover each other in the room**
    When someone joins, Realtime sends a `USER_JOINED` notice via [RealtimeCallback](#doc-api-reference-data-types-realtimecallback), which includes the user's connection info (`cid`).
    Both parties can also see each other's `connection ID (cid)` by calling [`getRealtimeUsers()`](#doc-api-reference-realtime-getrealtimeusers).

3. **One user starts a call**
    The caller uses the other user's `cid` with `connectRTC(...)` to begin WebRTC negotiation.

4. **The other user receives an incoming call**
    The receiver gets an `rtc:incoming` event and can accept or reject.
    - If accepted, `connectRTC(...)` completes on the receiver side.
    - Both browsers request camera/microphone access if media is enabled.

5. **Media streams and connection events are handled**
    - Local stream is shown in the local video element.
    - Remote stream arrives in the `track` event and is shown in the remote video element.
    - Connection lifecycle events (`connecting`, `connected`, `disconnected`, `failed`, `closed`) update UI and cleanup state.
    - Optional RTC data channel events (`open`, `message`, `close`, etc.) are handled in the same callback.

In short: **Realtime is used for signaling (who is online, incoming call events), and WebRTC is used for the actual peer-to-peer media/data connection.**

### 1. Basic UI Interface

Here we add video elements to display local and remote streams, and a simple dialog for incoming/outgoing calls.
The `call_dialog` &lt;dialog&gt; content is generated dynamically when an RTC call event occurs.

```html
<!-- skapi should be previously initialized... -->

<style>
    video {
        width: 320px;
        height: 240px;
        border: 1px solid black;
    }
</style>

<!-- Video elements for displaying local and remote media streams -->
<video id="localVideo" autoplay muted></video>
<video id="remoteVideo" autoplay hidden></video>

<!-- Dialog used for outgoing and incoming calls -->
<dialog id="call_dialog"></dialog>
```

### 2. Setting Up Realtime and RTC

Below is a simple example of setting up an RTC video call.

```js
let call = null; // This will later be set to the resolved RTC connection object.

// This RTCCallback is used in connectRTC() on both the calling and receiving sides.

function RTCCallback(e) {
    console.log("RTC Callback:", e);

    switch (e.type) {
        // RTC Events
        case "negotiationneeded":
            // RTC negotiation is needed. Send an offer.
            break;

        case "track":
            // Attach the incoming media stream to the remote video element.
            document.getElementById('remoteVideo').srcObject = e.streams[0];
            document.getElementById("remoteVideo").play();
            break;

        case "connectionstatechange":
            // RTC connection state

            if (
                e.state === "disconnected" ||
                e.state === "failed" ||
                e.state === "closed"
            ) {
                // RTC has disconnected. Hide the opponent video element.
                document.getElementById('remoteVideo').hidden = true;

            } else if (e.state === "connecting") {
                // Callback executed when the user is connected to RTC.
                // Show the opponent video element.
                document.getElementById('remoteVideo').hidden = false;
            }
            break;

        // Data Channel Events
        case "close":
            // Data channel is closed
            break;
        case "message":
            // Data channel message received from remote peer.
            console.log(`Message received from RTC data channel:`, e.data);
            break;
        case "open":
            // Data channel opened
            break;
        case "bufferedamountlow":
            // Data channel low buffer
            break;
        case "error":
            // Data channel error
            break;
    }
}

// Realtime event listener
// This callback listens for users joining the room.
async function RealtimeCallback(rt) {
    if(rt.type === 'notice') {
        if(rt.code === 'USER_JOINED') {
            if(call) return;

            // When another user joins the room, start an RTC call.
            if(skapi.user.user_id !== rt.sender) {
                call = await skapi.connectRTC({cid: rt.sender_cid, media: { audio: true, video: true}}, RTCCallback);

                call_dialog.innerHTML = /*html*/`
                    <p>Outgoing call</p>
                    <button onclick="call.hangup(); call_dialog.close();">Reject</button>
                `;

                call_dialog.showModal(); // Display outgoing call dialog

                rtcConnection = await call.connection; // Save resolved RTC connection object
                document.getElementById('localVideo').srcObject = rtcConnection.media; // Show outgoing local media stream
            }
        }
    }

    if (rt.type === 'rtc:incoming') {
        // incoming RTC call
        call = rt;

        call_dialog.innerHTML = /*html*/`
            <p>Incoming call</p>
            <button onclick='
                call.connectRTC({ media: {audio: true, video: true} }, RTCCallback)
                    .then(rtc => {
                        rtcConnection = rtc; // Save resolved RTC connection object
                        document.getElementById("localVideo").srcObject = rtcConnection.media; // Show outgoing local media stream
                        call_dialog.close();
                    })
            '>Accept</button>
            <button onclick="call.hangup(); call_dialog.close();">Reject</button>
        `;

        call_dialog.showModal(); // Display incoming call dialog
    }

    else if (rt.type === 'rtc:closed') {
        // RTC connection is completely closed
        call = null;
    }
}

skapi.connectRealtime(RealtimeCallback); // Connect to Realtime
skapi.joinRealtime({ group: 'RTCCall' }); // Join a Realtime group
```

# Notifications <a id="doc-notification-send-notifications"></a>

Skapi provides methods to manage push notifications, including subscribing, unsubscribing, and sending notifications. This guide explains how to implement push notifications using Skapi from the client side.

:::danger HTTPS REQUIRED.
Notifications only work in an HTTPS environment.
You need to set up HTTPS when developing notification features for your web application.

You can host your application on skapi.com or on your own servers.
:::

## Setting Up Service Worker

The first step for web push notifications is to set up a service worker. In this guide, we use a file named `sw.js` in the project folder. The service worker handles incoming notifications and user interactions in the background, so notifications can still arrive even when the site is closed. This file must exist in your project and be registered correctly, or push notifications will not work.

### sw.js
```js
self.addEventListener('push', function(event) {
    const data = event.data.json();
    const title = data.title || "Default Title";
    const options = {
        body: data.body || "Default Body",
        icon: 'icon-192x192.png',
        badge: 'icon-192x192.png'
    };

    event.waitUntil(
        self.registration.showNotification(title, options)
    );
});

self.addEventListener('notificationclick', function(event) {
    event.notification.close();
    let url = event.target.location.origin;
    event.waitUntil(
        clients.openWindow(url)
    );
});
```

## Subscribing to Notifications

To receive push notifications, users must subscribe first. This requires a VAPID key and a registered service worker. You can get the VAPID key by calling [`vapidPublicKey()`](#doc-api-reference-realtime-vapidpublickey), then subscribe by calling [`subscribeNotification()`](#doc-api-reference-realtime-subscribenotification).

### Steps:
1. Retrieve the VAPID key using [`vapidPublicKey()`](#doc-api-reference-realtime-vapidpublickey).
2. Request notification permission from the user.
3. Add the service worker file (`sw.js`) to your project.
4. Register the service worker.
5. Subscribe to push notifications using `navigator.serviceWorker.pushManager.subscribe()`.
6. Send the subscription details to Skapi using [`subscribeNotification()`](#doc-api-reference-realtime-subscribenotification).

### Code Example:
```js
/**
 * Converts a Base64 string into a Uint8Array, required for push subscriptions.
 * This function ensures correct padding and decoding.
 */
function urlBase64ToUint8Array(base64String) {
    // Ensure the Base64 string has the correct padding
    const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
    const base64 = (base64String + padding)
        .replace(/-/g, "+") // Convert URL-safe characters back
        .replace(/_/g, "/");

    // Decode Base64 to binary
    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);

    // Convert binary data into a Uint8Array
    for (let i = 0; i < rawData.length; ++i) {
        outputArray[i] = rawData.charCodeAt(i);
    }

    return outputArray;
}

// Retrieve the VAPID public key from Skapi
const vapidResponse = await skapi.vapidPublicKey();
const vapid = vapidResponse.VAPIDPublicKey;

// Check if the browser supports service workers
if (!("serviceWorker" in navigator)) {
    alert("Service workers are not supported in this browser.");
    return;
}

// Request notification permission from the user
const permission = await Notification.requestPermission();
if (permission !== "granted") {
    alert("Permission not granted for notifications");
    return;
}

// Register the service worker (sw.js should handle push events)
const registration = await navigator.serviceWorker.register("/sw.js");

// Ensure the service worker is ready
await navigator.serviceWorker.ready;

// Subscribe to push notifications using the VAPID public key
const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true, // Ensures notifications are always visible to the user
    applicationServerKey: urlBase64ToUint8Array(vapid), // Convert VAPID key to Uint8Array
}).then(sub => sub.toJSON()); // Convert subscription object to JSON format

// Log the subscription object for debugging
console.log("Subscription object:", subscription);

// Send the subscription details to Skapi to store the user's push subscription
await skapi.subscribeNotification(subscription.endpoint, subscription.keys);
```

## Unsubscribing to Notifications

To stop receiving notifications, users must unsubscribe by calling [`unsubscribeNotification()`](#doc-api-reference-realtime-unsubscribenotification) with the endpoint and keys.

### Steps:
1. Retrieve the current push subscription from `navigator.serviceWorker.ready.pushManager.getSubscription()`.
2. If a subscription exists, call `unsubscribe()` on it.
3. Notify Skapi by calling [`unsubscribeNotification()`](#doc-api-reference-realtime-unsubscribenotification).

### Code Example:
```js
// Ensure the service worker is ready before interacting with push notifications
const registration = await navigator.serviceWorker.ready;

// Retrieve the current push subscription (if it exists)
const subscription = await registration.pushManager.getSubscription();

// Check if there is an active subscription
if (!subscription) {
    console.error("No subscription found"); // Log an error if the user is not subscribed
    return;
}

// Convert the subscription object to a plain JSON format
const subscriptionJSON = subscription.toJSON();

// Unsubscribe the user from push notifications
await subscription.unsubscribe();

// Inform Skapi to remove this subscription from its records
const response = await skapi.unsubscribeNotification(subscription.endpoint, subscriptionJSON.keys);
```

## Sending Notifications <a id="doc-notification-send-notifications-sending-notifications"></a>

You can let users send notifications to each other with [`postRealtime()`](#doc-api-reference-realtime-postrealtime).
If the recipient is not connected to Realtime, set the `notification` argument to send a push notification message.

```js
skapi.postRealtime(
    { msg: "Hello World!" },
    'recipient_user_id',
    {
        title: "New Message",
        body: "Someone sent you a message!"
    }
).then(res => console.log(res));
```

If the recipient has subscribed to the Push Notification API, they will receive the message set in the `notification` argument.

The example below shows how to send a notification regardless of whether the recipient is connected to Realtime. By setting `config.always` to `true`, a notification is always triggered for the recipient.

```js
skapi.postRealtime(
    { msg: "Hello World!" },
    'recipient_user_id',
    {
        title: "New Message",
        body: "Someone sent you a message!",
        config: { always: true }
    }
).then(res => console.log(res));
```

## Notifications from Records <a id="doc-notification-send-notifications-notifications-from-records"></a>

Users can also be notified about records: when a user they subscribed to posts a record with `table.subscription.notify_subscribers`, or when someone references a record posted with `table.subscription.notify_referencing_records`. The subscriber turns these on with `get_notified: true` in [`subscribe()`](#doc-api-reference-database-subscribe) and registers the device as shown above. See [Subscription notifications](#doc-database-subscription-notifications) for who is notified and when.

These push messages carry the record beside `title` and `body`:

```ts
{
    title: string;
    body: string;
    type: "record" | "reference"; // "record": a new record. "reference": a new record referencing one of the user's records.
    record_id: string; // The new record.
    table: string; // Table name of the new record.
    user_id: string; // User who posted the new record.
    reference?: string; // Only for "reference": the record that was referenced.
}
```

Your service worker can use them to open the record when the notification is clicked. For example, with a page `/record.html?id=...` in your app:

### sw.js
```js
self.addEventListener('push', function(event) {
    const data = event.data.json();
    const options = {
        body: data.body,
        icon: 'icon-192x192.png',
        badge: 'icon-192x192.png',
        // Kept on the notification for the click handler below.
        data: { record_id: data.record_id || null, reference: data.reference || null }
    };

    event.waitUntil(
        self.registration.showNotification(data.title, options)
    );
});

self.addEventListener('notificationclick', function(event) {
    event.notification.close();
    const record = event.notification.data || {};
    // A reference opens the record that was referenced (the post that got a comment).
    const id = record.reference || record.record_id;
    const url = id
        ? self.location.origin + '/record.html?id=' + encodeURIComponent(id)
        : self.location.origin;
    event.waitUntil(
        clients.openWindow(url)
    );
});
```

## Sending Notifications (For Admins) <a id="doc-notification-send-notifications-sending-notifications-for-admins"></a>

Admins can use the [`pushNotification()`](#doc-api-reference-realtime-pushnotification) method to send notifications to users.
**Only admins** can use this method to send notifications to **all** users.
You can also target a specific user or multiple users by providing their IDs, up to 1000 per call. Each of them gets the notification on every device they registered. If no user IDs are specified, the notification will be sent to all users who have subscribed to notifications in your system.

### Steps:
1. Call [`pushNotification()`](#doc-api-reference-realtime-pushnotification) with the title and body.
2. Optionally, provide user IDs to send notifications to specific users.
3. If no user IDs are provided, the notification will be sent to all subscribers.

### Code Examples:
```js
skapi.pushNotification(
    {
        title: "Hello",
        body: "Hi there!"
    }
); // Sends to all subscribers
```

```js
skapi.pushNotification(
    {
        title: "Admin Alert",
        body: "Only for selected users"
    },
    ["user1", "user2"]
);
```

# Automated Emails <a id="doc-email-email-templates"></a>

When the user signup, reset password, or change email, subscribes to public newsletters, get invited to your project,
the system will send an automated email to the user.
You can customize the email template of these automated emails by sending your templates to the email endpoints.

E-Mail endpoints can be found in your `Automated Emails` page in your Skapi admin page.

:::warning Register an email alias first
Automated emails are sent from your project's **email alias**, so a template can only be set once the project has one. Register it in your project's `Settings` page. Until then the `Automated Emails` page asks you to register it, and a template sent to an endpoint is not saved: Skapi replies to your address with a notice saying that an email alias is needed. Templates you already set keep being sent.
:::

:::warning Your address must pass the sender trust check
Skapi only accepts a template from an address whose mail passes **SPF, DKIM and DMARC**, the checks that prove an email really comes from the address it names.
Mail that fails any of them, or comes from a domain that publishes none of them, is treated as suspicious and is not processed. Skapi replies to your address with a notice that lists each check and its result.
If you get that notice, ask your email provider, or whoever manages your domain, to check the trust settings (SPF, DKIM and DMARC) of your address, then send it again.
Addresses at the large email providers usually pass these checks already.
:::

:::warning Templates and the sending address are project settings
Setting or deleting a template, and choosing the address your automated emails are sent from, belong to the **project owner's Skapi account**, and to Skapi staff when you ask Skapi for help. An [admin](#doc-admin-permissions-project-settings-belong-to-the-project-owner) of your project, access group `99` included, is refused with `INVALID_REQUEST` and `Only the project owner can change project settings.`

A template is set by sending it **from the project's email address**, the project owner's, to the endpoint the `Automated Emails` page shows. Mail from any other address is not processed. When that address passes the sender trust check below, Skapi replies to it that only the project owner can set templates.
:::

In the `Automated Emails` page, select an email type you want to set the template.

- **Signup Confirmation**

  Sent when a user signs up with `signup_confirmation` on in [`signup()`](#doc-api-reference-authentication-signup), or when they call [`resendSignupConfirmation()`](#doc-api-reference-authentication-resendsignupconfirmation). It carries the link that activates the account.

- **Welcome E-Mail**

  Sent at a user's first login if their email address is already verified, for example after a confirmed signup, an accepted invitation, or a first [`openIdLogin()`](#doc-api-reference-authentication-openidlogin).

- **Verification E-Mail**

  Sent with a one-time code by [`verifyEmail()`](#doc-api-reference-user-verifyemail) for an unverified email address, or by [`forgotPassword()`](#doc-api-reference-authentication-forgotpassword) for a verified one.

- **Invitation E-Mail**

  Sent by [`inviteUser()`](#doc-api-reference-admin-inviteuser) and [`resendInvitation()`](#doc-api-reference-admin-resendinvitation), with the generated password and the link that accepts the invitation.
  You can also invite users from the `Users` page in your admin page in Skapi website.

- **Newsletter Subscription**

  Sent when a visitor who is not logged in subscribes to your public newsletter, with a link to confirm the subscription.

- **Custom Email**

  Sent by a ticket's [Send e-mail action](#doc-tickets-actions-mail-send-an-e-mail) to the address the ticket names, with the values the ticket fills in. See [Custom e-mails](#doc-email-email-templates-custom-e-mails).

The tabs on the page are named **Signup**, **Welcome**, **Verification**, **Invitation**, **Newsletter** and **Custom**; on a narrow screen they are a select. Once you select one, the page shows:

- **Set Template**: the template setter, the email endpoint that takes templates for the selected type. Click the address to copy it, or click **[Compose]** to open a new message to it in your mail app. Every type has its own address.
- **Placeholders**: the placeholders the selected type fills in, split into **Required** and **Optional**.
- **Current template**: the subject of the template in use, or `Built-in default`, and when this type of email is sent. **[Preview]** opens it.

Below them is the list of the templates you have sent for that type: the subject, the **Template ID** (which a ticket's Send e-mail action names for a custom template; the column copies it with a click and can be hidden with the column picker) and when it was received. On every tab but **Custom**, the template your project sends carries an **In use** chip next to its subject.

![The Automated Emails page on the Signup tab: the Set Template address with Compose and Upload links, the required and optional placeholders, the current template with a Preview link, and the list of uploaded templates with their subject, an In use chip on one of them, Template ID and date](https://docs.skapi.com/screenshots/mail-page.webp)

*The Automated Emails page. The Set Template address takes templates for the selected tab.*

### Placeholder information <a id="doc-email-email-templates-placeholder-information"></a>

Hover over a placeholder, or move to it with the keyboard, to see what it turns into for the selected type. Click it to copy it, then paste it into your template.

![A tooltip over the ${name} placeholder: the user's name from their profile, or empty when they have not set one, with Click to copy under it](https://docs.skapi.com/screenshots/mail-placeholders.webp)

*Every placeholder explains itself and copies with one click.*

See [Template Placeholders](#doc-email-email-templates-template-placeholders) for the full list per type.

### Choosing the template in use

Every template you send is added to the list of its type, newest first, with its subject and when it was received. Sending a template never changes the one in use: whichever tab's address you send to, the template is only listed, and nothing changes until you choose it here. The **In use** chip marks the one your project sends. To switch, click the row of another template to open its preview, check it, and click **Use as Signup Confirmation template** (the button names the type) under the preview; a confirmation follows. On the template in use the button reads **In use**.

![The preview of a template that is not in use, with a Use as Signup Confirmation template button under the rendered email](https://docs.skapi.com/screenshots/mail-use-template.webp)

*Choose a template from its preview, where you can see what you are choosing.*

![The Set as template confirmation: use "Your order ${order} is on its way" as the Signup Confirmation template, with Cancel and Confirm buttons](https://docs.skapi.com/screenshots/mail-set-template.webp)

*New emails of this type use the chosen template right away. The template it replaces stays in the list.*

![The template list after the change: the newest template carries the In use chip and Current template shows its subject](https://docs.skapi.com/screenshots/mail-in-use.webp)

*The chosen template is marked In use, and Current template shows its subject.*

To remove templates, tick their rows and click the trash icon. Deleting the template in use sends that type back to the built-in default.

### Opening a template

Click a row of the list, or **[Preview]** on the **Current template** row, to open the template the way a recipient sees it. Under the preview, the button chooses the template for the tab's type of email, or reads **In use** on the one already chosen. **Fill placeholders** is on when the preview opens, and swaps every placeholder, in the subject and in the body, for a sample value taken from your own account and project.

![The template preview with Fill placeholders on: the subject reads Welcome to Padaria Aurora, Marina, the body greets Marina by name with her email address, and the button under it reads In use](https://docs.skapi.com/screenshots/mail-preview-filled.webp)

*Fill placeholders on: the email as a user would receive it.*

Turn **Fill placeholders** off to see the template as you wrote it, with every placeholder in place. This is the quickest way to check that a template carries the placeholders its type needs.

![The same preview with Fill placeholders off: ${service_name}, ${name} and ${email} appear as written](https://docs.skapi.com/screenshots/mail-preview-raw.webp)

*Fill placeholders off: the raw template.*

### Sending a template <a id="doc-email-email-templates-sending-a-template"></a>

Following example shows the format for email endpoints:

```
tpxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxx@mail.skapi.com
```

Endpoints in the older `xxxxxxxxxxxxxxxxxxxx-tpxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@mail.skapi.com` format keep working, so a saved address does not need to be updated.

To customize the email template, write it as an ordinary email in your own mail app and send it to the endpoint address:

1. Put the **Set Template** address of the type in **To**. **[Compose]** fills it in for you.
2. Write the **subject** and the **body**. The subject of your email becomes the subject of the automated email, and both can hold placeholders.
3. For the types that carry a link, select the link text and set its URL to **`https://link.skapi.com`**, using your mail app's insert link or edit link tool.
4. Send it **from your project's email address**.

![A mail compose window: From the project owner's address, To the Set Template address, a subject with ${service_name}, a body with ${name} and ${email}, and an Edit link dialog setting the link URL to https://link.skapi.com](https://docs.skapi.com/screenshots/mail-compose-template.webp)

*Composing a signup confirmation template. Any mail app with a link tool works the same way.*

Skapi checks the template when it arrives (see [When a template is rejected](#doc-email-email-templates-when-a-template-is-rejected)) and replies to you by email.
A template that passes is added to the list for its type: choose it there to use it.

:::danger
- **DO NOT** share your email endpoint address with anyone. This endpoint is unique to your project and should be kept private.
- You must use the same email address that you used to signup to Skapi.
:::

### Uploading a template <a id="doc-email-email-templates-uploading-a-template"></a>

A template does not have to come by e-mail. **[Upload]**, next to **[Compose]** on the **Set Template** row, takes it from your computer, as an **HTML file or as plain text**:

1. Give the template a **subject**. It can hold placeholders, like a mailed subject.
2. Pick the **body**:
   - **HTML file**: an `.html` file saved from your mail or page editor. It is stored as it is, inline styles and images included. The HTML must be **e-mail HTML**, the kind every mail client renders: text with markup in it, no `<script>`, and every element that has to be closed closed where it should be. A page that leans on scripts or on external stylesheets does not work as an e-mail.
   - **Plain text**: typed in, or loaded from a `.txt` file. It goes out as a simple HTML e-mail: each blank line starts a paragraph, and web addresses become links, so the link placeholder written as `https://link.skapi.com` becomes a clickable link.
3. A template of a type that has [required placeholders](#doc-email-email-templates-required-placeholders-for-signup-confirmation-email) must contain **all of them**, in either form: the signup confirmation and the newsletter confirmation need the link, the verification e-mail `${code}`, the invitation the link, `${email}` and `${password}`.
4. The dialog checks all of this as you go, the way the backend checks a mailed template: the subject, the required placeholders, the size (up to 240 KB; inline images do not count) and the HTML. A problem is named with its line; **Upload** stays off until it is fixed. A placeholder the type never fills in is pointed out as a warning, because it would go out empty. **[Preview]** shows the body with sample values.

![The Upload a Signup Confirmation Template dialog: a subject with ${name}, Plain text chosen, a short text body with ${email} and https://link.skapi.com, a Checked line with the size, and the preview rendered below](https://docs.skapi.com/screenshots/mail-upload.webp)

*A signup confirmation template from plain text, checked and previewed.*

![The same dialog with an HTML file chosen and two problems listed in red: a span opened on line 10 is still open where the div closes on line 11, and the template is missing the required placeholder https://link.skapi.com; the Upload button is disabled](https://docs.skapi.com/screenshots/mail-upload-problems.webp)

*A file the checks refuse. The backend makes the same checks again and answers with the same sentences.*

An uploaded template is stored exactly as a mailed one, in the list of its type with a Template ID. It is **not in use** until you choose it there, like any other. On the Custom tab the upload is the template a ticket's Send e-mail action names.

**From an AI agent.** The [Skapi MCP server](#doc-introduction-getting-started-connecting-the-skapi-mcp-server)'s `manageEmailTemplates` tool lets the project owner's agent compose a template and upload it the same way, as e-mail HTML or plain text, under the same checks, and list the templates of a type. It is available to the project owner's account only, and it never puts a template in use: you choose it on this page.

## Custom e-mails <a id="doc-email-email-templates-custom-e-mails"></a>

The **Custom** tab holds templates that no event sends on its own: a ticket sends them, with its [Send e-mail action](#doc-tickets-actions-mail-send-an-e-mail), to the address the ticket names. An order confirmation, a receipt, a notice to a member who just unlocked something: anything a webhook or a signed-in call should trigger.

![The Automated Emails page on the Custom tab: the Set Template address with Compose and Upload links, the optional placeholders, a Sending row reading By a ticket, and a list of templates with their subject, their template ID with a copy button, and when each was received](https://docs.skapi.com/screenshots/mail-custom.webp)

*The Custom tab. Every template has a template ID, which a Send e-mail action names.*

- Send a template to the tab's **Set Template** address the way you send any other, from your project's email address. The reply names its **template ID**, and the list shows the ID next to the subject with a button that copies it.
- No template carries an **In use** chip and the preview offers no **Use as** button: a custom template is never the current template of anything, and nothing is sent until a ticket names it. The tab keeps up to **100** templates; the next one is refused with a reply. Delete templates you no longer need by ticking their rows and clicking the trash icon. A ticket whose template was deleted fails that action when it runs.
- **Placeholders.** `${service_name}` and `${email}`, the recipient, are filled in by default. Any other `${name}` you write, such as `${order}` or `${name}`, is filled in by the ticket, which gives the values under `placeholders`; a name the ticket does not give stays as written. None is required.
- Each e-mail a ticket sends counts as one e-mail send of the month, like a newsletter to one subscriber. See [Sending limits](#doc-email-newsletters-sending-limits).

## Overriding the template for a single call <a id="doc-email-email-templates-overriding-the-template-for-a-single-call"></a>

The template you set in the `Automated Emails` page is your project's template: every e-mail of that
type uses it. Some methods also take a `template` option that replaces it for one call only, which is
useful when a single flow needs different wording, branding, or a different language from the rest of
your project.

Each value is the `message_id` of a template you have already sent to the e-mail endpoint. Open the
`Automated Emails` page and select the e-mail type: every template you have uploaded is listed there
with its `message_id`.

The e-mail that actually goes out is chosen in this order:

1. the `message_id` passed in the `template` option of the call
2. your project's template for that e-mail type
3. Skapi's built in default

Methods that accept it:

| Method | Argument | Type |
|---|---|---|
| [`signup()`](#doc-api-reference-authentication-signup) | `options.template` | `{ signup_confirmation?: string; welcome?: string }` |
| [`verifyEmail()`](#doc-api-reference-user-verifyemail) | `options.template` | `{ verification?: string }` |
| `verifyPhoneNumber()` | `options.template` | `{ verification?: string }` |
| [`forgotPassword()`](#doc-api-reference-authentication-forgotpassword) | `options.template` | `{ verification?: string }` |
| [`openIdLogin()`](#doc-api-reference-authentication-openidlogin) | `params.template` | `{ welcome?: string }` |

Every value is an optional non empty `string`. Any other type, including an empty string, throws
`INVALID_PARAMETER`. Keys you leave out simply fall through to your project's template, so you can
override the welcome e-mail while leaving the signup confirmation alone.

:::warning
`option.template.signup_confirmation` requires `option.signup_confirmation` to be set on the same
call. Without it there is no confirmation e-mail to apply the template to, and the call throws
`INVALID_PARAMETER`.
:::

Overriding the signup confirmation and welcome e-mails:

```js
skapi.signup(
    { email: 'user@email.com', password: 'password' },
    {
        signup_confirmation: true,
        template: {
            signup_confirmation: 'signupconfirmationtemplateidxxxxxxxxxxxx',
            welcome: 'welcometemplateidxxxxxxxxxxxxxxxxxxxxxxx'
        }
    }
).then(res => {
    console.log(res); // SUCCESS: The account has been created. User's signup confirmation is required.
});
```

Overriding the verification e-mail. `verifyEmail()` with no code issues a new one, so the template
applies there; when you pass the code back to verify it, no e-mail is sent and the option is ignored:

```js
skapi.verifyEmail(undefined, {
    template: { verification: 'verificationtemplateidxxxxxxxxxxxxxxxxxx' }
}).then(res => {
    console.log(res); // SUCCESS: Verification code has been sent.
});
```

The same option covers the password reset code, since it is sent as a verification e-mail:

```js
skapi.forgotPassword(
    { email: 'user@email.com' },
    { template: { verification: 'verificationtemplateidxxxxxxxxxxxxxxxxxx' } }
).then(res => {
    console.log(res); // SUCCESS: Verification code has been sent.
});
```

If the `message_id` given for a verification e-mail cannot be found in your project, Skapi falls back
to your project's template rather than failing the call, and the e-mail is still sent.

## Template Placeholders <a id="doc-email-email-templates-template-placeholders"></a>

E-Mail templates take placeholders: text that Skapi replaces with a value when the email is sent.
Each email type fills in its own set. A **required** placeholder must appear in the template, or the template is rejected when it arrives.
An **optional** one is filled in wherever you use it.

| Email type | Required | Optional |
|---|---|---|
| Signup Confirmation | `https://link.skapi.com` | `${email}`, `${name}`, `${service_name}` |
| Welcome E-Mail | none | `${email}`, `${name}`, `${service_name}` |
| Verification E-Mail | `${code}` | `${email}`, `${name}`, `${service_name}` |
| Invitation E-Mail | `https://link.skapi.com`, `${email}`, `${password}` | `${name}`, `${service_name}`, `${username}` |
| Newsletter Subscription | `https://link.skapi.com` | `${email}`, `${service_name}` |
| Custom Email | none | `${email}`, `${service_name}`, and any `${name}` of your own that the ticket fills in |

- **`${service_name}`**: Name of your project.
- **`${name}`**: User's name from the profile. If the user has not set their name, it will be replaced with empty string.
- **`${email}`**: The address the e-mail is sent to: the user's email address, or the address a subscription confirmation goes to.

A placeholder's value is always inserted as **text**. A user's name, email address or password can hold any characters, and characters such as `<`, `>` and `&` show exactly as the user typed them, so a value can never add a link, an image or any other markup to your template. In the subject line, a line break in a value becomes a space.

The newsletter subscription confirmation is sent to an email address rather than to a user account, so it has no `${name}`; its `${email}` is that address.

In the `Automated Emails` page, hover over a placeholder to see what it turns into for the selected type, and click it to copy it. See [Placeholder information](#doc-email-email-templates-placeholder-information).

## The link placeholder <a id="doc-email-email-templates-the-link-placeholder"></a>

Signup confirmation, invitation and newsletter subscription emails carry a link. Put **`https://link.skapi.com`** in your template as the URL of a link, and Skapi replaces it with the real link when the email is sent.

## When a template is rejected <a id="doc-email-email-templates-when-a-template-is-rejected"></a>

Skapi checks every template it receives before saving it. A template is rejected when:

- it is missing one or more of the required placeholders for its type, or
- its HTML is larger than 240kb. Images and attachments are stored separately and do not count toward this.

A rejected template is not saved and does not appear in the template list. Skapi replies to the address that sent it with an email that names the template type and the subject, and lists every required placeholder that was missing and what each one is for. Add the missing placeholders and send the template again to the same endpoint.

A template that passes gets a reply saying it has been uploaded. It is not used until you select it in the `Automated Emails` page.

A template **uploaded** from the dashboard goes through the same checks, in the dialog before it is sent: a missing placeholder, HTML that is not e-mail HTML, or a body over the size is named there, and the upload does not start until it is fixed.

## Required Placeholders for signup confirmation email <a id="doc-email-email-templates-required-placeholders-for-signup-confirmation-email"></a>

When sending signup confirmation email, you must include a link with **`https://link.skapi.com`** as its URL in your email content.
The placeholder URL **`https://link.skapi.com`** will be replaced with the actual link that confirms the user's signup.

Select the link text in your mail app, open its insert link or edit link tool, and set the web address to **`https://link.skapi.com`**. The example in [Sending a template](#doc-email-email-templates-sending-a-template) does exactly this, with **`${service_name}`** in the subject and **`${name}`** and **`${email}`** in the body.

## Required Placeholders for verification email

When sending verification email, you must include **`${code}`** placeholders in your email content.
The **`${code}`** placeholder will be replaced with the verification code that the user can use to verify their email.

Example:
```
Your verification code is: ${code}
```

## Required Placeholders for invitation email <a id="doc-email-email-templates-required-placeholders-for-invitation-email"></a>

Below are the required placeholders for invitation email.

- **`https://link.skapi.com`**: Link to accept the invitation.
- **`${email}`**: Invited person's login email.
- **`${password}`**: Temporary password for the invited person.

When user clicks on the link, they will be able to login with the temporary password.

You can invite users to your project from the user page in your project page in Skapi website.

### Optional placeholders <a id="doc-email-email-templates-optional-placeholders"></a>

These are not required. A template that leaves them out keeps working exactly as before.

- **`${name}`**: The invited person's name. Worth including to make the e-mail less anonymous.
- **`${username}`**: The account's permanent login username, when the invitation was created with one.
  For an invitation with no username it renders the invited person's e-mail address instead, which is
  that account's login ID, so a line such as `Sign in with ${username}` reads correctly either way. It is
  never empty. A resent invitation renders the same value, except for an invitation sent before the
  backend started keeping the plain username with the invitation, whose resend renders the e-mail address.

Once an account invited with a username accepts the invitation, the e-mail address the invitation was
sent to logs it in as well as the username, because accepting verifies the address, so `${email}` is
correct for it too. The address does not log the account in while the invitation is pending, and if it
is already a login ID another account of the project was granted by the time the invitation is accepted,
email login is not enabled and only the username logs the account in. See
[Login IDs](#doc-admin-permissions-login-ids).

## Required Placeholders for public newsletter subscription confirmation email

When user subscribes to your public newsletters user receives subscription confirmation email.
The subscription confirmation email contains a link to confirm the subscription.

You must include a link with **`https://link.skapi.com`** as its URL in your email content. `${service_name}` and `${email}`, the address being subscribed, are the other placeholders this email fills in.

# Sending Newsletters <a id="doc-email-newsletters"></a>

You can send public newsletters or Service Email to your users by sending your email to the endpoint email address.
The following example shows the format for email endpoints for sending newsletters:

```
nl00xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxx@mail.skapi.com
```

Every newsletter endpoint starts with `nl`, followed by the group number (`00` for Newsletter, `01` for Service Email) and the address code, with your project ID after the dot.
Endpoints in the older `xxxxxxxxxxxxxxxxxxxx-00xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@mail.skapi.com` format keep working, so a saved address does not need to be updated.

Go to `Newsletters` page, select the tab (`Newsletter`, `Service Email` or a named group), and the page will show the email endpoint address to send the newsletter.

![The Newsletters page of a Skapi project: one tab per list, the group's name as code gives it, the sending address with a Send link, a Save without sending address with Compose and Upload links, monthly sends, subscribers with View and Add links, and the table of newsletters with their IDs](https://docs.skapi.com/screenshots/newsletter-page.webp)

*The Newsletters page. Every list has its own sending address, its own subscriber count and its own history of sent mail.*

:::warning Only the project owner sends newsletters
A newsletter is sent by the **project owner**, from the **project's email address**, to the endpoint address the `Newsletters` page shows.
Admins of your project, access group `99` included, cannot send newsletters.
Mail from any other address is not processed. When that address passes the sender trust check below, Skapi replies to it that only the project owner can send newsletters.
:::

:::tip Newsletters work on every plan, Trial included
A Trial project can collect newsletter subscribers and send newsletters to them.
The Trial plan includes **50 e-mail sends per month**, **50 newsletter subscribers** and **50 MB of e-mail storage**.

A send costs one e-mail send per subscriber it is delivered to.
One newsletter to a full list of 50 subscribers therefore uses all 50 sends of a Trial project, so the Trial allowance is one full send a month.

| Plan | E-mail sends per month |
| --- | --- |
| Trial | 50 per project, and 200 per owner across all their Trial projects |
| Standard | 5,000 per project |
| Premium | 50,000 per project, and past that billed as overage at $1.00 per 1,000 sends |

The per-project number is shown as **Monthly Email Sends** on your project's plan card in the Skapi dashboard.
Automated e-mails, such as the signup confirmation and the newsletter subscription confirmation, are not newsletter sends and do not count against this limit. **Invitation e-mails do count**: every invitation an admin sends or resends is one send, because it is mail the project chooses to send. See [Inviting Users](#doc-admin-invite).
On the Trial and Standard plans, a send past the monthly limit is refused with `Monthly email send limit reached. Consider upgrading your plan.`

Sending also stops on every plan once a project collects too many spam complaints, and a refused newsletter is always answered by a reply from the endpoint address. See [Sending limits](#doc-email-newsletters-sending-limits) below.
:::

## The Newsletters page

Everything about sending happens on the `Newsletters` page of your project in the Skapi dashboard.

- **Tabs.** `Newsletter` is the public list anyone can join with an email address, `Service Email` is the list of your signed-in users, and every [named group](#doc-email-newsletters-named-newsletters) you create gets a tab of its own. **+ New group** creates one. On a narrow screen the tabs are a select whose last option creates a group.
- **Send Newsletter.** The sending address of the selected list. Click the address to copy it, or click **[Send]** to open a new message to it in your mail app. Write the newsletter there, send it from your project's email address, and it goes out to every subscriber of that list with an unsubscribe link at the bottom.
- **Save without sending.** A second address of the list. A newsletter mailed to it is stored and listed with a **Not sent** mark, and goes out to nobody until you send it from the list or a ticket sends it. See [Saving a newsletter without sending it](#doc-email-newsletters-saving-a-newsletter-without-sending-it).
- **Monthly sends.** How many sends this month have used out of the plan's allowance, and the rules that can stop a send.
- **Subscribers.** How many addresses are on this list and on all lists together. **[View]** opens the list, **[Add]** adds addresses by hand. See [Managing subscribers](#doc-email-newsletters-managing-subscribers).
- **Access.** On a named group's tab, who may subscribe, and **[Delete group]**.
- **Sent newsletters.** Every newsletter sent to this list, and every one saved without sending, with its subject, its **Newsletter ID** (which a ticket's Send newsletter action names; the column copies it with a click and can be hidden with the column picker), the date, and how many subscribers read it, complained about it or bounced. Select rows and use the trash icon to delete them from your email storage. Copies already delivered stay in subscribers' inboxes.

The page needs an **email alias**, the address your project sends from. Register it on your project's `Settings` page first: until then the page shows a notice and sends you there.

### Writing and sending a newsletter

A newsletter is an ordinary email. Write it in your own mail app and send it to the list's sending address:

1. Put the sending address of the list in **To**. **[Send]** on the `Newsletters` page opens a new message with it filled in.
2. Write the **subject** and the **body** as you would write any email. Formatting, links and images are delivered as they are.
3. Send it **from your project's email address**, the address your Skapi account is registered with.

![A mail compose window: From the project owner's address, To the newsletter sending address, a subject reading Spring menu is here, a short newsletter with a list of items, a link and an attached image, and a Send button](https://docs.skapi.com/screenshots/newsletter-compose.webp)

*Composing a newsletter. Any mail app works the same way; there is no editor to learn.*

Skapi forwards the email to every subscriber of that list, adds an unsubscribe link at the bottom, and the newsletter appears under **Sent newsletters** with its subject, the date, and how many subscribers read it, complained or bounced. A newsletter that cannot be sent is answered by a reply from the sending address that says why.

### Saving a newsletter without sending it <a id="doc-email-newsletters-saving-a-newsletter-without-sending-it"></a>

Every list has a second address, shown under **Save without sending**, that starts with `nd` where the sending address starts with `nl`:

```
nd00xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxx@mail.skapi.com
nd.promo-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxx@mail.skapi.com
```

A newsletter mailed to it, from your project's email address like any other, is checked and stored the same way but delivered to nobody; the reply says so and names its **newsletter ID**. The row appears under **Sent newsletters** with **Not sent** in its Sent column.

![The Newsletters page with a Save without sending address under the sending address, its Compose and Upload links, a send icon and a delete icon above the table, and a Sent newsletters list with a Newsletter ID column whose first row reads Not sent](https://docs.skapi.com/screenshots/newsletter-not-sent.webp)

*A newsletter saved without sending. Tick it and click the send icon above the table when you are ready.*

- **Send it from the page.** Tick the newsletter, or several, and click the send icon at the top right of the table, next to the delete icon; the dialog lists them and says how many subscribers each goes to. A newsletter goes out exactly as one mailed to the sending address does, counts the same sends, and its mark goes. A newsletter that already went out can be ticked and sent again.
- **Send it from a ticket.** A ticket's [Send newsletter action](#doc-tickets-actions-nlsd-send-a-newsletter) names the list and the newsletter ID and sends it when the ticket is consumed: a launch mail at the time a webhook arrives, a reminder a signed-in call triggers. The action may also send again a newsletter that already went out.
- [`getNewsletters()`](#doc-api-reference-email-getnewsletters) returns `dry: true` on a newsletter saved this way, and `sent`, the time it was last sent from the page or by a ticket, once it went out.

### Uploading a newsletter <a id="doc-email-newsletters-uploading-a-newsletter"></a>

**[Upload]**, next to **[Compose]** on the **Save without sending** row, stores a newsletter from your computer instead of your mail app, as an **HTML file or as plain text**:

- **HTML file**: stored as it is, inline styles and images included (up to 240 KB; inline images do not count). It must be **e-mail HTML**, the kind every mail client renders: text with markup in it, no `<script>`, every element that has to be closed closed where it should be.
- **Plain text**: typed in, or loaded from a `.txt` file. Each blank line starts a paragraph and web addresses become links. `${name}`, `${email}` and `${service_name}` are filled in for each subscriber, as in a mailed newsletter.

The dialog checks the subject, the size and the HTML, naming any problem with its line, and **[Preview]** shows the newsletter with sample values.

![The Upload a Newsletter dialog: a subject, Plain text chosen, a short newsletter body with ${name} and a link, and a Checked line with the size](https://docs.skapi.com/screenshots/newsletter-upload.webp)

*A newsletter saved from plain text. It is listed as Not sent until you send it.*

An uploaded newsletter is stored exactly as one mailed to the Save without sending address: dry, sent to nobody, listed as **Not sent**, counted into the project's e-mail storage and never as a send. Send it from the list, or from a ticket's Send newsletter action, when it is ready.

**From an AI agent.** The [Skapi MCP server](#doc-introduction-getting-started-connecting-the-skapi-mcp-server)'s `manageNewsletters` tool lets the project owner's agent compose a newsletter and save it the same way, as e-mail HTML or plain text, and list a group's newsletters. It is available to the project owner's account only, and it never sends: the newsletter waits in the list as Not sent until you send it, or a ticket does.

## Subscribing Users To Public Newsletters

You can send public newsletters to your users by sending your email to the endpoint email.

First, the users must subscribe to the public newsletter to receive your public newsletters:

:::code-group
```html [Form]
<form onsubmit="skapi.subscribeNewsletter(event).then(res => alert(res))">
    <input type="email" name="email" placeholder='your@email.com'/>
    <input hidden name="redirect" value="https://your.domain.com/successpage"/>
    <input hidden name="group" value="public"/>
    <input type="submit" value="Subscribe"/>
</form>
```

```js [JS]
skapi.subscribeNewsletter({
    email: 'users@email.com',
    redirect: 'https://your.domain.com/successpage',
    group: 'public'
}).then(res => alert(res));
```
:::

The example above shows how to let your visitors subscribe to the public newsletter by calling [`subscribeNewsletter()`](#doc-api-reference-email-subscribenewsletter).

When the request is successful, user will receive a confirmation email to verify their email address.
User can confirm their email address by clicking the link in the email.
If the confirmation is successful, the user will be redirected to the redirect url provided in the [`subscribeNewsletter()`](#doc-api-reference-email-subscribenewsletter) parameter.

All the public newsletters will have unsubscribe link at the bottom of the email.
When the user clicks the unsubscribe link, they will no longer receive your public newsletters.
Each link is signed for the address it was sent to, so it cannot be edited to unsubscribe someone else.

:::tip A ready-made subscription form
The [Newsletter example](#doc-full-example-newsletter) of the full template is this form as a working page, already pointed at your project. Open it from **Application Examples** on your project's `Settings` page and link it, or copy its form into your own page.
:::

For more detailed information on all the parameters and options available with the [`subscribeNewsletter()`](#doc-api-reference-email-subscribenewsletter) method,
please refer to the API Reference below:

### [`subscribeNewsletter(params, callbacks):Promise<string>`](#doc-api-reference-email-subscribenewsletter)

:::warning
If the user is logged in, they will not be asked to confirm their email address.
Instead, they must have their [`email verifed`](#doc-user-account-email-verification).
:::

## Subscribing Users To Service Email <a id="doc-email-newsletters-subscribing-users-to-service-email"></a>

Service Email (group `1`, `authorized`) is the mailing list for your users with an account. To subscribe to Service Email the user must be logged in.
Service Email can be useful to send information, notifications, and other project-related emails.

First, user must subscribe to Service Email to receive the email.

:::warning
- User must be logged in to subscribe to your Service Email.
- User must have their email verified to subscribe to your Service Email.
:::

:::code-group

```html [Form]
<form onsubmit="skapi.subscribeNewsletter(event).then(res => alert(res))">
    <input hidden name="group" value="authorized"/>
    <input type="submit" value="Subscribe"/>
</form>
```

```js [JS]
skapi.subscribeNewsletter({
    group: 'authorized'
}).then(res => alert(res));
```
:::

The example above shows how to let your users subscribe to Service Email by calling [`subscribeNewsletter()`](#doc-api-reference-email-subscribenewsletter).

## Named Newsletters <a id="doc-email-newsletters-named-newsletters"></a>

Your service is not limited to a single mailing list.
You can create **named newsletter groups** on the `Newsletters` page, and each group keeps its own subscribers, its own sending address, and its own sent mail history.
For example, one service can run a `promo` list next to a `news` list, and a subscriber of one never receives the other.

A group name is 2 ~ 20 lowercase alphanumeric characters, has to contain at least one letter, and cannot be one of the reserved names: `tp`, `admin`, `public`, `authorized`, `newsletter`, `forward`, `all`, `true`, `false`, `null`.
A name that reads as a number in exponent notation, such as `1e5`, is refused as well.
`public` and `authorized` are reserved because they are the names of the two numeric groups: everywhere a group is given, including `email_subscription` at signup, `public` means group `0` and `authorized` means group `1`, so no named group can take them.

Every group is created with a `restriction`, which decides who may subscribe to it, and who may read its sent mail:

| `restriction` | In the dashboard | Type | Who can subscribe |
| --- | --- | --- | --- |
| `0` | Anyone | Named public newsletter | Anyone. Visitors subscribe with their email address and confirm it by email, exactly like the public newsletter. |
| `1` | Any signed in user | Named service newsletter | Any logged in user with a verified email, exactly like Service Email. |
| `2` ~ `99` | Access group and above | Named service newsletter | Logged in users with a verified email, whose access group is equal to or higher than the value. |

In short, a named **public** newsletter is open to your visitors, and a named **service** newsletter is open only to the users of your service.
That is the same difference as the one between the public newsletter and Service Email above, and the only thing that changes is that the list is addressed by name instead of by number.

The numeric groups are untouched by any group you create: `public` (group `0`), `authorized` (group `1`) and the access groups `2` ~ `99` keep working exactly as they do today.

### Creating a named group <a id="doc-email-newsletters-creating-a-named-group"></a>

Groups are created by the project owner on the `Newsletters` page. Click **+ New group**.

![The New Group dialog: a Name field, an optional Label field and a Subscribers choice of Anyone, Any signed in user or Access group and above](https://docs.skapi.com/screenshots/newsletter-group.webp)

*A named group: the name becomes part of its sending address, the label is what the tab shows, and Subscribers is the group's restriction.*

- **Name** is the group name described above. It becomes part of the group's sending address.
- **Label** is an optional display name, 60 characters at most, shown on the tab instead of the name.
- **Subscribers** is the restriction: **Anyone**, **Any signed in user**, or **Access group and above** with the access group number a user needs.

A service can hold up to 20 named groups.

:::warning
A group's name and restriction are fixed once it is created.
A name that already exists is refused, and changing a restriction means deleting the group and creating it again, which also deletes its subscribers.
:::

### Subscribing to a named newsletter

Subscribing works exactly as it does for the public newsletter and Service Email: give the group name where you would have given `public` or `authorized`.

:::code-group
```html [Form]
<form onsubmit="skapi.subscribeNewsletter(event).then(res => alert(res))">
    <input type="email" name="email" placeholder='your@email.com'/>
    <input hidden name="redirect" value="https://your.domain.com/successpage"/>
    <input hidden name="group" value="promo"/>
    <input type="submit" value="Subscribe"/>
</form>
```

```js [JS]
skapi.subscribeNewsletter({
    email: 'users@email.com',
    redirect: 'https://your.domain.com/successpage',
    group: 'promo'
}).then(res => alert(res));
```
:::

When the group's restriction is `0`, an anonymous subscriber receives a confirmation email and is redirected to the `redirect` url once the link is clicked, just like the public newsletter.
Named newsletters always carry an unsubscribe link at the bottom of the email.

:::warning
`email` and `redirect` are only for groups with `restriction: 0`.
To subscribe to a group with a higher restriction, the user must be logged in, must have their [`email verified`](#doc-user-account-email-verification), and their access group must be equal to or higher than the group's restriction.
Otherwise the request fails with `Access denied. Your access group is insufficient to subscribe to group "promo".`
:::

[`getNewsletterSubscription()`](#doc-api-reference-email-getnewslettersubscription), [`unsubscribeNewsletter()`](#doc-api-reference-email-unsubscribenewsletter) and [`getNewsletters()`](#doc-api-reference-email-getnewsletters) all take a group name in the same place:

```js
skapi.getNewsletterSubscription({ group: 'promo' }).then(subs => {
    // subscriptions of the 'promo' group
});

skapi.unsubscribeNewsletter({ group: 'promo' }).then(res => {
    // user is unsubscribed from the 'promo' newsletter
});

skapi.getNewsletters({ searchFor: 'timestamp', value: Date.now(), condition: '<', group: 'promo' }).then(newsletters => {
    // newsletters.list is an array of newsletters sent to the 'promo' group
});
```

### Subscribing at signup <a id="doc-email-newsletters-subscribing-at-signup"></a>

A user can be subscribed to a newsletter the moment they confirm their e-mail, by giving `email_subscription` to [`signup()`](#doc-api-reference-authentication-signup).
It takes the newsletter to subscribe to:

| `email_subscription` | Subscribes the user to |
| --- | --- |
| `0` or `'public'` | The public newsletter (group `0`) |
| `1` or `'authorized'` or `true` | Service Email (group `1`) |
| The name of a named group, such as `'promo'` | That named newsletter group |
| `false` or left out | Nothing |

```js
skapi.signup(
    { email: 'user@email.com', password: 'password' },
    { signup_confirmation: true, email_subscription: 'promo' }
).then(res => alert(res));
```

The subscription is made when the user opens the signup confirmation link, which also verifies their e-mail. No separate newsletter confirmation e-mail is sent: the signup confirmation is the confirmation.

:::warning
- `signup_confirmation` is required. A user whose e-mail is never confirmed is never subscribed.
- A named group has to exist when `signup()` is called, otherwise the call fails with `Newsletter group "promo" does not exist for this service.` If the group is deleted before the user confirms, or its restriction asks for an access group the new account does not have, the user is created without the subscription.
- Only `0` and `1` are accepted as numbers. The access groups `2` ~ `99` are not newsletters a signup can choose; create a named group with that restriction instead.
:::

### When a confirmation e-mail is sent

Whether a subscriber gets a newsletter confirmation e-mail depends on how they subscribe, not on the group:

| How the subscription is made | Confirmation e-mail |
| --- | --- |
| A visitor subscribes with their e-mail address, to the public newsletter or to a named group whose restriction is `0` | **Sent.** The subscription starts when the link in it is clicked, and the visitor is then taken to the `redirect` url. |
| A logged in user subscribes, to Service Email, to a named group or to the public newsletter | **Not sent.** The account's e-mail has to be [verified](#doc-user-account-email-verification) already. |
| `email_subscription` at signup | **Not sent.** The signup confirmation link is the confirmation: the subscription is made when it is opened. |
| The project owner adds addresses on the `Newsletters` page | **Not sent.** The addresses are subscribed right away. |

The confirmation e-mail is the newsletter subscription template of the [Automated Emails](#doc-email-email-templates) page.

### The sending address of a named group

A named group has an endpoint address of its own, starting with `nl.` and the group name:

```
nl.promo-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxx@mail.skapi.com
```

The older `xxxxxxxxxxxxxxxxxxxx-promo-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@mail.skapi.com` format keeps working too.

Select the group's tab on the `Newsletters` page. The **Send Newsletter** row shows the address, the **Subscribers** row shows how many addresses the group holds, and the **Access** row shows who may subscribe.

![A named group's tab on the Newsletters page: its name as code gives it, its own sending address, its subscriber count, an Access row reading Anyone can subscribe, and a Delete group link](https://docs.skapi.com/screenshots/newsletter-named-group.webp)

*A named group has its own address, its own count and an Access row.*

Send your email to that address, and it goes out to every subscriber of that group.

:::warning
The newsletter has to be sent **from your service's email address**, by the project owner.
An email that reaches the endpoint from any other address is refused, so knowing the address is not enough to send a newsletter as your service.
The address is shown once your project has an email alias.
:::

:::warning Your address must pass the sender trust check
Skapi only accepts a newsletter from an address whose mail passes **SPF, DKIM and DMARC**, the checks that prove an email really comes from the address it names.
Mail that fails any of them, or comes from a domain that publishes none of them, is treated as suspicious and is not processed. Skapi replies to your address with a notice that lists each check and its result.
If you get that notice, ask your email provider, or whoever manages your domain, to check the trust settings (SPF, DKIM and DMARC) of your address, then send it again.
Addresses at the large email providers usually pass these checks already.
:::

### Deleting a named group <a id="doc-email-newsletters-deleting-a-named-group"></a>

Only the project owner deletes a group. On the group's tab, click **[Delete group]** on the **Access** row and confirm.

![The Delete Group confirmation: the group will be permanently deleted along with its subscribers, the newsletters sent to it and its sending address](https://docs.skapi.com/screenshots/newsletter-delete-group.webp)

*Deleting a group removes its subscribers, the newsletters sent to it and its sending address with it.*

:::warning
Deleting a group deletes every subscription of that group and every newsletter sent to it.
Those subscribers and that sent mail are gone for good, and creating the same name again starts from an empty list.
:::

### Subscriber limit

Subscribers are counted per service, across every newsletter group together, named and numeric.

| Plan | Subscribers |
| --- | --- |
| Trial | 50 |
| Standard | 5,000 |
| Premium | 50,000 |

On the Trial and Standard plans, a subscription past the limit is refused with `Newsletter subscriber limit reached. Consider upgrading your plan.`

On the Premium plan the excess is metered instead of stopped: new subscribers keep being accepted, and the subscribers past the limit are billed as overage, currently $1.00 per 1,000 subscribers-month.

Subscribers a project already has are kept when it moves to the Trial plan, and newsletters can still be sent to them.
Only new subscriptions are refused while the count is over the Trial limit of 50.

### Sending limits <a id="doc-email-newsletters-sending-limits"></a>

Three things can stop a newsletter, whatever plan the project is on.

**The project's own monthly sends.** Sends are counted **per recipient**, not per newsletter: one newsletter to a full list of 50 subscribers uses 50 sends. A Trial project has 50 a month, Standard 5,000, Premium 50,000 with the excess billed. Only newsletter fan-out counts. Signup confirmations, e-mail verification, template setter mail and every other system e-mail are not newsletter sends.

**The per-owner Trial cap.** On top of each Trial project's own 50 sends, one owner can send **200 newsletter mails per calendar month (UTC) in total across every Trial project they have**. The counter is the same per-recipient count, added up over all of the owner's Trial projects. Standard and Premium projects are always counted per project and have no owner cap, so moving a project to a paid plan takes it out of the shared pool.

**The complaint shutoff, on every plan.** A project stops sending newsletters, and invitation e-mails with them, when, in the current UTC month, it reaches **5 spam complaints**, or a **complaint rate of 0.5 percent** once at least 100 mails have gone out, whichever comes first. Only newsletter sending stops: the project keeps working, every other e-mail it sends keeps working, and nothing about it is suspended or deleted. The block is not lifted by an upgrade and does not clear itself at the start of the next month. Skapi staff clear it after looking at the project, so [contact Skapi](mailto:support@broadwayinc.com) if your project is blocked.

You can watch this on the `Newsletters` page of the dashboard: every sent newsletter lists its complaints, its bounces and how many subscribers it reached.

**Every refusal is answered.** When a newsletter is refused, for the complaint shutoff, for the per-owner Trial cap or for the project's own monthly sends, the sender gets a reply **from the project's newsletter endpoint address** saying which limit was reached and what to do about it: send less, upgrade the plan, or contact Skapi when it is a complaint shutoff. Nothing is delivered to your subscribers in that case, and no sends are spent.

### Email storage

Sent newsletters are stored, and they count against the plan's email storage limit: 50 MB on Trial, 1 GB on Standard and 5 GB on Premium.

On the Trial and Standard plans, exceeding email storage deletes the oldest newsletters to reclaim space, and the project is never suspended for it.
On the Premium plan nothing is deleted and the excess is billed as overage.

A project that moves to the Trial plan with more than 50 MB of sent newsletters stored is therefore not suspended for them: its oldest newsletters are deleted until the rest fits.
The Premium plan never deletes a newsletter to make room.
See [Plans and Limits](#doc-introduction-plans) for what a suspended project can and cannot do.

## Managing subscribers <a id="doc-email-newsletters-managing-subscribers"></a>

This section is for the project owner and admins.
The users of your app only subscribe, check and unsubscribe their own address, as the sections above describe.

The project owner reads, searches and adds the subscribers of a list from the `Newsletters` page of the Skapi dashboard.
An admin reads them with [`getNewsletterSubscription()`](#doc-api-reference-email-getnewslettersubscription), and what an admin gets back depends on their access group.

### Viewing and searching subscribers

Select the list's tab and click **[View]** on the **Subscribers** row.

![The Subscribers dialog: the count for this group and for all groups, a search field, the list of addresses and Prev and Next paging](https://docs.skapi.com/screenshots/newsletter-subscribers.webp)

*The subscriber list of a group, sorted by e-mail address.*

The list is sorted by e-mail address and paged with **Prev** and **Next**.
The search field finds the subscribers whose e-mail address **starts with** what you type: `john` finds `john@example.com` and `johnny@example.org`, but not `bigjohn@example.com`.

![The Subscribers dialog after searching for "ana": only the addresses that start with ana are listed](https://docs.skapi.com/screenshots/newsletter-search.webp)

*Searching matches the start of the address.*

### Adding subscribers by hand

Not every subscriber comes through a form. To move a list from another tool, or to add customers who asked in person, click **[Add]** on the **Subscribers** row.

![The Add Subscribers dialog with three pasted addresses staged and a button reading Add 3 subscribers](https://docs.skapi.com/screenshots/newsletter-add.webp)

*Paste addresses, check the staged list, then add them.*

Paste the addresses, one per line or separated by commas, and click **Add to list**.
Malformed addresses are set aside and shown, duplicates are folded together, and the addresses that remain are listed so you can remove one before you click **Add**.

Addresses added this way are subscribed **right away, without a confirmation mail**, so add only people who have asked to hear from you.
Everyone else should come in through the subscription form, which confirms the address first.

The `Service Email` tab has no **[Add]**: its subscribers are your users, who subscribe and unsubscribe with their own account.

### Who can read the subscriber list <a id="doc-email-newsletters-who-can-read-the-subscriber-list"></a>

The **whole subscriber list** of a newsletter is available to the **project owner** and to **admins** (access groups `90` ~ `99`).

The project owner opens it on the `Newsletters` page, as shown above.

An admin gets the same list by calling [`getNewsletterSubscription()`](#doc-api-reference-email-getnewslettersubscription) with a `group` and no `user_id`.
The result is sorted by e-mail address and paginated like any `DatabaseResponse`:

```js
// signed in as an admin (access group 90 ~ 99)
skapi.getNewsletterSubscription({
    group: 'public'
}, { limit: 100 }).then(res => {
    const list = Array.isArray(res) ? res : res.list;
    // subscribers of the public newsletter
    // in access groups 90 ~ 98 a row comes back masked, with a token beside the mask:
    // { timestamp: 1000, group: 0, active: true,
    //   subscribed_email: "j**@**.com",
    //   subscriber_token: "2eUY7PRYvgv3sTKMoO2BuupbnS2o_6jDlU2p2o95lxXvgQsTpSFN4c5zfBfS" }
    if (!Array.isArray(res) && !res.endOfList) {
        // call again with { fetchMore: true } for the next page
    }
})
```

Every other user gets **their own** subscriptions from the same call and cannot list the subscribers of a newsletter.

:::warning Admins in access groups 90 ~ 98 read masked addresses
The subscriber list of a group returns every `subscribed_email` **masked**, as `j**@**.com`, to an admin in access groups `90` ~ `98`.
The **project owner**, **Skapi staff** and admins in access group **`99`** read the addresses in full, so grant access group `99` only to accounts you trust with every subscriber address. Every user reading their own subscriptions reads their own address in full.

**A masked address is not an address.** You cannot send mail to it, and it is not unique: `john@example.com` and `jane@example.com` both come back as `j**@**.com`. Never key a list, a `Set`, a selection or a distinct count on it.

**`subscriber_token` is what tells two masked rows apart.** Every masked row of a group listing carries one, beside the masked address. It is **stable**, so the same subscriber gives the same token on every call and on every page, and it is **distinct**, so two different addresses never give the same one. Key your rows on it, and your list stops losing the subscribers the mask reads alike.

The token is opaque: it is not a readable address, your code cannot turn it back into one, and it is not something to display or to mail. It is **scoped** to the project, the project owner and the newsletter group, so the same subscriber carries a different token in another group, and a token from one group or project means nothing in another. Its length varies with the address, so keep it as a plain string.

Use the token to key rows, selections and counts while you work with a listing, across its calls and pages. **Do not store it as a lasting id for a subscriber**: the platform can reissue tokens, and after that the same subscriber reads as a new token.
A caller who reads addresses in full gets **no** `subscriber_token` at all: the property is simply absent from the row.

**Paging works normally, but hand the cursor back unchanged.** For an admin in access groups `90` ~ `98` the `startKey` of a page comes back sealed, as `{ seal: '...' }` instead of the database's own key, because the raw key names the last row of the page with its address in full. Ordering, page size, `endOfList` and `{ fetchMore: true }` are unchanged, and `fetchMore` replays the sealed cursor for you.
If you pass a `startKey` yourself, pass back **exactly** the object the previous page returned. Rebuilding it, editing it, adding a key to it, or carrying it to another project, group or account is refused with `INVALID_PARAMETER` and `"startKey" does not belong to this request.`

Reading **another user's own subscriptions** by `user_id` is masked for admins in access groups `90` ~ `98` as well, as it always has been. That call answers with one subscriber's rows and no cursor, so it carries no `subscriber_token`: there is nothing to tell apart. See [Newsletters, subscribers and notifications](#doc-admin-permissions-newsletters-subscribers-and-notifications).
:::

An admin can also pass another user's `user_id` to read that user's own subscriptions, masked in access groups `90` ~ `98`. Everyone else gets `No access.` for a `user_id` that is not their own.
An admin reading their **own** subscriptions passes their own `user_id`, which comes back unmasked, because a `group` with no `user_id` is the group listing for any admin.

### Searching subscribers by e-mail <a id="doc-email-newsletters-searching-subscribers-by-e-mail"></a>

Pass `email` together with `group` to get only the subscribers whose e-mail address **starts with** that text, the same search the dashboard runs.
`john` finds `john@example.com` and `johnny@example.org`, but not `bigjohn@example.com`.
The text is trimmed and matched in lowercase, the results come back sorted by e-mail address, and they page with `fetchMore` like the full list.

The search belongs to the **project owner**, **Skapi staff** and admins in access group **`99`**, the three that read addresses in full:

```js
// signed in as the project owner, as Skapi staff, or as an admin in access group 99
skapi.getNewsletterSubscription({
    group: 'public',
    email: 'john'
}, { limit: 100 }).then(res => {
    const list = Array.isArray(res) ? res : res.list;
    // subscribers of the public newsletter whose e-mail address starts with "john"
    if (!Array.isArray(res) && !res.endOfList) {
        // call again with { fetchMore: true } for more matches
    }
})
```

`email` works on the numeric groups and on [named newsletters](#doc-email-newsletters-named-newsletters) alike.
It requires `group`, and it cannot be combined with `user_id`.

:::danger The e-mail search is closed to admins in access groups 90 ~ 98
The search matches the **stored** address, never the mask, so an open search hands back a masked address one letter at a time: ask for `j`, then `jo`, then `joh`, and each answer says whether the guess was right. That is exactly the caller the mask exists for, so the search is refused for them, before anything is queried:

```ts
{
    code: "INVALID_REQUEST";
    message: "No access.";
}
```

It is the same refusal an account that is not an admin already gets for `email`, so nothing new has to be handled in your code.
An admin below access group `99` who needs to find a subscriber pages the group listing instead, and keeps track of the row with its `subscriber_token`.
:::

## Checking if the user is subscribed to Service Email

You can let the user check if they have subscribed to Service Email by calling [`getNewsletterSubscription()`](#doc-api-reference-email-getnewslettersubscription).

[`getNewsletterSubscription()`](#doc-api-reference-email-getnewslettersubscription) may resolve either to a bare array of subscriptions or to a `DatabaseResponse` object (with a `.list` property) when the response is paginated. Normalize the result before checking its length:

```js
skapi.getNewsletterSubscription({
    group: 'authorized'
}).then(subs => {
    const list = Array.isArray(subs) ? subs : subs.list;
    if (list.length) {
        // user is subscribed to Service Email
    }
    else {
        // no subscription
    }
})
```

:::tip Reading every subscriber is an owner and admin feature
Called by a user of your app, [`getNewsletterSubscription()`](#doc-api-reference-email-getnewslettersubscription) returns that user's own subscriptions only.
Listing and searching the whole subscriber list of a newsletter belongs to the project owner, from the `Newsletters` page, and to admins. See [Managing subscribers](#doc-email-newsletters-managing-subscribers).
:::

## Unsubscribing from Service Email

You can let the user unsubscribe from Service Email by calling [`unsubscribeNewsletter()`](#doc-api-reference-email-unsubscribenewsletter).

```js
skapi.unsubscribeNewsletter({
    group: 'authorized'
}).then(res => {
    // user is unsubscribed from Service Email
})
```

## Fetching Sent Emails

You can fetch sent emails from the database by calling [`getNewsletters()`](#doc-api-reference-email-getnewsletters).
By default, it fetches all the public newsletters from the database in descending timestamp.

In the newsletter object, the `url` is the URL of the html file of the newsletter. You can use the URL to fetch the newsletter content.

```js
skapi.getNewsletters().then(newsletters => {
    // newsletters.list is an array of newsletters
    /*
    {
        message_id: string; // Message ID of the newsletter
        timestamp: number; // Timestamp of the newsletter
        complaint: number; // Number of complaints
        read: number; // Number of reads
        subject: string; // Subject of the newsletter
        bounced: string; // Number of bounces
        url: string; // URL of the newsletter
        delivered: number; // Number of users the newsletter was delivered to
        dry?: boolean; // true on a newsletter saved without sending
        sent?: number; // when it was last sent from the Newsletters page or by a ticket
    }
    */
})
```

For more detailed information on all the parameters and options available with the [`getNewsletters()`](#doc-api-reference-email-getnewsletters) method,
please refer to the API Reference below:

### [`getNewsletters(params, options?): Promise<DatabaseResponse<Newsletter>>`](#doc-api-reference-email-getnewsletters)

### Fetching Sent Emails with Conditions

You can fetch sent emails from the database with conditions by calling [`getNewsletters()`](#doc-api-reference-email-getnewsletters).

Below is an example of fetching Service Email sent to the project users before 24 hours ago in descending order.

For full parameters and options, see [`getNewsletters(params, options?)`](#doc-api-reference-email-getnewsletters).

```js
skapi.getNewsletters({
    searchFor: 'timestamp',
    value: Date.now() - 86400000, // 24 hours ago
    condition: '<',
    group: 'authorized',
}, { ascending: false }).then(newsletters => {
    // newsletters.list is an array of newsletters
    /*
    {
        message_id: string; // Message ID of the newsletter
        timestamp: number; // Timestamp of the newsletter
        complaint: number; // Number of complaints
        read: number; // Number of reads
        subject: string; // Subject of the newsletter
        bounced: string; // Number of bounces
        url: string; // URL of the newsletter
        delivered: number; // Number of users the newsletter was delivered to
        dry?: boolean; // true on a newsletter saved without sending
        sent?: number; // when it was last sent from the Newsletters page or by a ticket
    }
    */
})
```

# Receiving Inquiries <a id="doc-email-inquiries"></a>

Skapi provides a built-in E-Mail service that allows you to receive inquiries from your users.

You can use [`sendInquiry()`](#doc-api-reference-email-sendinquiry) to let your visitors send inquiries to your e-mail.

## Sending Inquiry

:::code-group

```html [Form]
<form id="inquiry-form" onsubmit="skapi.sendInquiry(event).then(res => {
    alert(res) // 'SUCCESS: Inquiry has been sent.'
})">
    <label for="name">Name:</label>
    <input type="text" id="name" name="name" required>
    <label for="email">E-Mail:</label>
    <input type="email" id="email" name="email" required>
    <label for="subject">Subject:</label>
    <input type="text" id="subject" name="subject" required>
    <label for="message">Message:</label>
    <textarea id="message" name="message" required></textarea>
    <button type="submit">Send Inquiry</button>
</form>
```

```js [JS]
let params = {
    name: 'John Doe',
    email: 'john@doe.com',
    subject: 'Inquiry',
    message: 'Hello, I have a question.'
}
skapi.sendInquiry(params).then(inquiry => {
    console.log(inquiry); // 'SUCCESS: Inquiry has been sent.'
});
```
:::

:::warning
Be sure to turn on the `Allow Inquiries` option in the [Project Settings](https://docs.skapi.com/introduction/what-is-skapi.html#project-settings).
:::

# Admin Features <a id="doc-admin-intro"></a>

Skapi provides a set of methods for managing your project.

These methods are available only to users with the `admin` role.

Project owners can grant the `admin` role to other users.

:::danger NOTE
Before using admin methods, create an admin user from your Skapi project Users page. Then use that account to access admin methods, or grant admin access to other users.
:::

The **Users** page of your project in the Skapi dashboard does the same work without code: create or invite accounts, grant access groups, block, unblock and delete accounts. [Managing Users](#doc-admin-account) shows the dashboard steps next to each method.

An AI agent can do it for you as well. The Skapi MCP server searches users, sends, resends and cancels invitations, creates accounts, grants access groups, updates profiles, and blocks, unblocks and deletes accounts from a prompt, for the project owner and for admins of the project, under the rules on this page. See [Connecting the Skapi MCP server](#doc-introduction-getting-started-connecting-the-skapi-mcp-server).

The lists below are the summary. [Admin Permissions](#doc-admin-permissions) is the full policy: the access group rules that apply to every action on another account, the record, file, newsletter and notification rights in detail, and the exact message every refusal returns.

## What Admins Can Do

Admins use high access groups (`90` ~ `99`) and can perform the following actions:

- Read database data in any access group. Fetching a single record by its `record_id` is the exception: for admins in access groups `90` ~ `98`, a record in a **higher** access group than their own is still refused with `No access to the record.`
- Read subscription records and their files without subscribing.
- Upload records to any access group.
- Upload, update and delete records while the database is frozen.
- View the record counts of every access group, private and admin totals included, in table information.
- Delete user accounts in a lower access group than their own.
- Update the profiles of user accounts in a lower access group than their own. Admins in access group `99` can update every account but their own. See [Editing a user's profile](#doc-admin-permissions-editing-a-user-s-profile).
- View the e-mail address and `misc` of every user, and search users by e-mail.
- Invite users to the project.
- View, resend and cancel pending invitations.
- Create users in the project.
- Assign access groups to users, up to their own access group, on accounts in a lower access group than their own.
- Remove private record access from users, on any record.
- Block or unblock users in a lower access group than their own.
- Delete any record, including private and read-only records.
- Attach files to records of other users, and delete their files with [`deleteFiles()`](#doc-api-reference-database-deletefiles). Files on a private record are locked to its user. See [Files on Records of Other Users](#doc-database-handling-files-files-on-records-of-other-users).
- Upload private, subscription and read-only records, like every user of the project.
- Fetch the list of newsletter subscribers. Admins in access groups `90` ~ `98` read those addresses **masked**, as `j**@**.com`, with an opaque `subscriber_token` beside each one to tell two rows apart, and the list pages as usual on a sealed cursor they hand back unchanged. Searching the list by e-mail address is not theirs: it belongs to the project owner, Skapi staff and access group `99`. See [Managing Newsletter Subscribers](#doc-email-newsletters-managing-subscribers) and [Newsletters, subscribers and notifications](#doc-admin-permissions-newsletters-subscribers-and-notifications).
- Send notifications to users.
- Change the [subscription settings](#doc-database-subscription-who-can-change-subscription-settings) of records uploaded by other users. Private records are excluded, and so are read-only records for admins below access group `99`. On records posted by anonymous users, settings can only be kept or turned off.

Admins in access group `99` can also:

- Read the subscriber list with the addresses **in full**, and search it by e-mail address.
- View the delivery statistics of sent newsletters: reads, bounces and complaints.
- Register, list and delete named newsletter groups.
- Subscribe or unsubscribe any e-mail address to a newsletter group.
- Update records uploaded by other users, including read-only records. Private records stay off limits.

## What Admins Cannot Do

Admins cannot perform the following actions:

- Read the data of other users' private records they have no access to.
- Change the data or settings of records uploaded by other users, apart from their subscription settings. Admins in access group `99` can, except private records.
- Attach files to, or delete files from, private records of other users.
- Give a user an access group higher than their own, when granting access, creating an account or sending an invitation.
- Change the access group of an admin in access group `99`, unless they are in access group `99` themselves.
- Act on an account whose access group is the same as their own or higher, unless they are in access group `99`: changing its access group, updating its profile, blocking or unblocking it, and deleting it are all refused with `No access to modify admin.` A [disabled](#doc-user-account-disable-recover-account) account counts with the access group it had before it was disabled. See [The rank rule](#doc-admin-permissions-the-rank-rule).
- Update their own account with [`updateUserAttributes()`](#doc-api-reference-admin-updateuserattributes), admins in access group `99` and the project owner included. Every account updates its own profile with [`updateProfile()`](#doc-api-reference-user-updateprofile) and no `user_id`, like any user.
- Mark a user's e-mail address verified. An address an admin sets is written unverified and the account loses its e-mail login until the user verifies the new address themselves. See [Login IDs](#doc-admin-permissions-login-ids).
- Change a login `username`. It is permanent.
- In access groups `90` ~ `98`, read subscriber addresses in full. The subscriber list comes back masked, as `j**@**.com`, and a masked address cannot be mailed and is not unique, so rows are told apart by their `subscriber_token` instead.
- In access groups `90` ~ `98`, search the subscriber list by e-mail address. The search matches the stored address, so it would give the mask back one letter at a time, and it is refused with `No access.`
- Send newsletters or set e-mail templates, in **any** access group, `99` included. Only the project owner does, from the project's email address. See [Sending Newsletters](#doc-email-newsletters).
- Change project settings, in **any** access group, `99` included.

Project settings are available only to the project owner's Skapi account, through the Skapi project pages, and to Skapi staff when you ask Skapi for help.
That covers the project name and CORS, Allow Signup, Allow Inquiries, Allow Anonymous Posts, Require Login, Freeze Database, the API key and the Secret Keys, the subdomain and the 404 page, the OpenID loggers, the sender e-mail address and the automated e-mail templates, and creating, disabling, enabling or deleting the project.
An admin request for any of them is refused with `INVALID_REQUEST` and `Only the project owner can change project settings.` Admins still upload and delete the files of a hosted site, and still manage users, records, invitations, newsletters and notifications. See [Project settings belong to the project owner](#doc-admin-permissions-project-settings-belong-to-the-project-owner).

Admin methods are useful when building projects that require admin access to manage users and data.

:::danger
Admin methods are powerful and should be used with caution.
Admins can delete user accounts and data, which can cause irreversible damage to your application.
:::

## What Both Admins and Project Owners Cannot Do

- Change or view user account passwords. No method resets another account's password, for any role.
- Read private database data of other users without access to it. See [Private records](#doc-admin-permissions-private-records).
- Read encrypted record data of other users without being given access.
- Change a login `username`, or mark another account's e-mail address verified.

:::info Encrypted records
[Encryption](#doc-database-encryption) only ever covers the data of a private record, and the project owner and admins receive that data only where they have access to it. See [Private records](#doc-admin-permissions-private-records).
An admin the record's uploader gives access to with [`grantPrivateRecordAccess()`](#doc-api-reference-database-grantprivateaccess) receives the data and can decrypt it, because the grant wraps the record's key for them. A record reached through a reference arrives sealed, and opens only for the users its key was shared with.
Private access cannot be granted to the project owner at all: `Private access cannot be granted to service owners.`
:::

## What Project Owners Cannot Do <a id="doc-admin-intro-what-project-owners-cannot-do"></a>

The project owner's Skapi account is not a user of the project, so it cannot:

- Upload records to the private access group.
- Give its own records subscription settings.
- Upload read-only records, or make an existing record read-only.

A record that already is private or read-only can still be updated under the usual rules.

The project owner can change the subscription settings of an existing record that belongs to a user of the project, unless the record is private. The record stays that user's.

The project owner's own records can never have subscription settings, whoever sends the request, admins included. A request that adds or turns one on is refused with `Records of the project owner cannot have subscription settings.` Settings that an older record of the project owner already has can be kept as they are or turned off.

Records posted by anonymous (signed-out) users do not belong to a user of the project either, so neither the project owner nor admins can give them subscription settings. A request that adds or turns one on is refused with `Anonymous records cannot have subscription settings.` Settings that such a record already has can be kept as they are or turned off.

::: tip
Apart from these, project owners can do everything that admins can do.
:::

# Admin Permissions <a id="doc-admin-permissions"></a>

This page is the complete policy for what an admin account and the project owner can do in your project, and what neither of them can do.
[Admin Features](#doc-admin-intro) is the short summary of the same rules. This page is the detail, including the exact message every refusal returns.

Every rule about who may do what is enforced on the server, so it holds whatever sends the request: the SDK, a request you build yourself, or the Skapi dashboard. A few refusals also happen in the SDK before the request leaves, and those are marked as such.

## Roles <a id="doc-admin-permissions-roles"></a>

Every account of your project has an **access group**, a number from `0` to `99` kept in its profile.
An account starts in access group `1` unless it was created or invited with another one.
Access groups `90` and up are admins.

| Role | Who it is | How it is given |
| --- | --- | --- |
| User | Access groups `0` ~ `89`. An ordinary account of your project. | Signing up, or being created or invited into the project. |
| Admin | Access groups `90` ~ `98`. Can use the admin methods, with the limits on this page. | [`grantAccess()`](#doc-api-reference-admin-grantaccess), or an account created or invited with that access group, or the `Users` page of the Skapi dashboard. |
| Admin, access group `99` | Every admin right, with none of the access group limits. | The same three ways. |
| Project owner | The Skapi account that created the project. It is **not** a user of the project, and it has no access group. | Owning the project. |
| Skapi staff | Support accounts, used only when you ask Skapi for help. | Skapi. |

Throughout the documentation, "admins" means access groups `90` ~ `99`, and the project owner is named separately where the rule differs for it.

Two things follow from the project owner not being a user of your project:

- Its own records can never be private, read only, or carry subscription settings. See [What the project owner cannot do](#doc-admin-permissions-what-the-project-owner-cannot-do).
- It never appears in [`getUsers()`](#doc-api-reference-user-getusers), and no user can grant it private record access.

:::info Who is an admin, and who is a full admin
Two different lines run through this page:

- **Admin** (access groups `90` ~ `99`, and the project owner): may call the admin methods at all.
- **Access group `99` and the project owner**: also exempt from the access group rules, and allowed to update another user's record data and read only records.

Wherever a rule says "admins in access groups `90` ~ `98`", access group `99` admins and the project owner are not limited by it.
:::

## Rules that apply to every action on another account

### The access group ceiling <a id="doc-admin-permissions-the-access-group-ceiling"></a>

An admin can never hand out an access group higher than their own.
An admin in access group `95` can grant up to `95`, create an account at up to `95` and invite at up to `95`.
The project owner has no ceiling.

[`grantAccess()`](#doc-api-reference-admin-grantaccess) refuses with `Cannot grant an access group higher than your own.`, and [`createAccount()`](#doc-api-reference-admin-createaccount) and [`inviteUser()`](#doc-api-reference-admin-inviteuser) refuse with `Cannot give a user an access group higher than your own.`, both with the `INVALID_REQUEST` code.

### The rank rule <a id="doc-admin-permissions-the-rank-rule"></a>

An admin in access groups `90` ~ `98` may not act on an account whose access group is **the same as or higher than their own**.
Two admins in access group `95` cannot touch each other's accounts, and neither of them can touch an access group `99` account.

The rule covers every method that acts on another account:

- [`grantAccess()`](#doc-api-reference-admin-grantaccess)
- [`updateUserAttributes()`](#doc-api-reference-admin-updateuserattributes), and [`updateProfile()`](#doc-api-reference-user-updateprofile) with another user's `user_id`
- [`blockAccount()`](#doc-api-reference-admin-blockaccount) and [`unblockAccount()`](#doc-api-reference-admin-unblockaccount)
- [`deleteAccount()`](#doc-api-reference-admin-deleteaccount)

The refusal is the same everywhere, and nothing in the request is changed:

```ts
{
    code: "INVALID_REQUEST";
    message: "No access to modify admin.";
}
```

Admins in access group `99` and the project owner are not limited by this rule. They reach every account of the project, except their own through `updateUserAttributes()`, see [Your own account](#doc-admin-permissions-your-own-account).

:::warning Disabled accounts
A [disabled](#doc-user-account-disable-recover-account) account counts at the access group it had before it was disabled, so an access group `90` admin cannot delete a disabled access group `99` admin.

An account that was disabled **before this rule shipped** is the exception: disabling it overwrote its stored access group with `1`, whatever its group really was. Such an account ranks as access group `1` for these rules, and it comes back from recovery as an ordinary access group `1` user, until its access group is set again.
:::

### Access group 99 accounts and grantAccess <a id="doc-admin-permissions-access-group-99-accounts-and-grantaccess"></a>

Moving an access group `99` account to another access group is reserved for access group `99` admins and the project owner.
Any other admin is refused by [`grantAccess()`](#doc-api-reference-admin-grantaccess) with:

```ts
{
    code: "INVALID_REQUEST";
    message: "No access to expel admin.";
}
```

This is the older, narrower rule, and it is what an access group `99` target answers on `grantAccess()`. Every other target at or above the caller's own access group answers `No access to modify admin.` instead.

### Your own account <a id="doc-admin-permissions-your-own-account"></a>

- **Your profile.** Nobody, the project owner and access group `99` admins included, may edit their own account with [`updateUserAttributes()`](#doc-api-reference-admin-updateuserattributes). Use [`updateProfile()`](#doc-api-reference-user-updateprofile) with no `user_id`, the same call any user makes. `updateProfile()` given your own `user_id` still ignores it and updates your own profile, exactly as before.
- **Your access group.** `grantAccess()` refuses your own `user_id` with `User cannot self grant access.`
- **Blocking.** `blockAccount()` and `unblockAccount()` refuse your own `user_id` with `User cannot block self.` and `User cannot unblock self.`
- **Deleting.** [`deleteAccount()`](#doc-api-reference-admin-deleteaccount) with your own `user_id` deletes your own account. It is not ranked, because it is your own account, and it is irreversible. A user who is not an admin leaves the project with [`disableAccount()`](#doc-user-account-disable-recover-account-disabling-account) instead.

### Accounts of other projects

An admin reaches only the accounts of their own project. A `user_id` that belongs to another project is refused with `No access.`, `User does not exists.` or `User not found`, depending on the method, and nothing about that account is returned.

## Managing user accounts

### Creating accounts <a id="doc-admin-permissions-creating-accounts"></a>

[`createAccount()`](#doc-admin-account-creating-user-accounts) creates a confirmed account with the password you choose. The account can log in at once.

- `access_group` defaults to `1` and is capped by [the ceiling](#doc-admin-permissions-the-access-group-ceiling).
- The e-mail address is **not** verified by this method. An account you create with a `username` therefore logs in with that username alone until the user verifies the address. An account created without one logs in with the address it was created with, which is its permanent login ID. See [Login IDs](#doc-admin-permissions-login-ids).
- Accounts count against your plan's user limit. Over it, the request is refused with `User limit exceeded.`

### Inviting users

[`inviteUser()`](#doc-admin-invite) sends an invitation e-mail carrying the login ID, a random password and an acceptance link that is valid for 7 days.
Accepting it verifies the e-mail address, so an invited account has its e-mail login from the moment it accepts.

- `access_group` defaults to `1` and is capped by [the ceiling](#doc-admin-permissions-the-access-group-ceiling).
- While an invitation to an address is still pending, a second invitation to that address is refused with `User is already invited.`
- [`resendInvitation()`](#doc-admin-invite-resending-invitations) sends the same temporary password again, and its `${username}` placeholder renders the login username for an invitation created with a `username`, and the e-mail address otherwise.
- [`cancelInvitation()`](#doc-admin-invite-cancelling-invitations) deletes the pending account. An invitation that was already accepted is refused with `User already exists.`

### Granting an access group <a id="doc-admin-permissions-granting-an-access-group"></a>

[`grantAccess()`](#doc-admin-account-assigning-access-group-to-users) sets another account's access group, from `0` to `99`. `'admin'` is accepted as a name for `99`.

Besides [the ceiling](#doc-admin-permissions-the-access-group-ceiling), [the rank rule](#doc-admin-permissions-the-rank-rule) and [the access group `99` rule](#doc-admin-permissions-access-group-99-accounts-and-grantaccess), the target must be usable:

- A blocked account is refused with `Cannot grant access to suspended user.` Unblock it first.
- An account that has not confirmed its signup is refused with `User signup is not confirmed.`

In the dashboard, this is the **Grant Access** dialog on the **Users** page: it takes one group from `1` to `99` for every selected account, and an account's own form takes `0` to `99`. The same refusals apply.

![The Grant Access dialog on the Users page, asking for an access group between 1 and 99](https://docs.skapi.com/screenshots/users-grant-access.webp)

*Grant Access on the Users page.*

:::tip
An access group is a number your own code reads as well. Records, tables and named newsletter groups are gated by it, so moving an account to `90` or above gives it the admin methods **and** read and write access to every access group in the database, with one exception for a single record fetched by its `record_id`. See [Reading and uploading](#doc-admin-permissions-reading-and-uploading) and [Access Restrictions](#doc-database-access-restrictions).
:::

### Editing a user's profile <a id="doc-admin-permissions-editing-a-user-s-profile"></a>

[`updateUserAttributes()`](#doc-admin-account-updating-user-attributes), and [`updateProfile()`](#doc-api-reference-user-updateprofile) with another user's `user_id`, change another account's profile. Attributes you leave out are not touched.

Only profile attributes can be changed: `email`, `name`, `nickname`, `birthdate`, `gender`, `phone_number`, `address`, `picture`, `profile`, `website`, `family_name`, `given_name`, `middle_name`, `locale`, `misc`, and the `email_public`, `phone_number_public`, `address_public`, `gender_public` and `birthdate_public` flags.
Any other attribute name is refused before anything is read or written, with `INVALID_PARAMETER` and `"<attribute>" cannot be changed.`

That list is deliberate. An access group, a block, a signup confirmation and an e-mail verification are **not** profile attributes, and none of them can be set through this method:

- Change an access group with [`grantAccess()`](#doc-admin-permissions-granting-an-access-group).
- Block and unblock with [`blockAccount()` and `unblockAccount()`](#doc-admin-permissions-blocking-and-unblocking).
- Only the account itself can verify its own e-mail address, with [`verifyEmail()`](#doc-api-reference-user-verifyemail).

:::warning Changing a user's e-mail takes their e-mail login away
An e-mail address you set here is written **unverified**, whoever sends the request, the project owner and access group `99` admins included, and the account's e-mail login is removed in the same request.

The account keeps logging in with its username, or with the address it was created with if it was created without one. Any other e-mail login comes back only after the user runs [`verifyEmail()`](#doc-api-reference-user-verifyemail) on the new address, and then a few seconds later, on the account's next token rather than in the `verifyEmail()` response.

Set the `email_public` flag only once you know the address is right. See [Login IDs](#doc-admin-permissions-login-ids).
:::

A login `username` is permanent. A request that tries to set the login handle without an `email` is refused with `INVALID_REQUEST` and `The login username is permanent and cannot be changed.`

### Login IDs <a id="doc-admin-permissions-login-ids"></a>

An account logs in with **one or two** login IDs:

- The **login ID it was created with**: its `username` if it was created with one, otherwise the e-mail address it signed up with. It is permanent. No method changes it, and it keeps working after the account changes its e-mail.
- Its **e-mail login**: the account's current e-mail address, granted only once that address is **verified**.

An address is verified by clicking the signup confirmation link, by accepting an invitation, or by [`verifyEmail()`](#doc-api-reference-user-verifyemail). The e-mail login arrives a few seconds after that, on the account's next token. Before it, that address answers `INCORRECT_USERNAME_OR_PASSWORD`.

This is what an admin needs to know about it:

- An account created with [`createAccount()`](#doc-admin-permissions-creating-accounts) with a `username`, or signed up with a `username` in a project with no signup confirmation, logs in **only** with its username until it verifies its e-mail address.
- An account you invite gets its e-mail login when it accepts the invitation.
- An e-mail you change with [`updateUserAttributes()`](#doc-admin-permissions-editing-a-user-s-profile) removes the account's e-mail login until the user verifies the new address.

When a login ID is already taken, [`createAccount()`](#doc-api-reference-admin-createaccount) and [`inviteUser()`](#doc-api-reference-admin-inviteuser) are refused with `EXISTS` and `E-mail "user@email.com" is already a login ID in this service.`, or `Username "jane@email.com" is already a login ID in this service.` for a `username` that is an e-mail address logging another account in.
A refusal means one thing: the address is already a login ID the other account was **granted**, its own login ID or its own verified e-mail.

:::warning An account can lose its e-mail login without anyone touching it
An e-mail login an account holds **without** having verified the address blocks nothing. It is removed, and the address is given to the account that proves it.

So an account of your project can lose its e-mail login when another account signs up with that address, is created or invited with it, has an admin change its e-mail to it, logs in with OpenID under it, or simply verifies it. The account that loses it keeps logging in with the login ID it was created with.
:::

### Blocking and unblocking <a id="doc-admin-permissions-blocking-and-unblocking"></a>

[`blockAccount()`](#doc-admin-account-blocking-user-accounts) stops an account from logging in. [`unblockAccount()`](#doc-admin-account-unblocking-user-accounts) lets it in again. Both follow [the rank rule](#doc-admin-permissions-the-rank-rule).

Blocking does not delete anything, and it does not change the account's access group. A blocked account cannot be given a new access group until it is unblocked.

### Deleting accounts

[`deleteAccount()`](#doc-admin-account-deleting-user-accounts) deletes another account and everything it owns. It follows [the rank rule](#doc-admin-permissions-the-rank-rule).

:::danger
This is irreversible. The account's records, files and subscriptions go with it. There is no recovery, and no undo.
:::

A user disabling their **own** account is a different thing: the account is disabled, and its data is kept for 90 days so that the user can recover it. See [Disable and Recover Account](#doc-user-account-disable-recover-account).

### What an admin can see about a user <a id="doc-admin-permissions-what-an-admin-can-see-about-a-user"></a>

[`getUsers()`](#doc-user-account-get-users) returns more to an admin than to a user:

- The `email` of every account, including addresses the user has not made public, plus `email_verified`.
- The `misc` field of every account. For everyone else, `misc` is visible only to the account itself.
- `searchFor: 'email'` searches those addresses, so an admin can find an account by an address that is not public.

Everything else follows the normal rules: an attribute a user has not made public is not returned to other users, and `user_id` searched with a `value` array (looking up several known ids at once) requires the `=` condition.

## Records <a id="doc-admin-permissions-records"></a>

Record rights are set by the access group of the record and by who uploaded it. The full rules are in [Access Restrictions](#doc-database-access-restrictions); this section is the admin side of them.

### Reading and uploading <a id="doc-admin-permissions-reading-and-uploading"></a>

Admins (access groups `90` ~ `99`) and the project owner:

- Query records in **every** access group, including access group `99`, the group named `admin`.
- Read [subscription](#doc-database-subscription) records and their files without subscribing to anyone.
- Upload records to any access group, including one higher than their own. Anyone else is refused with `User has no access to this group.`
- See the private and admin record counters in [table information](#doc-database-table-info-querying-tables), which other users do not get.

Private records are the exception, and they are covered on their own below.

:::warning Fetching one record by its `record_id` is stricter
Reading a table group by group is a query, and every admin queries every access group. Fetching a **single** record, by its `record_id` or by a `unique_id`, is checked against that one record instead, and there only admins in access group `99` and the project owner are exempt from the access group rules.

An admin in access groups `90` ~ `98` who fetches a record in a **higher** access group than their own is refused with:

```ts
{
    code: "INVALID_REQUEST";
    message: "No access to the record.";
}
```

The same record still comes back in a query of that access group. So an admin in access group `95` reaches every record up to access group `95` by `record_id`, and records in access groups `96` ~ `99` only through a query, unless the record's uploader has [granted them access](#doc-database-access-restrictions-grant-private-access) to that record.
:::

### Updating another user's record <a id="doc-admin-permissions-updating-another-user-s-record"></a>

| Who | What it may change on another user's record |
| --- | --- |
| Admins in access groups `90` ~ `98` | The [subscription settings](#doc-database-subscription-who-can-change-subscription-settings) only. |
| Admins in access group `99`, and the project owner | The data and every setting, read only records included. |
| Everyone else | Nothing. |

An admin in access groups `90` ~ `98` who changes anything else is refused, and the first differing field is named:

```ts
{
    code: "INVALID_REQUEST";
    message: "Admins can only change the subscription settings of another user's record (data differs).";
}
```

The comparison is made against the **stored** record, not against the keys your request carries, so a form that sends the whole configuration back unchanged is fine.

A **read only** record can be updated only by the project owner and admins in access group `99`. Everyone else, its own uploader included, is refused with `Record is read only.`

A record you change this way stays its user's record, and its subscribers stay its user's subscribers.

### Subscription settings <a id="doc-admin-permissions-subscription-settings"></a>

The project owner and every admin can change the subscription settings of another user's record, with three exceptions:

- A **private** record is its user's alone.
- A record of the **project owner** can never have subscription settings, whoever asks: `Records of the project owner cannot have subscription settings.`
- A record posted by an **anonymous** visitor belongs to no user of the project, so its settings can only be kept as they are or turned off: `Anonymous records cannot have subscription settings.`

See [Who can change subscription settings](#doc-database-subscription-who-can-change-subscription-settings).

### Private records <a id="doc-admin-permissions-private-records"></a>

`private` is the only access group whose contents may be [end to end encrypted](#doc-database-encryption), so the line around it is absolute:

- Only the record's own user can move a record **into or out of** `private`.
- Nobody else changes anything on a private record, subscription settings included, and nobody else attaches or deletes its files.
- Admins and the project owner read another user's private record data only where they have access to it, the same way as any other user: the record was shared with them with [`grantPrivateRecordAccess()`](#doc-api-reference-database-grantprivateaccess), or they have private access to the record it references, because they uploaded that record or it was shared with them. Being able to read the referenced record is not enough, so a public record opens none of the private records attached to it. Private access is never granted to the project owner, so the owner reads another user's private record only when it references a record the owner uploaded.
- Without that access, the project owner and admins in access group `99` still see the record, and its `data` comes back as `{ __is_private__: null }`, however they reach it: by `record_id`, in a listing of a table or of the whole project, in a listing that names `access_group: 'private'`, or in a `tag` query. Everything else on the record stays visible, and their own private records are never withheld from them. Admins in access groups `90` ~ `98` without access cannot fetch the record at all: they are refused with `User has no private access.`

What admins **can** do on a private record:

- **Delete** it. [`deleteRecords()`](#doc-database-delete-records) reaches any record in the project.
- **Remove private access** from a user with [`removePrivateRecordAccess()`](#doc-database-access-restrictions-remove-private-access), on any record of the project, not only their own.

Granting private access stays with the record's user, and with the users that user allowed to grant others. Private access can never be granted to the project owner: `Private access cannot be granted to service owners.`

### Files

Files are an admin right of their own, separate from a record's data and settings.
The project owner and every admin (access groups `90` ~ `99`) can attach files to another user's record and delete its files, as long as the record is not private.

- **Attach** by passing the files to [`postRecord()`](#doc-api-reference-database-postrecord) with the record's `record_id`. For admins in access groups `90` ~ `98` the call must change nothing else, and a read only record refuses them with `Record is read only.`
- **Delete** with [`deleteFiles()`](#doc-database-handling-files-deleting-files-by-url), read only or not, up to 1000 URLs in one call.

A file attached this way is stored under the **record's user**, so that user can delete it, it moves with the record and it is deleted with the record.
Files on a private record are locked to its user, for everyone: `Only the owner of a private record can delete its files.`

See [Files on Records of Other Users](#doc-database-handling-files-files-on-records-of-other-users).

:::info Uploads are signed for the size they declare
Every upload, from an admin or a user, is signed for exactly the byte count the request declares, and storage refuses a file of any other size. The SDK declares the exact size of what it uploads, encrypted files and empty files included, so nothing changes for SDK uploads.
:::

### Deleting records <a id="doc-admin-permissions-deleting-records"></a>

[`deleteRecords()`](#doc-database-delete-records) lets an admin, and the project owner, delete any record of the project, including private and read only records. Every user deletes only their own.

### A frozen database

When the database is frozen in your project settings, users cannot write to it. Admins (access groups `90` ~ `99`) and the project owner still can: they upload, update and delete records and files as usual, so a frozen project stays manageable.

Everyone else is refused with `Database is frozen. Write access is denied for this user.`, and on a delete with `Database is frozen. Delete access is denied for this user.`

## Newsletters, subscribers and notifications <a id="doc-admin-permissions-newsletters-subscribers-and-notifications"></a>

| Action | Who |
| --- | --- |
| Read the whole subscriber list of a group | The project owner and admins (access groups `90` ~ `99`). In access groups `90` ~ `98` the addresses come back masked, with an opaque `subscriber_token` beside each one, and the paging cursor comes back sealed |
| Search the subscriber list of a group by e-mail address | The project owner, Skapi staff and admins in access group `99`. Admins in access groups `90` ~ `98` are refused with `No access.` |
| Subscribe or unsubscribe any e-mail address to a group | The project owner and admins in access group `99` |
| Register, list and delete [named newsletter groups](#doc-email-newsletters-named-newsletters) | The project owner and admins in access group `99` |
| Send push notifications to all users, or to chosen users | The project owner and admins (access groups `90` ~ `99`) |
| Notify one's own subscribers of a new record or reference (`notify_subscribers`, `notify_referencing_records`) | Any signed-in user, for their own records. Only subscribers who set `get_notified` and can read the record are notified. See [Subscription notifications](#doc-database-subscription-notifications) |
| Send a newsletter, and set an e-mail template | The project owner only, from the project's email address. Admins in any access group, `99` included, cannot. The SDK has no method for either: the owner sends the email to the address the Skapi dashboard shows. |
| Read a sent newsletter's delivery statistics | The project owner and admins in access group `99` |

[`getNewsletters()`](#doc-api-reference-email-getnewsletters) returns a sent newsletter's read, bounce and complaint counts only to the project owner and admins in access group `99`. Everyone else who may read that group gets the message id, the timestamp and the subject.

[`getNewsletterSubscription({ group })`](#doc-email-newsletters-who-can-read-the-subscriber-list) returns the whole list to an admin. Anyone else gets their own subscriptions. `email` narrows the list to the addresses that start with what you type, and it belongs to the project owner, Skapi staff and admins in access group `99`: everyone else is refused with `No access.`

:::warning Admins in access groups 90 ~ 98 read masked addresses
The subscriber list of a group returns the `subscribed_email` of every row **masked**, as `j**@**.com`, to an admin in access groups `90` ~ `98`.
The project owner, admins in access group `99` and Skapi staff read the addresses in full, and every user reading **their own** subscriptions reads their own address in full.

**A masked address is not a usable address.** You cannot send mail to it, and it is not unique: `john@example.com` and `jane@example.com` both come back as `j**@**.com`. Using it as a key silently loses rows, and counting distinct values with it undercounts.

**Each masked row of a group listing carries a `subscriber_token`.** It is opaque, it is the same string for the same subscriber on every call and every page, and it is a different string for a different address, so it is what a list, a selection or a distinct count is keyed on. It is a key for working with a listing, not an id to store: the platform can reissue tokens, and the same subscriber then reads as a new one. It is not a readable address and cannot be turned into one client side, and it is scoped to this project, this project owner and this newsletter group, so the same subscriber reads as a different token in another group and a token from another group or project matches nothing here. A caller who reads addresses in full is sent no token at all, and the single user path read with `user_id` carries none either.

**The paging cursor is sealed for them.** The `startKey` a page hands back is the database's own key, and it names the last row of that page as it is stored, address in full. For an admin in access groups `90` ~ `98` the server seals it instead, so the page hands back `{ seal: '...' }` and nothing readable leaves. Ordering, page size, `endOfList` and `fetchMore` are unchanged. A `startKey` passed in has to be the previous page's object unchanged: a rebuilt, edited or borrowed cursor, a sealed cursor sent by a caller who reads full addresses, or a plain one sent by a caller who does not, is refused with `INVALID_PARAMETER` and `"startKey" does not belong to this request.`

**The e-mail search is closed to them.** It matches the **stored** address, never the mask, so leaving it open to the caller the mask exists for would hand a masked address back one letter at a time. An admin in access groups `90` ~ `98` is refused with `INVALID_REQUEST` and `No access.`, the same refusal an account that is not an admin gets.
:::

Reading **another user's** subscriptions with their `user_id` has always masked the address for admins in access groups `90` ~ `98`, and still does. An admin reading their own subscriptions unmasked passes their **own** `user_id`, because a `group` with no `user_id` is the group listing for any admin.

Masking is a guard rail, not a wall. It keeps subscriber addresses off everyday admin screens, and it is what makes the addresses of subscribers who never created an account in your project hard to read for an admin below access group `99`.

It does not make an admin account safe to hand out, and one limit is worth stating plainly: **the mask only ever protects subscribers who are not accounts of your project.** An admin in access groups `90` ~ `98` reads the e-mail address of every **account** with [`getUsers()`](#doc-user-account-get-users), public or not, as [What an admin can see about a user](#doc-admin-permissions-what-an-admin-can-see-about-a-user) describes. So for any subscriber who also signed up, the address is one ordinary call away. `getUsers()` is deliberately not restricted, because an admin who manages accounts needs to see them. What masking, the token and the sealed cursor harden is the list of addresses that exist **only** as newsletter subscriptions.

Every other user may only subscribe and unsubscribe their own address. [`pushNotification()`](#doc-notification-send-notifications-sending-notifications-for-admins) refuses an account that is not an admin with `User has no access to send notifications`.

## Project settings belong to the project owner <a id="doc-admin-permissions-project-settings-belong-to-the-project-owner"></a>

**Project settings are not an admin right, at any access group.** They are changed by the **project owner's Skapi account** on the Skapi project pages, and by Skapi staff when you ask Skapi for help. An admin of your project, access group `99` included, is refused:

```ts
{
    code: "INVALID_REQUEST";
    message: "Only the project owner can change project settings.";
}
```

The SDK has no method for any of them, so this is the answer a request built by hand gets when it carries an admin's token.

Owner only, everything that configures the project itself:

- The project **name** and description, and the **CORS** list.
- **Freeze Database**, **Allow Signup**, **Allow Inquiries**, **Allow Anonymous Posts to Database**, **Require Login**, and the AI agent option.
- The project's [**secret key**](https://docs.skapi.com/service-settings/additional.html#secret-key) and API key, and the keys on the [**Secret Keys**](#doc-api-bridge-client-secret-request-registering-secret-keys) page. Listing those keys answers with their stored values, so reading them is owner only as well.
- **Website hosting**: the subdomain, the files of the hosted site, its 404 page and its CDN refresh. See [Hosting your website](#doc-hosting-hosting).
- The **OpenID loggers**, registering, updating, deleting and listing them, since a logger's stored URL, headers and parameters come back with the listing.
- The **sender e-mail address** the project's mail is sent from, and the [automated e-mail templates](#doc-email-email-templates), which decide what the project's verification, welcome, invitation and confirmation mail says.
- **Creating, disabling, enabling and deleting the project**, and its plan and billing.

Unchanged, and still normal admin work: users, invitations, records and their files, newsletters and [named newsletter groups](#doc-email-newsletters-named-newsletters), notifications, and reading analytics. [Tickets](#doc-tickets-introduction) were already the project owner's alone, with their own message: `Only the project owner can register tickets.`

:::info Website hosting is the project owner's
The files of the hosted site are uploaded and deleted on the project's **Web Hosting** page by the project owner, as are its **404 page** and its **CDN refresh**, and the **subdomain** they are served on is registered, moved and dropped on the project's **Settings** page. No admin does any of it, access group `99` included. See [Hosting your website](#doc-hosting-hosting).
:::

## What admins cannot do

- Read or change any account's **password**. There is no method that resets another account's password, for any role. A user recovers their own password with [`forgotPassword()`](#doc-api-reference-authentication-forgotpassword), which needs a verified e-mail address.
- Mark an e-mail address **verified**. An address an admin sets is written unverified, and only the account itself can verify it.
- Change their **own** profile through [`updateUserAttributes()`](#doc-api-reference-admin-updateuserattributes), or their own access group, or block or unblock themselves.
- Give an access group **higher than their own**, or, in access groups `90` ~ `98`, act at all on an account at or above their own access group.
- Change a **login `username`**. It is permanent.
- Read another user's **private** records, or attach and delete their files, or change anything on them.
- Change the **data or settings** of another user's record, apart from its subscription settings, unless they are in access group `99`.
- Update a **read only** record, unless they are in access group `99`.
- Move a record **into or out of** `private`. Only the record's own user can.
- Grant **private record access** on another user's record. Only its user, and the users that user allowed to grant others, can.
- Upload or delete the files of the **hosted site**, or refresh its CDN cache, unless they are in access group `99`. An admin in access groups `90` ~ `98` is refused with `No access.`
- Change **project settings**, such as freezing the database, allowing signup, the CORS list, the subdomain, the OpenID loggers, the Secret Keys or the automated e-mail templates. This holds for admins in access group `99` as well, and there is no SDK method for any of them: project settings are managed by the project owner on the Skapi project pages. A request that carries an admin's token is refused with `Only the project owner can change project settings.` See [Project settings belong to the project owner](#doc-admin-permissions-project-settings-belong-to-the-project-owner).

## What neither admins nor the project owner can do

- Read or change an account's password.
- Read another user's private record data. A private record fetched by the project owner or an access group `99` admin comes back without its data, and admins in access groups `90` ~ `98` cannot fetch it by its `record_id` at all: they get `User has no private access.`
- Read [encrypted](#doc-database-encryption) record data of another user without being granted access to it, and private access can never be granted to the project owner, so the project owner can never read it. Only the record's uploader can re-encrypt a record, so removing a user's access does not take back a copy that user already decrypted.
- Change a login `username`.
- Move another user's record into or out of `private`.

## What the project owner cannot do <a id="doc-admin-permissions-what-the-project-owner-cannot-do"></a>

The project owner's Skapi account is not a user of your project, so its own records are limited:

- It cannot upload records to the `private` access group: `The project owner cannot upload records to the private access group.`
- It cannot upload read only records, or make an existing record of its own read only: `The project owner cannot upload read-only records.`
- Its own records can never have subscription settings, whoever sends the request, admins included.

A record that already is private or read only, and belongs to a user, still follows the usual rules: the project owner can update it, change its subscription settings unless it is private, and delete it.

:::tip
Apart from these, the project owner can do everything an access group `99` admin can do.
:::

## Errors

Every message below is the exact text the server returns, and every code is the `code` field of the error.

### Managing accounts

| Code | Message | Returned by |
| --- | --- | --- |
| `INVALID_REQUEST` | `No access.` | Any admin method called by an account that is not an admin, and any method aimed at an account of another project |
| `INVALID_REQUEST` | `Invalid access.` | The SDK, before the request leaves, when the signed in account is below access group `90` |
| `INVALID_REQUEST` | `User needs to login.` | The SDK, before the request leaves, when nobody is signed in |
| `INVALID_REQUEST` | `No access to modify admin.` | `grantAccess()`, `updateUserAttributes()`, `updateProfile()` with another `user_id`, `blockAccount()`, `unblockAccount()`, `deleteAccount()` |
| `INVALID_REQUEST` | `No access to expel admin.` | `grantAccess()` on an access group `99` account |
| `INVALID_REQUEST` | `Cannot grant an access group higher than your own.` | `grantAccess()` |
| `INVALID_REQUEST` | `Cannot give a user an access group higher than your own.` | `createAccount()`, `inviteUser()` |
| `INVALID_PARAMETER` | `"access_group" should be within range: 0 ~ 99.` | `grantAccess()` |
| `INVALID_REQUEST` | `User cannot self grant access.` | `grantAccess()` |
| `INVALID_REQUEST` | `Cannot grant access to suspended user.` | `grantAccess()` on a blocked account |
| `INVALID_REQUEST` | `User signup is not confirmed.` | `grantAccess()` on an account that has not confirmed its signup |
| `INVALID_REQUEST` | `Cannot modify attributes of the current user.` | `updateUserAttributes()` with your own `user_id`, from the SDK and from the server |
| `INVALID_PARAMETER` | `At least one attribute to update is required.` | The SDK, `updateUserAttributes()` with only a `user_id` |
| `INVALID_PARAMETER` | `No user_id provided.` | `updateUserAttributes()`, `updateProfile()` with another `user_id` |
| `INVALID_PARAMETER` | `No attributes provided.` | `updateUserAttributes()`, `updateProfile()` with another `user_id`, when the request carries no attributes at all |
| `INVALID_PARAMETER` | `"<attribute>" cannot be changed.` | `updateUserAttributes()`, for an attribute outside the editable list |
| `INVALID_REQUEST` | `The login username is permanent and cannot be changed.` | `updateUserAttributes()` sending a login handle without an `email` |
| `EXISTS` | `E-mail "user@email.com" is already a login ID in this service.` | `createAccount()`, `inviteUser()`, `updateUserAttributes()` |
| `EXISTS` | `Username "jane@email.com" is already a login ID in this service.` | `createAccount()`, `inviteUser()` |
| `EXISTS` | `User already exists.` | `createAccount()`, `inviteUser()`, `resendInvitation()`, `cancelInvitation()` on an account that already exists |
| `EXISTS` | `User is already invited.` | `inviteUser()` while an invitation to that address is pending |
| `NOT_EXISTS` | `No invitation found for: user@email.com.` | `resendInvitation()`, `cancelInvitation()` |
| `INVALID_REQUEST` | `User limit exceeded.` | `createAccount()`, `inviteUser()` |
| `INVALID_REQUEST` | `Monthly email send limit reached. Consider upgrading your plan.` | `inviteUser()`, `resendInvitation()`: an invitation e-mail counts as one of the project's monthly e-mail sends. See [Inviting Users](#doc-admin-invite) |
| `INVALID_REQUEST` | `E-mail sending is turned off for this project because of spam complaints.` | `inviteUser()`, `resendInvitation()`, while the project's newsletter sending is turned off for complaints |
| `INVALID_REQUEST` | `User cannot block self.` | `blockAccount()` with your own `user_id` |
| `INVALID_REQUEST` | `User cannot unblock self.` | `unblockAccount()` with your own `user_id` |
| `INVALID_REQUEST` | `Admin access only.` | `deleteAccount()` on another account, from an account that is not an admin |
| `NOT_EXISTS` | `User does not exists.` | `grantAccess()`, `blockAccount()`, `unblockAccount()`, `deleteAccount()` |
| `NOT_EXISTS` | `User not found` | `updateUserAttributes()`, `updateProfile()` with another `user_id` |

### Records and files

| Code | Message | Returned by |
| --- | --- | --- |
| `INVALID_REQUEST` | `User has no access to this group.` | `postRecord()` uploading above your own access group |
| `INVALID_REQUEST` | `No access to the record.` | `getRecords()` fetching one record by `record_id` that is in a higher access group than the caller's own, admins in access groups `90` ~ `98` included |
| `INVALID_REQUEST` | `User has no private access.` | `getRecords()` fetching one private record by `record_id`, from an account without private access to it, admins in access groups `90` ~ `98` included |
| `INVALID_REQUEST` | `User has no access to update this record.` | `postRecord()` on another user's record, from an account that is not an admin |
| `INVALID_REQUEST` | `Record is read only.` | `postRecord()` on a read only record, from anyone but the project owner and access group `99` admins |
| `INVALID_REQUEST` | `Admins can only change the subscription settings of another user's record (<field> differs).` | `postRecord()` from admins in access groups `90` ~ `98` |
| `INVALID_REQUEST` | `Only the owner of a record can move it into or out of the private access group.` | `postRecord()` from the project owner or an admin on another user's private record |
| `INVALID_REQUEST` | `User has no access to change the private access of the record.` | `postRecord()` moving another user's record out of `private` |
| `INVALID_REQUEST` | `Records of the project owner cannot have subscription settings.` | `postRecord()` |
| `INVALID_REQUEST` | `Anonymous records cannot have subscription settings.` | `postRecord()` |
| `INVALID_REQUEST` | `The project owner cannot upload records to the private access group.` | `postRecord()` from the project owner |
| `INVALID_REQUEST` | `The project owner cannot upload read-only records.` | `postRecord()` from the project owner |
| `INVALID_REQUEST` | `The record should be owned by the user.` | `deleteFiles()` from an account that is not an admin |
| `INVALID_REQUEST` | `Only the owner of a private record can delete its files.` | `deleteFiles()` on a private record |
| `INVALID_REQUEST` | `Record should be owned by the user.` | `removePrivateRecordAccess()` from an account that is not an admin |
| `INVALID_REQUEST` | `Private access cannot be granted to service owners.` | `grantPrivateRecordAccess()` naming the project owner |
| `INVALID_REQUEST` | `Database is frozen. Write access is denied for this user.` | `postRecord()`, `uploadFiles()`, `deleteFiles()` while the database is frozen |
| `INVALID_REQUEST` | `Database is frozen. Delete access is denied for this user.` | `deleteRecords()` while the database is frozen |
| `INVALID_PARAMETER` | `"size" is required and must be the exact byte size of the file, an integer of 0 or more.` | A hosting or file store upload request that does not declare its exact size |
| `INVALID_REQUEST` | `No access.` | Uploading or deleting a file of the hosted site, and refreshing its CDN cache, from anyone but the project owner, access group `99` admins and Skapi staff |

### Newsletters and notifications

| Code | Message | Returned by |
| --- | --- | --- |
| `INVALID_REQUEST` | `No access.` | `getNewsletterSubscription()` searching by `email`, from anyone but the project owner, Skapi staff and admins in access group `99`, admins in access groups `90` ~ `98` included. Also reading another user's subscriptions from an account that is not an admin |
| `INVALID_PARAMETER` | `"startKey" does not belong to this request.` | `getNewsletterSubscription({ group })`, for a paging cursor that was rebuilt, edited, minted for another project, owner or group, or sent by the other side of the masking line |
| `INVALID_PARAMETER` | `"group" is required to search by "email".` | `getNewsletterSubscription()` |
| `INVALID_PARAMETER` | `"email" cannot be used with "user_id".` | `getNewsletterSubscription()` |
| `INVALID_PARAMETER` | `"email" should be 1 ~ 255 characters.` | `getNewsletterSubscription()`, before the access check above |
| `INVALID_REQUEST` | `User has no access to send notifications` | `pushNotification()` from an account that is not an admin |
| `INVALID_PARAMETER` | `"user_ids" can name up to 1000 users.` | `pushNotification()` with more than 1000 user IDs |

### Project settings

| Code | Message | Returned by |
| --- | --- | --- |
| `INVALID_REQUEST` | `Only the project owner can change project settings.` | Every [project settings](#doc-admin-permissions-project-settings-belong-to-the-project-owner) request from an account that is not the project owner's Skapi account, admins in access group `99` included |
| `INVALID_REQUEST` | `Only the project owner can register tickets.` | Registering, updating or deleting a [ticket](#doc-tickets-introduction) as an admin |

# Inviting Users <a id="doc-admin-invite"></a>

Admins can invite users to the project by using the [`inviteUser()`](#doc-api-reference-admin-inviteuser) method.

When a user is invited, an invitation email is sent to the user with a link to accept the invitation.

In the invitation email, the user will see the login email and randomly generated password, and a link to accept the invitation.

User should click on the link to accept the invitation within 7 days and they will be able to login to the project using the email and password provided in the invitation email. Accepting the invitation verifies the email address, so for an invitation created with a `username` both the username and the email log the account in from that moment, unless that address is already a login ID another account was granted; see [Setting the Invited User's Profile](#doc-admin-invite-setting-the-invited-user-s-profile).

:::warning Invitations are e-mail sends
Every invitation e-mail counts as **one of the project's monthly e-mail sends**, the same count a newsletter uses, and a resent invitation counts again. Past the plan's monthly limit an invitation is refused with `Monthly email send limit reached. Consider upgrading your plan.`, and no account is created. A project whose e-mail sending is turned off for spam complaints is refused with `E-mail sending is turned off for this project because of spam complaints.` The limits per plan are in [Sending Newsletters](#doc-email-newsletters-sending-limits) and [Plans and Limits](#doc-introduction-plans).
:::

An AI agent can do this for you. The Skapi MCP server sends, resends, cancels and lists invitations from a prompt, for the project owner and for admins of the project. See [Connecting the Skapi MCP server](#doc-introduction-getting-started-connecting-the-skapi-mcp-server).

This example demonstrates using the [`inviteUser()`](#doc-api-reference-admin-inviteuser) method to invite a user to the project.
When the request is successful, the string "SUCCESS: Invitation has been sent. (User ID: xxx...)" is returned.

:::code-group

```html [Form]
<form onsubmit="skapi.inviteUser(event).then(user => console.log(user))">
    <input name="email" placeholder="E-Mail" required/>
    <input type="submit" value="Invite" />
</form>
```

```js [JS]
skapi.inviteUser(
    {
        email: 'user@email'
    },
).then(user => {
    console.log(user);
    /*
    Returns:
    "SUCCESS: Invitation has been sent. (User ID: xxx...)"
    */
});
```
:::

:::tip
The user will be able to login to the project using the email and password provided in the invitation email.
It is recommended to change the password after the first login.
:::

:::warning
An invitation is refused with `EXISTS` when its email is already a login ID another account was granted,
its own login ID or an address it has verified, for example the verified email of a user who was created
with a `username`, and, for an invitation with a `username`, also when the email is the login ID another
account was created with:
`E-mail "user@email.com" is already a login ID in this service.` A `username` is refused the same way
only when it is an e-mail address another account was granted that way:
`Username "jane@email.com" is already a login ID in this service.` An email login another account holds
without having verified the address blocks nothing: it is removed, that account keeps logging in with the
login ID it was created with, and the invitation goes out. See
[Login IDs](#doc-admin-permissions-login-ids). While an invitation to an email is
still pending, a second invitation to that email is refused with `User is already invited.`, whatever the
`username` of either invitation. Once the first invitation was accepted, `User is already invited.` means
the invitation's own login ID (its `username`, or its email without one) was already used to create or
invite another account, and otherwise the result depends on the `username` of both invitations. See
[inviteUser() errors](#doc-api-reference-admin-inviteuser),
[Inviting the same email again](#doc-api-reference-admin-inviting-the-same-email-again) and
[When a login ID is already taken](#doc-authentication-create-account-when-a-login-id-is-already-taken).
:::

For more detailed information on all the parameters and options available with the [`inviteUser()`](#doc-api-reference-admin-inviteuser) method,
please refer to the API Reference below:

### [`inviteUser(params, options?): Promise<'SUCCESS: Invitation has been sent. (User ID: xxx...)'>`](#doc-api-reference-admin-inviteuser)

## Inviting from the dashboard

Click **+ Add User** on the **Users** page of your project and choose **Send Invitation**. Enter the **Email**, and optionally a **Redirect URL** to open after the user accepts, a permanent **Username**, and any profile fields. Click **Send Invitation**. The account appears in the list once the invitation is accepted.

![The Add User form in Send Invitation mode, with Email, Redirect URL and Username fields above the profile sections, and a Send Invitation button](https://docs.skapi.com/screenshots/users-invite.webp)

*Send Invitation. Skapi emails a temporary password and an acceptance link, and the user has 7 days to accept.*

## Setting the Invited User's Profile <a id="doc-admin-invite-setting-the-invited-user-s-profile"></a>

You can fill in the invited user's profile as part of the invitation. Every attribute you pass is
written to the account when the invitation is sent, and it stays as you set it when the user accepts:
accepting only activates the account and marks its email verified.

:::code-group

```html [Form]
<form onsubmit="skapi.inviteUser(event).then(res => console.log(res))">
    <input name="email" placeholder="E-Mail" required/>
    <input name="username" placeholder="Username (optional)"/>
    <input name="name" placeholder="Name"/>
    <input name="phone_number" placeholder="+0012341234"/>
    <input name="access_group" type="number" min="1" max="99" placeholder="Access Group"/>
    <input type="submit" value="Invite" />
</form>
```

```js [JS]
skapi.inviteUser(
    {
        email: 'user@email.com',
        username: 'jane_doe',
        name: 'Jane Doe',
        phone_number: '+0012341234',
        birthdate: '1990-01-01',
        access_group: 10
    }
).then(res => {
    console.log(res);
    // SUCCESS: Invitation has been sent. (User ID: xxx...)
});
```
:::

A few of these behave in ways worth knowing:

- **`username`** is optional. When you set one it is the account's permanent login ID and can never be
  changed. Accepting the invitation verifies the email address, so the email it was sent to logs the
  account in as well from then on, unless that address is already a login ID another account was
  granted. While the invitation is pending, the email does not log the account in. See
  [E-Mail and username](#doc-authentication-create-account-e-mail-and-username).
- **`access_group`** defaults to `1` when left out. Set it here to invite someone straight into a
  higher group instead of granting access after they accept. An admin cannot invite someone into an
  access group higher than their own.
- **`email_public`** and the other `*_public` flags default to `false`.

To show the username in the invitation e-mail itself, use the optional `${username}` placeholder in
your template. See [Optional placeholders](#doc-email-email-templates-optional-placeholders).

## Redirecting After Acceptance

Pass `confirmation_url` to send the user to a page of your own once they accept the invitation.
Setting it also lets you subscribe them to [Service Email](#doc-email-newsletters-subscribing-users-to-service-email) at the same time with `email_subscription`,
which is only accepted together with `confirmation_url`.

:::code-group

```html [Form]
<form onsubmit="skapi.inviteUser(event, {
        confirmation_url: 'https\:\/\/your.website.com/welcome',
        email_subscription: true
    }).then(res => console.log(res))">
    <input name="email" placeholder="E-Mail" required/>
    <input type="submit" value="Invite" />
</form>
```

```js [JS]
skapi.inviteUser(
    { email: 'user@email.com' },
    {
        confirmation_url: 'https://your.website.com/welcome',
        email_subscription: true
    }
).then(res => {
    console.log(res);
    // SUCCESS: Invitation has been sent. (User ID: xxx...)
});
```
:::

:::warning
`confirmation_url` must not contain the `#` character, and `email_subscription` without a
`confirmation_url` throws `INVALID_PARAMETER`.
:::

## Send Invitations with Custom Templates <a id="doc-admin-invite-send-invitations-with-custom-templates"></a>

Invitation emails are sent using the default template configured in your Skapi project.

You can use your own custom HTML email template by providing a template URL and custom subject line.

:::code-group

```html [Form]
<form onsubmit="skapi.inviteUser(event, {
        template: {
            url: 'https\:\/\/your.custom.template/file.html',
            subject: 'Exclusive Invitation'
        }
    }).then(user => console.log(user))">
    <input name="email" placeholder="E-Mail" required/>
    <input type="submit" value="Invite" />
</form>
```

```js [JS]
skapi.inviteUser(
    {
        email: 'user@email'
    },
    {
        template: {
            url: 'https://your.custom.template/file.html',
            subject: 'Exclusive Invitation'
        }
    }
).then(user => {
    console.log(user);
    /*
    Returns:
    "SUCCESS: Invitation has been sent. (User ID: xxx...)"
    */
});
```
:::

:::danger
The template must include required placeholders in the HTML content.
For more information, see [Required Placeholders for Invitation E-Mail](#doc-email-email-templates-required-placeholders-for-invitation-email).
:::

The optional `${name}` and `${username}` placeholders are available as well. `${username}` renders the
account's login username when the invitation was created with one, and the invited person's e-mail
address otherwise. A resent invitation renders the same value as the first e-mail; see
[Resending Invitations](#doc-admin-invite-resending-invitations) for the one exception.

## Resending Invitations <a id="doc-admin-invite-resending-invitations"></a>

Admins can resend invitations to users who have not accepted the invitation by using the [`resendInvitation()`](#doc-api-reference-admin-resendinvitation) method.

A resent invitation is an e-mail too: it counts as one more of the project's monthly e-mail sends, and past the plan's limit it is refused with the same `Monthly email send limit reached. Consider upgrading your plan.`

This example demonstrates using the [`resendInvitation()`](#doc-api-reference-admin-resendinvitation) method to resend an invitation to a user.
When the request is successful, the string "SUCCESS: Invitation has been re-sent. (User ID: xxx...)" is returned.

:::code-group

```html [Form]
<form onsubmit="skapi.resendInvitation(event).then(user => console.log(user))">
    <input name="email" placeholder="E-Mail" required/>
    <input type="submit" value="Resend Invitation" />
</form>
```

```js [JS]
skapi.resendInvitation(
    {
        email: 'user@email'
    },
).then(user => {
    console.log(user);
    /*
    Returns:
    "SUCCESS: Invitation has been re-sent. (User ID: xxx...)"
    */
});
```
:::

The resent e-mail carries the same temporary password as the first one and renders `${username}` as the first e-mail did:
the login username for an invitation created with a `username`, the email otherwise. The exception is an
invitation sent before the backend started keeping the plain username with the invitation: its resend
renders the email, even though that address does not log a `username` invitee in until they accept.

Resending is refused with `No invitation found for: user@email.com.` (`NOT_EXISTS`) when no invitation
is found for the email, and with `User already exists.` (`EXISTS`) when the account found for it is not a
pending invitation. Which of the two an accepted invitation gives depends on how it was made: one made
without a `username` gives `User already exists.`, and one made with a `username` gives
`No invitation found for: user@email.com.` See
[`resendInvitation()`](#doc-api-reference-admin-resendinvitation).

## Getting Sent Invitations

Admins can get a list of invitations that have been sent by using the [`getInvitations()`](#doc-api-reference-admin-getinvitations) method.

This example demonstrates using the [`getInvitations()`](#doc-api-reference-admin-getinvitations) method to get a list of invitations that have been sent.
When the request is successful, the [DatabaseResponse](#doc-api-reference-data-types-databaseresponse) containing the list of invitations is returned.

The `email` parameter can be used to filter the invitations by email.
When the `email` parameter is set, only invitations with the email starting with the given string will be returned.

:::code-group

```html [Form]
<form onsubmit="skapi.getInvitations(event).then(invitations => console.log(invitations))">
    <input name="email" placeholder="Search for email"/>
    <input type="submit" value="Get Invitations" />
</form>
```

```js [JS]
skapi.getInvitations(
    {
        email: 'user@email'
    },
).then(invitations => {
    console.log(invitations);
    /*
    Returns:
    {
        list: [
            {
                email: 'user@email',
                ...
            },
            ...
        ],
        ...
    }
    */
});
```
:::

For more detailed information on all the parameters and options available with the [`getInvitations()`](#doc-api-reference-admin-getinvitations) method,
please refer to the API Reference below:

### [`getInvitations(params?, fetchOptions?): Promise<DatabaseResponse<UserProfile>>`](#doc-api-reference-admin-getinvitations)

## Cancelling Invitations <a id="doc-admin-invite-cancelling-invitations"></a>

Admins can cancel invitations that have been sent by using the [`cancelInvitation()`](#doc-api-reference-admin-cancelinvitation) method.

This example demonstrates using the [`cancelInvitation()`](#doc-api-reference-admin-cancelinvitation) method to cancel an invitation that has been sent.
When the request is successful, the string "SUCCESS: Invitation has been canceled." is returned.

:::code-group

```html [Form]
<form onsubmit="skapi.cancelInvitation(event).then(response => console.log(response))">
    <input name="email" placeholder="E-Mail" required/>
    <input type="submit" value="Cancel Invitation" />
</form>
```

```js [JS]
skapi.cancelInvitation(
    {
        email: 'user@email'
    },
).then(response => {
    console.log(response);
    /*
    Returns:
    "SUCCESS: Invitation has been canceled."
    */
});
```
:::

# Managing Users <a id="doc-admin-account"></a>

Admins can manage user accounts by creating, deleting, blocking, and unblocking accounts. Admins can also grant access to users and cancel invitations.

Every action on this page can also be done without code, from the **Users** page of your project in the Skapi dashboard. You are the project owner there, so the owner's rules on this page apply.

An AI agent can do it for you as well. The Skapi MCP server searches users, creates accounts, grants access groups, updates profiles, and blocks, unblocks and deletes accounts from a prompt, for the project owner and for admins of the project, under the same rules. See [Connecting the Skapi MCP server](#doc-introduction-getting-started-connecting-the-skapi-mcp-server).

![The Users page of the Skapi dashboard: a table of accounts with Email, Name, Approval and Misc columns, a search bar, and the selection icons for grant access, unblock, block and delete](https://docs.skapi.com/screenshots/users-list.webp)

*The Users page. Tick accounts with the checkboxes, then use the icons at the top right of the table: **Grant access**, **Unblock users**, **Block users** and **Delete users**. Clicking a row opens the account's own form.*

## Assigning Access Group to Users <a id="doc-admin-account-assigning-access-group-to-users"></a>

Users that have admin access can grant access to other users by using the [`grantAccess()`](#doc-api-reference-admin-grantaccess) method.

This example demonstrates using the [`grantAccess()`](#doc-api-reference-admin-grantaccess) method to grant access to a user.

When the request is successful, the string "SUCCESS: Access has been granted to the user." is returned.

You can grant user access group levels from 1 to 99.

Access groups `90` and above are admins, and `'admin'` is accepted as a name for `99`, the group that is exempt from the access group rules on this page. See [Roles](#doc-admin-permissions-roles).

An admin cannot grant an access group higher than their own: an admin in access group `95` can grant up to `95`, and the request fails with `Cannot grant an access group higher than your own.` otherwise. The project owner has no such limit.

An admin in access group `99` cannot be expelled by a lower admin: only another access group `99` admin or the project owner can change their access group. Anyone else gets `No access to expel admin.`

An admin in access groups `90` ~ `98` also cannot change the access group of an account that is already in the same access group as their own or a higher one. That request is refused with `INVALID_REQUEST` and `No access to modify admin.`, and nothing is changed. Admins in access group `99` and the project owner are not limited by it. See [The rank rule](#doc-admin-permissions-the-rank-rule).

The account must be usable, too: a blocked account is refused with `Cannot grant access to suspended user.`, and an account that has not confirmed its signup with `User signup is not confirmed.` You cannot change your own access group: `User cannot self grant access.`

:::code-group
```html [Form]
<form onsubmit="skapi.grantAccess(event).then(response => console.log(response))">
    <input name="user_id" placeholder="User ID" required/>
    <input name="access_group" placeholder="Access Group" required/>
    <input type="submit" value="Grant Access" />
</form>
```

```js [JS]
skapi.grantAccess(
    {
        user_id: 'user_id',
        access_group: 1
    },
).then(response => {
    console.log(response);
    /*
    Returns:
    "SUCCESS: Access has been granted to the user."
    */
});
```
:::

### In the dashboard

Open the **Users** page, tick the accounts to change, and click the **Grant access** icon at the top right of the table. Enter the access group, from `1` to `99`, and click **Grant**. Every selected account gets the same group.

![The Grant Access dialog over the Users page, asking for an access group between 1 and 99 for the two selected users](https://docs.skapi.com/screenshots/users-grant-access.webp)

*Grant Access sets one access group on every selected account.*

To change one account, click its row instead. The **Access** section of the form has an **Access Group** field, from `0` to `99`. Change it and click **Update**.

![An account's form on the Users page, with User Info at the top and an Access section holding the Access Group field and the Blocked checkbox](https://docs.skapi.com/screenshots/users-detail.webp)

*An account's form. Access Group and Blocked sit in the Access section, and Delete is at the bottom.*

## Creating User Accounts <a id="doc-admin-account-creating-user-accounts"></a>

Admins can create user accounts by using the [`createAccount()`](#doc-api-reference-admin-createaccount) method.

This example demonstrates using the [`createAccount()`](#doc-api-reference-admin-createaccount) method to create a user account.

**You should provide the user's email and password.** Other user attributes are optional.

:::code-group
```html [Form]
<form onsubmit="skapi.createAccount(event).then(response => console.log(response))">
    <input name="email" placeholder="E-Mail" required/>
    <input name="password" placeholder="Password" required/>
    <input name="name" placeholder="Name"/>
    <input type="submit" value="Create Account" />
</form>
```

```js [JS]
skapi.createAccount(
    {
        email: 'user@email',
        password: 'password',
        name: 'User Name'
    },
).then(response => {
    console.log(response);
    /*
    Returns:
    The created user's profile object (UserProfile & { email_admin: string; username: string }).
    */
});
```
:::

When the request is successful, the created user's profile object (UserProfile & { email_admin: string; username: string }) is returned.

And the user will be able to log in with the created email and password right away, when you create the account without a `username`: that address is then the account's own login ID.

[`createAccount()`](#doc-api-reference-admin-createaccount) method does not require any confirmation from the user.

If you pass a `username`, it is the account's permanent login ID and cannot be changed later. The account
logs in with that username right away, and with its email only once that address is verified.

:::warning An account created with a username has no email login yet
`createAccount()` does not verify the email address, whoever creates the account. So an account you
create with a `username` logs in with that username alone until the user verifies the address with
[`verifyEmail()`](#doc-api-reference-user-verifyemail), and email login then starts a few seconds
later, on the account's next token. An account created without a `username` is unaffected: the address
it was created with is its own permanent login ID and works from the start. See
[Login IDs](#doc-admin-permissions-login-ids).
:::

Without a `username`, the request is refused with `EXISTS` and
`E-mail "user@email.com" is already a login ID in this service.` when the email is already a login ID
another account was granted: the address that account logs in with, or an address it has verified.
An email that another account was itself created or invited with, without a `username`, is the same
login ID again and gives `User already exists.` instead.

With a `username`, the same `E-mail` error is given whenever the email is a login ID another account was
granted, either as the login ID that account was created or invited with or as its verified email. The
`username` itself is refused only when it is an e-mail address another account was granted that way
(`Username "jane@email.com" is already a login ID in this service.`, or
`The login ID is already used by another account in this service.` when the refusal comes from the check
made as the account is created). A `username` that another account was already created or invited with
gives `User already exists.`, for example when the same account is created twice. See the
[`createAccount()` errors](#doc-api-reference-admin-createaccount) and
[When a login ID is already taken](#doc-authentication-create-account-when-a-login-id-is-already-taken).

An email login another account holds **without** having verified the address blocks nothing. It is
removed, that account keeps logging in with the login ID it was created with, and your request goes
through.

For more detailed information on all the parameters and options available with the [`createAccount()`](#doc-api-reference-admin-createaccount) method.

### In the dashboard

Click **+ Add User** on the **Users** page and keep **Create Account** selected. **Email** and **Password** (at least 6 characters) are required. **Username** is optional and permanent once set. The other sections fill in the profile, and the **Public Visibility** checkboxes decide which of those attributes other users can see. Click **Create**, and the account can log in right away.

![The Add User form in Create Account mode, with the Email, Password and Username fields, then the Identity, Contact, Profile, Public Visibility and Misc sections](https://docs.skapi.com/screenshots/users-create.webp)

*Create Account. The account exists as soon as you click Create, with the password you typed.*

## Updating User Attributes <a id="doc-admin-account-updating-user-attributes"></a>

Admins can update any user's profile attributes by using the [`updateUserAttributes()`](#doc-api-reference-admin-updateuserattributes) method.

You must provide the target user's `user_id`, along with at least one attribute to update. Any attribute you omit is left unchanged.

When the request is successful, the string "SUCCESS: User attributes updated." is returned.

:::code-group
```html [Form]
<form onsubmit="skapi.updateUserAttributes(event).then(response => console.log(response))">
    <input name="user_id" placeholder="User ID" required/>
    <input name="name" placeholder="Name"/>
    <input name="misc" placeholder="Misc"/>
    <input type="submit" value="Update Attributes" />
</form>
```

```js [JS]
skapi.updateUserAttributes(
    {
        user_id: 'xxx...', // User ID to update.
        name: 'New Name',
        misc: 'Updated by admin'
    },
).then(response => {
    console.log(response);
    /*
    Returns:
    "SUCCESS: User attributes updated."
    */
});
```
:::

The updatable attributes are the same profile attributes used in [`createAccount()`](#doc-api-reference-admin-createaccount): `name`, `email`, `phone_number`, `address`, `gender`, `birthdate`, `misc`, `picture`, `profile`, `website`, `nickname`, and the `*_public` visibility flags.

### When the change takes effect <a id="doc-admin-account-when-the-change-takes-effect"></a>

An admin's update is written to the user's **account** right away: the attributes the request carries
are stored on the login account the moment the request returns.

What your app reads, though, is the user's **record**: the profile that
[`getUsers()`](#doc-api-reference-user-getusers) returns, that the dashboard's **Users** page
lists, and that other users see when they look the user up. The record is a copy of the account that
is rewritten each time the user is issued a token, and an admin's update does not issue one. So the
record keeps the previous values until the user next **logs in**, or until the session the user already
has renews its token. A token lasts up to a day, and a session renews it on its first request after it
expires. From that moment the record carries the new values everywhere.

Until then:

- The **Users** page shows the new values on the row you edited, but shows the previous values again
  after a reload, because a reload reads the record.
- `getUsers()`, and every other user's view of the profile, return the previous values.
- The user's own session is in the same position: [`getProfile()`](#doc-api-reference-authentication-getprofile)
  answers from the token the user holds, so the user sees the previous values until that login or
  renewal, and their own `updateProfile()` in the meantime starts from them.

`misc` is the one exception: it is written to the record in the same request, so `getUsers()` and the
Users page show the new value at once. The user's own session still catches up at the next login or
renewal.

Nothing is lost in between: the account holds the new values, and the next token carries them into
the record. When a user has to see an update right away, ask them to log out and log in again.

:::warning Changing a user's email takes their email login away
The new address is written **unverified**, whoever sends the request, the project owner and admins in
access group `99` included: this method cannot mark an e-mail address verified. The account's email
login is removed in the same request, so no email reaches it until the user verifies the new address
with [`verifyEmail()`](#doc-api-reference-user-verifyemail), and then a few seconds later, on the
account's next token rather than in the `verifyEmail()` response.

The account keeps logging in with its `username`, or with the address it was created with when it was
created without one. Make sure the new value is correct before setting the matching `email_public` or
`phone_number_public` flag. See [Login IDs](#doc-admin-permissions-login-ids).
:::

Sending the address the account already has, already verified, is not a change: it is left out of the
request, and the account keeps its verification and its email login.

An admin in access groups `90` ~ `98` can update only accounts whose access group is lower than their
own. Updating an account in the same access group or a higher one is refused with
`INVALID_REQUEST` and `No access to modify admin.`, and nothing in that request is changed. A
[disabled](#doc-user-account-disable-recover-account) account counts with the access group it had before
it was disabled. The project
owner and admins in access group `99` can update every account. See
[The rank rule](#doc-admin-permissions-the-rank-rule).

Nobody updates their **own** account with this method, the project owner and admins in access group `99`
included. Your own `user_id` is refused with `INVALID_REQUEST` and
`Cannot modify attributes of the current user.`, before anything is read or written. Update your own
profile with [`updateProfile()`](#doc-api-reference-user-updateprofile) and no `user_id`, the same
call any user makes.

An `email` that is already a login ID another account was granted, as the login ID that account was
created with or as an address it has verified, is refused with `EXISTS` and
`E-mail "user@email.com" is already a login ID in this service.`, and nothing in that request is changed.
An email login another account holds without having verified the address is removed instead, and your
request goes through. The server derives the email login from the new address itself.
A login `username` is permanent: no admin method changes it, and a request that tries to set the login
handle without an `email` is refused with `INVALID_REQUEST` and
`The login username is permanent and cannot be changed.` See
[`updateUserAttributes()`](#doc-api-reference-admin-updateuserattributes).

[`updateProfile()`](#doc-api-reference-user-updateprofile) called with another user's `user_id`
sends this same request, so it follows the same rules and gives the same errors. Called with your own
`user_id` it drops the ID and updates your own profile instead, which is the call every user makes.

### In the dashboard

Click the account's row on the **Users** page, edit the fields of the form, and click **Update**. The
row shows the new values at once, and a notice at the bottom of the page reminds you that the change
takes effect when the user next logs in: see [When the change takes effect](#doc-admin-account-when-the-change-takes-effect).

## Deleting User Accounts <a id="doc-admin-account-deleting-user-accounts"></a>

Admins can delete user accounts by using the [`deleteAccount()`](#doc-api-reference-admin-deleteaccount) method.

This example demonstrates using the [`deleteAccount()`](#doc-api-reference-admin-deleteaccount) method to delete a user account.

When the request is successful, the string "SUCCESS: Account has been deleted." is returned.

```js [JS]
skapi.deleteAccount(
    {
        user_id: 'xxx...' // User ID to delete.
    },
).then(response => {
    console.log(response);
    /*
    Returns:
    "SUCCESS: Account has been deleted."
    */
});
```

:::warning
This action is irreversible. Once an account is deleted, it cannot be recovered.
All the user's data will be deleted from the database.
:::

An admin in access groups `90` ~ `98` can delete only accounts whose access group is lower than their
own. Any other account is refused with `INVALID_REQUEST` and `No access to modify admin.`, and nothing
is deleted. A [disabled](#doc-user-account-disable-recover-account) account counts with the access group
it had before it was disabled. Admins in access group `99` and the project owner can delete any account
of the project. See [The rank rule](#doc-admin-permissions-the-rank-rule).

Passing your own `user_id` deletes your own account, and is not ranked.

### In the dashboard

Tick the accounts on the **Users** page and click the **Delete users** icon, or click **Delete** at the bottom of an account's form. The dialog says how many accounts will be deleted. Click **Delete** to confirm.

![The Delete Users dialog: two users will be permanently deleted, all of their data will be removed, and the action cannot be undone](https://docs.skapi.com/screenshots/users-delete.webp)

*Delete Users. There is no undo.*

## Blocking User Accounts <a id="doc-admin-account-blocking-user-accounts"></a>

Admins can block user accounts by using the [`blockAccount()`](#doc-api-reference-admin-blockaccount) method.

This example demonstrates using the [`blockAccount()`](#doc-api-reference-admin-blockaccount) method to block a user account.

When the request is successful, the string "SUCCESS: The user has been blocked." is returned.

```js [JS]
skapi.blockAccount(
    {
        user_id: 'xxx...' // User ID to block.
    },
).then(response => {
    console.log(response);
    /*
    Returns:
    "SUCCESS: The user has been blocked."
    */
});
```

Once an account is blocked, the user will not be able to log in to the project.

An admin in access groups `90` ~ `98` can block only accounts whose access group is lower than their own.
Any other account is refused with `INVALID_REQUEST` and `No access to modify admin.`, and nothing is
changed. Your own `user_id` is refused with `User cannot block self.` See
[The rank rule](#doc-admin-permissions-the-rank-rule).

### In the dashboard

Tick the accounts on the **Users** page, click the **Block users** icon, then **Block**. A blocked account shows **Suspended** in the **Approval** column. For one account, ticking the **Blocked** checkbox in the **Access** section of its form and clicking **Update** does the same.

![The Block Users dialog: two users will be blocked and will not be able to access your service](https://docs.skapi.com/screenshots/users-block.webp)

*Block Users.*

## Unblocking User Accounts <a id="doc-admin-account-unblocking-user-accounts"></a>

Admins can unblock user accounts by using the [`unblockAccount()`](#doc-api-reference-admin-unblockaccount) method.

This example demonstrates using the [`unblockAccount()`](#doc-api-reference-admin-unblockaccount) method to unblock a user account.

When the request is successful, the string "SUCCESS: The user has been unblocked." is returned.

```js [JS]

skapi.unblockAccount(
    {
        user_id: 'xxx...' // User ID to unblock.
    },
).then(response => {
    console.log(response);
    /*
    Returns:
    "SUCCESS: The user has been unblocked."
    */
});
```

Unblocking follows the same rule as blocking: an admin in access groups `90` ~ `98` is refused an
account in the same access group as their own or a higher one with `No access to modify admin.`, so a
lower admin cannot let back in an account a higher admin blocked. Your own `user_id` is refused with
`User cannot unblock self.`

### In the dashboard

Tick the blocked accounts, click the **Unblock users** icon, then **Unblock**. For one account, clear the **Blocked** checkbox in its form and click **Update**.

![The Unblock Users dialog: two users will be unblocked](https://docs.skapi.com/screenshots/users-unblock.webp)

*Unblock Users.*

For the full policy, including what neither admins nor the project owner can do, see
[Admin Permissions](#doc-admin-permissions).

# Hosting your website <a id="doc-hosting-hosting"></a>

Skapi hosts the files of a static website under a subdomain of `skapi.com`, served over HTTPS through a CDN. Upload the files, and they are online.

Everything about hosting is done on your project's pages at [skapi.com](https://www.skapi.com), by the **project owner's Skapi account**:

- The address, the subdomain your site is served on, is registered, changed and removed on the project's **Settings** page.
- The files, the 404 page and the CDN are managed on the project's **Web Hosting** page.

## Quick start

1. Open your project's **Settings** page and click **Register** in the **Web Hosting** card.
2. Type a name, wait for **This address is available.**, and click **Register**.
3. Wait a few minutes for the address to go live.
4. Open the **Web Hosting** page and click **+ Upload Files**, or drag your site's folder onto the file list.
5. Click **[Open]** on the **Address** row to see your site.

If your site is a single-page app, also set `index.html` as the [404 page](#doc-hosting-hosting-setting-the-404-page).

:::tip Let an AI agent do it
The Skapi MCP server can register a project's first address and upload, edit and delete the files of its site from a prompt. See [Connecting the Skapi MCP server](#doc-introduction-getting-started-connecting-the-skapi-mcp-server).
:::

## Registering your subdomain

Before you upload files, the project needs a subdomain. Open the project's **Settings** page and click **Register** in the **Web Hosting** card.

![The Web Hosting card on the Settings page of a project with no address: No hosting address yet, and a Register button](https://docs.skapi.com/screenshots/hosting-settings-empty.webp)

*The Web Hosting card on the Settings page, before an address is registered.*

The **Web Hosting** page itself sends you there while the project has no address: it opens with a notice, and **OK** takes you to the card.

![The Web Hosting page of a project with no address: a notice reading Register a web hosting subdomain in Settings first, over a page whose rows are all empty](https://docs.skapi.com/screenshots/hosting-no-address.webp)

*The Web Hosting page without an address. Nothing can be uploaded yet.*

The dialog checks the name as you type and lets you register it once it is available.

![The Web Hosting Address dialog: the name aurora typed in front of .skapi.com, the message This address is available, and a Register button](https://docs.skapi.com/screenshots/hosting-register.webp)

*The name is checked while you type. Register unlocks when the address is available.*

Under the field, the dialog says one of:

| Message | What it means |
| --- | --- |
| **Checking availability...** | The name is being looked up. |
| **This address is available.** | You can register it. |
| **This address is already in use.** | Another project has it. Choose another name. |
| **This address is reserved. Please choose another name.** | The name is one of the reserved ones described below. |

A subdomain is 5 to 32 characters long and can use lowercase letters, digits and hyphens. It cannot start or end with a hyphen, or have two hyphens in a row. Your site is then served at `https://<name>.skapi.com`.

Since every website is hosted under `skapi.com`, names that could pass as an official page are reserved and cannot be registered:

- Skapi's own addresses and brand, such as `tutorial` or `skapi-support`.
- Sign-in, payment, support and notice pages, such as `checkout`, `password-reset` or `secure-login`.
- Names of banks, payment services and other brands that are impersonated for logins, such as `paypal` or `mybank-verify`, and any brand next to a sign-in or support word, such as `netflix-login`.

A brand used for a project of your own, such as `netflix-clone` or `kakao-map-demo`, can be registered.

A new address takes a few minutes to go live. Until then the card says that it is being set up, and the **Web Hosting** page cannot list files yet.

Once it is live, the card shows the address. Click the address to open your site, and click the copy icon beside it to copy it.

![The Web Hosting card on the Settings page with an address: aurora.skapi.com with a copy icon, and Change and Remove buttons](https://docs.skapi.com/screenshots/hosting-settings-card.webp)

*The card once the address is registered.*

### Changing or removing the address

**Change** in the **Web Hosting** card moves the site to a new name. The old address is released and the new one set up, which again takes a few minutes; the file list is unavailable while the change is in process, and the name cannot be changed again until it is done.

![The Change Web Hosting Address dialog: the new name padaria-aurora in front of .skapi.com, the message This address is available, and a Change button](https://docs.skapi.com/screenshots/hosting-change.webp)

*Changing the address. The files move with the site.*

**Remove** releases the address. The site stops being served there, and the files stay in storage, so registering a new name serves them again.

![The Remove Hosting Address confirmation: your site stops being served at this address, files stay in storage, with Cancel and Remove buttons](https://docs.skapi.com/screenshots/hosting-remove.webp)

*Removing the address takes the site offline and keeps the files.*

:::warning The subdomain is a project setting
Registering a subdomain, moving it and dropping it belong to the **project owner's Skapi account**. An [admin](#doc-admin-permissions-project-settings-belong-to-the-project-owner) of the project, access group `99` included, is refused with `INVALID_REQUEST` and `Only the project owner can change project settings.`
:::

:::danger A temporarily banned subdomain
Skapi can temporarily ban a subdomain, for example one reported for abuse. While it is banned the site is offline, the **Web Hosting** page says that the address has been suspended, and the subdomain of the project cannot be changed or removed: every request is refused with `INVALID_REQUEST` and `Current subdomain "<name>" is temporarily banned.` The name stays reserved to the project during the ban.
:::

## The Web Hosting page

The **Web Hosting** page has a card for the state of the site, and the list of its files under it.

![The Web Hosting page: a card with the Address, Storage in Use, 404 Page and CDN rows, the Upload Files button, and the file list with folders and files, their size and upload time](https://docs.skapi.com/screenshots/hosting-page.webp)

*The Web Hosting page of a site with files.*

The card has four rows:

- **Address**: where the site is served. **[Open]** opens it in a new tab.
- **Storage in Use**: how much file storage the site takes.
- **404 Page**: the file that answers for an address that does not exist. See [Setting the 404 page](#doc-hosting-hosting-setting-the-404-page).
- **CDN**: whether the CDN serves the latest files. See [The CDN](#doc-hosting-hosting-the-cdn).

## Uploading your website files

On the **Web Hosting** page, click **+ Upload Files**, use the folder icon on the right of the toolbar to upload a whole folder, or drag files or folders onto the file list. On a phone, tap the list to pick files.

![An empty file list on the Web Hosting page: a cloud icon and the line Drop files or folders here, or use Upload Files](https://docs.skapi.com/screenshots/hosting-empty.webp)

*A site with no files yet. Drop your site's folder here, or use the buttons.*

While files upload, the first row of the list names the file on the wire and counts the files done. The list fills in as each file lands.

Folders are kept: a file uploaded as `css/style.css` is served at `https://<name>.skapi.com/css/style.css`. The list shows one folder at a time. Click a folder to open it, and click a file to open it on your site in a new tab. The path above the list shows where you are, and every part of it leads back up, as does the first row of an opened folder.

![The file list inside the img folder: the path reads /img, the first row /img/ leads back up, followed by a menu folder and four image files](https://docs.skapi.com/screenshots/hosting-folder.webp)

*Inside a folder. The path and the first row both lead back up.*

A folder row shows the total size of what it holds. Click a column header to sort by **Name**, **Size** or **Uploaded**, and use the column icon on the right of the header to show or hide columns.

A file uploaded to a path that already exists replaces the old file.

### Downloading and deleting files

Tick the files or folders you want. The toolbar counts the selection, and its icons download it as a ZIP file or delete it.

![The Delete Files confirmation over the file list, with one file and one folder ticked: 1 file and 1 folder will be permanently deleted from your site](https://docs.skapi.com/screenshots/hosting-delete.webp)

*Deleting a file and a folder. A folder is deleted with everything inside it.*

A folder is deleted with everything inside it, and a delete cannot be undone.

:::info
- Only the **project owner** can upload and delete hosted files. Admins of the project cannot, access group `99` included.
- A file name cannot start with `#`: that character marks a folder in the file list, and the upload is refused.
- Hosted files count toward your plan's [file storage](#doc-introduction-plans), together with record files and cloud files. The **Storage in Use** row on the page shows how much your site takes.
:::

:::danger
Since the files are hosted publicly, **DO NOT** upload any sensitive files in your hosting page.
:::

## index.html

The `index.html` file in the root of your site is served at `https://<name>.skapi.com/`, so it is the page a visitor gets for the address itself.

## Setting the 404 page <a id="doc-hosting-hosting-setting-the-404-page"></a>

The **404 Page** row on the **Web Hosting** page names the file that answers when a visitor opens an address that does not exist on your site. It reads **Not set** until you choose one.

1. Click **[Upload]** on the **404 Page** row.
2. Pick an HTML file. It is uploaded to the root of your site and set as the 404 page.

The row then shows the file, as `/404.html` in the screenshot above. **[Replace]** uploads another file in its place, and **[Remove]** clears the setting; the file itself stays in the list.

Choosing it is a project setting, so only the **project owner's Skapi account** can, and an admin is refused with `Only the project owner can change project settings.`

:::danger
If you are using an **SPA framework** such as Vue, React, or Angular, **YOU MUST** set your **`index.html`** as the 404 page, so every route of your app is answered by it.
:::

## The CDN <a id="doc-hosting-hosting-the-cdn"></a>

Your files are served through a CDN. A new file is served as soon as it is uploaded. When you overwrite or delete a file, the CDN can keep serving the old copy for a while, and the **CDN** row on the **Web Hosting** page then says **Needs refresh**.

![The Web Hosting card with the CDN row reading Needs refresh and a Refresh link](https://docs.skapi.com/screenshots/hosting-card-needs-refresh.webp)

*The CDN row after a file was overwritten or deleted.*

Click **[Refresh]** and confirm to republish every file.

![The Refresh CDN confirmation: refreshing the CDN republishes every file and takes a few minutes, uploads and deletes are paused until it finishes](https://docs.skapi.com/screenshots/hosting-cdn-refresh.webp)

*Refreshing the CDN.*

It takes a few minutes. The row reads **Refreshing. This takes a few minutes.** and uploads and deletes are paused until it finishes, and the row reads **Up to date** when it is done.

# Plans and Limits <a id="doc-introduction-plans"></a>

Every project runs on one of three plans.
The Skapi dashboard shows the Trial plan as **Free**; this documentation uses the name **Trial**, which is what the API and the plan card call it.
The limits below are per project.

| | Trial | Standard | Premium |
| --- | --- | --- | --- |
| Price | Free | $19 per month | $89 per month |
| User accounts | 50 | 50,000 | 100,000 |
| Database storage | 500 MB | 3 GB | 10 GB |
| File storage, pooled | 1 GB | 120 GB | 600 GB |
| Email storage | 50 MB | 1 GB | 5 GB |
| E-mail sends per month | 50 | 5,000 | 50,000 |
| Newsletter subscribers | 50 | 5,000 | 50,000 |
| Bandwidth per month | 10 GB | 100 GB | 400 GB |
| Queued ticket runs per month | 100 | 10,000 | 100,000 |
| Ticket log retention | 30 days | 90 days | Kept |
| Past a limit | Stop | Stop | Billed as overage |

One Trial limit is counted per owner rather than per project: across every Trial project one owner has, 200 newsletter mails per calendar month, on top of each project's own 50.
Standard and Premium are always counted per project and have no owner cap.

E-mail sends are the mails a project chooses to send: every recipient of a newsletter, and every invitation e-mail sent or resent to a user. Automated mail, such as the signup confirmation, is not counted. See [Sending Newsletters](#doc-email-newsletters-sending-limits) and [Inviting Users](#doc-admin-invite).

File storage is one pooled figure covering your web hosting, your record file attachments and your AI indexed files.

Queued ticket runs are the runs of a ticket that go on after a Respond action answered the caller: at once, after a delay or at a time. See [Queued ticket runs](#doc-introduction-plans-queued-ticket-runs).

A Trial project never expires and needs no card.

## Trial and Standard: hard stop

The Trial and Standard plans stop at their limits instead of billing you for the excess.

- New signups past the user limit are refused.
- Record writes past the database storage limit are refused.
- File uploads past the file storage limit are refused.
- Newsletter sends past the monthly limit are refused, and so are subscriptions past the subscriber limit. One newsletter costs one send per subscriber it goes to, so one send to a full Trial list of 50 subscribers uses all 50 monthly sends. A Trial project is also refused once its owner has used the 200 newsletter mails a month their Trial projects share. Invitation e-mails count as sends too, resends included, and are refused past the project's monthly limit; see [Inviting Users](#doc-admin-invite).
- When database storage or file storage goes over the plan limit, the project is suspended. Email storage never suspends a project: the oldest newsletters are deleted to make room instead.

Writes stop at the limit, so a project normally never gets far past one.
A project that is over a storage limit anyway, for example because it moved to a smaller plan, is suspended.

## Suspended projects

While a project is suspended, its end users cannot use it.
API requests, sign in, signups, file links and hosted pages are refused, and the API answers `SERVICE_DISABLED: The service is suspended.`

You keep access in the Skapi dashboard.
You can open the project, list its records, its files, its hosting files and its newsletters, and delete any of them.
Other settings are locked until the project is back to normal.

A suspended project returns to normal on its own as soon as its stored data is back under every limit of its plan.
Nothing has to be clicked, and the dashboard has a **Check again** button while you wait.
Subscribing to, or upgrading to, a plan that holds the project's data also brings it back.
Enabling a suspended project from the dashboard is refused with `Cannot enable a suspended service. Delete data until storage is under the plan limits, or upgrade the plan.`

A project that is still suspended 30 days later is permanently deleted, with all of its data.
A project with an active subscription is never deleted: it stays suspended until its data fits.

We e-mail the project owner when a project is suspended, and again at least 7 days before it would be deleted.
The dashboard shows which limits are over, by how much, the earliest date the project could be deleted, and a link to the page where that data can be deleted.

## Premium: overage

The Premium plan has no hard stop.
Past an included limit the excess is metered and added to your next invoice, and nothing is ever refused or suspended for going over.

| Metric | Rate past the included limit |
| --- | --- |
| File storage | $0.05 per GB-month |
| Database storage | $0.50 per GB-month |
| Email storage | $0.10 per GB-month |
| E-mail sends | $1.00 per 1,000 |
| Newsletter subscribers | $1.00 per 1,000 subscribers-month |
| Queued ticket runs | $1.00 per 1,000 |
| Bandwidth | $0.12 per GB, not billed yet |
| Monthly active users | Free |

Storage overage is time weighted rather than a high water mark, so a project that sits over a limit for two days pays for two days and not for the month.

## Queued ticket runs <a id="doc-introduction-plans-queued-ticket-runs"></a>

A ticket's [Respond](#doc-tickets-actions-resp-answer-the-caller) action answers the caller at once and can let the rest of the chain go on later: right away in the background, after a delay, or at a time. Every run that goes on is a **queued run**, counted for the UTC month it was queued in. Trial includes 100 a month, Standard 10,000 and Premium 100,000; Premium is billed $1.00 per 1,000 past that.

On Trial and Standard a Respond that would queue a run past the month's figure fails with `QUOTA_EXCEEDED` at that action: the ticket's other actions, and every Respond that stops, keep working, and the count resets with the month. The **Plan & Usage** card of the project shows the month's queued runs against the plan, and the Tickets page says so when they are used up. See [Going on later](#doc-tickets-actions-going-on-later-queued-runs).

The log rows a ticket writes (its consumptions and their action rows) count toward the project's database storage, shown as their own part of the Database figure on Plan & Usage. They are kept 30 days on Trial, 90 days on Standard and for good on Premium, then expire on their own; deleting a ticket deletes its logs, and the owner can [clear a ticket's log](#doc-tickets-errors-clearing-the-log) before a time at any moment.

## Bandwidth

Every plan includes a monthly bandwidth allowance, counted from CDN and storage access logs and reset at the start of each UTC month.
Your project's current figure is on its plan card in the dashboard.

Bandwidth is metered but not enforced yet.
Requests past the allowance are still served, and Premium bandwidth past the allowance is not billed.
Both will be announced before either is switched on.
When enforcement is on, a Trial or Standard project past its allowance gets `EXCEED_LIMIT: Service has exceeded monthly bandwidth limit.` on downloads and hosted pages until the next month starts.
A project is never suspended for bandwidth, and Premium is never refused.

## Email storage

Newsletters you send are stored, and they count against the plan's email storage limit.

On the Trial and Standard plans, exceeding email storage deletes the oldest newsletters to reclaim space, and the project is never suspended for it.
Trial includes 50 MB and Standard 1 GB.
On the Premium plan nothing is ever deleted and the excess is billed.

See [Sending Newsletters](#doc-email-newsletters) for the send and subscriber limits.

## Newsletter complaints

On every plan, a project stops sending newsletters when, in the current UTC month, it reaches 5 spam complaints, or a complaint rate of 0.5 percent once at least 100 mails have gone out, whichever comes first.

Newsletter sending stops, and so do invitation e-mails.
The project keeps working, its automated e-mails keep going out, and nothing about it is suspended or deleted.
An upgrade does not lift the block and it does not clear itself when the month rolls over: Skapi staff clear it after looking at the project, so contact us if yours is blocked.

Whenever a newsletter is refused, for this, for the per-owner Trial cap or for the project's own monthly sends, the sender gets a reply from the project's newsletter endpoint address saying which limit was reached.

## Cancelling and failed payments

Cancelling a paid plan keeps that plan until the end of the billing period.
The project then moves to the Trial plan.
The cancellation alone never suspends it; it is suspended only if it holds more than Trial allows on that day, and the cancel dialog in the dashboard tells you beforehand if it does.

If a payment fails, the paid plan stays in place while the payment provider retries the charge.
Only when every payment retry has failed does the project move to the Trial plan.

## Large record data <a id="doc-introduction-plans-large-record-data"></a>

Record `data` larger than 32 KB is stored as a file in your project's file storage instead of in the database, and it counts toward file storage rather than database storage.

The SDK puts it back together for you, so `record.data` still comes back whole.

# Full Examples <a id="doc-full-example-intro"></a>

The full example is a plain HTML and JavaScript template that shows every Skapi feature working, one page per feature. There is no framework and no build step: each page loads `skapi-js` from a CDN, describes what it demonstrates, and runs the real thing against your project. Open a page, try it, then view its source to see how it is done.

| Example | What it demonstrates |
| --- | --- |
| [User Account](#doc-full-example-user-account) | Sign up, log in, verify an email address, reset or change a password, update a profile, remove and recover an account. |
| [Newsletter](#doc-full-example-newsletter) | Let visitors subscribe to your newsletter with their email address. |
| [Send Inquiry](#doc-full-example-inquiry) | A contact form that sends a message to you, the project owner. |
| [Database](#doc-full-example-database) | A photo gallery: upload posts with files, like, comment, subscribe to other users and query posts in different ways. |
| [Realtime Chat](#doc-full-example-realtime) | Group chat and private messages over a WebSocket connection. |
| [RTC Video Chat](#doc-full-example-webrtc) | Video calls and a data channel between two users with WebRTC. |

## Have your project ID ready <a id="doc-full-example-intro-have-your-project-id-ready"></a>

The demos run against **your** Skapi project: the accounts, posts and messages you create in them land in your project's users and database. Every demo page takes the project ID from its address, as `?pid=<Project ID>`.

Enter your project ID below once. It is kept in this browser, and the demo links on these pages carry it from then on. You can find the project ID on your project's page at [skapi.com](https://www.skapi.com), where the Application Examples section also offers these links with the ID already filled in.

Open the example index: `https://cdn.broadwayinc.com/temp/v2/index.html?pid=<Project ID>` (put your project ID in place of `<Project ID>`).

![The index page of the Skapi HTML template: a short introduction and a menu with one entry per feature page](https://docs.skapi.com/screenshots/template-index.webp)

*The index page. Every entry opens one feature page, and the project ID travels with every link.*

You can also open every page from your project's `Settings` page at [skapi.com](https://www.skapi.com): the **Application Examples** card has an **[Open]** link per page, already carrying your project ID, and a **[Download]** link for the ZIP.

![The Application Examples card on a project's Settings page, one row per example page with an Open link, and a Download row for the ZIP](https://docs.skapi.com/screenshots/settings-examples.webp)

*Application Examples on the Settings page.*

Pages that need a logged-in user (Database, Realtime Chat, RTC Video Chat and the account pages) send you to the login page first and bring you back afterwards. Newsletter and Send Inquiry work without an account.

## Download

Download every page of the template as one ZIP file: [skapi-templates.zip](https://cdn.broadwayinc.com/temp/v2/skapi-templates.zip)

Unzip it, then either open a page with `?pid=<Project ID>` in the address or set the project ID in `service.js`. The files are plain HTML and JavaScript, so they run from any web server, or straight from the file system for everything except the video chat, which needs HTTPS or `localhost`.

## What is shared by every page

- `service.js` creates the `skapi` object every page uses. It reads the project ID from `?pid=`, keeps it in `sessionStorage`, adds it to every link and form action, and sends a visitor who is not logged in from the pages that need a user to `login.html` and back. Its `userReady` promise resolves to the logged-in user's profile, or `null`.
- `main.css` is one small stylesheet shared by every page.
- Each page is a short HTML file with the form or demo, a few lines of script, and comments that say which Skapi method does what.

# Full Example: User Account <a id="doc-full-example-user-account"></a>

The User Account pages demonstrate Skapi's account system end to end. Every step is one method call, usually with a plain HTML form as its argument.

Open the User Account demo: `https://cdn.broadwayinc.com/temp/v2/account.html?pid=<Project ID>` (put your project ID in place of `<Project ID>`).

You need your [project ID](#doc-full-example-intro-have-your-project-id-ready) to open the demo.

![The User Account page of the template: a description of each account feature with a link to its page, and the logged-in user's name with a Logout button](https://docs.skapi.com/screenshots/template-account.webp)

*`account.html`, the hub of the account pages, with a user logged in.*

![The Login page of the template: an email address field, a password field and a Login button](https://docs.skapi.com/screenshots/template-login.webp)

*`login.html`. The form is passed to `login()` as it is.*

![The Sign up page of the template: email address, password, name and the other optional fields, and a Sign up button](https://docs.skapi.com/screenshots/template-signup.webp)

*`signup.html`. Email address and password are required; the other fields are optional.*

## What it demonstrates

| Page | Method | What happens |
| --- | --- | --- |
| `signup.html` | [signup()](#doc-authentication-create-account) | Creates an account from an email address, a password and a name. With the `signup_confirmation` option a confirmation email is sent, and the account can log in only after the link in it is clicked. |
| `login.html` | [login()](#doc-authentication-login-logout) | Signs the user in. The form's `action` is the page Skapi opens afterwards, so a visitor sent here from another page goes back to it. The `autoLogin` option logs the user in again on their next visit. |
| `email-verification.html` | [verifyEmail()](#doc-user-account-email-verification) | Sends a 6 digit code to the user's email address, then verifies the address with that code. A verified address is needed to reset a password and to recover an account. |
| `forgot-password.html`, `reset-password.html` | [forgotPassword(), resetPassword()](#doc-authentication-forgot-password) | Sends a verification code, then sets a new password with it. |
| `change-password.html` | [changePassword()](#doc-user-account-change-password) | Changes the password of the logged-in user. |
| `update-profile.html` | [updateProfile()](#doc-user-account-update-account) | Edits the name, birthdate and email address, and whether the email address is shown to other users. |
| `remove-account.html` | [disableAccount()](#doc-user-account-disable-recover-account) | Removes the account. It is disabled rather than deleted right away, so it can still be recovered. |
| `recover-account.html` | [recoverAccount()](#doc-user-account-disable-recover-account) | When a removed account tries to log in, the login fails with `USER_IS_DISABLED` and the login page offers to send a recovery email. The link in it enables the account again. |

`account.html` ties the pages together: it describes each feature, links to its page, and shows who is logged in with a logout button ([logout()](#doc-authentication-login-logout)).

## Download

The pages are part of the full template: [skapi-templates.zip](https://cdn.broadwayinc.com/temp/v2/skapi-templates.zip). See [Full Examples](#doc-full-example-intro) for how to run it.

# Full Example: Newsletter <a id="doc-full-example-newsletter"></a>

The Newsletter page lets a visitor subscribe to your project's newsletter with their email address. No account is needed.

Open the Newsletter demo: `https://cdn.broadwayinc.com/temp/v2/newsletter.html?pid=<Project ID>` (put your project ID in place of `<Project ID>`).

You need your [project ID](#doc-full-example-intro-have-your-project-id-ready) to open the demo.

![The Newsletter page of the template: a short explanation, an email address field and a Subscribe button](https://docs.skapi.com/screenshots/template-newsletter.webp)

*`newsletter.html`. One email field, and the group `public` in a hidden field.*

## What it demonstrates

- `newsletter.html` is one form with an email field and a hidden `group` field set to `public`, the newsletter for everyone. The submit event is passed straight to [subscribeNewsletter()](#doc-email-newsletters): the `name` of each field is the name of the parameter.
- The method resolves to a message that the page shows to the visitor, and an error's message is shown the same way.
- You write and send the newsletters from your project's page at [skapi.com](https://www.skapi.com).

## Download

The page is part of the full template: [skapi-templates.zip](https://cdn.broadwayinc.com/temp/v2/skapi-templates.zip). See [Full Examples](#doc-full-example-intro) for how to run it.

# Full Example: Send Inquiry <a id="doc-full-example-inquiry"></a>

The Send Inquiry page is a contact form. What a visitor writes in it is emailed to you, the project owner. No account is needed.

Open the Send Inquiry demo: `https://cdn.broadwayinc.com/temp/v2/inquiry.html?pid=<Project ID>` (put your project ID in place of `<Project ID>`).

You need your [project ID](#doc-full-example-intro-have-your-project-id-ready) to open the demo.

![The Send Inquiry page of the template: name, email address, subject and message fields, and a Send Inquiry button](https://docs.skapi.com/screenshots/template-inquiry.webp)

*`inquiry.html`. The four fields are read from the form by `sendInquiry()`.*

## What it demonstrates

- `inquiry.html` is one form with the visitor's name, email address, a subject and a message. The submit event is passed to [sendInquiry()](#doc-email-inquiries), which reads the four fields from the form.
- The form is disabled while the request runs, and the method's result, or the error's message, is shown under it.

## Download

The page is part of the full template: [skapi-templates.zip](https://cdn.broadwayinc.com/temp/v2/skapi-templates.zip). See [Full Examples](#doc-full-example-intro) for how to run it.

# Full Example: Database <a id="doc-full-example-database"></a>

The Database page is a photo gallery built on the Skapi database. Every post is a record with an image file, a description and tags, and the page shows how records are uploaded with files, queried in different orders and connected to each other. It needs a logged-in user.

Open the Database demo: `https://cdn.broadwayinc.com/temp/v2/database.html?pid=<Project ID>` (put your project ID in place of `<Project ID>`).

You need your [project ID](#doc-full-example-intro-have-your-project-id-ready) to open the demo.

![The Database page of the template: an explanation of the gallery, the Post a photo form with a file input, a description, tags and a Private post checkbox, and the Posts section below](https://docs.skapi.com/screenshots/template-database.webp)

*`database.html`. The form uploads a post with its photo; the Posts section lists them four at a time.*

## What it demonstrates

| Feature | How the page does it |
| --- | --- |
| Uploading a post with a file | [postRecord()](#doc-database-create) with the form's file input, the description and the tags, in the table `posts`. The upload progress is shown on the button. See [Handling Files](#doc-database-handling-files). |
| Private posts | A checked "Private" box uploads the post to the access group `private`, so only its uploader can read it. See [Access Restrictions](#doc-database-access-restrictions). |
| Listing posts | [getRecords()](#doc-database-fetch) four at a time, newest first, with the `fetchMore` option behind the "Fetch more posts" button. |
| Most liked | A like is a record in the table `likes` that references the post, and the post was uploaded with `prevent_multiple_referencing`, so each user can like it once. The reference count orders the "Most liked" view through the reserved index `$referenced_count`. See [Referencing](#doc-database-referencing). |
| Comments and "Most commented" | A comment is a record in the table `comments` with the compound index name `comment.<record_id>`, and [getIndexes()](#doc-database-indexing) lists those index names ordered by their totals. |
| My posts, tags | The reserved index `$user_id` fetches the user's own posts, and a `tag` in the query fetches the posts with that tag. See [Tags](#doc-database-tags). |
| Subscriptions and the feed | Subscribing to an uploader with [subscribe()](#doc-database-subscription) puts their posts in the user's feed, fetched with `getFeed()`. |
| Deleting a post | [deleteRecords()](#doc-database-delete-records) on the user's own posts. |
| Who posted | [getUsers()](#doc-user-account-get-users) fetches the uploader's and each commenter's profile, cached so every user is fetched once. |

The view is chosen with the URL hash (`#mostliked`, `#mostcomment`, `#myposts`, `#myprivate`, `#feed`, `#tag=<tag>`), so each way of querying is a few lines in one function.

## Download

The page is part of the full template: [skapi-templates.zip](https://cdn.broadwayinc.com/temp/v2/skapi-templates.zip). See [Full Examples](#doc-full-example-intro) for how to run it.

# Full Example: Realtime Chat <a id="doc-full-example-realtime"></a>

The Realtime Chat page is a group chat with private messages, built on Skapi's realtime connection. It needs a logged-in user. Open it in another browser or device with a second account and join the same group to chat.

Open the Realtime Chat demo: `https://cdn.broadwayinc.com/temp/v2/realtime.html?pid=<Project ID>` (put your project ID in place of `<Project ID>`).

You need your [project ID](#doc-full-example-intro-have-your-project-id-ready) to open the demo.

![The Realtime Chat page of the template: an explanation of the realtime API, a form to create a group and the table of chat groups](https://docs.skapi.com/screenshots/template-realtime.webp)

*`realtime.html` before a group is joined: create a group, or pick one from the table.*

## What it demonstrates

| Feature | How the page does it |
| --- | --- |
| Connecting | [connectRealtime()](#doc-realtime-connecting) opens the WebSocket connection once the user is known, with one callback that receives every message. |
| Listing and creating groups | [getRealtimeGroups()](#doc-realtime-group) lists the groups that have someone in them. Creating a group is joining a name nobody has joined yet. |
| Joining a group | [joinRealtime()](#doc-realtime-group) with the group name from the URL hash; `getRealtimeUsers()` lists who is already there, and their profiles come from [getUsers()](#doc-user-account-get-users). |
| Group messages | [postRealtime()](#doc-realtime-post) with the form and the group name. Everyone in the group, including the sender, receives it as a `message`. |
| Private messages | Clicking a name in the participant list opens a dialog; `postRealtime()` with a user ID instead of a group name delivers the message to that user only, as a `private` message. |
| Joining and leaving | The `notice` messages with the codes `USER_JOINED`, `USER_LEFT` and `USER_DISCONNECTED` keep the participant list current. |

## Download

The page is part of the full template: [skapi-templates.zip](https://cdn.broadwayinc.com/temp/v2/skapi-templates.zip). See [Full Examples](#doc-full-example-intro) for how to run it.

# Full Example: RTC Video Chat <a id="doc-full-example-webrtc"></a>

The RTC Video Chat page connects two browsers directly for video, audio and text with WebRTC, with Skapi doing the signaling over its realtime connection. It needs a logged-in user, and WebRTC works over HTTPS or on `localhost`. Open it in another browser or device with a second account and call each other.

Open the RTC Video Chat demo: `https://cdn.broadwayinc.com/temp/v2/webrtc.html?pid=<Project ID>` (put your project ID in place of `<Project ID>`).

You need your [project ID](#doc-full-example-intro-have-your-project-id-ready) to open the demo.

![The RTC Video Chat page of the template: an explanation of the signaling, Video and Audio checkboxes, and the table of users online with a call button per user](https://docs.skapi.com/screenshots/template-webrtc.webp)

*`webrtc.html` before a call: the other users on the page, each with a call button.*

## What it demonstrates

| Feature | How the page does it |
| --- | --- |
| Finding each other | Every visitor of the page joins the realtime group `RTCCall`; `getRealtimeUsers()` and the `USER_JOINED` and `USER_LEFT` notices keep a table of the other users online, with their connection IDs. |
| Calling | [connectRTC()](#doc-realtime-webrtc) with the other user's connection ID and the chosen media (video, audio). The call's `connection` promise resolves once the other side accepts. |
| Receiving a call | The callee gets an `rtc:incoming` realtime message and answers it with `connectRTC()` on that message to accept, or `hangup()` to reject. |
| The streams | The `track` event carries the other user's media stream into a video element; the caller's own stream is shown muted. |
| Text over the data channel | Messages typed on the page are sent over the connection's default data channel, straight between the two browsers, and shown on both sides. |
| Hanging up | `hangup()` on either side ends the call; the `connectionstatechange` event returns both pages to the user list. |

## Download

The page is part of the full template: [skapi-templates.zip](https://cdn.broadwayinc.com/temp/v2/skapi-templates.zip). See [Full Examples](#doc-full-example-intro) for how to run it.

# Version History <a id="doc-versionlog-versions"></a>

### Current version: 2.2.6

**2.2.6**

- `consumeTicket()` resolves with **what the ticket answers**: the receipt `{ ticket_id, consume_id, user_id, is_test, timestamp, hash }` as before, or the `body` a **Respond** action of the ticket composed, as sent and whatever its status. The generic `consumeTicket<T>()` names that body's type. An answer with `stage` still rejects. See [Actions](#doc-tickets-actions-resp-answer-the-caller).
- `getTickets()` takes **`from` and `to`** (milliseconds) on a log listing (`ticket_id: '#<id>#'`), and the owner may list the **action rows** of one consumption with `ticket_id: '@<id>#<consume id>#'`: one row per action that ran, with what it sent, what came back and its error. See [Errors and Logs](#doc-tickets-errors-the-action-rows).
- New **`clearTicketLog({ ticket_id, before })`** deletes a ticket's log rows before a time (default now), in the background. The remaining count and the per-user limits stay. Project owner only. See [Clearing the log](#doc-tickets-errors-clearing-the-log).
- `TicketAction` gains the **`resp`** (Respond), **`cond`** (Check answer), **`mail`** (Send e-mail) and **`nlsd`** (Send newsletter) kinds, a **Then chain** (`actions`) on every action that answers something, and **`retry`**. An answer is checked by a Check answer placed in the Then chain: one list of rows on that answer, whole or by key, whatever its shape, and nothing else; the `req` action's own `condition` is no longer registered. A `req` now answers the **whole response**, `{ status, headers, body }`, so its Then chain reads `${response[body][key]}`, `${response[status]}` and `${response[headers][content-type]}` (header names in lowercase); before, `${response}` was the body alone. A ticket saved before keeps answering the body until it is saved again; `Ticket` gains **`rules_version`**, which tells the two apart, and the dashboard moves the references of such a ticket under `[body]` when you open it. See [A request's answer was its body](#doc-deprecated-deprecated-a-request-s-answer-was-its-body). `${result}` is gone: an action's answer is read as `${response}` in its Then chain. `TicketErrorCode` gains `QUOTA_EXCEEDED` and `ALREADY_RESPONDED`. See [Actions](#doc-tickets-actions).
- `TicketCondition.data` is **`body`**, and the request body is read as **`${body}`** and `${body[key]}` instead of `${data}`: the name says what it is, next to `params` and `headers`. This is a type break for code that builds a condition with `data` rows; registration still accepts `data` and stores `body`, stored tickets keep running as written, and listings return `body`. See [`${data}` and `data` rows are now `${body}` and `body` rows](#doc-deprecated-deprecated-data-and-data-rows-are-now-body-and-body-rows).
- `uploadFiles()` resolves with **`errors`** beside `completed` and `failed`: one entry per file storage did not accept, `{ file, name, error }`, where `error` is a `SkapiError` whose `code` is the storage code (`AccessDenied`, `EntityTooLarge` ...), `NETWORK_ERROR`, `TIMEOUT`, `ABORTED` or `TOO_MANY_REQUESTS`. New options **`timeout`**, the milliseconds one file may take (a file that takes longer is aborted and counted as failed with `TIMEOUT`; by default there is no limit), and **`throwOnFileError`**, which rejects at the first refused file with its error (the files before it stay stored). The types `UploadFilesParams`, `UploadFilesResult` and `FileUploadError` are exported. `postRecord()` is unchanged. See [Uploading Files to an Existing Record](#doc-database-handling-files-uploading-files-to-an-existing-record).
- New **`postRecords(items)`**: several `postRecord()` calls as one call, 100 records to a request. Each item is `{ data, config }`, the two arguments of `postRecord()`. Resolves with `{ list, failed }`: `list` the saved records in the order given, `failed` the refused items as `{ index, code, message }`; a refused item never rejects the call and the items after it are still saved. Files are not attached by it. The types `PostRecordsItem` and `PostRecordsResult` are exported. See [Creating Several Records at Once](#doc-database-create-creating-several-records-at-once).
- `uploadFiles()` with **five files or more asks for its upload permits 100 to a request** (`POST /get-signed-urls`) instead of one request per file; the files still go up one after another and the result is the same. A file the server refuses a permit for is listed in `failed` and `errors` with the server's code, and its `bin_endpoints` slot is an empty string. Where a region does not serve the bulk route yet, the permits are requested one per file as before. See [Many files](#doc-database-handling-files-many-files).
- **The 429 retry is bounded.** A request, or a file upload, that keeps being answered with `429` and a `Retry-After` is retried at most 6 times, each wait the server's value plus a little jitter, and then fails with `TOO_MANY_REQUESTS` and a message naming the endpoint. A `429` without `Retry-After` fails at once, as before.
- Backend (ships with the API, not the SDK): new route **`POST /get-signed-urls`**, up to 100 upload permits in one request with the same checks per file as `/get-signed-url`, a per-file answer (`list` one slot per file, `failed` by index), and existence (`check_existence` on hosting and file store uploads) answered by one listing per target folder instead of a probe per file.
- Backend (ships with the API, not the SDK): hosting and file store folder sizes are updated **once per folder per batch** of stored files instead of once per file, and the hosting down-stream worker takes ten messages per invocation, reporting each on its own.
- `Newsletter` gains **`dry`**, true on a newsletter saved without sending, and **`sent`**, the time a stored newsletter was last sent from the `Newsletters` page or by a ticket. See [Saving a newsletter without sending it](#doc-email-newsletters-saving-a-newsletter-without-sending-it).
- Backend (ships with the API, not the SDK): the `Automated Emails` page gains a **Custom** tab for the templates a ticket's `mail` action sends, identified by their template ID; `${email}` is filled in every automated e-mail, the newsletter subscription confirmation included; and a template you send is only added to its list, nothing changes until you choose it. Every newsletter address has a **Save without sending** twin: a newsletter mailed to it is stored, marked **Not sent**, and sent later from the page or by a ticket's `nlsd` action. See [Custom e-mails](#doc-email-email-templates-custom-e-mails) and [Sending Newsletters](#doc-email-newsletters).
- Backend (ships with the API, not the SDK): an automated e-mail template, and a newsletter, can be **uploaded** from the `Automated Emails` and `Newsletters` pages, as an HTML file or as plain text, instead of being mailed to the setter or sending address. The dialog and the backend make the same checks (e-mail HTML, the type's required placeholders, the size); an uploaded template is never put in use and an uploaded newsletter is stored without sending, like one mailed to the `Save without sending` address.
- Backend (ships with the API, not the SDK): a spreadsheet question (BunnyQuery's exact answers over a stored .xlsx, .xls or .csv) refuses a file it cannot read with a message that names the file and what it is (a PDF, a web page saved under the file's name, a legacy .xls under .xlsx, an empty or cut-short file), instead of the parser's own error coming back as an internal error.
- Backend (ships with the API, not the SDK): `retry` on a Send e-mail or Send newsletter action never repeats a send that was already queued, and never retries a refusal that waiting cannot change (an address that is none, a template or newsletter that no longer exists, the month's sends used up). Bookkeeping that fails after a send went out leaves a `warning` on the action's log row instead of failing the action.
- Backend (ships with the API, not the SDK): a ticket's log rows expire after the plan's retention (Free 30 days, Standard 90, Premium never), deleting a ticket deletes its logs, and the ticket rows count as database storage. Queued ticket runs are metered per month. See [Plans and Limits](#doc-introduction-plans-queued-ticket-runs).

**2.2.5**

- `forwardRequest()` and `clientSecretRequest()` find **`$CLIENT_SECRET` at any depth** of `headers`, `data` and `params`, as the server substitutes it, not only in their top-level values. `data: { auth: { key: '$CLIENT_SECRET' } }` now counts.
- A request that names a secret but carries **no `$CLIENT_SECRET` anywhere is sent without the secret name** instead of being refused with `INVALID_PARAMETER`: there is nothing for the key to fill, so it is forwarded as written, like a request that names none. A `$CLIENT_SECRET` inside a `multipart` body does not count, because that body is never substituted.

**2.2.4**

- `signup()` option **`email_subscription`** takes the newsletter to subscribe the user to once they confirm their e-mail: `0` or `'public'` (the public newsletter), `1` or `'authorized'` (Service Email), or the **name of a named newsletter group**. `true` still means Service Email. The subscription is made when the signup confirmation link is opened, with no newsletter confirmation e-mail of its own; a user whose e-mail is never confirmed is never subscribed. A named group has to exist when `signup()` is called. See [Subscribing at signup](#doc-email-newsletters-subscribing-at-signup).
- Backend (ships with the API, not the SDK): `adminSignup()` and `inviteUser()` take the same values for `email_subscription`.
- `public` and `authorized` stay reserved as group names: they are the names of the numeric groups `0` and `1` wherever a group is given.

**2.2.3**

- `forwardRequestHistory()` and `clientSecretRequestHistory()` items carry **`responded`**, the time the third-party API's response finished arriving, in milliseconds. It is recorded once and never changes. `updated` is the time of the last status change, and for a streamed request `forwardRequestFinalize()` moves it to the moment the response was stored, so `responded` is the value to show as the time of an answer. `responded - executed` is how long the call took. Absent on a request that has not settled, on one that failed before any response arrived, and on requests made before this release. See [Timestamps](#doc-api-bridge-request-history-timestamps).
- Backend (ships with the API, not the SDK): the status answer that settles a poll on a streamed request carries `responded` too, so a reader has the time before it finalizes the request.

**2.2.2**

- `getUsers()` takes a **list in `value` for every attribute** it can search, not only `user_id`. `{ searchFor: 'email', value: ['alice@example.com', 'bob@example.com'] }` looks each address up directly and returns the accounts that exist, in one call. Every item of the list is checked the way the single value is, so a wrong type is refused with `INVALID_PARAMETER` before the request is sent. See [Fetch multiple users by a list of e-mails](#doc-user-account-get-users-fetch-multiple-users-by-a-list-of-e-mails).
- A list is a set of exact values, not a range: it works with the default `=` condition only and takes no `range`. Anything else is refused with `INVALID_PARAMETER` and `Conditions are not allowed on a list of "<attribute>" values. Search for a single value to use a condition or range.`
- A single `user_id` is no longer refused a `condition` or a `range` by the SDK. Only a list is.
- Backend (ships with the API, not the SDK): fixed `getIndexes()` ignoring `order.condition` when `order.by` is `index_name`. Every condition was dropped and the name was matched exactly, so `>=` did not search by prefix, and `order.by: 'index_name'` with no `order.value` returned an empty list. `>=` is now the 'starts with' search it was documented to be, `>` and `<` return the names after and before the value, and with no `order.value` every index name is listed in order.
- Backend (ships with the API, not the SDK): `condition: '<='` is an **'ends with'** search in `getTables()`, `getTags()` and `getUniqueId()`: table names, tags and unique IDs that end with the given value. `getTags()` searches the tags of `table`, or every table when `table` is omitted. It was an exact match before, the same as omitting `condition`, so a query that passed `<=` for that has to drop the condition. `>=` is unchanged and still means 'starts with', and `>` and `<` are still lexicographic. The matches come back in the order of the name read from its last character backwards. The SDK type declarations were updated to describe it. See [Check answer](#doc-api-reference-data-types-condition).
- Backend (ships with the API, not the SDK): fixed `getTags({ tag })` with no `table` failing with an error. It returns that tag in every table it appears in, as documented.
- Backend (ships with the API, not the SDK): `getIndexes()` with `order.by: 'index_name'` and `condition: '<='` is an **'ends with'** search on the index name. It matches the name segment right under `index`, or the first segment when `index` is omitted, and returns the names nested under a match as well. It was an exact match before. The SDK type declarations were updated to describe it. See [Searching index names](#doc-database-indexing-searching-index-names).

**2.2.1**

- `postRecord()` takes **`notification: { title, body }`**, the text of the push notification a new record sends to the uploader's subscribers when it is posted with `table.subscription.notify_subscribers`. Both fields are required, and together they must fit 3072 bytes. Without it, subscribers see a default title and body. It is read only when a record is created: an update ignores it, and so does a record posted without `notify_subscribers`. A value that is not an object, or is missing `title` or `body`, is refused with `INVALID_PARAMETER` before the request is sent. See [Notifying subscribers of a new record](#doc-database-subscription-notifying-subscribers-of-a-new-record).
- `subscribe()` called again for a user you already subscribe to **changes only the options you pass** and keeps the rest, so `get_notified` can be turned on or off without touching `get_feed`. Before, an option left out was sent as `false`, which switched it off. A new subscription still starts with every option off. See [Subscribing](#doc-database-subscription-subscribing).
- Fixed: `subscribe()` with `get_email: false` was refused with `User has no verified email address.` for a user without a verified e-mail. Only `get_email: true` needs one.
- `requestBatchSize` defaults to `50`, up from `30`. See [Advanced Settings](#doc-introduction-codebase-advanced-settings).
- The `subscription` of a returned record carries `is_subscription_record`, and the `Subscription` and `PostRecordConfig` types document what each option does.
- Backend (ships with the API, not the SDK): `notify_subscribers` and `notify_referencing_records` send push notifications. A record created with `notify_subscribers` notifies the uploader's subscribers who subscribed with `get_notified`, and every new reference to a record posted with `notify_referencing_records` notifies that record's uploader's subscribers. Only subscribers who can read the record are told, a `private` record never notifies, the user who posted is never told, and only references made by signed-in users notify. The push message carries `type`, `record_id`, `table` and `user_id` beside `title` and `body`, and `reference` for a reference. See [Notifications](#doc-database-subscription-notifications) and [Notifications from Records](#doc-notification-send-notifications-notifications-from-records).
- Backend (ships with the API, not the SDK): `pushNotification()` with a list of user IDs reaches every one of them, on every device each registered, up to 1000 users per call. See [Sending Notifications (For Admins)](#doc-notification-send-notifications-sending-notifications-for-admins).

**2.2.0**

- `new Skapi("<Project ID>")` written with the placeholder of the documentation, angle brackets included, **asks for the project ID** instead of refusing to start: with a prompt in a browser, and with a question on the terminal in Node. The answer is kept for the session, the browser tab or the Node process, so it is asked once. A copied example runs before you have edited it. Replace the placeholder with your project ID before you publish.
- The ticket types follow the current ticket rules. This is a type break: code that sets `request` on a ticket condition, or `secret` on a signature, no longer compiles.
  - `TicketRequestCondition` is gone, and so is `request` on `TicketCondition` and `TicketResponseCondition`: call the URL with a `req` action and check its answer with that action's `condition`. See [Checking the response](#doc-tickets-actions-checking-the-response).
  - `TicketSignatureCondition` names its Secret Key `secretName`, not `secret`.
  - The `req` action's `exe` gains `secretName`, the Secret Key that `${CLIENT_SECRET}` stands for in its `headers`, `data` and `params`. See [Sending a Secret Key](#doc-tickets-actions-sending-a-secret-key).
  - `TicketConditionRow.value` is `string | number | boolean | null | undefined` or a list of the first four, instead of `any`.
  - An `acsg` action's `group` may be a `${...}` reference, such as `"${placeholder[GROUP]}"`.
  - `TicketAction` lists only the four actions a project can register: `acsg`, `acsr`, `pstr` and `req`.
  - `Ticket` gains `legacy?: boolean`, which `getTickets()` returns to the project owner on a ticket saved before this release.
- Backend (ships with the API, not the SDK): tickets are now a general webhook handler with one rule for every part of a condition. `ip` and `user_agent` pass when any listed value matches, and a value that is empty, `null` or missing is no check. `headers`, `data`, `params` and `user` pass when every key listed passes, rows on the same key being alternatives, and a field the request does not carry is a mismatch of its key. The first row that matches a key sets its replacement and captures, and a row that does not match captures nothing. `!=` with a list means none of them, `>=` and `<=` on two strings mean starts with and ends with, `=` compares strictly, and `data` and `params` rows can compare with `null` and `undefined`. The query string is read on POST as well, each value JSON-parsed. A `signature` condition verifies an HMAC with a Secret Key named in `secretName`. See [Conditions and Placeholders](#doc-tickets-conditions).
- Backend (ships with the API, not the SDK): every reference in an action value is written inside `${ }` and starts with its root, such as `${data[order][id]}`, `${params[code]}`, `${placeholder[NAME]}`, `${user[email]}`, `${ip}` or `${response[id]}`, and text outside `${ }` is always literal. A `req` action can send a Secret Key with `secretName` and `${CLIENT_SECRET}`, each call is held to the Destinations of the key it carries, and every copy of the key is taken out of what the call answers before it is checked, logged or returned. Its response check uses the same rows as the ticket's condition, and its nested `actions` read the answer as `${response}`. A `Host` header cannot be set on a `req` action, and a header value holding a line break, NUL or a character outside Latin-1 fails the call with `REQUEST_FAILED` and `reason: "invalid_header"`. See [Templating](#doc-tickets-conditions-templating) and [Sending a Secret Key](#doc-tickets-actions-sending-a-secret-key).
- Backend (ships with the API, not the SDK): the check-time `request` condition can no longer be registered. `user` and `record_access` work only for signed requests. Tickets saved before this release keep running by the rules they were saved with until they are saved again, and the dashboard marks them **previous rules**. See [Tickets saved before this release](#doc-deprecated-deprecated-tickets-saved-before-this-release).
- Backend (ships with the API, not the SDK): newsletter endpoint addresses now start with `nl`, like template addresses start with `tp`: `nl00xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxx@mail.skapi.com` for a numbered group (`00` Newsletter, `01` Service Email) and `nl.bunnyquery-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxx@mail.skapi.com` for a named group, with the project id after the dot. `newsletterGroupEndpoint()` and the `Newsletters` page return the new format. Addresses in the older `xxxxxxxxxxxxxxxxxxxx-00xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@mail.skapi.com` and `xxxxxxxxxxxxxxxxxxxx-bunnyquery-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@mail.skapi.com` formats keep working, and the two formats of one group share its 15 minute wait between newsletters. See [Sending Newsletters](#doc-email-newsletters).

**2.1.0**

- Added **`forwardRequest(form, options)`**, which replaces `clientSecretRequest()` and its whole family. It is the same method: the queue, the poll handle, `stream`, `realtime`, `expires` and the direct non-queued path are the same code, so a request started under either name is read, polled, cancelled, finalized and counted by both. **Every old name still works** and is marked deprecated, not removed: `clientSecretRequestStream()` is now `forwardRequestStream()`, `clientSecretRequestFinalize()` is `forwardRequestFinalize()`, `clientSecretRequestHistory()` is `forwardRequestHistory()`, `cancelClientSecretRequest()` is `cancelForwardRequest()`, `stopClientSecretPolling()` is `stopForwardRequestPolling()`, `clientSecretRequestQueueCount()` is `forwardRequestQueueCount()`, and `isPollStopped()` is unchanged and shared by both. Everything but `forwardRequest()` itself is an exact alias. See [Forwarding Requests](#doc-api-bridge-forward-request).
- The **first argument of `forwardRequest()` is the form**: a submit event, a form element, `FormData`, a plain object, or `null` when everything is already in the options. By default it is flattened to a plain key-value object, where repeated field names collapse to an array, and merged into `params` for `GET`, `DELETE` and `HEAD` and into `data` for every other method; on a key collision the explicit `options.data` or `options.params` value wins. **Files are dropped by that flattening**, because the merged object travels as JSON. See [The form is the first argument](#doc-api-bridge-forward-request-the-form-is-the-first-argument).
- **`multipart: true`** sends the form's own bytes instead, files included, exactly as a browser posting that form would send them, and relays them to the destination with the content type the body actually has. `data` is refused alongside it, in the SDK and on the server both. A raw body rides the ordinary request and so shares its payload budget: the encoded body is capped at 2 MB minus 64 KB (2,031,616 characters), which is roughly a 1.5 MB file. Upload anything larger first and send its url. See [Sending the raw form body with multipart](#doc-api-bridge-forward-request-sending-the-raw-form-body-with-multipart).
- **`secretName` is optional**, unlike the required `clientSecretName` it replaces. Name a key and `$CLIENT_SECRET` is substituted server side in the `url`, `headers`, `data` and `params`, the key's access group authorizes the caller, its allowed destinations are enforced, and at least one value has to carry the placeholder. Name none and nothing is substituted, no placeholder is required, and the request is forwarded with only what you supplied. On the wire the field is `secretName`, and the server still accepts `clientSecretName`, preferring `secretName` when both are sent, so an older SDK build keeps working.
- Added **`skapiHeaders`**, off by default, so a destination is told nothing about the caller unless you ask. `true` sends both `x-skapi-user` and `x-skapi-service`; `{ user: true }` or `{ service: true }` sends only that one. `x-skapi-user` is the signed-in caller's user id and is left out entirely for a signed-out caller rather than sent empty; `x-skapi-service` is the service the request runs against, which is not necessarily the caller's own connection service. Both are written on the server from the verified identity of the request. A header of your own starting with `x-skapi-` is now refused with `INVALID_PARAMETER`, matched without regard to case, by the SDK and the server both, which is what lets a destination trust the prefix.
- **`PATCH` and `HEAD`** join `GET`, `POST`, `PUT` and `DELETE`, on the new names and the old ones alike. `HEAD`, like `GET` and `DELETE`, carries the form's fields in the query string rather than the body.
- **`apiKeyHeader` and `apiKeyScheme` are gone**, along with the api key injection they configured. No `x-api-key` is added to a forwarded request any more, and passing either option is refused with `INVALID_PARAMETER` rather than ignored: a silently dropped key is a request that reaches the destination unauthenticated and fails there. A destination that wants a key names a stored client secret with `secretName` and puts `$CLIENT_SECRET` where the key goes.
- `responseType` on `forwardRequest()` shapes the resolved value: `'text'` hands back a string, `'json'` parses one, and a skapi status object is handed back untouched either way. **`responseType: 'response'` is refused** with `INVALID_PARAMETER`, because the destination is called server side and its body is relayed, so there is no live `Response` to hand back; the destination's own status code is the `status_code` of the row `forwardRequestHistory()` lists. `signal` aborts **this client's poll** and not the request, which may already be running at the destination: `cancelForwardRequest()` is what removes it.
- `getNewsletterSubscription()` takes **`email`**. Together with `group`, it returns only the subscribers of that group whose e-mail address **starts with** the text, for the project owner, Skapi staff and admins in access group `99`: `john` finds `john@example.com` and `johnny@example.org`, but not `bigjohn@example.com`. The text is trimmed and matched in lowercase, and the results are sorted by e-mail address and page with `fetchMore` like the full list. It requires `group` and cannot be combined with `user_id`, and admins in access groups `90` ~ `98`, like every other account, are refused with `No access.`: they read the list with the addresses masked, so a search on the real address would give the mask away. An SDK before 2.0.6 drops `email` before the request leaves, so it returns the whole list instead; upgrade to search. See [Searching subscribers by e-mail](#doc-email-newsletters-searching-subscribers-by-e-mail).
- `updateProfile()` and `updateUserAttributes()` no longer send `preferred_username`, the login alias of an e-mail, when they change the e-mail. They sent it in the same call as the new address, before the address was verified, so a changed e-mail logged the account in before anyone had proved it belonged to the user. A changed e-mail is written unverified and now logs the account in only once it is verified with `verifyEmail()`, a few seconds after the verification succeeds. A username always logs in, and so does the e-mail an account without a username was created with. Any other previous e-mail stops logging in right after the change. An older SDK still sends the alias. On a user's **own** e-mail change the backend removes it again on the account's next token (see below), so the new address ends up logging the account in only once it is verified either way, but two things differ while an older SDK is in use: the change is refused outright when another account of the project already holds that address as a login ID, verified or not, where this release changes the e-mail and withholds only the login; and the unverified new address logs the account in for the few seconds before the alias is removed. An e-mail an admin changes with `updateUserAttributes()`, or with `updateProfile()` and another user's `user_id`, is not affected: the server ignores any alias the request sends, in every SDK version.
- `updateUserAttributes()` refuses your own `user_id` with `INVALID_REQUEST` and `Cannot modify attributes of the current user.` before any request is sent, for the project owner and access group `99` admins too. Change your own profile with `updateProfile()` without `user_id`. `updateProfile()` given your own `user_id` still ignores it and updates your own profile, as before.
- Backend (ships with the API, not the SDK): every admin, access groups `90` ~ `99`, can read a group's whole subscriber list with `getNewsletterSubscription({ group })`. Before, only access group `99` and the project owner could; the other admins got their own subscriptions. Admins in access groups `90` ~ `98` read that list with every address **masked** as `j**@**.com`, because they cannot send a newsletter to those subscribers. Each masked row also carries `subscriber_token`, an opaque value that is the same for the same address and different for different ones, so a list can tell two masked rows apart. Their next-page `startKey` is a sealed value: pass it back unchanged and paging works as usual. The project owner, Skapi staff and access group `99` admins are unaffected. `timestamp` is undefined on a group's whole subscriber list. See [Who can read the subscriber list](#doc-email-newsletters-who-can-read-the-subscriber-list).
- Backend (ships with the API, not the SDK): template addresses for automated emails now read `tpxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxx@mail.skapi.com`, the template code first and the project id after the dot. Addresses in the older `xxxxxxxxxxxxxxxxxxxx-tpxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@mail.skapi.com` format keep working. See [Automated Emails](#doc-email-email-templates).
- Backend (ships with the API, not the SDK): the link placeholder can be written **`https://link.skapi.com`**, which mail editors accept as a link URL. `https://link.skapi.app` is accepted too, and the original `https://link.skapi` keeps working in existing and new templates. See [The link placeholder](#doc-email-email-templates-the-link-placeholder).
- Backend (ships with the API, not the SDK): a template that is rejected when it arrives now gets one reply naming every required placeholder it is missing and what each is for, instead of a message about the first one followed by a generic "failed to process" email. Invitation templates are now checked for `${email}` and `${password}` as well as the link, as the documentation already required. See [When a template is rejected](#doc-email-email-templates-when-a-template-is-rejected).
- Backend (ships with the API, not the SDK): an account's e-mail login is granted only for its own **verified** e-mail, and only the server writes it: when the signup confirmation link or an accepted invitation verifies the e-mail, and on the account's next token after `verifyEmail()` succeeds. This gives an account created with a username an e-mail login it did not have before: an account created by `signup()` with a `username`, or by `createAccount()` with one, logged in by its username alone, confirmed or not, and its e-mail answered `INCORRECT_USERNAME_OR_PASSWORD`. From this release that same account also logs in with its e-mail, once the address is proved by the signup confirmation link, by accepting the invitation, or with `verifyEmail()` a few seconds after it succeeds. The username logs in from the start, as before. An account created without a username is unaffected: its e-mail is its permanent login ID and keeps working. An e-mail an admin changes with `updateUserAttributes()` is written unverified and the account's previous e-mail login is removed at once.
- Backend (ships with the API, not the SDK): a login alias written without that proof, by an older SDK or a direct call, is removed the next time the account gets a token, from the account's first token after this release onward: that first token records the alias the account is holding, and an alias that differs from it later, or one left behind by an e-mail change, is removed on the token that follows. An alias an account already holds when the release arrives is grandfathered, because a token cannot tell it from one the server wrote: it keeps logging that account in until a pass over the existing accounts clears it, or until another account is granted that e-mail's login and evicts it. While an account holds an alias for an address other than its own verified e-mail, it is not granted the e-mail login of the address it did verify, since the server grants that only to an account holding no alias. A signup, invitation, `createAccount()` or `updateUserAttributes()` e-mail change is refused with `EXISTS` only when the e-mail is already a login ID that another account of the project was granted: its original login ID, or its verified e-mail. Any other account holding that e-mail's login alias loses the alias instead of blocking the request.
- Backend (ships with the API, not the SDK): `openIdLogin()` reaches an existing account only by that account's **original** login ID, its username or the e-mail it was created with, never through an e-mail login. When the OpenID account's login ID is the e-mail login another account of the project was granted by verifying the address, an account created with a `username` whose e-mail is that address, or one whose e-mail was changed to it and verified, the login is refused with `EXISTS` and `The login ID of this OpenID account is already used by another account.`, with or without `merge`, and so is the rare case where the alias cannot be removed safely. Before, that account was treated as the OpenID account's own, so a `merge` for an address another account answered to replaced that account's password. When the other account holds the address as an e-mail login it was never granted, an alias an older SDK or a direct call wrote before the address was verified, or one held in another project, the alias is removed instead and the login goes on as it does for a login ID nobody holds: a **new** OpenID account is created, and the project then has two accounts on the one address, the older one under its own login ID and the new OpenID one. The alias is removed before signup restrictions and the user limit are checked, so it is gone even when the login is refused after that for another reason. See [openIdLogin](#doc-api-reference-authentication-openidlogin).
- Backend (ships with the API, not the SDK): `updateUserAttributes()`, and `updateProfile()` with another user's `user_id`, refuse the caller's own account for everyone with `INVALID_REQUEST` and `Cannot modify attributes of the current user.`, so an older SDK gets the same answer. Before, this route applied no access group rule at all: every admin from access group `90` up, and the project owner, could rewrite their own account through it, and any other account of the project, an access group `99` admin's included. From this release an admin in access groups `90` ~ `98` is refused an account whose access group is the same as or higher than their own here too, with `INVALID_REQUEST` and `No access to modify admin.`; access group `99` admins and the project owner still reach every account but their own.
- Backend (ships with the API, not the SDK): an admin in access groups `90` ~ `98` can no longer change the access group of, block, unblock or delete an account whose access group is the same as or higher than their own. A disabled account counts at the group it had, from this release on. An account disabled before it had its stored access group overwritten with a disabled group `1`, whatever its group really was, so it still ranks as group `1` for these rules, and it comes back from recovery as an ordinary group `1` user, until its stored group is corrected. `grantAccess()`, `blockAccount()`, `unblockAccount()` and `deleteAccount()` refuse such an account with `INVALID_REQUEST` and `No access to modify admin.` and change nothing, and so do `updateUserAttributes()` and `updateProfile()` with another user's `user_id` (also noted above); `grantAccess()` still answers `No access to expel admin.` for an access group `99` account. Access group `99` admins and the project owner are not limited, and an admin still cannot grant an access group higher than their own. Before, none of these routes compared the target's access group at all: `blockAccount()`, `unblockAccount()` and `deleteAccount()` carried no such rule, and the one guard that existed, `grantAccess()`'s protection of access group `99` accounts, never ran, because it read the access group from an attribute name the user pool does not store it under. An access group `90` admin could demote an access group `99` admin, and the only limit that did apply to `grantAccess()` was the ceiling on the group being handed out.
- Backend (ships with the API, not the SDK): a file upload is signed for exactly the byte count it declares, and storage refuses a file of any other size. skapi-js already declares the exact size of what it uploads, encrypted files and empty files included, so nothing changes for SDK uploads.
- Backend (ships with the API, not the SDK): a private record of another user now comes back **without its `data`** to the project owner and to admins in access group `99` however it was queried, not only when it is fetched by its `record_id`. A listing that names `access_group: 'private'`, an index query that reaches such records and a `tag` query all used to carry those payloads to a master. The withheld value is `{ __is_private__: null }`, everything else on the record is unchanged, and records the caller uploaded themselves are never withheld from them. `deleteRecords()` returns the records it removed the same way. See [Private Records](#doc-database-access-restrictions-private-records).
- Backend (ships with the API, not the SDK): a record's `data` larger than 32 KB is stored as a file in the project's file storage and counts toward file storage rather than database storage. skapi-js fetches it back into `record.data`, so nothing changes in how a record is written or read. A build from before this feature cannot fetch it, and the server fills the payload into the response for such a build instead, which is bounded by the size of one response: keep skapi-js up to date. See [Large Record Data](#doc-database-create-large-record-data).

**2.0.5**

- The `onResponse` callback of a **poll** now receives a second argument, `meta`, carrying facts about the *request* rather than about the response. It holds one field so far: **`meta.executed`**, the moment the worker actually began running the request, in milliseconds, the same value `clientSecretRequestHistory()` reports as `executed`. It arrives on the second argument because a settled poll resolves with the **destination's own answer**, and that answer belongs to the destination: there is nowhere in it to put a fact of skapi's without changing what the caller asked for. `updated - executed` is how long the call itself took, while `updated - created` also counts however long the request sat in the queue. Existing one-argument callbacks are unaffected, since a second argument is simply ignored by a function that does not declare it.
- `meta` reaches the two callbacks that are served by the poll: the `onResponse` on a `RequestHistory`'s **`poll()`**, and the `onResponse` passed to **`clientSecretRequest()`** when that request was queued and therefore polled. A `clientSecretRequest()` that answers directly, with no queue and no polling, calls `onResponse(res)` with no second argument, because nothing ever recorded an execution start for it. `clientSecretRequestStream()`'s `onResponse` does not receive one either.
- `meta.executed` can legitimately be absent, so read it as optional. The execution start rides on the **status envelope**, which only a non-terminal poll tick returns, so a request that began and finished between two ticks never showed one. Show nothing rather than substituting `created`: that reports a queue backlog as though the destination had been slow. When the value is missing and you still want it, it is on the request's own row and a later `clientSecretRequestHistory()` will carry it.
- A poll started from a `clientSecretRequestHistory()` item now begins already knowing the execution start, taken from the listing that produced it. Before this, such a poll could only learn it from a running tick, so a poll attached to a request that was **about to settle**, or that settled on the poll's very first read, delivered no `meta.executed` at all even though the value had just been listed. This is the common case when polls are rationed and a request waits for a free slot before one is attached. Nothing changes for a poll that does see a running tick: the row's own current value still wins.

**2.0.4**

- Never published. A version bump with no code change, superseded by 2.0.5 before release. There is no `2.0.4` on npm; upgrade from 2.0.3 straight to 2.0.5.

**2.0.3**

- `clientSecretRequestHistory()` items carry **`executed`**: the moment the request actually **began running**, in milliseconds. `created` is when the request was *enqueued*, and a queued request can wait there for a long time before a worker picks it up, so `updated - created` measures the wait plus the call. `updated - executed` is the call on its own, which is what you want when timing a destination rather than your own queue depth. Absent on a request that has not started yet, and on rows old enough to predate the server recording it, so treat it as optional and show nothing rather than a fallback: substituting `created` reports a queue backlog as though the destination were slow. The value is normalised to milliseconds to match `created` and `updated`, and it needs no change to how you call the method. See [RequestHistory](#doc-api-reference-data-types-requesthistory).

**2.0.2**

- Added **named newsletter groups**. A project can now run several separately addressable newsletters (a `bunnyquery` list and a `skapi` list, say), each with its own subscribers, its own sending address and its own sent mail history, next to the numeric groups, which keep working exactly as before. `subscribeNewsletter()`, `unsubscribeNewsletter()`, `getNewsletterSubscription()` and `getNewsletters()` all take a group name in `group`, wherever they took a number, `'public'` or `'authorized'`. See [Named Newsletters](#doc-email-newsletters-named-newsletters).
- Added `registerNewsletterGroup()`, `deleteNewsletterGroup()` and `newsletterGroupEndpoint()`, all project owner only. A group is registered with a `group` name, a `restriction` (`0` lets anyone subscribe with an e-mail confirmation, `1` requires a signed in user, `2` to `99` requires that access group; default `0`) and an optional display `name` of up to 60 characters. A project holds up to 20 groups, and a group's name and restriction are fixed once it is registered. `deleteNewsletterGroup()` removes the group **and every subscription in it**; a very large group may need more than one call, the response says how many subscriptions were removed, and the group is only gone once the call succeeds. `newsletterGroupEndpoint()` lists the project's groups with their restriction, label, subscriber count and sending address (`endpoint`, an empty string while the project has no sender e-mail). The `NewsletterGroup` type is exported. See [Creating a named group](#doc-email-newsletters-creating-a-named-group), [Deleting a named group](#doc-email-newsletters-deleting-a-named-group) and [newsletterGroupEndpoint](#doc-api-reference-email-newslettergroupendpoint).
- A group name is 2 to 20 lowercase alphanumeric characters with at least one letter. It cannot be one of the reserved names `tp`, `admin`, `public`, `authorized`, `newsletter`, `forward`, `all`, `true`, `false` and `null`, and it cannot read as a number such as `1e5`, because a GET parameter is JSON parsed on the server and would arrive as something other than a string. One shared validator now enforces this in every group taking method, and it also refuses a numeric group that is not an integer between `0` and `99`. **`'admin'` is no longer accepted as a `group`**: it was sent to the server as the literal word and never meant the admin access group, so pass `99` instead.
- Fixed: `subscribeNewsletter()` accepted an alphanumeric group name while `unsubscribeNewsletter()` and `getNewsletterSubscription()` refused every string, and `getNewsletterSubscription()` ran the stored token through `parseInt`, so a named subscription could be created and then never removed or read back. All four methods now speak the same grammar.
- `getNewsletterSubscription()` no longer requires `group`: omit it, or pass `null`, to list every group the user is subscribed to. The `group` on each returned item is a number for the numeric groups and the group name for a named one. `unsubscribeNewsletter()` takes a named group too, and `null` still means every group.
- `getNewsletters()` reads a named group's sent mail for anyone its restriction allows, signed in or not; a signed out caller reads a restriction `0` group through the public route. Each returned newsletter now carries `group`: a number for the `0` to `99` groups, the name for a named one. See [Newsletter](#doc-api-reference-data-types-newsletter).

**2.0.1**

- Fixed: `new Skapi("<Project ID>")` threw `Service ID is invalid` for a project in any region past the first ten in the region alphabet. A project in `eu-west-3`, the first such region to open, could not connect at all. The region travels inside the project ID as one character of an alphabet that runs through the ten digits and then `a` to `l`, and it was read back as a base62 digit, where `a` is 36 rather than 10. It is now read back by its position in that alphabet, which is identical for the ten digit regions, so every existing project ID decodes exactly as it did. Backend (ships with the API, not the SDK): the server side decoder carries the matching fix. Upgrade every client that decodes project IDs before creating a project in one of the newer regions.

**2.0.0**

- The `table: 'name'` shorthand no longer pins an access group on an **update**. Written as a plain string, a table still means access group `0`: `getRecords()`, `deleteRecords()` and a `postRecord()` that **creates** a record all send `{ name: 'name', access_group: 0 }`, which is what the string form has always meant. What changed is `postRecord()` with a `record_id`. 1.8.3 sent that same `access_group: 0` on an update, so writing the table as a string quietly **moved** the record to public whenever it had been living in some other group, and the only way to update it in place was to repeat the group on every call. An update now sends the table name with no access group at all, and the record keeps the group it is already in; pass `table: { name: 'name', access_group: ... }` when you actually want to move it. The object form is unchanged in both directions: it sends exactly the keys you wrote, so `table: { name: 'name' }` sends no access group and leaves the scope to the backend. See [The `table` shorthand and `access_group`](#doc-database-access-restrictions-the-table-shorthand-and-access-group).

- Added the project setting **`require_login`**, returned by `getConnectionInfo()` as `conf.require_login`. When a project sets it to `true`, the SDK refuses `getRecords()`, `getTables()`, `getTags()`, `getIndexes()` and `getUniqueId()` from a session with no signed-in user, throwing `REQUIRE_LOGIN`. Signed-in callers are unaffected, and the check applies only when the flag is present and exactly `true`, so every existing project behaves as it always did.
- `require_login` is a **guard rail, not an access control**. The backend still serves `access_group: 0` records to any unauthenticated caller, so anything reachable without the SDK stays reachable; what the flag prevents is an app leaking its public records through a signed-out page by accident. Records that must not be readable without an account should not be in access group 0. It is also unrelated to `prevent_anonymous`, which governs whether anonymous users may WRITE records.

- Added **client-side encryption for private records**. `new Skapi("<Project ID>", { encryption: true })` encrypts the `data` of every record saved to `access_group: 'private'`, and the contents of the files attached to it, in the browser, before any of it reaches the database. The key is derived from the user's own password and never leaves their device, so a provider who dumps the database, the storage bucket and every request log cannot recover the payload without a successful offline guess against that password. Writing and reading do not change: `postRecord()` seals, `getRecords()` opens, and there is no key for the end user to manage. **The option only takes effect when the class is initialized**, there is no method that turns it on later, and enabling it does not go back and encrypt what was already saved. Only `data` and the **contents** of files are covered: `index.name`, `index.value`, `tags`, filenames, sizes, timestamps and the record graph stay in plain text, because that is what the database queries on. The strength is capped by the user's password, since the Web Crypto API offers no memory-hard KDF. Read [Encrypting Private Record Data](#doc-database-encryption) in full before turning it on.
- `encryption` also accepts an object: `iterations` (PBKDF2 cost, default 600000, minimum 100000), `minPasswordLength` (refuses to enroll a weaker password, default off), `persistDevice` (stays unlocked across page reloads on that device, default `true`), `recovery` (`'code'` or `'none'`, default `'code'`), `trustPolicy` (`'tofu'` or `'strict'`, how a recipient's public key is trusted when sharing), `withheld` (`'null'` or `'sentinel'`, what `data` becomes when a record cannot be decrypted) and `table` (the reserved keyring table, default `__skapi__keyring`). See [Advanced Settings](#doc-introduction-codebase-advanced-settings).
- Added `getEncryptionStatus()`, `unlockEncryption()`, `lockEncryption()`, `takeRecoveryCode()`, `unlockWithRecoveryCode()`, `regenerateRecoveryCode()` and `isWithheld()`. A browser session is unlocked by logging in and stays unlocked across reloads; `unlockEncryption({ password })` is for a session restored from a token on a device that has never been unlocked, and for Node, which has no IndexedDB and is therefore always locked after a token restore.
- A **one-time recovery code** is minted in the browser at enrollment and parked for a single `takeRecoveryCode()` call, which returns it and forgets it. It is never transmitted, only the wrap of the master key is stored, and there is deliberately no way to fetch it again. Show it to the user once and have them confirm they saved it, because `forgotPassword()` and `resetPassword()` are unauthenticated and cannot touch the keyring: after a password reset the next login is **locked**, and `unlockWithRecoveryCode({ code, password })` is what repairs it, re-wrapping the keyring under the new password and returning a replacement code. A forgotten password with no recovery code and no unlocked device means those records are unreadable for good. `recovery: 'none'` opts out of issuing one.
- Sharing an encrypted record is the same call it always was and now carries the key with it. `grantPrivateRecordAccess()` wraps the record's data key to the recipient's published public key and writes it onto the record before granting access at the ACL level, so a failure never leaves a grant the recipient cannot use, and the owner does not need to be online when the grantee reads. `removePrivateRecordAccess()` removes the wrap and **rolls the data key**, so revocation is forward-only. Only the record's owner can share or revoke an encrypted record, delegated granting through `source.allow_granted_to_grant_others` does not work on them, and granting to a user who has never logged in with encryption enabled throws `ENCRYPTION_RECIPIENT_HAS_NO_KEY` and changes nothing.
- Files attached to a private record are sealed under a key derived from that record's data key, so anyone who can read the record can open its files with no extra key distribution. `getFile()` decrypts every form it returns, `file.size` is the plain text byte length and `file.stored_size` the real size on storage. **`record.bin[].url` serves the ciphertext for an encrypted file**, so `<img src>`, media elements, inline viewers, a plain `<a href>` download link, `updateProfile({ picture })` and any tool that renders the url directly stop working for those files, and byte-range requests go with them, which regresses video seeking and resumable downloads. Anything that needs the bytes has to call `getFile()`. Files on non-private records are untouched.
- A record you cannot decrypt never throws and never fails a whole page. Its `data` comes back as `null` and a new `encrypted` field carries the `status`, the `reason` (`NO_SESSION_KEY`, `NOT_A_RECIPIENT`, `BAD_KEY`, `BINDING_MISMATCH`, `CORRUPT`, `UNSUPPORTED_VERSION`, `ENCRYPTION_DISABLED` or `DATA_UNAVAILABLE`) and the list of user IDs that can. `withheld: 'sentinel'` returns a frozen, self-describing placeholder instead of `null`, tested with `isWithheld()`; writing that placeholder back is refused with `ENCRYPTION_CANNOT_REWRITE_WITHHELD`. A record stored in plain text has no `encrypted` field at all, which is how existing records keep working unchanged. This applies to the master account as well: it still reads and deletes any record in its project, and now receives the payload withheld, so **any dashboard or support tool that reads customer record data needs to be identified before this is turned on**.
- Changing a record's access group converts it in whichever direction it is going. Moving a record out of `'private'` writes its `data` back **decrypted** and returns its files to plain text, so a bare group change is a publish; moving one in encrypts both. Each direction is ordered so that an interruption leaves the record readable and re-running the update finishes the job, and neither can be done by a session that cannot decrypt the record. It is not a cheap operation: every attachment is downloaded, re-encoded and re-uploaded.
- Backend (ships with the API, not the SDK): moving a record **into or out of `access_group: 'private'` now clears every private-access grant on it**, whether or not encryption is enabled. In `'private'` a grant is the whole of a named user's access to that one record, while in any other group it only widens what an already-qualifying user may do, so grants between two non-private groups are left untouched. Grant the users again if they should still have access after the change.
- Limits worth knowing before enabling it: `crypto.subtle` needs a secure context, so a page served over plain http, localhost aside, cannot use the feature at all; OpenID accounts get no protection and stay locked with `OPENID_UNSUPPORTED`, because the backend mints their password; `data` is one sealed blob, so changing a single field means read, modify, write and requires decrypt access, while a metadata-only `postRecord(null, { record_id })` still leaves the payload alone; the payload inflates by about 33%, which puts the practical ceiling for a single-recipient record near 1.5MB and throws `ENCRYPTED_DATA_TOO_LARGE` past it; `__skapi_enc__` joins `__json__` and `__data__` as a reserved key in record data; keys are derived per project, so a cross-project write is refused; and an older SDK reading an encrypted record sees the raw envelope, so upgrade every client first. The claim also assumes you control the bundle: pin an exact version and self-host it, because a build served by the provider could ship code that takes the key.
- Added `forwardRequest()`. It relays a request to a destination of your choosing **from the server** and streams the response back as it arrives, with the project's API key attached server side where the browser cannot read it. See [Forward Request](#doc-api-bridge-forward-request).
- **A service is now called a project**, and the service ID is now the **project ID**. The single-token form is the primary way to initialize: `new Skapi("<Project ID>")`. The legacy service ID + owner ID pair is still accepted, and the SDK keeps the older names in its API surface, so `service_name`, `service_description`, the `service` parameter and `refetchServiceInfo` are unchanged. Documentation examples now use the `"<Project ID>"` placeholder, angle brackets included.
- New `skapi.project_id` class property, also returned by `getConnectionInfo()`: the public project ID token, composed from the connected project and its owner. Empty string when the project has no user owner.
- `clientSecretRequest()` accepts **`stream`** and **`onStream`**. With `stream` omitted or `false` nothing changes: the response is downloaded whole and stored on the request row, which is byte for byte what every existing caller already gets. With `stream: true` the server reads the destination's response **incrementally** and relays the raw bytes into a chunk store as they arrive, and `onStream(chunk, seq)` hands them to you in order, so a page can render an answer while the destination is still producing it. Streaming needs a queue, because the relayed text is appended to a polling row and only a queued request has one; the SDK mints a queue name when you do not give one. See [Streaming the Response](#doc-api-bridge-streaming-request) and [clientSecretRequest](#doc-deprecated-deprecated-clientsecretrequest).
- **Skapi does not parse the stream.** It relays bytes, and whatever format those bytes are in (Server-Sent Events, NDJSON, plain prose, anything at all) belongs to you and your destination: the framing, the parsing, and the buffering of a chunk that ended in the middle of a frame are all yours. That is deliberate, and it is why streaming works against **any** destination on day one rather than only the ones skapi knows about. Chunk boundaries are set by how fast the destination writes, never by your format, though a multi-byte character is never split across two chunks. The presence of `onStream` is what makes the poll loop fetch chunks at all, so a second reader can poll the very same request without it and get exactly the response it got before streaming existed.
- `stream` says how **skapi** reads the response; asking the destination to produce one incrementally is part of your own request and goes in `data` as that API requires. The two halves fail quietly on their own: with only the body's flag set, skapi buffers the whole event transcript into the stored response, where a reader expecting that API's normal document finds a wall of `data: {...}` lines; with only skapi's, the destination answers one plain document that is honestly relayed in pieces, so an incremental reader finds no records in it. Skapi catches neither, because your request body is yours and is never inspected.
- Added `clientSecretRequestStream(requestId, options)`, which reads a streamed request **this call did not start**: a page reload, a second tab, or a request opened from history that was never finalized. `onStream` on `clientSecretRequest()` only reaches the caller that made the request, so this is the way back to one. Still running, it polls and delivers text through `onStream` until the request settles, then resolves with the terminal status; already settled and not finalized, it reads every chunk once (paging internally) and resolves, which is what makes an unfinalized request re-readable; already finalized, it resolves with the stored body and streams nothing, since the chunks were released. `since` starts the read after a sequence number you already hold, the returned promise carries a `stop()`, and `stopClientSecretPolling()` reaches it exactly as it reaches a normal poll. A status envelope always carries `status`, `id` and `in_queue` together; a resolved value carrying none of them is **a finalized body**, which is how the two resolutions are told apart. Testing `status` alone is not enough, since a body you finalized may have a `status` field of its own. Only the caller who made a request can read it back: the identity half of a request id is taken from the request context on the server and never travels. See [clientSecretRequestStream](#doc-deprecated-deprecated-clientsecretrequeststream) and [StreamPollResult](#doc-api-reference-data-types-streampollresult).
- `clientSecretRequest()` also accepts **`realtime`**. With `realtime: true` the server pushes each relayed piece over skapi's websocket as it relays it, so `onStream` fires as the text is produced instead of on the next poll tick. It is purely an accelerator and never the source of truth: the poll keeps running against the same chunk store, and a missing websocket, a session that cannot open one, a room already taken by another streaming read or a connection that drops mid-response are none of them errors and none of them reported, because the read simply continues at poll speed. Nothing is delivered twice and nothing arrives out of order whichever transport carried it, and every piece is in the chunk store either way, so the response stays re-readable in full with `clientSecretRequestStream()`. Only meaningful alongside `stream: true` and an `onStream` callback: without somewhere to put the text, skapi will not open a connection your application did not ask for.
- `onStream` gained a third argument, `via`, which is `'socket'` when the websocket carried the piece and `'poll'` when the poll did. Both transports hand their text to the same callback by design, so this is the only thing that distinguishes a response delivered live from one polled the whole way. It is there to be observed, not acted on, and a callback that ignores it reads exactly as it did before. `new Skapi(id, owner, { network_logs: true })` also prints the transport to the console: one line the first time each transport carries a piece, and a tally when the read ends.
- Skapi's realtime connection is shared, so a `realtime: true` read borrows it carefully. A connection your application already opened is not replaced, its callback is not changed, and it is not closed; the pieces are read off the socket directly, so your own realtime callback never sees them. A connection skapi opens for a read is closed by that read. Joining a room **replaces** the room the connection was in, and the room is left rather than restored when the read settles, so re-join yours after the request settles if your application uses rooms of its own. One connection is in one room at a time, so of two responses streaming at once the first takes the room and the second reads at poll speed rather than evicting a reader mid-response. An application that never calls `connectRealtime()` has none of this to think about.
- The reply to a `realtime: true` request carries **`realtime_group`**, and `clientSecretRequestStream()` accepts it as `realtimeGroup` so a re-attached read is live too. It cannot be rebuilt from a request ID, so keep it beside the ID you keep; a read without it works exactly as it always has, at poll speed. See [Faster delivery over the websocket](#doc-api-bridge-streaming-request-faster-delivery-over-the-websocket).
- A streamed request settles with a **status and no body**, because the text was the stream. Added `clientSecretRequestFinalize(requestId, data, options)` to store the version you want **kept** as that request's history and release the relayed chunks it was assembled from. What you send becomes the request's stored result: the value `clientSecretRequestHistory()` lists as `response_body` and the value a later poll hands back. The content is entirely yours, and skapi neither validates nor interprets it: assembled text, a rebuilt response object, a summary, a single word. Only the caller who made the request can finalize it, only a **settled** request can be finalized, and a buffered request cannot, since its stored result is the destination's own answer and that answer is not yours to overwrite. Those cases come back as `{ finalized: false, message }` rather than throwing. Finalizing twice replaces the kept version, so a call lost to the network can simply be repeated, and a `failed` request is finalizable too, which is how you keep the 80% of a response that arrived before the stream died. See [clientSecretRequestFinalize](#doc-deprecated-deprecated-clientsecretrequestfinalize).
- **A streamed request's chunks are kept indefinitely until you finalize it.** Nothing expires them on a timer. Until then every read of that request has to fetch and re-parse all of its chunks, so a request nobody finalized gets slower and more expensive to open, forever, and its history entry stays empty. Finalize as soon as you have what you want: it is what turns a pile of relayed bytes into an ordinary history entry. The only things that clear chunks otherwise are `expires`, which removes the request and its chunks together when its history expires, and `cancelClientSecretRequest()`, which discards them, so finalize first if you want to keep the part that arrived.
- `clientSecretRequestHistory()` accepts `compact`, `queue_exact` and `queue_exclude`. `compact` returns label stubs in place of request and response bodies, which can be far larger than the listing that shows them; `queue_exact` restricts a `queue` filter to exactly the named queue instead of matching it as a prefix, and `queue_exclude` drops one queue's rows. Queue filters apply after the range read, so a page can come back short while more matches remain: keep paging by `startKey` / `endOfList`, never by a page's length. See [Request History](#doc-api-bridge-request-history) and [RequestHistory](#doc-api-reference-data-types-requesthistory).
- Fixed: `url` and `method` were ignored whenever a `queue` was given, so two different APIs sharing a queue name reported each other's requests.
- Fixed: an uncaught `QuotaExceededError` while saving the session cache. A large paged request history could exceed the session storage quota, which surfaced as an uncaught error on every tab switch.
- A record's `data` is now stored as JSON text and parsed back on read, so it returns exactly what was posted, whatever it is. Payloads that could not be saved at all before now round-trip: one containing an empty key (`{ "": "value" }`), one nested more than 32 levels deep, and one carrying a key longer than 65535 bytes. All three used to fail with an opaque server error naming neither the field nor the key. `data: null` now stores a null rather than removing the field, so it reads back as `null`; passing no `data` at all is still the metadata-only update that keeps what is stored. `NaN` and `Infinity` are not JSON: they are stored as `null`, and the record returned by `postRecord()` shows that `null` too, so what you get back is what was saved. **Records written this way can only be read back by this version of the SDK or later**, and `data` is roughly 10 to 30% larger in storage, so a record near the size limit is more likely to be offloaded to file storage.
- Fixed: a list in a record's `data` lost its order and its duplicates. `[1, 1, 1]` was stored as a database set and came back as `[1]`; it now reads back as written. An empty list and an empty object are stored as themselves too.
- Fixed: a string in `data` containing `*add` or `*sub` corrupted the record on update. The text was read as an internal counter instruction, which replaced `data` with a set of the remaining words.
- `deleteRecords()` now returns the records it deleted in the same shape every other method returns, so `record_id`, `table.name` and `data` read normally. It was the one record-returning method that never normalized, so it handed back raw database items with short keys and encoded values.

- `table.access_group` accepts `'*'` as shorthand for `'private'` on `postRecord()`, `bulkPostRecords()`, `getRecords()` and `deleteRecords()`. The SDK converts it to `'private'` before the request goes out, so the wire and the backend are unchanged, and an encrypted project seals a `'*'` write exactly like a `'private'` one. For reference, `'public'` is `0`, `'authorized'` is `1` and `'admin'` is `99`.

**1.8.3**

- Private files in a record's `bin` are now cached by the browser for a week. A private file is served under a URL that changes on every read, so browsers could never reuse it and the same file was downloaded again on every read. The first `getFile()` now downloads it and the rest are served locally, with no network request; take the URL from `getFile("endpoint")` to get the cached one. The `url` property on the bin object is unchanged, since that is the string `remove_bin` and `deleteFiles` expect back. Files reached through a granted private access key are unchanged, since their URL cannot be minted in a cacheable form.
- `getFile()` accepts `browserCache` and `refresh`. A URL requested with `expires` is signed fresh on every call, and since browsers cache by URL, the same unchanged file was downloaded again on every page load. `browserCache` caches the request that mints the URL instead, so the same URL comes back and the copy already downloaded stays usable, while `expires` stays as short as you like. `refresh` bypasses that cached URL, for a file that has changed or a load that failed because the cached URL had expired. See [Caching Expiring Files](#doc-database-handling-files-caching-expiring-files).
- Fixed: the client refused values the API accepts. Every key-segment length limit was checked against an SDK-only cap that was stricter than the platform's, so legal values were rejected before a request was ever sent: a tag was capped at **64** characters and a `table.name` / `index.name` at **128**, where the API allows **256** for each. All three now match the API. `index.value` was already correct at 256.
- `table.name`, `index.name` and each tag are limited to 256 characters, and `/`, `!`, `*`, `#` and `%` count as **3 characters each** toward that limit. A value that only overflows because of them is now refused with a message saying so, instead of failing as an opaque server error. `index.value` has no such rule: every character counts as one.
- Fixed: a `table.name`, `index.name` or tag containing a `%` came back changed. A tag written as `100%25off` was returned as `100%off`, and `a%2Fb` as `a/b`. Any string now reads back exactly as it was written.
- Fixed: a string `index.value` containing a `%` came back changed the same way, so `a%2Fb` was returned as `a/b`. Index values now read back exactly as written, and still compare exactly as written for `>`, `<`, `range`, and the `>=` 'starts with' and `<=` 'ends with' forms.
- Fixed: `getTables()`, `getTags()` and `getIndexes()` could not find a table or tag whose name contains `/`, `!`, `*` or `#`; the lookup returned nothing. They now match. An empty filter paired with a condition still means 'list everything'.
- Fixed: a file whose name contains a `%` went missing from `record.bin` entirely, and one named `50%20off.pdf` came back renamed to `50 off.pdf`, which no longer matched the stored file. Filenames now come back exactly as uploaded, agreeing with `getFile(url, { dataType: 'info' })`. For the same reason `remove_bin` now removes such a file instead of silently doing nothing.
- Fixed: `source.referencing_index_restrictions[].name` came back altered when it contained `/`, `!`, `*` or `#`, unlike `index.name` for the same string. Reading a record and re-saving it then broke referencing with 'Index value does not match the reference index restriction'.
- Fixed: a nest query (an `index.name` ending in a period, which matches the children of a compound index) could not find a child whose name contains `/`, `!`, `*`, `#` or `%`. A compound index such as `Band.Rock/Pop.year` returned nothing from the query meant to find it. `index.value`, `index.range`, and `order.value` on `getIndexes()` when `order.by` is `index_name`, all match now.

**1.8.2**

- Fixed: the local unique ID cache introduced in 1.7.1 was not scoped to a service, so one Skapi instance used across several services could resolve a unique ID to a record ID belonging to a **different** service. The symptom was a post whose `reference` was a unique ID being rejected with `NOT_EXISTS`, naming a record ID the caller never supplied. It only showed up when the same unique ID existed in more than one service, for example the same filename uploaded to two projects, and whichever service wrote to the cache last won. The cache is now keyed by service and owner, so a unique ID only ever resolves within the service the call targets.
- `bulkPostRecords()` result items now carry the reason a record was refused. The API reports a per-record rejection as an element inside the returned list rather than by throwing, and that element used to arrive as an empty record with the reason stripped, so a caller could not tell a rejection from a save. A refused element still has an empty `record_id`, which remains the test for whether a record saved, and now also carries `error` with the API's `code` and `message`. See [RecordData](#doc-api-reference-data-types-recorddata).

**1.8.0**

- `getRecords()` and `deleteRecords()`: `condition: '<='` on a `string` index value is now an **'ends with'** search. It was a lexicographic 'lesser or equal' comparison before, so any query that relied on the old meaning has to be rewritten. `>=` is unchanged and still means 'starts with', `>` and `<` are still lexicographic, and `number` / `boolean` values compare as before. When the index `name` is a compound name ending in a period, `>=` and `<=` match the child name segment from its start and its end respectively. This behavior is served by the API, so it applies to every client version; the SDK type declarations were updated to describe it. See [Indexing](#doc-database-indexing-ends-with-string-values).
- `clientSecretRequestHistory()` items now carry a `created` timestamp: the time the request was made, in milliseconds, stamped once and never rewritten. `updated` keeps its meaning as the time of the most recent status change, which for a settled request is when its response arrived. See [Fetching Request History](#doc-api-bridge-request-history).

**1.7.7**

- Added `stopClientSecretPolling()` to stop polling for client-secret requests without cancelling them. The request continues on the server; only the client stops asking. Stop a single request by `id`, a whole queue by `queue`, or every live poll by passing no arguments. Returns the number of polls stopped.
- Added `isPollStopped()` to tell a stopped poll apart from a real API result.
- The promise returned by `poll()` now carries a `stop()` method that stops that one poll.
- A stopped poll resolves with `{ id, status: 'stopped' }` instead of rejecting, and its `onResponse` / `onError` callbacks are not called. Stopping a request that is still waiting in a queue also removes it from that queue, freeing the slot for the next request.
- Backend (ships with the API, not the SDK): `queue_name` is now the plain queue name on every response that carries it. Polling a single request previously returned the internal queue id (`"<service>:<queue>|<sequence>"`) in that field, while history listings returned the plain name.

**1.7.1**

- `postRecord()` and `bulkPostRecords()` now accept a unique ID in place of `record_id` when updating a record. A locally known unique ID is resolved to its record ID on the client; otherwise the value is passed through for the server to resolve.

**1.7.0**

- Added the `refetchServiceInfo` class initialization option. When `true`, cached service info is bypassed and fresh service info is fetched on load.
- Fixed a bug where large bulk uploads could fail while persisting the local unique-ID map. Writes to session storage are now debounced and guarded, so a full or unavailable session storage no longer interrupts an upload.

**1.6.3**

- Added routing for the file text-extraction endpoint, enabling server-side text extraction from uploaded files.

**1.6.2**

- `getRecords()` `table` parameter now accepts a plain string as shorthand for `{ name: <table> }`.
- `getRecords()` `reference` parameter now accepts an object form `{ record_id?, unique_id?, user_id? }` in addition to a string.
- `getUniqueId()` `condition` now supports `'ne'` / `'!='` for negated matching, and its parameters are now optional.
- `subscribeNewsletter()` `email` now accepts an array to subscribe multiple addresses at once.
- New searchable index values: `access_group` in `getUsers()`, `bounced` in newsletter queries, and `number_of_records` in `getTables()`.
- `getRealtimeUsers()` `group` is now optional and defaults to the realtime group the user is currently joined to.
- Corrected many type declarations to match runtime behavior, making previously required parameters optional across `getTables()`, `getTags()`, `getProfile()`, `getInvitations()`, `getRealtimeGroups()`, `inviteUser()`, `createAccount()`, and others.

**1.6.1**

- `getConnectionInfo()` now returns a `conf` object exposing service flags: `freeze_database`, `prevent_signup`, `prevent_inquiry`, and `prevent_anonymous`.

**1.6.0**

- Added `clientSecretRequest()` support for secure third-party API calls using saved client secrets.
- Added automatic polling with `poll` and manual polling via returned `poll()` when status is `running` or `pending`.
- Added optional `queue` support so requests with the same queue name are processed sequentially.
- Added `clientSecretRequestHistory()` to list and filter past request results by `url`, `method`, `status`, or `queue`.
- Added `cancelClientSecretRequest()` to cancel pending queued client-secret requests.
- Added `clientSecretRequestQueueCount()` to check how many requests are waiting in a named queue.

**1.5.8**

- `clientSecretRequest()` and `clientSecretRequestHistory()` now reject negative `poll` values with an `INVALID_PARAMETER` error.

**1.5.7**

- `signup()` and `openidLogin()` accept `template` options for welcome and signup-confirmation emails.
- Email and phone-number verification methods accept optional `template` parameters.

**1.5.6**

- `getConnectionInfo()` now supports a `refresh` parameter to force-refresh the cached connection info.

**1.5.5**

- Added `ai_agent`, `service_description`, and `prevent_anonymous` fields to the service connection type.

- Table names, index names/values, and tag strings are no longer restricted from using delimiter characters.

**1.5.4**

- Added `queue` parameter to `clientSecretRequest()` and `clientSecretRequestHistory()`. Requests sharing the same `url`, `method`, and `queue` are processed sequentially in the order they are received. See [Client Secret Keys](#doc-api-bridge-client-secret-request).

**1.5.3**

- Fixed access-group condition handling in `getTables()`.

**1.5.2**

- Added `clientSecretRequestHistory()` to retrieve past client-secret request results, with optional polling for items still in `pending` status. See [Client Secret Keys](#doc-api-bridge-request-history).
- Added `poll` parameter to `clientSecretRequest()`: the polling interval in milliseconds for long-running third-party API calls.
- `getTables()` now returns dynamic record counts per access group.

**1.5.1**

- Improved internal logging.

**1.5.0**

- Works with any JavaScript build setup: Node.js, HTML, ESM, CJS, UMD, and TypeScript-based projects.
- New service ID format: "xxxxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxx" (backward compatible).
- Refactored and separated internal dependencies: [cocochex](https://github.com/broadwayinc/cocochex), [qpass](https://github.com/broadwayinc/qpass).
- Optimized and reduced build size.

**1.2.11**

- HOT FIX: Fixed bug for fetchMore parameter for all requests.

**1.2.10**

- Fixed error code on `signup()`.

**1.2.9**

- Removed dependency on Queuecumber.
- Fixed issues with uploading subscription records and feeds.
- Updated README.md.

**1.2.7**
- Fixed types, removed deprecated parameters.

**1.2.2**

- Corrected argument types in the class constructor.

**1.2.0**

- From 1.2.0 onward, the Skapi class can be initialized with just the service ID, without the owner ID. (Backward compatible)
- When initializing with a single service ID, use this format: `xxxxxxxxxxxx-xxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`

**1.1.10**

- Fixed a bug where `updateProfile()` could become unresponsive.

**1.1.8**

- Added several utility features. See [Utilities](https://docs.skapi.com/util/introduction.html)

**1.1.6**

- `openidLogin()` now supports the `merge` parameter, allowing users to merge their OpenID account into an existing account. See [Merging an OpenID Account with a Previous Account](#doc-authentication-openid-login-merging-an-openid-account-with-a-previous-account)
- `inviteUser()` now supports custom invitation email templates via a provided HTML URL. See [Send Invitations with Custom Templates](#doc-admin-invite-send-invitations-with-custom-templates)
- Refactored authentication flow for efficiency.

**1.1.5**

- Fixed a bug where multiple `getRecords()` requests sometimes resolve with empty record data.

**1.1.4**

- Fixed a bug in `listPrivateRecordAccess()` parameter handling.

**1.1.3**

- Corrected type declarations for the constructor options.
- Now users can list granted users of private records via `listPrivateRecordAccess()`. See [List Private Access Grants](#doc-database-access-restrictions-listing-private-access-grants)

**1.1.2**

- No breaking changes in this release.
- Skapi now queues requests in batches for efficiency (Default: 30 requests per batch).
- Skapi now provides more advanced class initialization options, including event listeners for login state, user profile updates, and batch processing. See [Advanced Settings](#doc-introduction-codebase-advanced-settings).
- `getNewsletters()` can now search for bounced emails and display delivery counts per email.

**1.0.265**

- Bug fix: Minor fix for admin purposes.

**1.0.264**

- Anonymous users can now use `skapi.postRecord()`. Only limited to public records.
- Bug fix: `skapi.getTags()` not resolving proper data.

**1.0.262**

- Corrected the casing of the resolved string returned by [`resendSignupConfirmation()`](#doc-api-reference-authentication-resendsignupconfirmation) to: `"SUCCESS: Signup confirmation e-mail has been sent."`

- During class initialization, if the constructor arguments are set to `"service_id"` and `"owner_id"`, a browser alert displays: `Replace "service_id" and "owner_id" with your actual Service ID and Owner ID.`

**1.0.260:**

- Service admin user invitations are now supported. [Learn more](#doc-admin-invite)
- Custom unique ID features have been added to the database. [Learn more](#doc-database-unique-id)
- Database referencing now offers index restriction controls, enabling fine-grained data ownership management. [Learn more](#doc-database-referencing-referencing-index-restrictions)
- The database subscription feature is now available. [Learn more](#doc-database-subscription)
- [WebRTC](#doc-realtime-webrtc) and [Web notification](#doc-notification-send-notifications) are now available, making it easy to build video chat and notification features for your application.
- Fixed various minor bugs.

# Deprecated <a id="doc-deprecated-deprecated"></a>

This page collects everything the rest of the documentation no longer describes: renamed methods, retired options, older request forms, and the ways older versions of `skapi-js` behave differently.

New code never needs any of it. It is here for when you maintain code written against an older name or version, or support users who have not upgraded yet.
When each change shipped is listed in the [Version History](#doc-versionlog-versions).

## clientSecretRequest() is now forwardRequest()

[`forwardRequest(form, options)`](#doc-api-reference-api-bridge-forwardrequest) and its companions replace the `clientSecretRequest()` family.
`forwardRequest()` is the same method as `clientSecretRequest()`, renamed. Everything that method does, the new one does, through the same code: the queue, the polling handle, `stream`, `realtime`, `expires`, and the direct non-queued path. Two things are new, and both are optional: the **first argument is a form**, and the **secret key is optional**.

**Every old name still works and is not going away.** Each is marked deprecated in the SDK with the name that replaces it.

| deprecated | use |
|---|---|
| `clientSecretRequest(params)` | [`forwardRequest(form, options)`](#doc-api-reference-api-bridge-forwardrequest) |
| `clientSecretRequestStream()` | [`forwardRequestStream()`](#doc-api-reference-api-bridge-forwardrequeststream) |
| `clientSecretRequestFinalize()` | [`forwardRequestFinalize()`](#doc-api-reference-api-bridge-forwardrequestfinalize) |
| `clientSecretRequestHistory()` | [`forwardRequestHistory()`](#doc-api-reference-api-bridge-forwardrequesthistory) |
| `cancelClientSecretRequest()` | [`cancelForwardRequest()`](#doc-api-reference-api-bridge-cancelforwardrequest) |
| `stopClientSecretPolling()` | [`stopForwardRequestPolling()`](#doc-api-reference-api-bridge-stopforwardrequestpolling) |
| `clientSecretRequestQueueCount()` | [`forwardRequestQueueCount()`](#doc-api-reference-api-bridge-forwardrequestqueuecount) |
| `isPollStopped()` | [`isPollStopped()`](#doc-api-reference-api-bridge-ispollstopped), unchanged and shared |

Everything but `forwardRequest()` itself is an **alias**: the same function under a second name, with the same arguments and the same return. A request started under either name is read, polled, cancelled, finalized and counted by both, so you can move one call at a time.

- [`forwardRequestHistory()`](#doc-api-reference-api-bridge-forwardrequesthistory) lists requests sent under **either** name: the history is the request's own, not the method's, so a request dispatched by `clientSecretRequest()` is listed there, and one dispatched by `forwardRequest()` is listed by `clientSecretRequestHistory()`.
- `stopClientSecretPolling()` and `stopForwardRequestPolling()` share one registry of live polls, so either name stops a poll started under the other. A poll stopped by either reads the same way to [`isPollStopped()`](#doc-api-reference-api-bridge-ispollstopped).

### Moving a clientSecretRequest() call

`forwardRequest()` is the exception to the aliases, because its first argument is the form. Moving a `clientSecretRequest(params)` call to it means splitting the old single argument: the request body becomes the first argument, and everything else stays in the options.

```js
// Before
skapi.clientSecretRequest({
    clientSecretName: 'my_secret',
    url: 'https://api.example.com/v1/report',
    method: 'POST',
    headers: { Authorization: 'Bearer $CLIENT_SECRET' },
    data: { report: 'quarterly' }
});

// After
skapi.forwardRequest({ report: 'quarterly' }, {
    secretName: 'my_secret',
    url: 'https://api.example.com/v1/report',
    method: 'POST',
    headers: { Authorization: 'Bearer $CLIENT_SECRET' }
});
```

### clientSecretName is now secretName

`secretName` names one of your project's [Secret Keys](#doc-api-bridge-client-secret-request-registering-secret-keys). It is **optional**, which is the one behavioural difference from the old `clientSecretName`: every `clientSecretRequest()` call has to name a key, while a `forwardRequest()` call that names none is forwarded with only what you supplied.

The request field is `secretName`, and the server accepts `clientSecretName` as an alias, preferring `secretName` when both are present. That is what keeps an older build of the SDK working against a current server.

### Types under the old names

The TypeScript signatures the SDK declares for the deprecated names still write `method` as the original four, `'GET' | 'POST' | 'DELETE' | 'PUT'`, and `clientSecretRequestStream()` writes its `onStream` as `(chunk: string, seq: number) => void`. At runtime every one of these is the new function, so `PATCH` and `HEAD` work through the old names as well, and `onStream` receives `via` too. A TypeScript caller that wants `PATCH` or `HEAD` calls the method under its new name.

The second argument of `forwardRequestStream()` is documented as [`ForwardRequestStreamOptions`](#doc-api-reference-data-types-forwardrequeststreamoptions). It was called `ClientSecretStreamOptions` in these docs before the method was renamed. Neither name is importable from `skapi-js`: the type is written inline in the method's signature.

### Deprecated method reference

#### clientSecretRequest <a id="doc-deprecated-deprecated-clientsecretrequest"></a>

**Deprecated. Use [forwardRequest](#doc-api-reference-api-bridge-forwardrequest), which takes the form as its first argument and makes the secret key optional.**

```ts
clientSecretRequest(
    params: {
        clientSecretName: string; // REQUIRED here, unlike secretName on forwardRequest(). The name of the secret key registered in your Skapi project.
        url: string; // The third-party API endpoint URL.
        method: 'GET' | 'POST' | 'DELETE' | 'PUT'; // The HTTP method.
        headers?: { [key: string]: string }; // Request headers as a key-value object.
        data?: { [key: string]: any }; // Request body as a key-value object (used when method is POST or PUT).
        params?: { [key: string]: string }; // Query parameters as a key-value object (used when method is GET or DELETE).
        poll?: number; // Optional polling interval in milliseconds. Must be a non-negative number.
        queue?: string; // Optional queue name. Requests sharing the same queue are processed sequentially on the server side.
        expires?: number; // Optional expiration time in seconds for the request record.
        stream?: boolean; // Read the destination's response INCREMENTALLY. Requires a queue, and mints one when you did not name one. Defaults to false.
        realtime?: boolean; // ALSO push each relayed piece over skapi's websocket. Only meaningful alongside stream and onStream. Defaults to false.
        onStream?: (chunk: string, seq: number, via?: 'socket' | 'poll') => void; // Called with each relayed piece, in order. Raw text, never parsed.
        onResponse?: (res: any, meta?: { executed?: number }) => void; // Called with the final API response once polling resolves, or immediately for non-queued direct responses. See forwardRequest for what `meta` carries.
        onError?: (err: any) => void; // Called when polling or the initial request fails.
    }
): Promise<any | {
    id: string;
    status: 'pending';
    queue_name: string;
    in_queue: number;
    realtime_group?: string;
    poll?: (arg?: { latency?: number; onStream?: (chunk: string, seq: number, via?: 'socket' | 'poll') => void }) => Promise<any>;
}>
```

Both names dispatch the same request through the same code. The differences are all at the door:
`clientSecretName` is **required** here, there is no form argument, and `multipart`, `skapiHeaders`,
`responseType` and `signal` are options of `forwardRequest()` only. Everything else, including the
queue, the poll handle, `stream`, `realtime` and `expires`, is identical.

#### clientSecretRequestStream <a id="doc-deprecated-deprecated-clientsecretrequeststream"></a>

**Deprecated. Use [forwardRequestStream](#doc-api-reference-api-bridge-forwardrequeststream), which is this same function.**

Identical in every way. Its TypeScript signature writes `method` as `'GET' | 'POST' | 'DELETE' | 'PUT'`
and its `onStream` as `(chunk: string, seq: number) => void`; the running function is the new one, `via` included.

#### clientSecretRequestFinalize <a id="doc-deprecated-deprecated-clientsecretrequestfinalize"></a>

**Deprecated. Use [forwardRequestFinalize](#doc-api-reference-api-bridge-forwardrequestfinalize), which is this same function.**

Identical in every way. Its TypeScript signature writes `method` as `'GET' | 'POST' | 'DELETE' | 'PUT'`.

#### clientSecretRequestHistory

**Deprecated. Use [forwardRequestHistory](#doc-api-reference-api-bridge-forwardrequesthistory), which is this same function.**

Identical in every way, and a request sent under either name is listed by both. Its TypeScript
signature writes `method` as `'GET' | 'POST' | 'DELETE' | 'PUT'`.

#### cancelClientSecretRequest

**Deprecated. Use [cancelForwardRequest](#doc-api-reference-api-bridge-cancelforwardrequest), which is this same function.**

Identical in every way. Its TypeScript signature writes `method` as `'GET' | 'POST' | 'DELETE' | 'PUT'`.

#### stopClientSecretPolling

**Deprecated. Use [stopForwardRequestPolling](#doc-api-reference-api-bridge-stopforwardrequestpolling), which is this same function.**

Identical in every way, and the two share one registry of live polls, so either name stops a poll
started under the other. Its TypeScript signature writes `method` as `'GET' | 'POST' | 'DELETE' | 'PUT'`.

#### clientSecretRequestQueueCount

**Deprecated. Use [forwardRequestQueueCount](#doc-api-reference-api-bridge-forwardrequestqueuecount), which is this same function.**

Identical in every way.

## The retired forwardRequest()

An earlier `forwardRequest(form, options)` relayed a request to **your own backend** over a different path. It held a live HTTP connection open to that backend and pushed its chunks straight through, injected your project's API key into a header of your choosing with `apiKeyHeader` and `apiKeyScheme`, refused a caller who was not signed in, always sent `x-skapi-user` and `x-skapi-service`, and resolved with a live `Response` under `responseType: 'response'`.

The SDK no longer calls that path, and the name now means the queued forwarder described in [Forwarding Requests](#doc-api-bridge-forward-request). Your own backend is simply another destination for it. What changes when you move from the old method:

- **The API key is not injected any more.** No `x-api-key` is sent on your behalf. Store the key on the [Secret Keys](#doc-api-bridge-client-secret-request-registering-secret-keys) page, name it with `secretName`, and put `$CLIENT_SECRET` where the key went.
- **`skapiHeaders` is off by default.** The old forwarder always sent `x-skapi-user` and `x-skapi-service`; now you ask for them. `x-skapi-user` is now the caller's **user id** on its own rather than a JSON profile.
- **Signing in is not required.** A signed-out visitor can call the new method, and the key's own **Locked** setting decides whether they may use it.
- **Streaming goes through the chunk store.** The response is relayed into a chunk store, read by polling, survives a reload through [`forwardRequestStream()`](#doc-api-bridge-streaming-request-reading-a-request-you-did-not-start), and stays stored until you finalize it. An `AbortSignal` in `signal` still stops **this client** receiving the rest and still leaves the request running, exactly as it did before.

### apiKeyHeader and apiKeyScheme

`apiKeyHeader` and `apiKeyScheme` are **refused** with `INVALID_PARAMETER` rather than ignored: a silently dropped key is a request that reaches the destination unauthenticated and fails there, which is a much longer way to find out. A destination that wants a key now names a stored secret with `secretName` and puts `$CLIENT_SECRET` where the key goes:

```js
skapi.forwardRequest({ report: 'quarterly' }, {
    secretName: 'my_backend_key',
    url: 'https://api.yourbackend.com/report',
    method: 'POST',
    headers: { 'x-api-key': '$CLIENT_SECRET' },
    skapiHeaders: true
});
```

`skapiHeaders: true` is how your backend still learns who is calling.

### responseType: 'response'

The SDK's TypeScript still declares `'response'` among the values of `responseType`, for the retired method's callers, but it is **refused** at runtime with `INVALID_PARAMETER`. The destination is called server side and its body is relayed, so there is no live `Response` object to hand back. The destination's own status code is the `status_code` of the row [`forwardRequestHistory()`](#doc-api-bridge-request-history) lists.

## Tickets

### `${result}` (removed 2026-10-08)

`${result}` and `${result[key]}` read the answer of the previous action in the same chain, by position. An action's answer is now read as `${response}` and `${response[key]}` inside that action's own Then chain (`actions`), or captured there by a Check answer action, on every action that answers something. Registration refuses `${result}` with a message that says so; no stored ticket used it. See [Then on every action](#doc-tickets-actions-then-on-every-action).

### `${data}` and `data` rows are now `${body}` and `body` rows <a id="doc-deprecated-deprecated-data-and-data-rows-are-now-body-and-body-rows"></a>

Since 2026-10-09 the received request body is read as `${body}` and `${body[key]}`, and the condition rows that read it are the `body` rows (`condition.body`); `${params}` and `params` rows read the query string as before. `${data}` and `data` rows were their names until then. A stored ticket keeps running as it was written: the engine still resolves `${data...}` and reads `data` rows. Registration accepts `data` rows and stores them as `body`, and a ticket listing hands back `body` rows and `${body}` references, so a ticket saved again from the dashboard is written the current way. [`TicketCondition`](#doc-api-reference-tickets-ticketcondition) names the rows `body`.

### A request's answer was its body <a id="doc-deprecated-deprecated-a-request-s-answer-was-its-body"></a>

Until 2026-10-09 an HTTP request action answered the parsed response body alone, so its Then chain read `${response[carrier]}`. It now answers the whole response, `{ status, headers, body }`, read as `${response[body][carrier]}`, `${response[status]}` and `${response[headers][content-type]}`. See [Failures and the answer](#doc-tickets-actions-failures-and-the-answer).

A ticket saved before that keeps answering the body alone, as it was written to read it, until it is saved again. Every registration since stamps the rules the ticket was saved under: [`getTickets()`](#doc-api-reference-tickets-gettickets) returns it to the project owner as `rules_version` (`2` now), and a ticket without it is one saved before. When you open such a ticket on the dashboard, every reference after an HTTP request is moved under `[body]` for you, a notice says so, and saving asks you to confirm: once saved, the ticket reads the whole response and the moved references read what they always did.

### Tickets saved before this release <a id="doc-deprecated-deprecated-tickets-saved-before-this-release"></a>

Tickets registered before this release keep running by the rules below until they are registered again, apart from the platform rules listed under [What applies to these tickets now](#doc-deprecated-deprecated-what-applies-to-these-tickets-now). The dashboard's **Tickets** page marks them **previous rules**, their form says **Saved under the previous rules**, and `getTickets()` returns them to the project owner with `legacy: true`. Saving one applies the current rules; what that changes is listed under [Saving one again](#doc-deprecated-deprecated-saving-one-again).

#### The previous format

A ticket had one `condition`, a `placeholder` map and a single `action` object:

```json
{
  "ticket_id": "order-paid",
  "description": "Order paid",
  "count": 100,
  "limit_per_user": true,
  "condition": {
    "return200": true,
    "method": "POST",
    "headers": [
      {
        "key": "x-webhook-source",
        "operator": "=",
        "value": "shop"
      }
    ],
    "body": [
      {
        "key": "type",
        "operator": "=",
        "value": "order.paid"
      }
    ]
  },
  "placeholder": {
    "ORDER": "order[id]"
  },
  "action": {
    "request": {
      "url": "https://api.example.com/orders/$ORDER/ship",
      "method": "POST",
      "headers": {
        "content-type": "application/json"
      },
      "data": {
        "event": "${type}"
      }
    }
  }
}
```

- `condition` held `return200`, `method` (`"GET"` or `"POST"`), `ip` and `user_agent` (`{ operator, value }` with one text value), rows in `headers`, `data`, `params` and `user` (`{ key, operator, value }`, plus `setValueWhenMatch` on `data` and `params`), `record_access`, and the check-time [`request`](#doc-deprecated-deprecated-the-request-condition).
- `placeholder` mapped a name to a path in the request data.
- `action` held any of `request` (`{ url, method, headers, data, params }`), `access_group` (a number) and `record_access` (a record id).

#### How a request was checked

1. The ticket itself: the issuer cannot consume it, then `time_to_live`, `count` and, for a signed-in consumer, `limit_per_user`.
2. On the long-form endpoints, the placeholders (below).
3. The condition, in the order `method`, `headers`, `ip`, `user_agent`, `data`, `params`, `user`, `request`. The first part that fails is answered.

The rows already combined by key, as they do now: every key must match, and rows on the same key are alternatives. What differed:

- **A missing field is an error.** A row whose path is not in the request answers `INVALID_MATCH: Path not found: <key>.` at once. A `data` row on a body that is not JSON, or a `params` row on a request without a query string, answers `INVALID_MATCH: Invalid path provided for the data.`
- **Header names are lower case.** Headers are read with lower-case names, so a `headers` row whose key has a capital letter answers `Path not found`.
- **The data.** `body` rows read the POST body and `params` rows the query string, on either method. Query values are the strings as received, never JSON-parsed.
- **Comparisons are loose.** `=` treats `true` as equal to `1`. On a string field, `>=` means starts with, but `<=` compares in character order. An ordering operator between a string and a number answers 500.
- **A list is one value in a row.** A row whose `value` is a list compares the field with the whole list, not with each member, so `= ["a", "b"]` matches only a field that is that list. Only `ip` and `user_agent` pass when any member matches.
- **`record_access` in the condition was never checked.**
- **`setValueWhenMatch`** rewrote the value in the request data, which the request action then read.

A mismatch answers `INVALID_MATCH` with the part named, such as `INVALID_MATCH: Invalid post data in key: type.`, `Invalid get parameter in key: code.`, `Invalid header in key: x-webhook-source.`, `Invalid user data in key: email_verified.`, `Invalid IP: 203.0.113.7` or `Invalid user agent: ...`. A wrong method answers `INVALID_REQUEST: Method not allowed for this ticket.` (`Method "get" is not allowed for this ticket.` on the short endpoints). A `user` row on an anonymous consumption answers `INVALID_REQUEST: User authentication required.`

#### Placeholders and templating

On the long-form endpoints (`/publ/consume/...` and `/auth/consume/...`), each entry of the `placeholder` map read its path from the request data (the POST body, or the query string on a GET) before the condition was checked, and every `$NAME` anywhere in the ticket's text, condition and action alike, was replaced by that value. A value that was missing or empty (`""`, `0`, `false`, `null`) left `$NAME` as written, and a value that was not text answered 500. The short endpoints (`/tp/`, `/tg/`, `/tpa/`) and the dry run applied no placeholders.

The request action had one more kind of value: `${key}`, the top-level `key` of the request data, as text. It was filled in the request's `headers`, in its `params` on a GET, and in its `data` and `url` on a POST. A `${...}` naming no key was sent as written, and nothing was percent-encoded. `${CLIENT_SECRET}` was an ordinary key: it read the request's own `CLIENT_SECRET` field.

#### Actions

The action object ran in a fixed order, and only when the condition passed:

1. `request`: one HTTP call. Its `data` is sent as JSON when the `content-type` header is exactly `application/json`, otherwise form encoded. A status of 400 or more answers `REQUEST_FAILED: Request failed with status code <status>`.
2. `access_group`: meant to set the signed-in consumer's access group. On an anonymous consumption it answers `INVALID_REQUEST: User authentication required.` before any action runs. On a signed-in one it never completes: the group is refused when it is written, and the consumption answers 500 after the request has run.
3. `record_access`: did nothing.

#### Answers

A success answers `{ "tkid", "hash" }`, as now. A failure answers plain text, `<CODE>: <message>`, such as `INVALID_REQUEST: Ticket expired.`, with status 400, or 200 when `return200` is on. An unexpected error answers 500 with `Internal server error`. There is no JSON error body and no `stage`, so [`consumeTicket()`](#doc-api-reference-tickets-consumeticket) rejects with a `SkapiError` whose `code` is `INVALID_REQUEST` (`ERROR` for the other codes, such as `INVALID_MATCH`), whose `message` is the text after the code, and which has no `cause`. With `return200` on, it resolves with the text.

#### What applies to these tickets now <a id="doc-deprecated-deprecated-what-applies-to-these-tickets-now"></a>

A few platform rules apply to every ticket, these included:

- The project must exist and be active, and a signed-in caller must belong to the project. These are checked before the ticket is read, and answered with the current [JSON error body](#doc-tickets-errors-the-error-body).
- A request sent by a ticket, which carries an `X-Skapi-Ticket` header, is refused.
- A ticket id that starts with `#` or `!`, which the previous register accepted, answers `TICKET_NOT_FOUND`: those prefixes name the consumption log and the per-user usage rows.
- Outbound calls go through the same vetted client as a [`req` action](#doc-tickets-actions-url-and-address-rules): no private or Skapi addresses, redirects are not followed, each call has 10 seconds within the 25-second budget of a consumption, and the engine sets `Host` to the URL's host and `X-Skapi-Ticket` itself, dropping a copy of either that the ticket's headers carry. A call that is refused or cannot complete, a header value it cannot send included, answers 500.
- A dry run never uses up a count. Consumptions are written to the Log tab in the [current format](#doc-tickets-errors-the-log-row), with `"legacy": true` in `outcome`, by the [current rules of what gets logged](#doc-tickets-errors-what-gets-logged).

#### Saving one again <a id="doc-deprecated-deprecated-saving-one-again"></a>

The form shows the ticket converted to the current format: each `placeholder` entry becomes a capture-only row (in `data`, or in `params` when the method is GET), every `$NAME` becomes `${placeholder[NAME]}`, the request becomes a `req` action whose `${key}` values become `${body[key]}` (`${params[key]}` on a GET ticket) in the parts that filled them in (its headers, its `params` on a GET, its `data` and `url` on a POST), `access_group` becomes an `acsg` action and `record_access` an `acsr` action. A `${...}` anywhere else in the request was sent as written, so it becomes `$${...}`, which still sends it as written. `${CLIENT_SECRET}` is left as written, so saving asks you to name a [Secret Key](#doc-tickets-actions-sending-a-secret-key) for it.

Saving asks you to confirm, and from then on the ticket runs by the [current rules](#doc-tickets-conditions-how-each-part-decides). Read the converted form before you confirm. What can change:

- **Which requests pass.** A missing field is a mismatch instead of an error, `<=` on two strings means ends with, `=` compares strictly (`true` is not `1`), and header names ignore case. A list `value` in a row passes when any member matches (with `!=`, when none does) instead of being compared as a whole. Query string values are JSON-parsed, so `?qty=2` is the number `2` and no longer equals the text `"2"`. A `record_access` in the condition starts being checked, and passes only for a signed-in consumer who holds that record.
- **`$NAME` in a condition row.** The form shows it as `${placeholder[NAME]}`, but a row's `value` is literal under the current rules, so the row compares the field with that text and stops matching. Rewrite such a row before you save.
- **The check-time `request` condition is removed.** Saving drops it; see [The request condition](#doc-deprecated-deprecated-the-request-condition) for the `req` action that replaces it.
- **Placeholders are captured on every endpoint,** not only on the long-form routes.
- **What the actions send.** A request action's values are percent-encoded in its URL, and `${CLIENT_SECRET}` only means the Secret Key the action names. A record access action, which did nothing before, now grants access, and an access group action, which always failed before, now sets the group.
- **The answers.** Failures are answered with the [JSON error body](#doc-tickets-errors-the-error-body) instead of plain text.

Once it is saved, the [dry run](#doc-tickets-introduction-dry-run) tries the ticket under the current rules.

### The request condition <a id="doc-deprecated-deprecated-the-request-condition"></a>

A ticket's condition could carry a `request`: one HTTP call made while the condition was checked, whose `match` rows were compared with the response before the response was thrown away. It also ran on the dry run, which is otherwise meant to run nothing.

```json
"placeholder": {
  "LICENCE": "licence"
},
"condition": {
  "request": {
    "url": "https://api.example.com/licences/$LICENCE",
    "method": "GET",
    "match": [
      {
        "key": "status",
        "operator": "=",
        "value": "active"
      }
    ]
  }
}
```

It can no longer be registered: registration refuses it with `the "request" condition was removed. Call the URL with a req action and check its answer with a Check answer action in its Then chain ("actions") instead.`, and drops a blank one (`null`, `{}`, `""`). A ticket saved before this release keeps running its `request` until it is saved again; the dashboard then removes it, after you confirm.

The same check is a [`req` action](#doc-tickets-actions-req-http-request-with-its-own-condition-and-chain) placed first, with its answer checked by a [Check answer](#doc-tickets-actions-checking-the-response) in its Then chain:

```json
{
  "act": "req",
  "exe": {
    "url": "https://api.example.com/licences/${body[licence]}",
    "method": "GET",
    "actions": [
      {
        "act": "cond",
        "exe": {
          "response": [
            {
              "key": "status",
              "operator": "=",
              "value": "active"
            }
          ]
        }
      }
    ]
  }
}
```

The actions that should run only when the check passes go after it in the chain, or into its `actions`. Unlike the condition, it can also carry a [Secret Key](#doc-tickets-actions-sending-a-secret-key), capture from the answer, and act on it.

### signature.secret is now secretName

A ticket's `condition.signature.secretName`, the name of the Secret Key that signs the sender's requests, was called `secret` before 2026-09-18. That name is still accepted when you register a ticket and is converted to `secretName`, which is what gets stored. It is still read on tickets stored earlier, so no registered ticket breaks.

### A response check on an action (`condition`, `match`)

Until 2026-10-09 a `req` action could carry a `condition` of its own, checked against its response (`headers` and `data` rows), and before that `match` rows checked the same way. An answer is checked by a [Check answer](#doc-tickets-actions-cond-check-answer) action in the action's Then chain now, on every action that answers something: its `response` rows read the answer whole (key `""`) or by key. Registering a `condition` or a `match` on an action is refused with a message that says so: `"actions[0].exe.condition": a response check is a Check answer action in the action's Then chain ("actions"): its "response" rows read the answer, whole (key "") or by key.` A ticket saved with one keeps running as it was until it is saved again; the dashboard names the field when such a ticket is saved, and the rows move into a Check answer in the Then chain.

### The long-form consume endpoints

Every ticket also still answers on the older long-form routes, which carry the owner ID beside the project's service ID. They behave exactly like the [current endpoints](#doc-tickets-introduction-endpoints), with one exception: for a [ticket saved before this release](#doc-deprecated-deprecated-tickets-saved-before-this-release), only the long-form routes apply its `placeholder` map.

| route | auth | same as |
|---|---|---|
| `POST/GET /publ/consume/<service_id>/<owner_id>/<ticket_id>` | none | `POST /tp/...` and `GET /tg/...` |
| `POST /auth/consume/<service_id>/<owner_id>/<ticket_id>` | Cognito | `POST /tpa/...` |

## Newsletters sent by admins

Admins in access group `99` could once send newsletters for a project, from their own email address, to an admin newsletter address that the `admin-newsletter-request` API minted for them.
Since newsletters became owner only, that API refuses every caller with `INVALID_REQUEST` and `Only the project owner can send newsletters.`, and mail to an admin newsletter address is not processed.
When its sender passes the sender trust check, Skapi replies that admin newsletter addresses are no longer used.
Send newsletters as the project owner, from the project's email address, to the address the `Newsletters` page shows. See [Sending Newsletters](#doc-email-newsletters).

## The service ID and owner ID pair

A project used to be identified by two values, a service ID and an owner ID. The `Skapi` constructor still accepts that pair in place of the single [project ID](#doc-introduction-getting-started), and converts it to the same project ID.

The [full example](#doc-full-example-intro) template's `service.js` uses the single project ID.

## E-mail templates

### https://link.skapi

`https://link.skapi` is the original form of the [link placeholder](#doc-email-email-templates-the-link-placeholder), and it still works everywhere. Templates that use it do not need to be changed. The screenshots on the [Automated Emails](#doc-email-email-templates) page still show it.

`https://link.skapi.com` replaced it because it reads as a real web address, so mail editors that refuse a link to `https://link.skapi` accept it.

### Templates saved before template checks

Templates sent before Skapi started [checking templates](#doc-email-email-templates-when-a-template-is-rejected) are not checked again. Invitation templates are now checked for `${email}` and `${password}` as well as the link, so an invitation template without them that was accepted before is rejected if you send it again.

## Older versions of skapi-js

### signup() and the EXISTS code

A signup is refused when its login ID is already a login ID another account of the project was granted. `signup()` reports that refusal with code `EXISTS` from skapi-js 2.0.6. Earlier versions report it differently:

- **1.2.14-beta.1 through 2.0.5** (stable releases 1.5.0 through 2.0.5): code `INVALID_REQUEST`, with
  the same message as 2.0.6.
- **1.2.9 through 1.2.14-beta.0** (stable releases 1.2.9 through 1.2.12): code `INVALID_REQUEST`, with
  Cognito's whole error text as the message, for example
  `PreSignUp failed with error #EXISTS: E-mail "user@email.com" is already a login ID in this service.`
- **1.0.97-beta.4 through 1.2.8**: Cognito's own error, passed on unchanged rather than as a skapi error,
  with code `UserLambdaValidationException` and the same whole text as its message.

If your app handles `EXISTS` and has to support those versions, also accept code `INVALID_REQUEST` or
`UserLambdaValidationException` with a message ending in `is already a login ID in this service.` or
`is already used by another account in this service.`, which matches every form.
`createAccount()` and `inviteUser()` report `EXISTS` in every version.

See [When a login ID is already taken](#doc-authentication-create-account-when-a-login-id-is-already-taken) for the current behaviour.

### A user's own e-mail change

When a user changes their **own** email with
[`updateProfile()`](#doc-api-reference-user-updateprofile), skapi-js 2.0.5 and earlier add a login
handle for the new address to the same request. The server removes such a handle again on the account's
next token, because the address is not verified, so the address ends up logging the account in only once
it is verified either way. Two things still differ while an old version is in use:

- The change is refused outright when another account of your project already holds that address as a
  login ID, verified or not. From 2.0.6 the email is changed and only the login is withheld.
- The unverified new address logs the account in for the few seconds before the handle is removed.

An email an admin changes with
[`updateUserAttributes()`](#doc-api-reference-admin-updateuserattributes), or with
`updateProfile()` and another user's `user_id`, is not affected: the server ignores any login handle the
request sends, in every SDK version.

Those handles are also where most e-mail logins held **without** a verified address come from. Such a login grants nothing: it is removed, and the address goes to the account that proves it. See [Login IDs](#doc-admin-permissions-login-ids).

Upgrade to skapi-js 2.0.6 or later, or pin a CDN URL to it, to get the behaviour described in [Create Account](#doc-authentication-create-account-e-mail-and-username).

### Large record data

Record `data` larger than 32 KB is stored as a file, and the SDK puts it back together for you. A build of `skapi-js` from before this feature does not know how to fetch the value back. The server fills the payload into the response for such a build instead, but that fallback is bounded by the size of a single response, so a read of several large records can still hand an older build a `{ "__data__": ... }` marker where its data should be.

Pin a version by all means, and upgrade it before you start storing large payloads. See [Large record data](#doc-introduction-plans-large-record-data).

### Encrypted records

An older SDK reading an [encrypted record](#doc-database-encryption) sees the raw envelope. It loses nothing, but it cannot read it. Upgrade every client before you start encrypting records.

### Require Login and the metadata listings

[`getTables()`](#doc-api-reference-database-gettables), [`getTags()`](#doc-api-reference-database-gettags), [`getIndexes()`](#doc-api-reference-database-getindex) and [`getUniqueId()`](#doc-api-reference-database-getuniqueid) used to be served to anyone who knew the project ID. With [Require Login](https://docs.skapi.com/service-settings/service-settings.html#require-login) on, which is also the setting of a project that has never set it, the backend now refuses them to a request with no signed-in user. That includes an older SDK, which has no `REQUIRE_LOGIN` gate of its own and sends the request anyway.

An integration that lists them without a signed-in user now gets an error where it used to get a list. If a signed-out page genuinely needs the listing, turn Require Login off in the project settings.

# API Reference <a id="api-reference"></a>

# API Reference: Connection <a id="doc-api-reference-connection"></a>

Below are the parameters and return data type references for the methods in TypeScript format.

## getConnectionInfo <a id="doc-api-reference-connection-getconnectioninfo"></a>

```ts
getConnectionInfo(params?: { refresh?: boolean }): Promise<ConnectionInfo>
```

See [ConnectionInfo](#doc-api-reference-data-types-connectioninfo)

#### Errors

```ts
{
    code: "NOT_EXISTS";
    message: "Project does not exists. Register your project at skapi.com"
}
```

## mock <a id="doc-api-reference-connection-mock"></a>

```ts
mock(
    data: SubmitEvent | { [key: string]: any } & { raise?: 'ERR_INVALID_REQUEST' | 'ERR_INVALID_PARAMETER' | 'SOMETHING_WENT_WRONG' | 'ERR_EXISTS' | 'ERR_NOT_EXISTS'; },
    options?: {
        auth?: boolean; // Requires authentication
        method?: string; // HTTP method. Default is 'POST'
        responseType?: 'blob' | 'json' | 'text' | 'arrayBuffer' | 'formData' | 'document'; // Response data type. Default is 'json'
        contentType?: string; // Content-Type header. Default is 'application/json'
        progress?: ProgressCallback;
    }
): Promise<{[key:string]: any}>
```

See [ProgressCallback](#doc-api-reference-data-types-progresscallback)

## getFormResponse <a id="doc-api-reference-connection-getformresponse"></a>

```ts
getFormResponse(): Promise<any>
```

# API Reference: Authentication <a id="doc-api-reference-authentication"></a>

Below are the parameters and return data type references for the methods in TypeScript format.

## signup <a id="doc-api-reference-authentication-signup"></a>

```ts
signup(
    params: SubmitEvent | {
        email: string; // Required. Must be in email format. ex) user@email.com
        username?: string; // Optional. Becomes the account's PERMANENT login ID and can never be changed. The email logs the account in as well once that address is VERIFIED (not before), unless it is already a login ID another account was granted. A changed email logs the account in only after it is verified too.
        password: string; // At least 6 characters and a maximum of 60 characters.
        name?: string;
        phone_number?: string; // Must be in "+0012341234" format.
        address?: string | {
            formatted: string;
            locality: string;
            region: string;
            postal_code: string;
            country: string;
        }; // OpenID Standard Claims object is also supported.
        gender?: string;
        birthdate?: string; // Must be in YYYY-MM-DD format
        picture?: string; // URL of the profile picture.
        profile?: string; // URL of the profile page.
        website?: string; // URL of the website.
        nickname?: string; // Nickname of the user.
        misc?: string; // Additional string value that can be used freely. Visible to the account owner, to admins and to the project owner.
        email_public?: boolean; // Default = false
        phone_number_public?: boolean; // Default = false
        address_public?: boolean; // Default = false
        gender_public?: boolean; // Default = false
        birthdate_public?: boolean; // Default = false
    },
    options?: {
        /**
         * When true, user is required to confirm their signup confirmation on first login. (Default = false).
         * When URL or relative path of the website is given, It will redirect the user after successful confirmation.
         * NOTE: Relative path will not work if the website is not hosted.
         */
        signup_confirmation?: boolean | string;

        /**
         * The newsletter the user is subscribed to once they confirm their e-mail, with no newsletter
         * confirmation e-mail of its own: 0 or 'public' (the public newsletter), 1 or 'authorized' or true
         * (Service Email), or the name of a named newsletter group of the service. Requires
         * 'signup_confirmation'; a user whose e-mail is never confirmed is never subscribed. (Default = false)
         */
        email_subscription?: boolean | number | string;

        /** When true, user is logged in soon as the signup process is sucessful.
         * Cannot use with 'signup_confirmation'. (Default = false)
         */
        login?: boolean;

        /**
         * Per-call e-mail template overrides.
         * Each value is the message_id of a template already uploaded to your project's
         * Automated Emails page. Overrides your project's template for this call only.
         * See [Automated Emails](/email/email-templates.md#overriding-the-template-for-a-single-call).
         */
        template?: {
            /**
             * message_id of the template to use for the signup confirmation e-mail.
             * 'signup_confirmation' must also be set, otherwise throws INVALID_PARAMETER.
             */
            signup_confirmation?: string;

            /** message_id of the template to use for the welcome e-mail. */
            welcome?: string;
        };
    }
): Promise<
    UserProfile |
    "SUCCESS: The account has been created. User's signup confirmation is required." |
    "SUCCESS: The account has been created.">
```

See [UserProfile](#doc-api-reference-data-types-userprofile)

#### Errors
```ts
{
  code: 'EXISTS';
  message: "The account already exists.";
}
|
{
  code: 'EXISTS';
  message: 'E-mail "user@email.com" is already a login ID in this service.' | "The login ID is already used by another account in this service.";
  // 'E-mail': a signup without 'username' whose email is already the email login another account was GRANTED by verifying that address. The other: a 'username' that is such an address, which only an e-mail address such as "jane@email.com" can be.
}
|
{
  code: 'REQUEST_EXCEED';
  message: "Too many attempts. Please try again later.";
}
|
{
  code: 'CODE_DELIVERY_FAILURE';
  message: "Failed to deliver verification code.";
}
|
{
  code: 'INVALID_REQUEST';
  message: "Signup validation failed.";
}
|
{
  code: 'ERROR';
  message: "Failed to signup.";
}
```

A signup is refused when its login ID is already a login ID another account of the project was granted,
for example a signup without `username` using the verified email of an account that was created with a
username. A login ID an account holds without proof, such as an email login for an address it never
verified, refuses nothing: it is removed and the signup goes through.
A login ID another account was itself created with gives `The account already exists.` instead. A signup
with a `username` whose email is already another account's login ID is not refused: the account is
created and only its username logs it in. See
[When a login ID is already taken](#doc-authentication-create-account-when-a-login-id-is-already-taken).

## resendSignupConfirmation <a id="doc-api-reference-authentication-resendsignupconfirmation"></a>

```ts
resendSignupConfirmation(): Promise<'SUCCESS: Signup confirmation e-mail has been sent.'>
```

#### Errors
```ts
{
  code: 'INVALID_REQUEST',
  message: 'Least one login attempt is required.'
}
```

## login <a id="doc-api-reference-authentication-login"></a>

```ts
login(
    params: SubmitEvent | {
        username?: string; // The account's permanent login username, when it was created with one.
        email: string; // The account's current email, once it is verified, or the address the account was created with when it has no username. Required unless 'username' is given. An unverified address answers INCORRECT_USERNAME_OR_PASSWORD.
        password: string;
    }
): Promise<UserProfile>
```

See [UserProfile](#doc-api-reference-data-types-userprofile)

#### Errors
```ts
{
  code: "SIGNUP_CONFIRMATION_NEEDED";
  message: "The account signup needs to be confirmed.";
}
|
{
  code: 'USER_IS_DISABLED';
  message: 'This account is disabled.';
}
|
{
  code: 'INCORRECT_USERNAME_OR_PASSWORD';
  message: 'Incorrect username or password.';
}
|
{
  code: 'REQUEST_EXCEED';
  message: 'Too many attempts. Please try again later.';
}
```

## getProfile <a id="doc-api-reference-authentication-getprofile"></a>

```ts
getProfile(
    options?: {
        /** When true, JWT token is refreshed before fetching the user attributes. (Default = false) */
        refreshToken?: boolean;
    }
): Promise<null | UserProfile>
```

See [UserProfile](#doc-api-reference-data-types-userprofile)

## logout <a id="doc-api-reference-authentication-logout"></a>

```ts
logout(params?: { global: boolean; }): Promise<'SUCCESS: The user has been logged out.'>
```

## forgotPassword <a id="doc-api-reference-authentication-forgotpassword"></a>

```ts
forgotPassword(
    params: SubmitEvent | {
        email: string;
    },
    options?: {
        /**
         * Per-call e-mail template override.
         * See [Automated Emails](/email/email-templates.md#overriding-the-template-for-a-single-call).
         */
        template?: {
            /** message_id of the template to use for the e-mail carrying the password reset code. */
            verification?: string;
        };
    }
): Promise<'SUCCESS: Verification code has been sent.'>
```

#### Errors
```ts
{
    code: "NOT_EXISTS";
    message: "Username/client id combination not found."
}
|
{
    code: "INVALID_REQUEST";
    message: "User is disabled." | "User password cannot be reset in the current state."
}
|
{
    code: "REQUEST_EXCEED";
    message: "Attempt limit exceeded, please try after some time." | "Rate exceeded";
}
|
{
    code: "CODE_DELIVERY_FAILURE";
    message: "Unable to deliver code to user"
}
```

## resetPassword <a id="doc-api-reference-authentication-resetpassword"></a>

```ts
resetPassword(
    params: SubmitEvent | {
        email: string;
        code: string | number;
        new_password: string; // At least 6 characters and a maximum of 60 characters.
    }
): Promise<'SUCCESS: New password has been set.'>
```

## openIdLogin <a id="doc-api-reference-authentication-openidlogin"></a>

```ts
openIdLogin(
    params: SubmitEvent | {
        token: string; // ID/Access token fetched from OpenID API service
        id: string; // OpenID Logger ID registered in the project page.
        merge?: boolean | string[] // When true, merges with the previous account whose original login ID matches this OpenID account's. When string[] is given, account is merged with the specified OpenID attribute values. Never matches through an email login alias.

        /**
         * Per-call e-mail template override.
         * See [Automated Emails](/email/email-templates.md#overriding-the-template-for-a-single-call).
         */
        template?: {
            /** message_id of the template to use for the welcome e-mail, sent the first time this OpenID account is created. */
            welcome?: string;
        };
    }
): Promise<{
    userProfile: UserProfile;
    openid: { [attribute:string]: string };
}>
```

#### Errors
```ts
{
    code: "ACCOUNT_EXISTS";
    message: "The account already exists."; // This occurs when the user's OpenID unique ID has already been registered through a basic signup.
}
|
{
    code: "INVALID_REQUEST";
    message: "The account needs to be confirmed."; // This occurs when the account is already signed up and requires confirmation from the user
}
|
{
    code: "EXISTS";
    message: "The login ID of this OpenID account is already used by another account."; // This occurs, with or without 'merge', when the OpenID login ID is the email login another account of the project was GRANTED by verifying that address, and in the rare case where an unproven email login on another account cannot be removed safely.
}
```

`merge` matches an existing account by its original login identifier: the value it was created with,
its `username` if it has one, otherwise the email it signed up with. It never matches through an email
login alias. With a logger whose Username Key is `email`, that means:

- An account created without a `username` using that email is merged into, even if its email has been
  changed since.
- An account created with a `username` whose email is that address is **not** merged into.
- An account created without a `username` whose email was later changed to that address is **not**
  merged into either.

In the last two cases `openIdLogin()` never merges, because merging replaces the account's password and
must never act on an account that only answers to that email through its email login. What happens
instead depends on whether that account has **verified** the address:

- It has verified it, so the email login is one it was granted: `openIdLogin()` fails with the `EXISTS`
  error above, whether `merge` is set or not. The rare case where an unverified email login cannot be
  removed safely gives the same error.
- It has not verified it: that email login is removed, and a **new** OpenID account is created for the
  address, so two accounts of the project then carry the same email address. The other account keeps
  logging in with the login ID it was created with. The removal happens before signup restrictions are
  checked, so it stands even when the login is then refused because signup is off or the user limit is
  reached.

See [Login IDs](#doc-admin-permissions-login-ids).

# API Reference: User Account <a id="doc-api-reference-user"></a>

Below are the parameters and return data type references for the methods in TypeScript format.

## updateProfile <a id="doc-api-reference-user-updateprofile"></a>

```ts
updateProfile(
    params: SubmitEvent | {
        user_id?: string; // Optional, admins only. Another user's ID updates that account through updateUserAttributes(). Your own ID is ignored and your own profile is updated.
        name?: string; // Name of the user.
        email?: string; // Max 64 characters. Becomes unverified, and the account's email login is removed until the user verifies the new address with verifyEmail().
        phone_number?: string; // Must be in "+0012341234" format.
        address?: string | {
            formatted: string;
            locality: string;
            region: string;
            postal_code: string;
            country: string;
        }; // OpenID Standard Claims object is also supported.
        gender?: string; // Can be any string
        birthdate?: string; // Must be in YYYY-MM-DD format
        picture?: string; // URL of the profile picture.
        profile?: string; // URL of the profile page.
        website?: string; // URL of the website.
        nickname?: string; // Nickname of the user.
        misc?: string; // Additional string value that can be used freely. This value is only visible from skapi.getProfile()
        email_public?: boolean; // When set to true, email attribute is visible to others.
        phone_number_public?: boolean; // When set to true, phone_number attribute is visible to others.
        address_public?: boolean; // When set to true, address attribute is visible to others.
        gender_public?: boolean; // When set to true, gender attribute is visible to others.
        birthdate_public?: boolean; // When set to true, birthdate attribute is visible to others.
    }
): Promise<UserProfile>
```

See [UserProfile](#doc-api-reference-data-types-userprofile)

Called with the `user_id` of **another** user, this sends the same request as
[`updateUserAttributes()`](#doc-api-reference-admin-updateuserattributes), so the admin rules and
errors apply and it resolves with `'SUCCESS: User attributes updated.'` instead of a profile. Only
admins may do that, and no admin may name their own `user_id` there. Called with your **own** `user_id`,
the ID is dropped and your own profile is updated, which is what every user does.

Changing your own `email` makes it unverified and takes the account's email login with it. The new
address logs the account in once you verify it with [`verifyEmail()`](#doc-api-reference-user-verifyemail), a few seconds later
on the next token. Your `username`, and the address a no-username account was created with, are
unaffected. See [Which ID logs a user in](#doc-user-account-update-account-which-id-logs-a-user-in).

## changePassword <a id="doc-api-reference-user-changepassword"></a>

```ts
changePassword(params: SubmitEvent | {
    new_password: string; // At least 6 characters and a maximum of 60 characters.
    current_password: string;
}): Promise<'SUCCESS: Password has been changed.'>
```

## verifyEmail <a id="doc-api-reference-user-verifyemail"></a>

```ts
verifyEmail(params?: SubmitEvent | {
    /**
     * When code value is given, Skapi will try to verify the code.
     * When Called with out any argument, Skapi will issue a new verification.
     */
    code: string;
}, options?: {
    /**
     * Per-call e-mail template override. Applies to the call that issues a new
     * verification code, and is ignored when a code is being verified.
     * See [Automated Emails](/email/email-templates.md#overriding-the-template-for-a-single-call).
     */
    template?: {
        /** message_id of the template to use for the verification e-mail. */
        verification?: string;
    };
}): Promise<string>
```

#### Errors
```ts
{
    code: "LimitExceededException";
    message: "Attempt limit exceeded, please try after some time.";
}
|
{
    code: "CodeMismatchException";
    message: "Invalid verification code provided, please try again.";
}
```

Verifying the address is also what grants the account its **email login**: from then on the address logs
the account in, a few seconds later on the account's next token rather than in this response. If another
account of the project was already granted that address as a login ID, the email login is not granted,
and the account keeps logging in with the ID it was created with. An email login another account holds
**without** having verified the address is removed, and the address goes to the account that verified
it. See [Login IDs](#doc-admin-permissions-login-ids).

## disableAccount <a id="doc-api-reference-user-disableaccount"></a>

```ts
disableAccount(): Promise<'SUCCESS: account has been disabled.'>;
```

## getUsers <a id="doc-api-reference-user-getusers"></a>

```ts
getUsers(
    params?: SubmitEvent | {
        searchFor:
            'user_id' |
            'name' |
            'email' |
            'phone_number' |
            'address' |
            'gender' |
            'birthdate' |
            'locale' |
            'subscribers' |
            'timestamp' |
            'access_group' |
            'approved';
        value: string | number | string[]; // Appropriate value type for searchFor

        /**
         * Cannot be used with range. Default = '='.
         * '>' means more than. '<' means less than.
         * For strings, '>=' means 'starts with'.
         */
        condition?: '>' | '>=' | '=' | '<' | '<=' | 'gt' | 'gte' | 'eq' | 'lt' | 'lte';
        range?: string | number; // Cannot be used with condition.
    },
    fetchOptions?: FetchOptions
): Promise<DatabaseResponse<UserPublic>>;

```

See [FetchOptions](#doc-api-reference-data-types-fetchoptions)

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

See [UserPublic](#doc-api-reference-data-types-userpublic)

## recoverAccount <a id="doc-api-reference-user-recoveraccount"></a>

```ts
recoverAccount(redirect?: boolean | string): Promise<'SUCCESS: Recovery e-mail has been sent.'>;
```

# API Reference: Database <a id="doc-api-reference-database"></a>

Below are the parameters and return data type references for the methods in TypeScript format.

## postRecord <a id="doc-api-reference-database-postrecord"></a>

```ts
postRecord(
    data: SubmitEvent | { [key: string] : any } | null | undefined,
    config: {
        record_id?: string; // Used only when updating an existing record; not available to anonymous users. This can also be a unique ID if the record was created with one.
        unique_id?: string; // Unique ID to set to the record; not available to anonymous users. If null is given, it will remove the previous unique ID when updating.
        /** When the table is given as a string value, the value is the table name. */
        /** 'table.name' is optional when 'record_id' or 'unique_id' is used. */
        /** CREATE (no 'record_id'): a table given as a string sets table.name and pins table.access_group to 0 ('public'). */
        /** UPDATE ('record_id' given): no access group is sent in either form, and the record stays in the access group it is already in. Name the group to move it. */
        /** A table object sends only the keys that were written, so leaving 'access_group' out of it sends none. See [Access Restrictions](/database/access-restrictions.md#the-table-shorthand-and-access-group) */
        table?: {
            name?: string; // 1..256 characters, where / ! * # % each count as 3. Blocks control chars and sentinel U+10FFFF.
            access_group?: number | 'private' | '*' | 'public' | 'authorized' | 'admin';  // Default: 'public', otherwise not available to anonymous users. '*' is shorthand for 'private'.
            /** Subscription settings; not available to anonymous users, and never allowed on the project owner's own records. */
            /** On an update, settings left out keep their stored values, and null turns every setting off. */
            /** The project owner and admins can change them on an existing record of another user, unless it is private. On a record posted by an anonymous user they can only keep or turn them off. See [Subscription](/database/subscription.md#who-can-change-subscription-settings) */
            subscription?: {
                is_subscription_record?: boolean; // When true, record will be uploaded to subscription table.
                upload_to_feed?: boolean; // When true, record will be shown in the subscribers feeds that is retrieved via getFeed() method. Off unless set.
                notify_subscribers?: boolean; // When true, creating the record sends a push notification to the uploader's subscribers who subscribed with get_notified. See "notification".
                feed_referencing_records?: boolean; // When true, records referencing this record are added to the feed of this record's uploader.
                notify_referencing_records?: boolean; // When true, every new record referencing this one sends a push notification to this record's uploader's subscribers who subscribed with get_notified.
            } | null;
        };
        /** Text of the push notification sent when the record is created with table.subscription.notify_subscribers. Both required, together at most 3072 bytes. Without it, subscribers see a default text. Ignored on updates. See [Notifications](/database/subscription.md#notifications) */
        notification?: {
            title: string;
            body: string;
        } | null;
        readonly?: boolean; // Default: false. When true, the record cannot be updated. (Not available to anonymous users)
        index?: {
            name: string; // Custom index name: 1..256 characters, where / ! * # % each count as 3. Blocks control chars and sentinel U+10FFFF, and cannot start with '$'.
            value: string | number | boolean; // String value: 0..256 chars. Blocks control chars and sentinel U+10FFFF only.
        };
        tags?: string[] | null; // null removes all tags.
        source?: {
            referencing_limit?: number; // Default: null (Infinite)
            prevent_multiple_referencing?: boolean; // If true, a single user can reference this record only once.
            only_granted_can_reference?: boolean; // When true, only the user who has granted private access to the record can reference this record.
            can_remove_referencing_records?: boolean; // When true, owner of the record can remove any record that are referencing this record. Also when this record is deleted, all the record referencing this record will be deleted.
            referencing_index_restrictions?: {
                name: string; // 1..256 characters, where / ! * # % each count as 3. Blocks control chars and sentinel U+10FFFF, and cannot start with '$'.
                value?: string | number | boolean; // String value: 0..256 chars. Blocks control chars and sentinel U+10FFFF only.
                range?: string | number | boolean; // String range: 0..256 chars. Blocks control chars and sentinel U+10FFFF only.
                condition?: 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne' | '>' | '>=' | '<' | '<=' | '=' | '!='; // Allowed index value condition. Checked when a referencing record is posted: on a string value '>=' is a 'starts with' check, while '<=' is a plain 'lesser or equal' comparison and is not 'ends with'.
            }[] | null;
            allow_granted_to_grant_others?: boolean; // When true, the user who has granted private access to the record can grant access to other users.
        };
        reference?: string; // Reference to another record. When value is given, it will reference the record with the given value. Can be record ID or unique ID.
        remove_bin?: BinaryFile[] | string[] | null; // If the BinaryFile object or the url of the file is given, it will remove the bin data(files) from the record. The file should be uploaded to this record. If null is given, it will remove all the bin data(files) from the record. (not available to anonymous users)
        progress?: ProgressCallback; // Progress callback function. Useful when uploading files.
    },
    /** Files to attach to the record. Each file is uploaded with a permit issued for exactly its byte size, so a request that sends a different number of bytes is refused by storage. The project owner and admins (access groups 90 ~ 99) can attach files to another user's record unless it is private. See [Files on Records of Other Users](/database/handling-files.md#files-on-records-of-other-users) */
    files?: { name: string; file: File }[]
): Promise<RecordData>
```

See [RecordData](#doc-api-reference-data-types-recorddata)

See [ProgressCallback](#doc-api-reference-data-types-progresscallback)

See [BinaryFile](#doc-api-reference-data-types-binaryfile)

#### Errors
```ts
{
    code: "INVALID_REQUEST";
    // Another account (the project owner or an admin) set table.access_group of a user's
    // private record to a non-private group, moving it out of the 'private' access group.
    message: "User has no access to change the private access of the record.";
}
|
{
    code: "INVALID_REQUEST";
    // Only the record's own user can move it into the 'private' access group, or change
    // anything else on it while it is private, subscription settings and attaching files
    // included. This holds for the project owner and admin accounts too.
    // This error and the one above are sent only to the project owner or an admin account.
    // Other users are refused earlier with "User has no access to update this record."
    message: "Only the owner of a record can move it into or out of the private access group.";
}
|
{
    code: "INVALID_REQUEST";
    // The project owner's own records can never have subscription settings, whoever sends
    // the request. Keeping the settings an older record already has, or turning them off,
    // is allowed.
    message: "Records of the project owner cannot have subscription settings.";
}
|
{
    code: "INVALID_REQUEST";
    // A record posted by an anonymous (signed-out) user belongs to no user of the project,
    // so the project owner and admins cannot add or turn on its subscription settings.
    // Keeping the settings it already has, or turning them off, is allowed.
    message: "Anonymous records cannot have subscription settings.";
}
|
{
    code: "INVALID_REQUEST";
    // Admins in access groups 90 ~ 98 can change only the subscription settings of another
    // user's record. {field} names the first field that differs from the stored record,
    // for example data, index, tags, table.name, table.access_group, readonly, unique_id,
    // reference, remove_bin, source.<key> or table.subscription.<key>.
    // Attaching files is a separate right: passing files with nothing else changed is
    // allowed. Removing a stored file with remove_bin is a change, so use deleteFiles().
    message: "Admins can only change the subscription settings of another user's record ({field} differs).";
}
|
{
    code: "INVALID_REQUEST";
    // Admins in access groups 90 ~ 98 changing the subscription settings of another user's
    // record: the record was changed after this request read it, for example saved by its
    // user. Nothing was written. Send the request again, and it is judged against the record
    // as it is stored then.
    message: "The record changed while it was being updated. Try again.";
}
|
{
    code: "INVALID_REQUEST";
    // The project owner's account is not a user of the project.
    message: "The project owner cannot upload records to the private access group." | "The project owner cannot upload read-only records.";
}
|
{
    code: "INVALID_REQUEST";
    // The record belongs to another user and the caller is not the project owner or an admin.
    // Checked before the private access group errors above, so it is also what such a caller
    // gets for another user's private record.
    message: "User has no access to update this record.";
}
|
{
    code: "INVALID_REQUEST";
    // Only the project owner and admins in access group 99 can update a read-only record.
    message: "Record is read only.";
}
|
{
    code: "INVALID_REQUEST";
    // An attached file may not take the name the record's own offloaded data file uses:
    // the file input name "__data__", or the file name "__json__.json".
    // See Large Record Data: /database/create.md#large-record-data
    message: '"__data__/__json__.json" is a reserved file name.';
}
```

Each attached file is uploaded with a permit issued for exactly the byte size the request declares, so
storage refuses a request that then sends a different number of bytes and stores nothing. The SDK always
declares the true size of the file it sends, [encrypted](#doc-database-encryption) and empty files
included. See [Uploading Files](#doc-database-handling-files-uploading-files).

## postRecords <a id="doc-api-reference-database-postrecords"></a>

```ts
postRecords(
    /** One or more items, each the two arguments of postRecord() as one object. 100 records go out in one request. */
    items: {
        /** The record's data, as postRecord()'s first argument: an object, or null. Leave it out to keep a record's stored data as it is on an update. */
        data?: { [key: string]: any } | null;
        /** The record's configuration, as postRecord()'s second argument: the same keys, the same rules. */
        config: PostRecordConfig;
    }[]
): Promise<PostRecordsResult>
```

The records of a request are saved in order. An item that fails a check is reported in `failed` with its `index` in `items` and the same `code` and `message` [`postRecord()`](#doc-api-reference-database-postrecord) would have rejected with, and the items after it are still saved; the call resolves either way. `list` holds the saved records in the order their items were given, and `list.length + failed.length` is `items.length`. Only a failure of a request itself (the connection, the sign-in, a server error) rejects the call; the records saved by the requests before it stay saved. Files are not attached by this method: attach them to a saved record with [`uploadFiles()`](#doc-api-reference-database-uploadfiles). See [Creating Several Records at Once](#doc-database-create-creating-several-records-at-once).

See [`postRecord()`](#doc-api-reference-database-postrecord) for every key of `config`.

See [PostRecordsItem](#doc-api-reference-data-types-postrecordsitem)

See [PostRecordsResult](#doc-api-reference-data-types-postrecordsresult)

See [RecordData](#doc-api-reference-data-types-recorddata)

#### Errors
```ts
{
    code: "INVALID_PARAMETER";
    message: '"items" should be a non-empty array.';
}
```

## getRecords <a id="doc-api-reference-database-getrecords"></a>

```ts
getRecords(
    query: {
        record_id?: string; // When record ID is given, it will fetch the record with the given record ID. all other parameters are bypassed and will override unique ID.
        unique_id?: string; // Unique ID of the record. When unique ID is given, it will fetch the record with the given unique ID. All other parameters are bypassed.
        /** When the table is given as a string value, the given value will be set as table.name and table.access_group will be pinned to 0 ('public'). */
        /** A table object sends only the keys that were written: with 'access_group' left out, no access group is sent and the backend decides the scope. A signed in non-master reads group 0, the master reads every group in the table. See [Access Restrictions](/database/access-restrictions.md#the-table-shorthand-and-access-group) */
        /** 'table' is optional when 'record_id' or 'unique_id' is used. */
        table?: string | {
            name: string, // 1..256 characters, where / ! * # % each count as 3. Blocks control chars and sentinel U+10FFFF.
            access_group?: number | 'private' | '*' | 'public' | 'authorized' | 'admin'; // 0 to 99 if using number. 'public' = 0, 'authorized' = 1, 'admin' = 99, '*' = 'private'. Default: 'public'
            subscription?: string; // User ID that requester is subscribed to. (eg. "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
        };

        /**
         * When unique ID is given, it will fetch the records referencing the given unique ID.
         * When record ID is given, it will fetch the records referencing the given record ID.
         * When user ID is given, it will fetch the records uploaded by the given user ID.
         */
        reference?: string | { record_id?: string; unique_id?: string; user_id?: string };

        index?: {
            /** Reserved names: '$updated' | '$uploaded' | '$referenced_count' | '$user_id'. */
            /** Custom names: 1..256 characters, where / ! * # % each count as 3. Block control chars and sentinel U+10FFFF, and cannot start with '$'. */
            name: string | '$updated' | '$uploaded' | '$referenced_count' | '$user_id';
            value: string | number | boolean; // String value: 0..256 chars. Blocks control chars and sentinel U+10FFFF only.
            /** For a string value: '>=' = 'starts with', '<=' = 'ends with'. When the name is a compound name ending in '.', '>=' / '<=' match the child name segment (starts / ends with). '>' / '<' are lexicographic; numbers/booleans compare normally. */
            condition?: 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | '>' | '>=' | '<' | '<=' | '='; // cannot be used with range. Default: '='
            range?: string | number | boolean; // String range: 0..256 chars. Blocks control chars and sentinel U+10FFFF only.
        };

        tag?: string; // 1..256 characters, where / ! * # % each count as 3. Blocks control chars and sentinel U+10FFFF.
    },
    fetchOptions?: FetchOptions;
): Promise<DatabaseResponse<RecordData>>
```

See [RecordData](#doc-api-reference-data-types-recorddata)

See [FetchOptions](#doc-api-reference-data-types-fetchoptions)

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

A private record of another user comes back to the project owner and to admins in access group `99` with
its `data` as `{ __is_private__: null }` unless they have access to it: it was shared with them, or they
have private access to the record it references. This holds however it was reached: by `record_id`, by a listing of the
table, by a listing that names `access_group: 'private'`, or by a `tag` query. The rest of the record is
unchanged. Admins in access groups `90` ~ `98` without access cannot fetch such a record by its `record_id`
at all: they get `User has no private access.` See [Private records](#doc-admin-permissions-private-records).

## grantPrivateAccess <a id="doc-api-reference-database-grantprivateaccess"></a>
```ts
grantPrivateRecordAccess(
    params: {
        record_id: string;
        user_id: string | string[];
    }
): Promise<'SUCCESS: granted x users private access to record: xxxx...'>

```

:::warning
Moving the record into or out of the `private` access group **removes every grant on it**,
in either direction. See [Changing the Access Group Clears Private
Access](#doc-database-access-restrictions-changing-the-access-group-clears-private-access).
:::

#### Errors
```ts
{
    code: "INVALID_REQUEST";
    // The project owner's account can never be given private access to a record.
    message: "Private access cannot be granted to service owners.";
}
|
{
    code: "INVALID_REQUEST";
    message: "Record should be owned by the user.";
}
|
{
    code: "INVALID_REQUEST";
    message: "cannot process more than 100 users at once.";
}
|
{
    code: "INVALID_REQUEST";
    message: "At least 1 user id is required.";
}
```

A grant is what lets another user read a private record, admins in access group `99` included. It
cannot be given to the project owner. See [Private records](#doc-admin-permissions-private-records).

## removePrivateAccess <a id="doc-api-reference-database-removeprivateaccess"></a>
```ts
removePrivateRecordAccess(
    params: {
        record_id: string;
        user_id: string | string[];
    }
): Promise<string>

```

#### Errors
```ts
{
    code: "INVALID_REQUEST";
    message: "Private access cannot be granted to project owners.";
}
|
{
    code: "INVALID_REQUEST";
    message: "Record should be owned by the user.";
}
|
{
    code: "INVALID_REQUEST";
    message: "cannot process more than 100 users at once.";
}
|
{
    code: "INVALID_REQUEST";
    message: "At least 1 user id is required.";
}
```

## listPrivateRecordAccess <a id="doc-api-reference-database-listprivaterecordaccess"></a>
```ts
listPrivateRecordAccess(
    params: {
        record_id?: string;
        user_id?: string | string[];
    }
): Promise<DatabaseResponse<{
    user_id: string;
    record_id: string;
}>>
```

See [FetchOptions](#doc-api-reference-data-types-fetchoptions)

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

## deleteRecords <a id="doc-api-reference-database-deleterecords"></a>

```ts
deleteRecords({
    record_id?: string; // Record ID to delete. When record ID is given, it will delete the record with the given record ID. It will bypass all other parameters and will override unique ID.
    unique_id?: string; // Unique ID to delete. When unique ID is given, it will delete the record with the given unique ID. It will bypass all other parameters except record_id.

    /** Delete bulk records by query. Query will be bypassed when "record_id" is given. */
    /** When deleteing records by query, It will only delete the record that user owns. */
    /** When the table is given as a string value, the value is the table name, and table.access_group is pinned to 0 ('public'). A string table therefore deletes the PUBLIC records of that table, not the whole table. */
    /** A table object sends only the keys that were written: with 'access_group' left out, no access group is sent and the backend decides the scope. See [Access Restrictions](/database/access-restrictions.md#the-table-shorthand-and-access-group) */
    /** 'table' is optional when 'record_id' or 'unique_id' is used. */
    table: string | {
        name: string,
        access_group?: number | 'private' | '*' | 'public' | 'authorized' | 'admin'; // 0 to 99 if using number. 'public' = 0, 'authorized' = 1, 'admin' = 99, '*' = 'private'. Default: 'public'
        subscription?: string; // User ID that requester is subscribed to. (eg. "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
    };

    /**
     * When unique ID is given, it will fetch the records referencing the given unique ID.
     * When record ID is given, it will fetch the records referencing the given record ID.
     * When user ID is given, it will fetch the records uploaded by the given user ID.
     * When fetching record by record_id or unique_id that user has restricted access, but the user has been granted access to reference, user can fetch the record if the record ID or the unique ID of the reference is set to reference parameter.
     */
    reference?: string;

    index?: {
        /** Reserved names: '$updated' | '$uploaded' | '$referenced_count' | '$user_id'. */
        /** Custom names: 1..256 characters, where / ! * # % each count as 3. Block control chars and sentinel U+10FFFF, and cannot start with '$'. */
        name: string | '$updated' | '$uploaded' | '$referenced_count' | '$user_id';
        value: string | number | boolean; // String value: 0..256 chars. Blocks control chars and sentinel U+10FFFF only.
        /** For a string value: '>=' = 'starts with', '<=' = 'ends with'. When the name is a compound name ending in '.', '>=' / '<=' match the child name segment (starts / ends with). '>' / '<' are lexicographic; numbers/booleans compare normally. */
        condition?: 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | '>' | '>=' | '<' | '<=' | '='; // cannot be used with range. Default: '='
        range?: string | number | boolean; // String range: 0..256 chars. Blocks control chars and sentinel U+10FFFF only.
    };

    tag?: string; // 1..256 characters, where / ! * # % each count as 3. Blocks control chars and sentinel U+10FFFF.
}): Promise<string | DatabaseResponse<RecordData>>
```

## getTables <a id="doc-api-reference-database-gettables"></a>

```ts
getTables(
    query?: {
        table?: string; // If omitted, fetches the full list of tables.
        /** Default when omitted: EXACT match on the given table name. */
        /** 'gte' / '>=' is a prefix search: table names starting with the given value. */
        /** 'lte' / '<=' is a suffix search: table names ending with the given value. */
        /** 'gt' / '>' is lexicographic, so it spills past the prefix into every later table name. */
        condition?: 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | '>' | '>=' | '<' | '<=' | '=';
    },
    fetchOptions?: FetchOptions;
): Promise<DatabaseResponse<Table>>
```

When `condition` is omitted and `table` is given, the table name is matched **exactly**.
When `table` is omitted as well, every table is returned.

| Call | Result |
| --- | --- |
| `getTables()`, `getTables({})` | Every table in the project. |
| `getTables({ table: 'x' })` | Exact match on `'x'`. |
| `getTables({ table: 'x', condition: 'gte' })` | Prefix: every table name starting with `'x'`. |
| `getTables({ table: 'x', condition: 'lte' })` | Suffix: every table name ending with `'x'`. |
| `getTables({ table: '' })` | Error: `"table" should not be empty.` |
| `getTables({ condition: 'gte' })`, with no `table` | Error: `"table" is required for condition.` |

While you are exploring and do not know the exact spelling, pass `gte`: it is a prefix search, so it also surfaces related entries.
A table name very often carries a leading name shared with the rest of its data set, a source or dataset prefix for example, so an exact match finds one spelling and silently misses its siblings, while the prefix finds the whole family.
When you already know the exact name, omitting `condition` is the exact match you want.

**Requires a signed in user** when the project's [Require Login](https://docs.skapi.com/service-settings/service-settings.html#require-login) setting is on, and a project that has never set that option counts as **on**.
The SDK throws `REQUIRE_LOGIN` before the request leaves, and the backend refuses the same call with `INVALID_REQUEST`, so a direct API call is refused as well.

Table **names** are never filtered by access group: every table in the project is listed.
The per access group record counters are filtered to the caller: `number_of_records_in_access_group_public` for everyone, the counters up to and including the caller's own access group for a signed in user, and `number_of_records_in_access_group_private` and `number_of_records_in_access_group_admin` for admins and the project owner.
`number_of_records` and `size` are **not** filtered and stay totals over every access group, so `number_of_records` is normally larger than the counters you can see add up to.
See [Table Information](#doc-database-table-info).

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

See [Table](#doc-api-reference-data-types-table)

## getIndex <a id="doc-api-reference-database-getindex"></a>

```ts
getIndexes(
    query: {
        table: string;
        /** Omitted: PREFIX, lists every index of the table. */
        /** A name with no trailing '.': EXACT match on that index name. */
        /** A name ending in '.': PREFIX, lists the children of that compound index. ('Band.' lists 'Band.name', 'Band.year') */
        index?: string; // 1..256 characters for custom names, where / ! * # % each count as 3; blocks control chars and sentinel U+10FFFF, cannot start with '$'.
        order?: {
            by: 'average_number' | 'total_number' | 'number_count' | 'average_bool' | 'total_bool' | 'bool_count' | 'string_count' | 'index_name' | 'number_of_records';
            value?: number | boolean | string; // Required when 'order.condition' is given. A string when 'order.by' is 'index_name'.
            /** Default when omitted: EXACT match against 'order.value'. */
            /** When 'order.by' is 'index_name': 'gte' / '>=' is 'starts with' and 'lte' / '<=' is 'ends with' on the index name. */
            condition?: 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | '>' | '>=' | '<' | '<=' | '=';
        };
    },
    fetchOptions?: FetchOptions;
): Promise<DatabaseResponse<Index>>
```

`table` is **required**, so there is no no-argument form.
[`getIndexes()`](#doc-api-reference-database-getindex) has no top level `condition` either.
The only condition is `order.condition`, and it **requires** `order.value`: a query that sets `order.condition` without `order.value` is rejected.

| Call | Result |
| --- | --- |
| `getIndexes({ table: 't' })` | Every index of table `'t'`. |
| `getIndexes({ table: 't', index: 'Band' })` | Exact match on index `'Band'`. |
| `getIndexes({ table: 't', index: 'Band.' })` | Prefix: the children of the compound index, so `Band.name` and `Band.year`. |
| `getIndexes({ table: 't', order: { by: 'index_name', value: 'B' } })` | Exact match against the value. |
| `getIndexes({ table: 't', order: { by: 'index_name', value: 'B', condition: 'gte' } })` | Prefix: every index whose name starts with `'B'`. |
| `getIndexes({ table: 't', order: { by: 'index_name', value: 'e', condition: 'lte' } })` | Suffix: every index whose name ends with `'e'`. |
| `getIndexes({ table: 't', order: { by: 'index_name' } })` | Every index of table `'t'`, ordered by name. |
| `getIndexes({ table: 't', order: { by: 'total_number' } })` | The whole partition, ordered by that attribute. |
| `order.condition` without `order.value` | Error. |
| No `table` | Error: `"table" is required.` |

While you are hunting for an index whose exact spelling you do not know, order by `index_name` and pass `gte` instead of a bare `order.value`: it is a prefix search, so it also surfaces the related entries an exact match would silently miss.
`lte` searches from the other end: it matches the name segment right under `index`, or the first segment when `index` is omitted, by how it **ends**. See [Searching index names](#doc-database-indexing-searching-index-names).

**Requires a signed in user** when the project's [Require Login](https://docs.skapi.com/service-settings/service-settings.html#require-login) setting is on, and a project that has never set that option counts as **on**.
The SDK throws `REQUIRE_LOGIN` before the request leaves, and the backend refuses the same call with `INVALID_REQUEST`, so a direct API call is refused as well.

The listing is **not** filtered by access group, for any caller.
An index row is stored keyed by table and index name with the access group left out, so one row aggregates every group: `number_of_records`, `total_number`, `average_number` and the rest span the whole table, private and admin records included.
Any caller allowed to read this listing reads the project's entire index vocabulary. The records behind it stay gated.

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

See [Index](#doc-api-reference-data-types-index)

## getTags <a id="doc-api-reference-database-gettags"></a>

```ts
getTags(
    query?: {
        table?: string; // 1..256 characters, where / ! * # % each count as 3. Blocks control chars and sentinel U+10FFFF.
        tag?: string; // 1..256 characters, where / ! * # % each count as 3. Blocks control chars and sentinel U+10FFFF.
        /** Default when omitted: EXACT match on the tag when BOTH 'table' and 'tag' are given, otherwise '>=' (prefix). */
        /** 'gte' / '>=' is a prefix search. */
        /** 'lte' / '<=' is a suffix search: tags ending with the given value, in 'table', or in every table when 'table' is omitted. */
        condition?: 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | '>' | '>=' | '<' | '<=' | '=';
    },
    fetchOptions?: FetchOptions;
): Promise<DatabaseResponse<Tag>>
```

When `condition` is omitted and **both** `table` and `tag` are given, the tag is matched **exactly**.
When only `table` is given, the omitted `condition` defaults to `>=`, a prefix search, so the call lists every tag in that table.
When only `tag` is given, that same `>=` default searches every table, so the call finds that tag across the whole project.
When neither `table` nor `tag` is given, every tag in the project is returned, ordered by record count, descending.

| Call | Result |
| --- | --- |
| `getTags()`, `getTags({})` | Every tag in the project, ordered by record count, descending. |
| `getTags({ table: 't' })` | Every tag in table `'t'`. |
| `getTags({ table: 't', tag: 'g' })` | Exact match on tag `'g'` in table `'t'`. |
| `getTags({ tag: 'g' })`, with no `table` | Tag `'g'` across all tables. |
| `getTags({ table: 't', tag: 'g', condition: 'gte' })` | Prefix: every tag in `'t'` starting with `'g'`. |
| `getTags({ table: 't', tag: 'g', condition: 'lte' })` | Suffix: every tag in `'t'` ending with `'g'`. |
| `getTags({ tag: 'g', condition: 'lte' })`, with no `table` | Suffix: every tag ending with `'g'`, in every table. |
| `getTags({ condition: 'gte' })`, with neither `table` nor `tag` | Error: `"table" or "tag" is required for condition.` |

While you are exploring and do not know the exact spelling, pass `gte`: it is a prefix search, so it also surfaces related entries.
A tag very often carries a leading name shared with the rest of its data set, a series name for example, or an entity recorded once plainly and once with a parenthesised alias, so an exact match finds one spelling and silently misses its siblings, while the prefix finds the whole family.
When you already know the exact name, give both `table` and `tag` and omit `condition`: that is the exact match you want.

**Requires a signed in user** when the project's [Require Login](https://docs.skapi.com/service-settings/service-settings.html#require-login) setting is on, and a project that has never set that option counts as **on**.
The SDK throws `REQUIRE_LOGIN` before the request leaves, and the backend refuses the same call with `INVALID_REQUEST`, so a direct API call is refused as well.

The listing is **not** filtered by access group, for any caller.
A tag row is stored keyed by tag and table name with the access group left out, so `number_of_records` counts the records in every group together.
Any caller allowed to read this listing reads the project's entire tag vocabulary, tags carried only by private or admin records included. The records behind it stay gated, but a tag name is visible to every signed in user.

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

See [Tag](#doc-api-reference-data-types-tag)

## getUniqueId <a id="doc-api-reference-database-getuniqueid"></a>

```ts
getUniqueId(
    query: {
        unique_id?: string;
        /** Default when omitted: EXACT match on the unique ID. */
        /** 'gte' / '>=' is a prefix search: unique IDs starting with the given value. */
        /** 'lte' / '<=' is a suffix search: unique IDs ending with the given value. */
        condition?: 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne' | '>' | '>=' | '<' | '<=' | '=' | '!=';
    },
    fetchOptions?: FetchOptions;
): Promise<DatabaseResponse<UniqueId>>
```

| Call | Result |
| --- | --- |
| `getUniqueId()`, `getUniqueId({})` | Every unique ID in the project. |
| `getUniqueId({ unique_id: 'x' })` | Exact match on `'x'`. |
| `getUniqueId({ unique_id: 'x', condition: 'gte' })` | Prefix: every unique ID starting with `'x'`. |
| `getUniqueId({ unique_id: 'x', condition: 'lte' })` | Suffix: every unique ID ending with `'x'`. |
| `getUniqueId({ condition: 'gte' })`, with no `unique_id` | Error: `"unique_id" is required for condition.` |

**Requires a signed in user** when the project's [Require Login](https://docs.skapi.com/service-settings/service-settings.html#require-login) setting is on, and a project that has never set that option counts as **on**.
The SDK throws `REQUIRE_LOGIN` before the request leaves, and the backend refuses the same call with `INVALID_REQUEST`, so a direct API call is refused as well.

Called with no `unique_id`, this enumerates **every** unique ID in the project.
The listing is **not** filtered by access group: a row is keyed by the unique ID alone, so any caller allowed to read it sees the IDs of records in every group, each with the record ID it maps to. Fetching those records still goes through the usual access checks.

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

See [UniqueId](#doc-api-reference-data-types-uniqueid)

## subscribe <a id="doc-api-reference-database-subscribe"></a>
```ts
subscribe(
    {
        user_id: string; // User to subscribe to.
        get_feed?: boolean; // Records the user posts with upload_to_feed appear in getFeed().
        get_notified?: boolean; // Push notifications for the user's records posted with notify_subscribers, and for new references to their records with notify_referencing_records. The device also needs subscribeNotification().
        get_email?: boolean;
    }
): Promise<Subscription>
```

A new subscription starts with every option off. Calling `subscribe()` again for the same user changes only the options given and keeps the rest. See [Notifications](#doc-database-subscription-notifications)

See [Subscription](#doc-api-reference-data-types-subscription)

## unsubscribe <a id="doc-api-reference-database-unsubscribe"></a>
```ts
unsubscribe(
    {
        user_id: string;
    }
): Promise<'SUCCESS: The user has unsubscribed.'>
```

## blockSubscriber <a id="doc-api-reference-database-blocksubscriber"></a>

```ts
blockSubscriber(
    {
        user_id: string;
    }
): Promise<'SUCCESS: Blocked user ID "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".'>
```

## unblockSubscriber <a id="doc-api-reference-database-unblocksubscriber"></a>

```ts
unblockSubscriber(
    {
        user_id: string;
    }
): Promise<'SUCCESS: Unblocked user ID "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".'>
```

## getSubscriptions <a id="doc-api-reference-database-getsubscriptions"></a>

```ts
getSubscriptions(
    params: {
        // Must have either subscriber and/or subscription value
        subscriber?: string; // User ID of the subscriber (User who subscribed)
        subscription?: string; // User ID of the subscription (User being subscribed to)
        blocked?: boolean; // When true, fetches only blocked subscribers. Default = false
    },
    fetchOptions?: FetchOptions;
): Promise<DatabaseResponse<Subscription>>
```
See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

See [Subscription](#doc-api-reference-data-types-subscription)

## getFeed <a id="doc-api-reference-database-getfeed"></a>

```ts
getFeed(params?: { access_group?: number; }, fetchOptions?: FetchOptions): Promise<DatabaseResponse<RecordData>>
```

## getFile <a id="doc-api-reference-database-getfile"></a>

```ts
getFile(
    url: string,
    config?: {
        dataType?: 'base64' | 'download' | 'endpoint' | 'blob' | 'text' | 'info';
        expires?: number;
        browserCache?: number;
        refresh?: boolean;
        progress?: ProgressCallback;
    },
): Promise<Blob | string | FileInfo | void>
```

`browserCache` is the number of seconds the browser may reuse the URL minted for `expires`.
Without it, every call mints a brand new signed URL, and since the browser cache is keyed by URL,
the file is downloaded again every time even when nothing has changed.
With it, the request that mints the URL is answered from the browser cache and the same URL comes back,
so the copy already downloaded stays usable.
The URL's own lifetime is still `expires`: what keeps the file available past that is the cached copy, not the URL.
Only meaningful together with `expires`, and capped at 1 week.
Private files in a record's `bin` already do this for you, see [Caching Expiring Files](#doc-database-handling-files-caching-expiring-files).

`refresh` bypasses that cached mint and forces a fresh signed URL.
Use it when the file may have changed, or after a load failed because the cached URL had expired
and the file was no longer in the browser cache.

See [FileInfo](#doc-api-reference-data-types-fileinfo)

See [ProgressCallback](#doc-api-reference-data-types-progresscallback)

## uploadFiles <a id="doc-api-reference-database-uploadfiles"></a>

```ts
uploadFiles(
    /** The files to attach: a FormData, an HTMLFormElement, or the SubmitEvent of a form with file inputs. The input's name is the key the files are listed under in the record's bin. */
    fileList: FormData | HTMLFormElement | SubmitEvent,
    params: {
        /** Record ID of the record to attach the files to. */
        record_id: string;
        /** Callback for upload progress. */
        progress?: ProgressCallback;
        /** Milliseconds one file's upload may take. A file that takes longer is aborted and counted as failed with the code 'TIMEOUT'; the next file is then uploaded. Every file gets the full time. Omitted or 0: no limit. */
        timeout?: number;
        /** Reject at the first file storage does not accept, with that file's error (see FileUploadError), instead of resolving with it listed in `failed` and `errors`. The files stored before it stay stored. Default false. */
        throwOnFileError?: boolean;
    }
): Promise<UploadFilesResult>
```

Files are uploaded one after another. A file storage does not accept does not stop the rest: it is listed in `failed`, and `errors` says why, see [Uploading Files to an Existing Record](#doc-database-handling-files-uploading-files-to-an-existing-record).

With five files or more, the upload permits are requested 100 to a request (`POST /get-signed-urls`) instead of one request per file; the files still go up one after another and the result is the same. A file the server refuses a permit for is listed in `failed` and `errors` with the server's `code`, and its slot in `bin_endpoints` is an empty string. Where the bulk permit route is not available, the permits are requested one per file as before, automatically. See [Many files](#doc-database-handling-files-many-files).

See [UploadFilesResult](#doc-api-reference-data-types-uploadfilesresult)

See [FileUploadError](#doc-api-reference-data-types-fileuploaderror)

See [ProgressCallback](#doc-api-reference-data-types-progresscallback)

#### Errors
```ts
{
    code: "INVALID_PARAMETER";
    message: '"record_id" is required.' | '"timeout" should be a number of milliseconds, 0 or more.' | '"fileList" should be a FormData or HTMLFormElement.';
}
|
// Only with throwOnFileError: the error of the first file storage did not accept.
{
    code: string; // The storage code ('AccessDenied', 'EntityTooLarge' ...), 'NETWORK_ERROR', 'TIMEOUT', 'ABORTED', 'TOO_MANY_REQUESTS' or 'UPLOAD_FAILED'.
    message: string;
}
```

## deleteFiles <a id="doc-api-reference-database-deletefiles"></a>

```ts
deleteFiles({
    /** Endpoint URLs of record files (the url of a BinaryFile), up to 1000. They may belong to different records of the project. */
    /** A user who is not an admin can delete only the files of their own records, except files the project owner or an admin attached to them before such files were stored under the record's user. See [Files attached by another account](/database/handling-files.md#removing-files) */
    /** The project owner and admins (access groups 90 ~ 99) can also delete the files of another user's record, unless it is private. */
    /** Files on a private record can be deleted only by its user. See [Files on Records of Other Users](/database/handling-files.md#files-on-records-of-other-users) */
    endpoints: string | string[];
}): Promise<RecordData[]> // The records the files were removed from.
```

The whole list is checked before anything is deleted, so a refused request deletes no file.

See [RecordData](#doc-api-reference-data-types-recorddata)

#### Errors
```ts
{
    code: "INVALID_REQUEST";
    // The caller is not an admin, and either the record is not theirs or the file is one the
    // project owner or an admin attached to it before such files were stored under the record's user.
    message: "The record should be owned by the user.";
}
|
{
    code: "INVALID_REQUEST";
    // The record belongs to another user and is private. Refused for everyone, the project
    // owner and admins included.
    message: "Only the owner of a private record can delete its files.";
}
|
{
    code: "NOT_EXISTS";
    // A record that one of the files belongs to does not exist.
    message: 'Record "<record_id>" does not exists.';
}
|
{
    code: "INVALID_REQUEST";
    // The URL is not the endpoint of a record file in this project.
    message: "Invalid endpoint";
}
|
{
    code: "INVALID_REQUEST";
    // The file that holds a record's offloaded data cannot be deleted by URL.
    message: '"__data__/__json__.json" is a reserved file name.';
}
|
{
    code: "INVALID_REQUEST";
    message: "Cannot delete more than 1000 files at once.";
}
|
{
    code: "INVALID_REQUEST";
    // The database is frozen. Admins and the project owner are not affected.
    message: "Database is frozen. Write access is denied for this user.";
}
|
{
    code: "INVALID_PARAMETER";
    message: '"endpoints" should be type: array | string.';
}
```

# API Reference: E-Mail <a id="doc-api-reference-email"></a>

Below are the parameters and return data type references for the methods in TypeScript format.

## subscribeNewsletter <a id="doc-api-reference-email-subscribenewsletter"></a>

```ts
subscribeNewsletter(
    params: SubmitEvent | {
        /**
         * Numeric group 0 ~ 99, 'public' (group 0), 'authorized' (group 1),
         * or the name of a named newsletter group.
         * A group name is 2 ~ 20 lowercase alphanumeric characters, contains at least one letter,
         * is not a number in exponent notation such as '1e5',
         * and is not one of the reserved names:
         * 'tp', 'admin', 'public', 'authorized', 'newsletter', 'forward', 'all', 'true', 'false', 'null'.
         */
        group: number | 'public' | 'authorized' | string;
        email?: string | string[]; // only for public newsletters, or a named group with restriction 0
        redirect?: string; // only for public newsletters, or a named group with restriction 0. User will be redirected to this URL when confirmation link is clicked.
    }
): Promise<string>
```

## unsubscribeNewsletter <a id="doc-api-reference-email-unsubscribenewsletter"></a>

```ts
unsubscribeNewsletter(
    params: {
        /** Numeric group, 'public', 'authorized' or a named newsletter group. null unsubscribes from every group. */
        group: number | 'public' | 'authorized' | string | null;
    }
): Promise<string>
```

## getNewsletterSubscription <a id="doc-api-reference-email-getnewslettersubscription"></a>

```ts
getNewsletterSubscription(
    params: {
        /** Numeric group, 'public', 'authorized' or a named newsletter group. Omit for every group. */
        group?: number | 'public' | 'authorized' | string | null;
        user_id?: string; // Another user's subscriptions. Project owner and admins (access groups 90 ~ 99) only. Omit it to get your own, or the whole list of the group as the owner or an admin.
        email?: string; // Project owner, Skapi staff and admins in access group 99 only. Returns the subscribers of "group" whose e-mail address starts with this text. Requires "group", cannot be used with "user_id". Admins in access groups 90 ~ 98 are refused with "No access."
    },
    fetchOptions?: FetchOptions
): Promise<{
    active: boolean;
    timestamp: number; // Undefined on a group's whole subscriber list.
    group: number | string; // Number for the 0 ~ 99 groups, the group name for a named newsletter group.
    subscribed_email: string; // Masked as "j**@**.com" for admins in access groups 90 ~ 98, except on their own subscriptions.
    subscriber_token?: string; // Only on a masked row of a group's subscriber list. Opaque, stable per address, and different for different addresses.
}[] | DatabaseResponse<{
    active: boolean;
    timestamp: number;
    group: number | string; // Number for the 0 ~ 99 groups, the group name for a named newsletter group.
    subscribed_email: string; // Masked as "j**@**.com" for admins in access groups 90 ~ 98, except on their own subscriptions.
    subscriber_token?: string; // Only on a masked row of a group's subscriber list. Opaque, stable per address, and different for different addresses.
}>>
```

- Called with a `group` and no `user_id` by the project owner or an admin (access groups `90` ~ `99`), it returns **every subscriber** of that group, sorted by e-mail address.
- Called without `user_id` by any other user, it returns the signed in user's own subscriptions.
- Called with a `group` and an `email` by the project owner, Skapi staff or an admin in access group `99`, it returns only the subscribers of that group whose e-mail address **starts with** `email`, matched in lowercase and sorted by e-mail address. Everyone else, admins in access groups `90` ~ `98` included, gets `INVALID_REQUEST` and `No access.`
- Passing another user's `user_id` is refused with `No access.` unless the caller is the project owner or an admin (access groups `90` ~ `99`).
- `subscribed_email` comes back **masked**, as `j**@**.com`, to an admin in access groups `90` ~ `98`: on a group's subscriber list, and on another user's subscriptions read with `user_id`. The project owner, Skapi staff and admins in access group `99` get the addresses in full, and every user reading their own subscriptions gets their own address in full.
- A masked address cannot be mailed and is not unique, so it is never a key. `subscriber_token` is what tells two masked rows apart: it comes with every masked row of a **group's subscriber list**, is the same string for the same subscriber on every call and every page, and is a different string for a different address. It is opaque, it is scoped to this project, owner and group, and it is **absent** for a caller who reads addresses in full. The single user path read with `user_id` carries none. Do not store it as a lasting id: the platform can reissue tokens.
- Paging is unchanged, but for an admin in access groups `90` ~ `98` the `startKey` of a page is sealed, `{ seal: '...' }` rather than the database's own key. `fetchMore` replays it; a `startKey` you pass yourself has to be the previous page's object unchanged, or the request is refused with `INVALID_PARAMETER` and `"startKey" does not belong to this request.`

See [Searching subscribers by e-mail](#doc-email-newsletters-searching-subscribers-by-e-mail) and [Who can read the subscriber list](#doc-email-newsletters-who-can-read-the-subscriber-list).

## getNewsletters <a id="doc-api-reference-email-getnewsletters"></a>

```ts
getNewsletters(
    params?: {
        /**
         * Search points.
         * 'message_id' and 'subject' value should be string.
         * Others in numbers.
         */
        searchFor: 'message_id' | 'timestamp' | 'read' | 'complaint' | 'bounced' | 'subject';
        value: string | number;
        /** Numeric group, 'public', 'authorized' or a named newsletter group. */
        group: number | 'public' | 'authorized' | string;
        range?: string | number;
        /**
         * Defaults to '='
         */
        condition?: '>' | '>=' | '=' | '<' | '<=' | 'gt' | 'gte' | 'eq' | 'lt' | 'lte';
    },
    fetchOptions?: FetchOptions;
): Promise<DatabaseResponse<Newsletter>>
```

See [FetchOptions](#doc-api-reference-data-types-fetchoptions)

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

See [Newsletter](#doc-api-reference-data-types-newsletter)

## newsletterGroupEndpoint <a id="doc-api-reference-email-newslettergroupendpoint"></a>

Named newsletter groups are created and deleted by the project owner on the `Newsletters` page of the Skapi dashboard. See [Named Newsletters](#doc-email-newsletters-named-newsletters).

Project owner and admins in access group `99` only.

```ts
newsletterGroupEndpoint(): Promise<{
    groups: {
        /** Name of the newsletter group. */
        group: string;
        /**
         * Access group required to subscribe to the group, and to read its sent mail.
         * 0 is anyone, 1 is any logged in user, 2 ~ 99 is that access group.
         */
        restriction: number;
        /** Display label of the group. Empty string when none was set. */
        name: string;
        /** Number of subscribers of the group. */
        subscribers: number;
        /**
         * E-Mail address a newsletter for this group is sent to.
         * Empty string when the service has no sender e-mail set.
         */
        endpoint: string;
    }[];
}>
```

## registerNewsletterGroup <a id="doc-api-reference-email-registernewslettergroup"></a>

```ts
registerNewsletterGroup(
    params: {
        group: string;        // Name of the group: 2 to 20 lowercase letters and digits, at least one letter, not a reserved name ('public', 'authorized').
        restriction?: number; // Access group required to subscribe: 0 = anyone (default), 1 = any signed-in user, 2 ~ 99 = that access group and above.
        name?: string;        // Label shown on the dashboard, 60 characters max.
    }
): Promise<'SUCCESS: Group registered successfully.'>
```

Project owner only. The group gets its own sending address, returned by [`newsletterGroupEndpoint()`](#doc-api-reference-email-newslettergroupendpoint) with its subscriber count. A project can have up to 20 named groups. See [Named groups](#doc-email-newsletters-creating-a-named-group).

## deleteNewsletterGroup <a id="doc-api-reference-email-deletenewslettergroup"></a>

```ts
deleteNewsletterGroup(
    params: {
        group: string; // Name of the group to delete.
    }
): Promise<string> // 'SUCCESS: Group has been deleted along with N subscription(s).'
```

Project owner only. Deletes the group, every subscription to it, the newsletters sent to it and its sending address. This cannot be undone.

## sendInquiry <a id="doc-api-reference-email-sendinquiry"></a>

```ts
sendInquiry(
    params: {
        name: string;
        email: string;
        subject: string;
        message: string;
    }
): Promise<'SUCCESS: Inquiry has been sent.'>
```

# API Reference: Realtime Connection <a id="doc-api-reference-realtime"></a>

Below are the parameters and return data type references for the methods in TypeScript format.

## connectRealtime <a id="doc-api-reference-realtime-connectrealtime"></a>

```ts
connectRealtime(cb: RealtimeCallback): Promise<WebSocket | void>
```

On the initial connection the returned promise resolves to `undefined` (the WebSocket is delivered through the callback `cb`). On subsequent calls it resolves to the already-established `WebSocket`.

See [RealtimeCallback](#doc-api-reference-data-types-realtimecallback)

#### Errors
```ts
{
  code: 'INVALID_REQUEST';
  message: "Callback must be a function.";
}
|
{
  code: 'ERROR';
  message: "Skapi: WebSocket connection error.";
}
```

## postRealtime <a id="doc-api-reference-realtime-postrealtime"></a>

```ts
postRealtime(
    message: SubmitEvent | any,
    recipient: string, // User's ID or a group name
    notification?: {
      title: string;
      body: string;
      config?: {
        always: boolean; // When true, notification will always trigger the receiver's device regardless their connection state.
      }
    }
): Promise<{ type: 'success', message: 'Message sent.' }>
```

#### Errors
```ts
{
  code: 'INVALID_REQUEST';
  message: "No realtime connection. Execute connectRealtime() before this method.";
}
|
{
  code: 'INVALID_REQUEST';
  message: "User has not joined to the recipient group. Run joinRealtime('...')";
}
|
{
  code: 'INVALID_REQUEST';
  message: "Realtime connection is not open. Try reconnecting with connectRealtime().";
}
```

## joinRealtime <a id="doc-api-reference-realtime-joinrealtime"></a>

```ts
joinRealtime(params: {
  group: string | null, // Group name, or null to leave group
}
): Promise<{ type: 'success', message: string }>
```

#### Errors
```ts
{
  code: 'INVALID_REQUEST';
  message: "No realtime connection. Execute connectRealtime() before this method.";
}
```

## getRealtimeGroups <a id="doc-api-reference-realtime-getrealtimegroups"></a>

```ts
getRealtimeGroups(params?: {
        searchFor?: 'group' | 'number_of_users';
  value?: string | number; // Group name or number of users
        condition?: '>' | '>=' | '=' | '<' | '<=' | '!=' | 'gt' | 'gte' | 'eq' | 'lt' | 'lte' | 'ne';
  range?: string | number; // Cannot be used with condition.
    } | null,
    fetchOptions?: FetchOptions
): Promise<DatabaseResponse<{ group: string; number_of_users: number; }>>
```

## getRealtimeUsers <a id="doc-api-reference-realtime-getrealtimeusers"></a>

```ts
getRealtimeUsers(params?: {
        group?: string; // Group name. Defaults to the currently joined realtime group.
        user_id?: string; // User ID in the group
    },
    fetchOptions?: FetchOptions
): Promise<DatabaseResponse<{ user_id:string; cid:string; }[]>>
```

See [FetchOptions](#doc-api-reference-data-types-fetchoptions)

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

## closeRealtime <a id="doc-api-reference-realtime-closerealtime"></a>

```ts
closeRealtime(): Promise<void>
```

## connectRTC <a id="doc-api-reference-realtime-connectrtc"></a>

```ts
connectRTC(
  params: {
    cid: string;
    ice?: string;
    media?: {
      video: boolean;
      audio: boolean;
    } | MediaStream | MediaStreamConstraints;
    channels?: Array<RTCDataChannelInit | 'text-chat' | 'file-transfer' | 'video-chat' | 'voice-chat' | 'gaming'>;
  },
  callback: (e: RTCEvent) => void
): Promise<RTCConnector>
```

See [RTCConnector](#doc-api-reference-data-types-rtcconnector)

See [RTCEvent](#doc-api-reference-data-types-rtcevent)

#### Errors
```ts
{
  code: 'DEVICE_NOT_FOUND';
  message: "Requested media device not found.";
}
|
{
  code: 'INVALID_REQUEST';
  message: 'Data channel with the protocol "{protocol name}$" already exists.';
}
```

## vapidPublicKey <a id="doc-api-reference-realtime-vapidpublickey"></a>

```ts
vapidPublicKey(): Promise<{ VAPIDPublicKey: string }>
```

## subscribeNotification <a id="doc-api-reference-realtime-subscribenotification"></a>

```ts
subscribeNotification(
  endpoint: string,
  keys: {
    p256dh: string;
    auth: string;
  }
): Promise<'SUCCESS: Subscribed to receive notifications.'>
```

## unsubscribeNotification <a id="doc-api-reference-realtime-unsubscribenotification"></a>

```ts
unsubscribeNotification(
  endpoint: string,
  keys: {
    p256dh: string;
    auth: string;
  }
): Promise<'SUCCESS: Unsubscribed from notifications.'>
```

## pushNotification <a id="doc-api-reference-realtime-pushnotification"></a>

```ts
pushNotification(
  params: {
    title: string;
    body: string; // title and body together at most 3072 bytes.
  },
  user_ids?: string | string[] // Up to 1000 users. Each gets it on every device they registered. Without it, every registered device of the project gets it.
): Promise<"SUCCESS: Notification sent.">
```

# API Reference: Third-Party APIs <a id="doc-api-reference-api-bridge"></a>

Below are the parameters and return data type references for the methods in TypeScript format.

## forwardRequest <a id="doc-api-reference-api-bridge-forwardrequest"></a>

```ts
forwardRequest(
    form: SubmitEvent | HTMLFormElement | FormData | { [key: string]: any } | null, // Flattened into the request, or sent verbatim with multipart: true. null when everything is in the options.
    options: {
        secretName?: string; // The name of a key registered on your project's Secret Keys page, substituted for "$CLIENT_SECRET". OPTIONAL: a request that names none is forwarded with only what you supplied.
        url: string; // The destination endpoint URL.
        method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'; // Defaults to 'POST'.
        headers?: { [key: string]: string }; // Request headers as a key-value object. A name starting with "x-skapi-" is refused.
        data?: { [key: string]: any }; // Request body as a key-value object. Wins over the form on a key collision. Refused together with multipart: true.
        params?: { [key: string]: string }; // Query parameters as a key-value object, and the merge target of the form for GET, DELETE and HEAD.
        multipart?: boolean; // Relay the form's own bytes, the way a browser would send them, files included, instead of flattening it to fields. Keep the encoded body under 2 MB minus 64 KB. Defaults to false.
        skapiHeaders?: boolean | { user?: boolean; service?: boolean }; // Which of "x-skapi-user" and "x-skapi-service" the destination is told. Both values are written server side from the VERIFIED identity of the request. Defaults to false.
        poll?: number; // Optional polling interval in milliseconds. When > 0, the promise resolves immediately with the initial status object and the final result is delivered via onResponse/onError. When omitted or 0, the status object is returned with a poll() method to start polling manually. Must be a non-negative number.
        queue?: string; // Optional queue name. Requests sharing the same queue are processed sequentially on the server side.
        expires?: number; // Optional expiration time in seconds for the request record.
        stream?: boolean; // Read the destination's response INCREMENTALLY and relay the raw bytes as they arrive, instead of downloading the whole body first. Requires a queue, and mints one when you did not name one. Never sent to the destination. Defaults to false. See Streaming below.
        realtime?: boolean; // ALSO push each relayed piece over skapi's websocket, so onStream fires as the piece is relayed instead of on the next poll tick. Only meaningful alongside stream and onStream. Purely an accelerator: the poll still runs and still carries the read on its own if the socket cannot be opened. Defaults to false. See Streaming below.
        onStream?: (chunk: string, seq: number, via?: 'socket' | 'poll') => void; // Called with each relayed piece, in order, with the sequence number it was stored under and which transport carried it. Raw text, never parsed. Its presence is what makes the poll loop fetch chunks at all.
        onResponse?: (res: any, meta?: { executed?: number }) => void; // Called with the final response once polling resolves, or immediately for non-queued direct responses. `meta` carries facts about the REQUEST rather than the response, because a settled poll resolves with the destination's own answer and there is nowhere in that answer to put one of skapi's. `meta.executed` is when the worker BEGAN running the request, in milliseconds, the same value forwardRequestHistory() reports as `executed`; `updated - executed` is the call itself, while `updated - created` also counts the wait in the queue. Optional: a request that began and ended between two poll ticks never showed a status envelope and has none, so show nothing rather than falling back to `created`. A one-argument callback is unaffected. Delivered only when this request was QUEUED and therefore polled: a direct, non-queued response never went through a poll and is called with one argument.
        onError?: (err: any) => void; // Called when polling or the initial request fails.
        responseType?: 'json' | 'text'; // How the promise resolves with the destination's answer. A skapi status object is handed back untouched either way.
        signal?: AbortSignal; // Aborting stops THIS CLIENT polling the request. The request is already on the server and is NOT cancelled; cancelForwardRequest() is what removes it.
    }
): Promise<any | {
    id: string;           // Request ID in "stamp:entropy" format.
    status: 'pending';    // Queue status at the time of queueing. Newly queued requests are always 'pending'.
    queue_name: string;   // The queue this request belongs to (plain queue name).
    in_queue: number;     // Unresolved requests in this queue, INCLUDING this one. 1 means this request is at the head (processing next); 2 means one request is ahead of it.
    realtime_group?: string; // Present when the request was made with realtime: true. The room its pieces are pushed to. Keep it beside the ID if you may want to re-attach later: forwardRequestStream() takes it as realtimeGroup, and it cannot be rebuilt from the ID.
    poll?: (arg?: { latency?: number; onStream?: (chunk: string, seq: number, via?: 'socket' | 'poll') => void }) => Promise<any>; // Present when the request was queued. Call to start manual polling. In practice you only receive this object when poll is omitted or 0. See Behavior below.
}>
```

Relays a request to a destination of your choosing from Skapi's servers instead of from the browser,
optionally substituting one of your stored secret keys into it.

**The first argument is the form:** a submit event, a form element, a `FormData`, a plain object, or
`null` when everything is already in the options. Unless `multipart: true` is set it is **flattened**
to a plain key-value object, where repeated field names collapse to an array, and merged into
`params` for `GET`, `DELETE` and `HEAD` and into `data` for every other method. On a key collision
the explicit `options.data` or `options.params` value wins.

**Files are dropped by that flattening**, because the merged object travels as JSON and a file has no
representation in it. Set `multipart: true` to relay the form's own bytes instead, the way a browser
posting it would send them, files included, with the content type the body actually has.

**Behavior:**
- For non-queued requests (no `queue`), the response is returned directly and `onResponse` is also called with the result.
- When `poll > 0`, polling starts automatically and the promise resolves with the **final** result once polling finishes, not with the status object. (If `onResponse` is supplied, the promise resolves with that callback's return value.) Errors go to `onError`. A queue is auto-generated when no `queue` was given and either `poll > 0` or `stream: true`.
- When `poll` is `0` or omitted, the promise resolves with the status object plus a `poll()` method. Call `poll()` to start polling; results come via `onResponse`/`onError`.
- The promise returned by `poll()` also carries a `stop()` method that stops that one poll. See [stopForwardRequestPolling](#doc-api-reference-api-bridge-stopforwardrequestpolling).
- `secretName` is optional. When you name one, `$CLIENT_SECRET` is substituted server side in the `url` and in the values of `headers`, `data` and `params` at any depth, and the key's access group authorizes the caller. Nothing is substituted inside a `multipart` body. When `$CLIENT_SECRET` appears nowhere, the SDK leaves `secretName` out and sends the request as written. When you name none, nothing is substituted and no placeholder is required.
- When the named key has **Destinations** set and `url` does not match one of them, the request is refused with `INVALID_REQUEST` and `Destination is not allowed for this client secret.`, and nothing is sent. A matching request is sent with redirects turned off. A queued request is checked again right before it is sent. See [Restricting Where a Key Can Be Sent](#doc-api-bridge-client-secret-request-restricting-where-a-key-can-be-sent).
- `multipart: true` is refused together with `data`, and refused with no form in the first argument, both with `INVALID_PARAMETER`. The encoded body is capped at **2 MB minus 64 KB (2,031,616 characters)**, which is roughly a 1.5 MB file, because a raw body rides the ordinary request and shares its payload budget. Upload anything larger first and send its url.
- `responseType: 'text'` resolves with a string, `'json'` parses one, and a skapi status object is handed back untouched either way. The destination is called server side and its body is relayed, so the destination's own status code is the `status_code` of the row [forwardRequestHistory](#doc-api-reference-api-bridge-forwardrequesthistory) lists.
- `signal` aborts **this client's poll**, not the request. An already-aborted signal throws `INVALID_REQUEST` before anything is sent, and on the direct non-queued path there is no poll, so it does nothing.

:::info Who is calling
`skapiHeaders` is off by default, so the destination is told nothing about the caller. `true` sends
both `x-skapi-user` and `x-skapi-service`; `{ user: true }` or `{ service: true }` sends only that
one. `x-skapi-user` is the signed-in caller's **user id**, and is left out entirely for a signed-out
caller rather than sent empty. `x-skapi-service` is **the service this request runs against**, which
is not necessarily the caller's own connection service. Both are written on the server from the
verified identity of the request.

A header of your own whose name starts with `x-skapi-` is **refused** with `INVALID_PARAMETER`,
matched without regard to case, whatever `skapiHeaders` says. Both the SDK and the server refuse it,
and that refusal is what lets a destination trust the prefix.
:::

:::info
`queue_name` is the plain queue name (e.g. `"image-queue"`) on every response that carries it:
this one, `forwardRequestHistory()` results, and the object a `poll()` resolves with.
:::

**Streaming:**

With `stream` omitted or `false`, the response is downloaded whole and stored on the request row.
That is the default, and every existing caller behaves exactly as before.

With `stream: true`, the server reads the response incrementally and relays the raw bytes into a
chunk store as they arrive, so you can render an answer while the destination is still producing it.
Skapi does not parse the stream. It relays bytes, and whatever format those bytes are in (server-sent
events, NDJSON, anything else) is between you and your destination.

:::warning
`stream` says how **skapi** reads the response. Asking the destination to produce one incrementally
is part of your own request, so whatever that API requires goes in the request body, the first
argument, as usual (most spell it `"stream": true`). The two halves fail quietly on their own: set only the body's and skapi buffers
the whole event transcript into the stored response, where a reader expecting that API's normal
document finds a wall of `data: {...}` lines; set only skapi's and the destination answers one plain
document that is honestly relayed in pieces, so a reader looking for frames finds none. Skapi cannot
catch either, because your request body is yours and is never inspected.
:::

```js
let text = '';

let res = await skapi.forwardRequest({ report: 'quarterly', stream: true }, {
    url: 'https://api.example.com/v1/export',
    secretName: 'my_secret',
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'x-api-key': '$CLIENT_SECRET' },
    // Whatever THIS destination requires to answer in pieces. Passed through untouched;
    // skapi neither reads nor writes your body.
    // Skapi's own flag, telling it to relay that response as it arrives.
    stream: true,
    poll: 1000,
    onStream: (chunk) => { text += chunk; }
});

// A streamed request settles with a status and NO body: the text was the stream.
console.log(res.status); // 'resolved'

// Store the version you want kept, and release the chunks.
await skapi.forwardRequestFinalize(res.id, text, {
    url: 'https://api.example.com/v1/export',
    method: 'POST'
});
```

A streamed request's chunks are kept **indefinitely** until you finalize it. Nothing expires them on
a timer, and until then every read of that request has to fetch and re-parse all of them. Finalize as
soon as you have what you want: that is what turns a pile of relayed bytes into an ordinary history
entry.

See [Forwarding Requests](#doc-api-bridge-forward-request) and [Streaming Request](#doc-api-bridge-streaming-request)

## forwardRequestStream <a id="doc-api-reference-api-bridge-forwardrequeststream"></a>

```ts
forwardRequestStream(
    requestId: string, // The request ID ("stamp:entropy"), or an already-composed full ID.
    options: {
        url?: string; // The URL the request was sent to. Required unless requestId is a full ID.
        method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'; // The method it was sent with. Required unless requestId is a full ID.
        onStream?: (chunk: string, seq: number, via?: 'socket' | 'poll') => void; // Called with each relayed piece, in order, with its sequence number and which transport carried it. Raw text, never parsed.
        realtimeGroup?: string; // The realtime_group the dispatching forwardRequest() handed back. With it, this read ALSO listens on skapi's websocket while the request is still running. Without it the read works exactly as before, at poll speed.
        since?: number; // Start after this sequence number instead of from the beginning, so a reader that already holds part of a response does not receive it twice. Default 0.
        poll?: number; // Polling interval in milliseconds while the request is still running. Default 1000. Must be a finite, non-negative number.
        onResponse?: (res: any) => void; // Called once with whatever this resolves with.
        onError?: (err: any) => void; // Called if the read itself fails.
    }
): Promise<any> & { stop: () => void } // The request's terminal status, or the stored body when it was finalized.
```

Reads a streamed request **this call did not start**: a page reload, a second tab, or a request from
history that was never finalized. `forwardRequest()` streams into the callback of the caller
that started the request, and this is how any other reader gets at the same text.

**Behavior:**
- **Still running:** polls and delivers text through `onStream` until the request settles, then
  resolves with its terminal status.
- **Already finished, not finalized:** fetches the whole stored text at once (paging internally until
  there is none left), delivers it through `onStream` in order, and resolves. This is what makes an
  unfinalized request re-readable.
- **Already finalized:** resolves with the stored body itself, exactly the value that was kept.
  Nothing streams, because the chunks were released when that version was stored.

A status envelope always carries `status`, `id` and `in_queue` **together**, and a finalized body is
anything that does not. Do not test `status` alone: the body you finalized may carry a `status` field
of its own, and a destination's own response very often does. That is the one place the two resolved shapes
differ. See [StreamPollResult](#doc-api-reference-data-types-streampollresult) and
[ForwardRequestStreamOptions](#doc-api-reference-data-types-forwardrequeststreamoptions).

The returned promise carries a `stop()` that ends the read without touching the request itself, and
the read is registered under the same key a poll is, so
[stopForwardRequestPolling](#doc-api-reference-api-bridge-stopforwardrequestpolling) reaches it too. A stopped read resolves with
`{ id, status: 'stopped' }` and does not call `onResponse`.

See [Streaming Request](#doc-api-bridge-streaming-request)

## forwardRequestFinalize <a id="doc-api-reference-api-bridge-forwardrequestfinalize"></a>

```ts
forwardRequestFinalize(
    requestId: string, // The request ID ("stamp:entropy"), or an already-composed full ID.
    data?: any,        // The version to keep. Sent verbatim; sending nothing keeps nothing.
    options?: {
        url?: string;  // The URL the request was sent to. Required unless requestId is a full ID.
        method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'; // The method it was sent with. Required unless requestId is a full ID.
    }
): Promise<{
    finalized: boolean; // false when the request is unknown, has not finished yet, or was never streamed.
    message: string;    // Why, when finalized is false.
}>
```

Stores the version of a streamed request you want **kept** as that request's history, and releases
the relayed chunks it was assembled from. A streamed request settles with a status and no body, so
until you call this its history entry comes back with `response_body: null`.

The content is entirely yours. Skapi does not validate it, parse it, or interpret it: text you
assembled from the chunks, a rebuilt response object, a summary, a single word. It is stored as given
and returned as given, and it becomes what [forwardRequestHistory](#doc-api-reference-api-bridge-forwardrequesthistory)
lists and what a later poll of that request hands back.

**Behavior:**
- Only the caller who made the request can finalize it. Another user's ID addresses a request that
  does not exist, and answers `{ finalized: false, message: 'Request not found.' }`.
- Only a **streamed** request can be finalized. A buffered one already stored the destination's own
  answer, and that answer is not yours to overwrite.
- Only a **finished** request can be finalized. Finalizing a `pending` or `running` one would race
  the server's own settle, so it is refused with `finalized: false`.
- Finalizing twice simply replaces the kept version, so a call lost to the network can be repeated.
- Storing a result is what **deletes** the chunks. After this, `forwardRequestStream()` on the
  same request returns the stored body and streams nothing.

See [Streaming Request](#doc-api-bridge-streaming-request)

## forwardRequestHistory <a id="doc-api-reference-api-bridge-forwardrequesthistory"></a>

```ts
forwardRequestHistory(
    params: {
        url: string; // The destination endpoint URL used in the original request.
        method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'; // The HTTP method used in the original request.
        queue?: string; // Optional queue name to filter history by. When omitted, all requests for the given url and method are returned.
        status?: 'pending' | 'running' | 'resolved' | 'failed'; // Optional status filter.
        compact?: boolean; // Return lightweight label stubs instead of full request/response bodies. See below.
        queue_exact?: boolean; // Match the named queue exactly instead of as a prefix. See below.
        queue_exclude?: string; // Drop one queue's rows from the listing. See below.
    },
    fetchOptions?: FetchOptions // Pagination and fetch behavior options.
): Promise<DatabaseResponse<RequestHistory[]>>
```

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

See [RequestHistory](#doc-api-reference-data-types-requesthistory)

Each history item carries two timestamps, both in milliseconds: `created`, the time the
request was made (set once, never changes), and `updated`, the time of the most recent
status change (for a settled request, when its response arrived). An item whose response
arrived also carries `responded`, the time that response finished arriving. It is set once
and, unlike `updated`, does not move when a streamed request is finalized.

Listing modifiers:

- `compact: true` returns each item with label stubs (`request_text`, `response_text`,
  `response_complete_marker`, and `compact: true`) **instead of** `request_body` and
  `response_body`; the full bodies never leave the server. Use it to render long lists
  cheaply, then re-fetch without `compact` (or `poll()` a live item) when a full body is
  actually needed.
- `queue_exact: true` restricts a `queue` listing to exactly the named queue. Without it
  the queue lookup is a prefix range, so queue `"u1"` also matches `"u1-bg"` and every
  other queue that starts with `"u1"`.
- `queue_exclude: "<name>"` drops one queue's rows from the listing: the inverse filter,
  for fetching everything except a background queue.

Both queue filters are applied server-side after the range read, so a page can come back
short (or even empty) while more matches remain. Rely on `endOfList`/`startKey` to keep
paging, never on a page's length.

## cancelForwardRequest <a id="doc-api-reference-api-bridge-cancelforwardrequest"></a>

```ts
cancelForwardRequest(
    params: {
        url: string; // The destination endpoint URL of the request to cancel.
        method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'; // The HTTP method of the request to cancel.
        id: string; // The request ID to cancel.
        queue?: string; // Optional queue name the request belongs to. Provide this to also remove the request from the client-side queue.
    }
): Promise<{ removed: boolean; message: string }>
```

Cancels the **request**. To stop watching one without cancelling it, use
[stopForwardRequestPolling](#doc-api-reference-api-bridge-stopforwardrequestpolling).

## stopForwardRequestPolling <a id="doc-api-reference-api-bridge-stopforwardrequestpolling"></a>

```ts
stopForwardRequestPolling(
    params?: {
        url?: string; // The destination endpoint URL of the request. Required when identifying by id.
        method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'; // The HTTP method of the request. Required when identifying by id.
        id?: string; // The request ID whose poll should stop. Pair with url and method.
        queue?: string; // Stop every poll started with this queue name instead of a single request.
    }
): number // How many live polls were stopped.
```

Stops polling **without cancelling the request**. The request keeps running on the server; only this
client stops asking about it. Call it again later to pick the result back up.

Pass `id` (with `url` and `method`) to stop one request, `queue` to stop every poll on a queue, or no
arguments at all to stop every live poll.

**Behavior:**
- A stopped poll **resolves** with `{ id, status: 'stopped' }`. It does not reject, so `await` sites do
  not need a `catch`.
- `onResponse` and `onError` are **not** called for a stopped poll; a stop is not a result.
- Stopping a request that is still waiting in a queue also removes it from that queue, freeing the slot
  for the next request.
- Stopping an unknown or already-finished request is a no-op and returns `0`.

To cancel the request itself rather than just stop watching it, use
[`cancelForwardRequest()`](#doc-api-reference-api-bridge-cancelforwardrequest).

## isPollStopped <a id="doc-api-reference-api-bridge-ispollstopped"></a>

```ts
isPollStopped(
    res: any // A resolved poll result.
): boolean // true if the result came from stopForwardRequestPolling rather than the server.
```

Distinguishes a stopped poll from a real API result, so a handler can ignore it instead of treating
`{ status: 'stopped' }` as a response.

## forwardRequestQueueCount <a id="doc-api-reference-api-bridge-forwardrequestqueuecount"></a>

```ts
forwardRequestQueueCount(
    params: {
        queue: string; // The queue name to check.
    }
): Promise<{
    queue_name: string; // The queue name.
    in_queue: number;   // Number of requests currently waiting in the queue.
}>
```

## secureRequest <a id="doc-api-reference-api-bridge-securerequest"></a>

```ts
secureRequest<
    Params = {
        url: string;   // The URL of your custom API.
        data?: any;    // The data to send to your custom API.
        sync?: boolean; // When true, the requests are processed synchronously.
    },
    Response = { response: any; statusCode: number; url: string }
>(
    params: Params[] | Form<Params>, // A single request, an array of requests, or a form.
    url?: string
): Promise<Response | Response[]>
```

Mirrors a `POST` request to your own API through Skapi, so your API receives the signed-in
user's information alongside the request data. The user must be logged in, and this method
does not accept HTML forms.

When a secret key is set on the project's settings page, the mirrored request carries that
key, which is how your API can verify the call really came from Skapi.

See [Secure Post Request](https://docs.skapi.com/api-bridge/secure-post-request.html)

# API Reference: Admin <a id="doc-api-reference-admin"></a>

Below are the parameters and return data type references for the methods in TypeScript format.

## inviteUser <a id="doc-api-reference-admin-inviteuser"></a>

```ts
inviteUser(
    params: SubmitEvent | {
        email: string; // Required. The invitation, with the temporary password and the link to accept, is sent here.
        username?: string; // Optional. Becomes the invited account's PERMANENT login ID and can never be changed. Accepting the invitation verifies the email address, so the email logs the account in as well from then on.
        name?: string;
        nickname?: string;
        phone_number?: string; // Must be in "+0012341234" format.
        address?: string | {
            formatted: string;
            locality: string;
            region: string;
            postal_code: string;
            country: string;
        }; // OpenID Standard Claims object is also supported.
        gender?: string;
        birthdate?: string; // Must be in YYYY-MM-DD format.
        picture?: string; // URL of the profile picture.
        profile?: string; // URL of the profile page.
        website?: string; // URL of the website.
        misc?: string; // Additional string value that can be used freely. Visible to the account owner, to admins and to the project owner.
        email_public?: boolean; // Default = false. When true, the email is visible to other users.
        phone_number_public?: boolean; // Default = false. When true, the phone number is visible to other users.
        address_public?: boolean; // Default = false. When true, the address is visible to other users.
        gender_public?: boolean; // Default = false. When true, the gender is visible to other users.
        birthdate_public?: boolean; // Default = false. When true, the birthdate is visible to other users.
        openid_id?: string; // ID of an OpenID logger registered in the project, to link the invited account to it.
        access_group?: number; // 1~99. Default = 1. 99 is admin level. An admin cannot set it higher than their own access group.
    },
    options?: {
        /**
         * URL the user is taken to after they accept the invitation.
         * Must not contain the "#" character.
         */
        confirmation_url?: string;

        /**
         * When true, the user is subscribed to Service Email (group 1) once they accept.
         * Requires 'confirmation_url' to be set. (Default = false)
         */
        email_subscription?: boolean;

        /**
         * A custom HTML template for this invitation e-mail, used instead of the project's.
         * Both fields are required. See [Required Placeholders for Invitation E-Mail](/email/email-templates.md#required-placeholders-for-invitation-email).
         */
        template?: {
            url: string; // URL of the HTML template.
            subject: string; // Subject line of the e-mail.
        };
    }
): Promise<'SUCCESS: Invitation has been sent. (User ID: xxx...)'>
```

Every profile attribute given here is written to the account when the invitation is sent, and is kept
unchanged when the user accepts. Accepting only activates the account and marks its email verified.

#### Errors
```ts
{
  code: 'INVALID_REQUEST';
  message: "User needs to login." | "Invalid access."; // Only admins (access_group 90 and above) can invite.
}
|
{
  code: 'INVALID_REQUEST';
  message: "Email is required." | "User limit exceeded." | "Cannot give a user an access group higher than your own." | "OpenID logger not found." | 'Character "#" is not allowed in signup confirmation url.' | "Too many requests.";
}
|
{
  code: 'INVALID_REQUEST';
  message: "Monthly email send limit reached. Consider upgrading your plan." | "E-mail sending is turned off for this project because of spam complaints."; // An invitation is an e-mail: it counts as one of the project's monthly e-mail sends, and is refused like a newsletter past the plan's limit or while sending is turned off for complaints. No account is created. See "Inviting Users".
}
|
{
  code: 'EXISTS';
  message: "User is already invited."; // The email already has a pending invitation, whatever the 'username' of either invitation. Or the login ID this invitation would be created with ('username', or the email when there is none) is the one another account was already created or invited with, for example the same 'username' again, whatever the email. See "Inviting the same email again" below.
}
|
{
  code: 'EXISTS';
  message: 'E-mail "user@email.com" is already a login ID in this service.' | 'Username "jane@email.com" is already a login ID in this service.' | "The login ID is already used by another account in this service.";
  // Any invitation without 'openid_id'. Which message, and exactly when, is listed below.
}
|
{
  code: 'INVALID_PARAMETER';
  message: '"options.confirmation_url" is required for email subscription.' | '"options.template" should be type: <object> with "url" and "subject".';
}
```

Every invitation, with or without `openid_id`, is first checked for a pending invitation to the same
email, and refused with `User is already invited.` when there is one: an invitation that has not been
accepted, cancelled or expired, sent to an account whose email is still that address. After that,
every invitation without `openid_id` is checked before the account is created:

- **Without `username`**, it is refused with `E-mail "user@email.com" is already a login ID in this service.`
  when the email is a login ID another account was **granted** through its email login: an address that
  account has verified. An email that is another account's original login ID (its `username`, or the
  email it was created with when it has none) is a duplicate instead and gives `User is already invited.`
- **With `username`**, it is refused with the same `E-mail` error when the email is a login ID another
  account was granted in any way: as that account's original login ID, or as an address it has verified.
  It is refused with `Username "jane@email.com" is already a login ID in this service.` when the
  `username` is such an address, which only an e-mail address can be, so a `username`
  such as `jane_doe` never gets this error. A `username` that another account was created or invited
  with gives `User is already invited.`

An email login another account holds **without** having verified the address is not a login ID anyone
was granted. It refuses nothing: it is removed, that account keeps logging in with the login ID it was created with, and the
invitation goes out. See [Login IDs](#doc-admin-permissions-login-ids).

These checks are lookups made just before the account is created, not reservations: two invitations
for the same email sent at the same moment can both pass them. The account creation itself checks the
invitation's own login ID once more, and refuses one that already logs another account in through its
email login with `E-mail "user@email.com" is already a login ID in this service.` (without `username`) or
`The login ID is already used by another account in this service.` (with `username`). When a check
cannot get an answer in time, the invitation is not refused by it.

When `username` is given, the email starts logging the account in when the user accepts the invitation,
because accepting verifies the address, and not while it is pending. If that address is already a login
ID another account was granted by then, email login is not enabled and the username still logs the
account in. An invitation with `openid_id` never gets email login.

#### Inviting the same email again <a id="doc-api-reference-admin-inviting-the-same-email-again"></a>

While the first invitation is still pending, a second invitation to the same email is refused with
`User is already invited.`, whatever the `username` of either invitation. Once the first invitation was
accepted, what happens depends on the `username` of both invitations, because an invitation's login ID
is its `username` when it has one, and its email otherwise:

- **The same `username` again**, with the same email or another one: `User is already invited.`, also
  while the first invitation is pending.
- **No `username` both times**: `User is already invited.`
- **A `username` in either invitation, and not the same one**:
  `E-mail "user@email.com" is already a login ID in this service.`, since the email then logs the first
  account in.

To change the `username` of a pending invitation, cancel it with [`cancelInvitation()`](#doc-api-reference-admin-cancelinvitation)
first, then invite again.

## resendInvitation <a id="doc-api-reference-admin-resendinvitation"></a>

```ts
resendInvitation(
    params: {
        email: string; // Required. Max 64 characters.
    }
): Promise<'SUCCESS: Invitation has been re-sent. (User ID: xxx...)'>
```

A resent invitation renders `${username}` the same way as the first e-mail: the login username for an
invitation created with a `username`, the email otherwise. An invitation sent before the backend started
keeping the plain username with the invitation is the exception: a resend of it renders the email even
when it has a `username`, although that email does not log the invitee in until they accept.

#### Errors
```ts
{
  code: 'NOT_EXISTS';
  message: "No invitation found for: user@email.com."; // No invitation is found for the email. This is also the answer for an invitation made WITH a 'username' that has already been accepted.
}
|
{
  code: 'EXISTS';
  message: "User already exists."; // The account found for the email is not a pending invitation, for example an invitation made WITHOUT a 'username' that has already been accepted.
}
|
{
  code: 'INVALID_REQUEST';
  message: "Monthly email send limit reached. Consider upgrading your plan." | "E-mail sending is turned off for this project because of spam complaints."; // A resent invitation counts as one more monthly e-mail send, like the first one.
}
```

## getInvitations <a id="doc-api-reference-admin-getinvitations"></a>

```ts
getInvitations(params?: {
    email?: string; // When set, only invitations with the email starting with the given string will be returned.
}, fetchOptions?: FetchOptions): Promise<DatabaseResponse<UserProfile>>
```

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse).

See [UserProfile](#doc-api-reference-data-types-userprofile).

See [FetchOptions](#doc-api-reference-data-types-fetchoptions).

## cancelInvitation <a id="doc-api-reference-admin-cancelinvitation"></a>

```ts
cancelInvitation(params: {
    email: string; // email of the user to cancel the invitation.
}): Promise<"SUCCESS: Invitation has been canceled.">
```

## grantAccess <a id="doc-api-reference-admin-grantaccess"></a>

```ts
grantAccess(params: {
    user_id: string; // User ID to grant access.
    access_group: number; // Access group level of the user. (1~99) 99 is admin level. An admin cannot grant higher than their own access group, and cannot act on an account whose access group is the same as their own or higher.
}): Promise<'SUCCESS: Access has been granted to the user.'>
```

#### Errors
```ts
{
  code: 'INVALID_REQUEST';
  message: "No access to modify admin."; // The caller is an admin in access groups 90 ~ 98 and the account's access group is the same as or higher than their own. Nothing is changed.
}
|
{
  code: 'INVALID_REQUEST';
  message: "No access to expel admin."; // Changing the access group of an access group 99 account, from anyone but an access group 99 admin and the project owner.
}
|
{
  code: 'INVALID_REQUEST';
  message: "Cannot grant an access group higher than your own." | "User cannot self grant access." | "Cannot grant access to suspended user." | "User signup is not confirmed." | "No access."; // "No access." is an account of another project.
}
|
{
  code: 'INVALID_PARAMETER';
  message: '"access_group" should be within range: 0 ~ 99.';
}
|
{
  code: 'NOT_EXISTS';
  message: "User does not exists.";
}
```

An admin in access groups `90` ~ `98` can change the access group only of accounts whose access group is
lower than their own, and only to a group up to their own. A
[disabled](#doc-user-account-disable-recover-account) account counts with the access group it had before
it was disabled. Admins in access group `99` and the project owner are limited by neither rule. See
[Admin Permissions](#doc-admin-permissions-granting-an-access-group).

## createAccount <a id="doc-api-reference-admin-createaccount"></a>

```ts
createAccount(
    params: {
        name?: string;
        email: string; // Required.
        phone_number?: string;
        address?: string | {
            formatted: string;
            locality: string;
            region: string;
            postal_code: string;
            country: string;
        };
        gender?: string;
        birthdate?: string;
        misc?: string;
        picture?: string;
        profile?: string;
        website?: string;
        nickname?: string;
        email_public?: boolean; // When set to true, email attribute is visible to others.
        phone_number_public?: boolean; // When set to true, phone_number attribute is visible to others.
        address_public?: boolean; // When set to true, address attribute is visible to others.
        gender_public?: boolean; // When set to true, gender attribute is visible to others.
        birthdate_public?: boolean; // When set to true, birthdate attribute is visible to others.
        password: string; // Required. At least 6 characters and a maximum of 60 characters.
        username?: string; // Optional. Becomes the account's PERMANENT login ID and can never be changed. The email logs the account in only once the user verifies it with verifyEmail(): creating the account does not verify the address.
        access_group?: number; // 1~99. Default = 1. An admin cannot set it higher than their own access group.
    }
): Promise<UserProfile & { email_admin: string; username: string; }>
```

See [UserProfile](#doc-api-reference-data-types-userprofile).

#### Errors
```ts
{
  code: 'INVALID_REQUEST';
  message: "User needs to login." | "Invalid access."; // Only admins (access_group 90 and above) can create accounts.
}
|
{
  code: 'INVALID_REQUEST';
  message: "User limit exceeded." | "Cannot give a user an access group higher than your own." | "OpenID logger not found." | "Too many requests.";
}
|
{
  code: 'EXISTS';
  message: "User already exists."; // The login ID this account would be created with ('username', or the email when there is none) is the one another account was already created or invited with, for example the same 'username' again, whatever the email, or the same email again, both times without 'username'.
}
|
{
  code: 'EXISTS';
  message: 'E-mail "user@email.com" is already a login ID in this service.' | 'Username "jane@email.com" is already a login ID in this service.' | "The login ID is already used by another account in this service.";
  // Any request without 'openid_id'. The same conditions as inviteUser().
}
```

Every request without `openid_id` is checked before the account is created, with the same login ID
conditions and messages as [`inviteUser()`](#doc-api-reference-admin-inviteuser):

- **Without `username`**, `E-mail "user@email.com" is already a login ID in this service.` when the email
  is a login ID another account was granted through its email login, meaning an address that account has
  verified. An email that another account was itself created or invited with, without `username`, is a
  duplicate and gives `User already exists.`
- **With `username`**, the same `E-mail` error when the email is a login ID another account was granted
  in any way, and `Username "jane@email.com" is already a login ID in this service.` when the `username`
  is an e-mail address another account was granted that way. A `username` that another
  account was created or invited with is a duplicate and gives `User already exists.`

The account creation checks the account's own login ID once more, the same way as for an invitation, and
refuses it with `E-mail "user@email.com" is already a login ID in this service.` (without `username`) or
`The login ID is already used by another account in this service.` (with `username`). Unlike
`inviteUser()`, `createAccount()` does not check for a pending invitation to the same email.

A pending invitation that has a `username` does not hold its email as a login ID yet: the email starts
logging that account in when the invitation is accepted. Creating an account with that email,
without a `username` or with a different one, is therefore not refused: it creates a separate account,
and the invited account logs in with its username only once accepted.

When `username` is given, the email does **not** log the account in yet. `createAccount()` does not
verify the address, so the account logs in with its username until the user verifies the address with
[`verifyEmail()`](#doc-api-reference-user-verifyemail), after which email login starts on the
account's next token, a few seconds later. An account created without a `username` logs in with the
address it was created with from the start, because that address is its own permanent login ID. See
[Login IDs](#doc-admin-permissions-login-ids).

## updateUserAttributes <a id="doc-api-reference-admin-updateuserattributes"></a>

```ts
updateUserAttributes(
    params: {
        user_id: string; // Required. The ID of the user whose attributes will be updated.
        name?: string;
        email?: string; // Written unverified, and the account's email login is removed. The new address logs the account in only after the user verifies it with verifyEmail(). Refused with EXISTS when the address is already a login ID another account was granted. A 'username' is permanent and never changes.
        phone_number?: string;
        address?: string | {
            formatted: string;
            locality: string;
            region: string;
            postal_code: string;
            country: string;
        };
        gender?: string;
        birthdate?: string;
        misc?: string;
        picture?: string;
        profile?: string;
        website?: string;
        nickname?: string;
        email_public?: boolean; // When set to true, email attribute is visible to others.
        phone_number_public?: boolean; // When set to true, phone_number attribute is visible to others.
        address_public?: boolean; // When set to true, address attribute is visible to others.
        gender_public?: boolean; // When set to true, gender attribute is visible to others.
        birthdate_public?: boolean; // When set to true, birthdate attribute is visible to others.
    }
): Promise<'SUCCESS: User attributes updated.'>
```

At least one attribute must be provided in addition to the required `user_id`.

The update is stored on the account at once and reaches the user's record, which `getUsers()`, the dashboard's Users page and the user's own session read, when the user next logs in or their session next renews its token. `misc` reaches the record at once. See [When the change takes effect](#doc-admin-account-when-the-change-takes-effect).

#### Errors
```ts
{
  code: 'INVALID_REQUEST';
  message: "No access to modify admin."; // The caller is an admin in access groups 90 ~ 98 and the account's access group is the same as or higher than their own. Nothing is changed.
}
|
{
  code: 'INVALID_REQUEST';
  message: "Cannot modify attributes of the current user."; // The 'user_id' is the caller's own. Refused for everyone, the project owner and access group 99 admins included, before anything is read or written. Use updateProfile() with no 'user_id' instead.
}
|
{
  code: 'EXISTS';
  message: 'E-mail "user@email.com" is already a login ID in this service.'; // The new 'email' is already a login ID another account was granted: that account's original login ID (its 'username', or the email it was created with when it has none), or an address it has verified.
}
|
{
  code: 'INVALID_REQUEST';
  message: "The login username is permanent and cannot be changed."; // A request that sets the login handle ('preferred_username') without 'email'. updateUserAttributes() never sends one on its own.
}
|
{
  code: 'NOT_EXISTS';
  message: "User not found"; // No account of this project has the 'user_id'.
}
```

An admin in access groups `90` ~ `98` can update only accounts whose access group is lower than their
own: an admin in access group `95` can update an account in access group `94`, but not one in `95` or
`99`. Any other target is refused with `No access to modify admin.` before anything is changed. A
[disabled](#doc-user-account-disable-recover-account) account counts with the access group it had before
it was disabled. The project owner and admins in access group `99` are not limited by this rule. See
[The rank rule](#doc-admin-permissions-the-rank-rule).

Nobody updates their own account with this method. A `user_id` equal to the caller's own is refused with
`Cannot modify attributes of the current user.`, for the project owner and access group `99` admins as
well, both in the SDK before the request leaves and on the server. Use
[`updateProfile()`](#doc-api-reference-user-updateprofile) with no `user_id`.

When `email` changes, the server derives the account's email login from the new address itself and
ignores any login handle the request sends. Before anything is written it checks that the address is not
already a login ID another account was granted, and refuses the whole request with the `EXISTS` error
above when it is, so none of the other attributes in that request are changed either. An email login
another account holds without having verified the address is removed instead, and the request goes
through. If a check cannot be made, the request fails without changing anything and can be sent again.
No admin method changes a login `username`.

The new `email` is written unverified and the account's **email login is removed** in the same request,
whoever sends it, the project owner and admins in access group `99` included: this method cannot mark an
e-mail address verified. The account keeps logging in with its `username`, or with the address it was
created with when it has none, and gets an email login back only after the user runs
[`verifyEmail()`](#doc-api-reference-user-verifyemail) on the new address, on the account's next
token a few seconds later.

Sending the address the account already has, already verified, is not treated as a change: it is left
out of the request, and the account keeps its verification and its email login.

[`updateProfile()`](#doc-api-reference-user-updateprofile) called with the `user_id` of another
user sends this same request, so the same rules and errors apply, and it resolves with the same
`'SUCCESS: User attributes updated.'`. It leaves out every value that equals the caller's own profile,
so use `updateUserAttributes()` to set another user's attribute to the value the caller has.

## deleteAccount <a id="doc-api-reference-admin-deleteaccount"></a>

```ts
deleteAccount(params: {
    user_id: string; // An admin in access groups 90 ~ 98 can delete only accounts in a lower access group than their own. Your own user_id deletes your own account.
}): Promise<'SUCCESS: Account has been deleted.'>
```

#### Errors
```ts
{
  code: 'INVALID_REQUEST';
  message: "No access to modify admin."; // The caller is an admin in access groups 90 ~ 98 and the account's access group is the same as or higher than their own. Nothing is deleted.
}
|
{
  code: 'INVALID_REQUEST';
  message: "Admin access only." | "No access."; // Deleting another account from an account that is not an admin, or an account of another project.
}
|
{
  code: 'NOT_EXISTS';
  message: "User does not exists.";
}
```

## blockAccount <a id="doc-api-reference-admin-blockaccount"></a>

```ts
blockAccount(params: {
    user_id: string; // An admin in access groups 90 ~ 98 can block only accounts in a lower access group than their own.
}): Promise<'SUCCESS: The user has been blocked.'>
```

#### Errors
```ts
{
  code: 'INVALID_REQUEST';
  message: "No access to modify admin."; // The caller is an admin in access groups 90 ~ 98 and the account's access group is the same as or higher than their own. Nothing is changed.
}
|
{
  code: 'INVALID_REQUEST';
  message: "User cannot block self.";
}
|
{
  code: 'NOT_EXISTS';
  message: "User does not exists.";
}
```

## unblockAccount <a id="doc-api-reference-admin-unblockaccount"></a>

```ts
unblockAccount(params: {
    user_id: string; // An admin in access groups 90 ~ 98 can unblock only accounts in a lower access group than their own.
}): Promise<'SUCCESS: The user has been unblocked.'>
```

#### Errors
```ts
{
  code: 'INVALID_REQUEST';
  message: "No access to modify admin."; // The caller is an admin in access groups 90 ~ 98 and the account's access group is the same as or higher than their own, so a lower admin cannot let back in an account a higher admin blocked.
}
|
{
  code: 'INVALID_REQUEST';
  message: "User cannot unblock self.";
}
|
{
  code: 'NOT_EXISTS';
  message: "User does not exists.";
}
```

For all three methods a [disabled](#doc-user-account-disable-recover-account) account counts with the
access group it had before it was disabled, and admins in access group `99` and the project owner are
not limited by the rank rule. See [Admin Permissions](#doc-admin-permissions-the-rank-rule).

# API Reference: Tickets <a id="doc-api-reference-tickets"></a>

Below are the parameters and return data type references for the methods in TypeScript format.

Tickets are registered from the dashboard's **Tickets** page by the project owner. The SDK has no public register method. See [Tickets](#doc-tickets-introduction).

You can import the types in a TypeScript project as below:

```ts
import type {
    TicketAction,
    TicketCondition,
    TicketConditionRow,
    TicketError,
    TicketReceipt,
} from "skapi-js";
```

## consumeTicket <a id="doc-api-reference-tickets-consumeticket"></a>

```ts
consumeTicket<T = TicketReceipt>(
    params: {
        ticket_id: string; // The ticket to consume.
        method: 'GET' | 'POST'; // 'POST' sends data as the JSON body. 'GET' sends it as the query string.
        auth?: boolean; // Consume on the signed-in endpoint, as the logged-in user. POST only. Needed by a ticket whose condition has user or record_access.
        data?: { [key: string]: any }; // The request data: the JSON body of a POST, read by body rows and ${body}, or the query string of a GET, read by params rows and ${params}.
    }
): Promise<T> // The receipt below, or the body a Respond action of the ticket answered.

type TicketReceipt = {
    ticket_id: string;  // The ticket consumed.
    consume_id: string; // Id of this consumption: base62 of the timestamp in milliseconds, followed by 4 random characters.
    user_id: string;    // The consumer. The user id on the signed-in endpoint, else "<ip>(<user agent>)".
    is_test: boolean;   // Always false here: dry runs go through the check URL, which consumeTicket() never calls.
    timestamp: number;  // When the consumption happened, in milliseconds. Decoded from consume_id.
    hash: string;       // Proof string of the consumption.
}
```

Sends the request to `https://<first 4 characters of the project id>.skapi.dev/`: `POST /tpa/` with `auth`, `POST /tp/` anonymous, and `GET /tg/` with `data` as the query string.

**Behavior:**
- The body is inspected whatever the HTTP status. A body with `stage` is an error, including a `200` from a ticket with `return200`, and the promise rejects with `new SkapiError(body.message, { code: body.code, cause: body })`, so `err.code`, `err.cause.stage`, `err.cause.action` and `err.cause.detail` are available. `err.cause` is a [TicketError](#doc-api-reference-tickets-ticketerror).
- A body without `stage` is a success. The promise resolves with the receipt above, or, when a [Respond](#doc-tickets-actions-resp-answer-the-caller) action of the ticket answered, with the `body` it composed, as sent and whatever its status (a Respond with no `body` answers the receipt). `T` names that body's type: `consumeTicket<{ ok: boolean }>(...)`.
- `auth: true` with `method: 'GET'` throws before anything is sent.
- `SkapiError.cause` is typed `Error | TicketError`. In TypeScript, narrow it with `'stage' in err.cause` before reading the ticket keys.

#### Errors
```ts
{
    code: "INVALID_PARAMETER";
    message: "Signed-in consumption is POST only.";
}
|
{
    code: TicketError['code'];
    message: TicketError['message'];
    cause: TicketError;
}
```

See [Errors and Logs](#doc-tickets-errors)

## getTickets <a id="doc-api-reference-tickets-gettickets"></a>

```ts
getTickets(
    params: {
        ticket_id?: string; // One ticket. Omit for every ticket. An id containing '#' or '@' is refused unless the caller is the project owner.
        from?: number; // A log listing only: rows from this time on, in milliseconds since the epoch.
        to?: number;   // A log listing only: rows up to this time, in milliseconds since the epoch.
    },
    fetchOptions?: FetchOptions
): Promise<DatabaseResponse<{
    ticket_id: string;
    description: string;
    count?: number;         // Remaining consumptions. Absent = unlimited.
    time_to_live?: number;  // Absolute expiry, in milliseconds since the epoch. Absent = never.
    timestamp: number;      // Registered at, in milliseconds.

    // The keys below are only returned to the project owner. A user of your project
    // always gets the five keys above and nothing else.
    limit_per_user?: number | boolean; // true = once per user, n = n times. Absent or 0 = unlimited.
    updated?: number;                  // Last registered at, in milliseconds.
    condition?: TicketCondition;
    actions?: TicketAction[];
    legacy?: boolean;
    rules_version?: number; // The rules the ticket was saved under: 2 = an HTTP request answers the whole response. Absent on a ticket saved before 2026-10-09, which keeps answering the body alone until it is saved again. Project owner only. See https://docs.skapi.com/deprecated/deprecated.html#a-request-s-answer-was-its-body                  // true: marked "previous rules" on the dashboard. It runs by the rules it was saved with until it is registered again.
}[]>>
```

The user must be logged in.

A ticket with `legacy: true` keeps running by the rules it was saved with until it is registered again, which applies the current rules. Its `condition` and `actions` are returned converted to the current format. See [Tickets saved before this release](#doc-deprecated-deprecated-tickets-saved-before-this-release).

The project owner may also pass `ticket_id: '#<ticket_id>#'` to read the consumption log of that ticket, and `ticket_id: '@<ticket_id>#<consume_id>#'` to read the [action rows](#doc-tickets-errors-the-action-rows) of one consumption. The rows then have the shape [getConsumedTickets()](#doc-api-reference-tickets-getconsumedtickets) returns, plus `failed: true` on a failed consumption; `from` and `to` narrow either listing to a time range, each end optional. Each row carries `attempts` and, on a retried action, `retried`; a Send e-mail or Send newsletter that went out but could not be counted or marked sent carries a `warning`.

#### Errors
```ts
{
    code: "INVALID_REQUEST";
    message: "Invalid ticket id."; // A ticket_id containing '#' or '@' from a caller who is not the project owner.
}
|
{
    code: "INVALID_PARAMETER";
    message: '"to" is before "from".';
}
```

See [FetchOptions](#doc-api-reference-data-types-fetchoptions)

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

## clearTicketLog <a id="doc-api-reference-tickets-clearticketlog"></a>

```ts
clearTicketLog(
    params: {
        ticket_id: string; // The ticket whose log rows go.
        before?: number;   // Rows before this time, in milliseconds since the epoch. Default: now. Later than now is clipped to now.
    }
): Promise<{
    message: string;   // "SUCCESS: Clearing the log."
    ticket_id: string;
    before: number;    // The time applied.
}>
```

Deletes the ticket's consumption rows and action rows before `before`, in the background over the next minutes. The ticket itself, its remaining count and its per-user limits are unchanged. Project owner only.

#### Errors
```ts
{
    code: "INVALID_PARAMETER";
    message: '"before" should be a time in milliseconds since the epoch.';
}
```

See [Clearing the log](#doc-tickets-errors-clearing-the-log)

## getConsumedTickets <a id="doc-api-reference-tickets-getconsumedtickets"></a>

```ts
getConsumedTickets(
    params: {
        ticket_id?: string; // Only consumptions of this ticket. Omit for every ticket.
    },
    fetchOptions?: FetchOptions
): Promise<DatabaseResponse<{
    ticket_id: string;
    consume_id: string;
    user_id: string;     // The caller's own user id.
    is_test: boolean;    // Always false here: dry runs go through the check URL, which is anonymous, so they are never attributed to a user.
    timestamp: number;   // When the consumption happened, in milliseconds. Decoded from consume_id.
    description: string; // The consumption log as a JSON string. JSON.parse() it; outcome.ok says whether the consumption succeeded.
}[]>>
```

Lists the caller's own consumptions, which means the consumptions made on the signed-in endpoint. Anonymous consumptions are attributed to an address, not to a user, and never appear here. The user must be logged in.

See [The Log Row](#doc-tickets-errors-the-log-row)

See [FetchOptions](#doc-api-reference-data-types-fetchoptions)

See [DatabaseResponse](#doc-api-reference-data-types-databaseresponse)

## TicketCondition <a id="doc-api-reference-tickets-ticketcondition"></a>

```ts
type TicketCondition = {
    return200?: boolean; // Answer 200 even when the consumption fails (webhook friendly). Never changes whether it fails.
    method?: 'GET' | 'POST'; // Absent = both allowed.
    signature?: { // An HMAC of the request, made with a secret shared with the sender. Verified first, over the raw body.
        secretName: string; // The NAME of a Secret Key of the project, never the secret itself. Must exist at registration. Only read to verify; never sent anywhere.
        header: string; // The request header carrying the signature. Case-insensitive. Up to 256 characters.
        algorithm?: 'sha256' | 'sha1' | 'sha512'; // Default 'sha256'.
        encoding?: 'hex' | 'base64'; // How the signature in the header is written. Default 'hex'.
        separator?: string; // Splits the header value into items, each trimmed. 1 to 8 characters. Absent: the whole value is one item.
        parts?: string[]; // Patterns matched against each item, each with at most one ${name} capture. At least one captures ${signature}. Up to 10, each up to 256 characters. Default ['${signature}'].
        signed?: string; // Template of the signed bytes. Tokens: ${body}, ${method}, ${header:Name} and the captures of parts. Up to 512 characters. Default '${body}'.
        timestamp?: string; // Template resolving to unix time in seconds, or milliseconds above 10^12. Same tokens. Absent: no time check.
        tolerance?: number; // Seconds the timestamp may be away from now, 1 to 86400. Only used with timestamp. Default 300.
        secret_encoding?: 'raw' | 'base64' | 'hex'; // How the stored secret becomes the HMAC key. Default 'raw'.
        secret_prefix?: string; // Removed from the start of the stored secret before decoding. Up to 64 characters.
    };
    ip?: { // Passes when the caller's IP matches any value ('!=': none of them). A value that is "", [], null or missing is no check.
        operator: '=' | '!=' | '>' | '>=' | '<' | '<=' | 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
        value: string | string[];
    };
    user_agent?: { // Passes when the user agent matches any value ('!=': none of them). A value that is "", [], null or missing is no check.
        operator: '=' | '!=' | '>' | '>=' | '<' | '<=' | 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
        value: string | string[];
    };
    headers?: { // Every header listed must be sent and match. Rows on the same header are alternatives. Every row needs a value: null is refused.
        key: string; // Header name, matched case-insensitively.
        operator: '=' | '!=' | '>' | '>=' | '<' | '<=' | 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
        value: string | string[];
    }[];
    body?: TicketConditionRow[];   // Rows against the POST body. Refused on a GET ticket: a GET request has no body.
    params?: TicketConditionRow[]; // Rows against the query string, on GET and POST.
    user?: { // Signed requests only: an app user calling consumeTicket() with auth: true. A webhook always fails these (AUTH_REQUIRED). Actions read the same attributes as ${user[key]}.
        key: string; // A consumer attribute, such as "email_verified" or "access_group".
        operator: '=' | '!=' | '>' | '>=' | '<' | '<=' | 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
        value: string | number | boolean | (string | number | boolean)[]; // Required: null is refused.
    }[];
    record_access?: string; // Signed requests only. A record id the consumer must own or have been granted. A webhook always fails it (AUTH_REQUIRED). Actions read it as ${record_access}.
}
```

The parts are evaluated in the order `method`, `signature`, `ip`, `user_agent`, `headers`, `body`, `params`, `user`, `record_access`. The first one that fails is the one reported, and an empty or absent part is not checked.

Rows of `headers`, `body`, `params` and `user` follow one rule: rows with the same `key` are alternatives, rows with different keys must all be satisfied, and a field the request does not carry is a mismatch (a row compared with `null` or `undefined` aside, see [TicketConditionRow](#doc-api-reference-tickets-ticketconditionrow)). A failure is `CONDITION_FAILED` with `detail: { field, keys }`, `keys` listing every key that failed.

On two strings `>=` means "starts with" and `<=` means "ends with"; on two numbers they compare as numbers. `=` and `!=` compare strictly (`1` is not `"1"`). Ordering operators never pass on mixed types, booleans, `null` or a missing field. A `value` list passes `=` and the ordering operators when any member matches, and `!=` when none does.

`user` and `record_access` only pass for signed requests: an app user of this project calling [consumeTicket](#doc-api-reference-tickets-consumeticket) with `auth: true`. A third-party webhook has no session, so a ticket using either always fails for webhooks.

A `signature` that does not verify, for any reason, fails with `CONDITION_FAILED` and `detail: { field: "signature" }`. Public-key signatures (RSA, ECDSA, Ed25519) are not supported. See [Signature](#doc-tickets-conditions-signature).

See [Conditions and Placeholders](#doc-tickets-conditions)

## TicketConditionRow <a id="doc-api-reference-tickets-ticketconditionrow"></a>

```ts
type TicketConditionRow = {
    key: string; // A path relative to its own list, written without ${ }: "id" is the body's id, "order[id]" its order.id; in a Check answer, a path in the enclosing action's answer ("body[status]" on a response). Never templated.
    operator?: '=' | '!=' | '>' | '>=' | '<' | '<=' | 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; // A match row. Without it: a capture-only row, which never passes or fails anything.
    value?: string | number | boolean | null | undefined | Array<string | number | boolean | null>; // A literal, never templated. A match row with no value compares with undefined.
    setValueWhenMatch?: any; // When this row is the first to match its key, replaces the value at key (in the request, or in the answer for a Check answer), so ${body[...]} or ${response[...]} reads the replacement. null replaces nothing.
    placeholder?: string; // Stores the value at key (after any replacement) under this name, when the row matches or is capture-only. Actions read it as ${placeholder[NAME]}. Must match ^[A-Za-z_][A-Za-z0-9_]*$
}
```

A row of `condition.body` (the POST body) or `condition.params` (the query string), and a `response` row of a Check answer (`cond`).

- Rows are read in order, and every one of them: there is no short-circuit, so every capture-only row fills its placeholder. For each key the first match row that matches wins: its `setValueWhenMatch` replaces the value, in the data itself where the field exists, and its `placeholder` captures the result. The later match rows on that key are skipped without being evaluated, so nothing is re-checked against the replaced value; a later row on another key whose path runs through the replaced field reads the replacement. A match row that does not match captures nothing. At the ticket's condition keys and values are literal: `${...}` inside a row is plain text. In a `cond` action the key is literal but `value` and `setValueWhenMatch` may hold `${response...}` and `${placeholder[NAME]}`.
- A capture-only row captures whenever its path exists, and leaves the placeholder unset otherwise.
- Query string values are JSON-parsed when they parse, otherwise they stay strings: `?qty=2` is the number `2`, so a `params` row matches it with `value: 2`, not `"2"`. Header values are always strings.
- `null` and `undefined` keep their JavaScript meaning, and a missing field is `undefined`: `= null` passes on a field that is there and `null`; `!= null` on anything else, a missing field included; `= undefined` on a missing field; `!= undefined` on a field that is there, `null` included. Ordering operators never pass with either. `undefined` is a single value, never a list member.

See [Body and Params](#doc-tickets-conditions-body-and-params)

## TicketAction <a id="doc-api-reference-tickets-ticketaction"></a>

```ts
type TicketAction =
    | {
        act: 'acsg'; // Set the access group of a user. Answers the SUCCESS text the grant returns.
        exe: {
            group: number | 'admin' | string; // 1..99. 'admin' is 99. Or a reference that gives one, such as "${placeholder[GROUP]}".
            user_id?: string; // The target. Absent = the consumer.
            actions?: TicketAction[]; // The Then chain, run after the action. Reads the answer as ${response}; a cond in it checks the answer.
        };
        err?: TicketAction[]; // Error chain, run when this action fails.
        retry?: boolean; // Try again on a failure: up to 3 more times, 1, 2 and 4 seconds apart, within the time budget. Never for a failure inside Then.
    }
    | {
        act: 'acsr'; // Grant private access to a record. Answers the SUCCESS text the grant returns.
        exe: {
            record_id: string; // A record id, not a unique id.
            user_id?: string | string[]; // The grantees. Absent = the consumer.
            actions?: TicketAction[];
        };
        err?: TicketAction[];
        retry?: boolean;
    }
    | {
        act: 'pstr'; // Post a record. Everything except user_id, condition and actions is the postRecord() payload. Any other key (notification, remove_bin, reference_private_key, progress) is refused at registration. Answers the record (RecordData).
        exe: {
            table: string | { // A plain name is { name } in the public group.
                name: string;
                access_group?: number | 'public' | 'authorized' | 'admin' | 'private'; // 0..99. Absent: public on a create, unchanged on an update. The dashboard always writes it. 'private' needs user_id.
                subscription?: { // Needs user_id. null clears every setting on an update.
                    is_subscription_record?: boolean;
                    upload_to_feed?: boolean;
                    notify_subscribers?: boolean;
                    feed_referencing_records?: boolean;
                    notify_referencing_records?: boolean;
                } | null;
            };
            data?: any;
            index?: { name: string; value: string | number | boolean };
            tags?: string[];
            unique_id?: string; // A retried webhook then updates the same record instead of duplicating it.
            record_id?: string; // Update instead of create.
            reference?: string | null; // The record ID or unique ID of the record this one references.
            readonly?: boolean; // Needs user_id.
            source?: {
                referencing_limit?: number; // Default: null (Infinite)
                prevent_multiple_referencing?: boolean; // If true, a single user can reference this record only once.
                can_remove_referencing_records?: boolean; // When true, owner of the record can remove any record that are referencing this record. Also when this record is deleted, all the record referencing this record will be deleted.
                only_granted_can_reference?: boolean; // When true, only the user who has granted private access to the record can reference this record.
                /** Index restrictions for referencing records. null removes all restrictions. */
                referencing_index_restrictions?: {
                    name: string; // Allowed index name
                    value?: string | number | boolean; // Allowed index value
                    range?: string | number | boolean; // Allowed index range
                    condition?: 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne' | '>' | '>=' | '<' | '<=' | '=' | '!='; // Allowed index value condition. Checked when a referencing record is posted: on a string value '>=' is a 'starts with' check, while '<=' is a plain 'lesser or equal' comparison and is not 'ends with'.
                }[] | null;
                allow_granted_to_grant_others?: boolean; // When true, the user who has granted private access to the record can grant access to other users.
            };
            user_id?: string; // Post as this user: a user ID (UUID) once its references are filled in, not the project owner's own. Anything else fails the action. Absent = the project owner, who cannot create a private, read-only or subscription record.
            actions?: TicketAction[]; // The Then chain. Reads the record as ${response}: ${response[record_id]}.
        };
        err?: TicketAction[];
        retry?: boolean;
    }
    | {
        act: 'req'; // HTTP request with a nested chain on its response. Answers the whole response: { status, headers, body }.
        exe: {
            url: string; // Starts with http:// or https:// and a hostname, and resolves to a public address. Values put in with ${...} are percent-encoded. A URL that is one whole reference is refused. Sent as a browser sends it: a tab or line break removed, a hostname outside ASCII IDNA-encoded, a space, another control character or a character outside ASCII in the path or query percent-encoded.
            method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // Default GET. Written out, never a reference.
            secretName?: string; // A Secret Key of the project. Must exist at registration. ${CLIENT_SECRET} in the values of headers, data and params is its value. With it, the url's scheme and host must be written out.
            headers?: { [name: string]: string }; // Values templated. A Host or X-Skapi-Ticket header is refused: the engine sets both. A name that is not ASCII or holds ':', a line break or NUL is refused, and so is a value whose text outside ${...} holds a line break, NUL or a character outside Latin-1. A value that breaks that rule once its references are filled in fails the call (REQUEST_FAILED, reason 'invalid_header').
            data?: any; // POST/PUT body. Sent as JSON when a content-type header says application/json, else form encoded.
            params?: { [key: string]: any }; // Added to the query string on every method. A value that is not text is sent as compact JSON.
            actions?: TicketAction[]; // The Then chain, any action, run on an answer below 300. Reads the response as ${response}: ${response[status]}, ${response[headers][content-type]} (header names lowercase) and ${response[body][key]}; ${body} is still the request the ticket received. A cond in it checks the response.
        };
        err?: TicketAction[];
        retry?: boolean;
    }
    | {
        act: 'mail'; // Send e-mail: a custom template of the project to one address. Answers the SUCCESS text of the send.
        exe: {
            template: string; // The template's ID, as the Custom tab of Automated Emails and the upload reply show it. Literal; it must be stored when the ticket is registered.
            to: string; // The recipient: one address, or a reference that gives one, such as "${user[email]}" or "${body[email]}".
            placeholders?: { [name: string]: string | number | boolean }; // Values for the placeholders the template carries, by name: { "order": "${body[order][id]}" } fills ${order}. Templated. "service_name" and "email" (the recipient) are filled by default and may be overwritten here. Up to 30.
            actions?: TicketAction[]; // The Then chain. Reads the SUCCESS text as ${response}.
        };
        err?: TicketAction[];
        retry?: boolean;
    }
    | {
        act: 'nlsd'; // Send newsletter: a stored newsletter of a group to every subscriber of the group. Answers the SUCCESS text of the send.
        exe: {
            group: 'public' | 'authorized' | number | string; // "public", "authorized", a number from 2 to 99, or a named group. Literal.
            newsletter: string; // The newsletter's message_id (getNewsletters()). Literal; it must be stored under the group when the ticket is registered.
            actions?: TicketAction[];
        };
        err?: TicketAction[];
        retry?: boolean;
    }
    | {
        act: 'resp'; // Respond: answer the caller now, then stop or go on. One per run.
        exe: {
            status?: number | string; // 100 to 599, or a reference. Default 200. An error status is an answer, not a failure.
            body?: any; // Any JSON, templated. Absent: the receipt { tkid, hash }. A "stage" key is refused.
            resume?: 'stop' | '0m' | string | number; // After the answer: "stop" (default), "0m" (go on at once, in the background), "10m" | "2h" | "3d" (after that delay), or a time in milliseconds since the epoch (a literal in the past is refused). A run that goes on is a queued run, metered per month.
        };
        err?: TicketAction[];
    }
    | {
        act: 'cond'; // Check answer: rows on the answer of the action whose Then chain this is. A row that does not pass fails the chain here with CONDITION_FAILED. Answers nothing. Inside a Then chain only.
        exe: {
            response: TicketConditionRow[]; // Rows on the enclosing action's answer, whatever its shape: key is a literal path in it ("record_id" on a posted record, "body[status]" or "headers[content-type]" on a response), "" the whole answer as text. value and setValueWhenMatch may hold ${response...} and ${placeholder[NAME]}, resolved before the comparison; any other reference is refused.
        };
        err?: TicketAction[];
    };

```

Every string value of `exe` is templated right before the action runs (for `req`: `url`, `headers`, `data` and `params`; for `resp`: `status`, `body` and `resume`; `method`, `secretName`, a Then chain's `actions` and every part of a `cond` are not). Only text inside `${ }` is a reference, and everything outside it is used as written: `"orders"` and `"order[id]"` are plain text. A string that is exactly one `${...}` keeps the value's type; inside longer text the value becomes text. `$${...}` writes a literal `${...}`.

| reference | reads |
|---|---|
| `${body}`, `${body[key]}` | the request body the ticket received, at every depth. `${body[order][id]}` is the body's `order.id` |
| `${params}`, `${params[key]}` | the query string, on GET and POST |
| `${headers[name]}` | a request header, case-insensitive. `authorization` and `cookie` read `<redacted>` |
| `${placeholder[NAME]}` | a value a condition row captured |
| `${user}`, `${user[key]}` | the signed-in consumer's attributes. Signed requests only |
| `${ip}`, `${user_agent}`, `${method}` | the caller's IP address, user agent and HTTP method |
| `${record_access}` | the record id the condition's `record_access` names. Signed requests only |
| `${response}`, `${response[key]}` | the answer of the enclosing action, in its Then chain (`actions`) only: the record a `pstr` posted, the response of a `req` (`status`, `headers`, `body`), the SUCCESS text of a grant or a send |
| `${error}`, `${error[key]}` | in an `err` chain: `code`, `message`, `detail`, `action`, `path` |
| `${ticket}`, `${ticket[key]}` | this consumption: `id`, `service`, `owner`, `consume_id`, `timestamp`, `hash` |
| `${CLIENT_SECRET}` | reserved: see below |

Registration refuses anything else inside `${ }` (`${id}`, `${ip[x]}`, `${placeholder}`, `${[ip]}`), and a reference that can never resolve where it is written (`${response}` outside a Then chain, `${error}` outside an `err` chain), with `INVALID_PARAMETER` and a message that lists the valid forms. A reference that does not resolve when the action runs fails the action before it does anything: `PATH_NOT_FOUND`, `PLACEHOLDER_MISSING`, or `AUTH_REQUIRED` for `${user}` on a request that is not signed in. See [Templating](#doc-tickets-conditions-templating).

`${CLIENT_SECRET}` is only allowed in the `headers`, `data` and `params` values of a `req` that names a `secretName`, and registration refuses it anywhere else. Each call is held to the Destinations of the Secret Key it carries, and every copy of the key's value in the response, as sent or escaped up to three times over with percent-encoding, backslash escapes and HTML character references (mixed character by character), is replaced by the text `${CLIENT_SECRET}` before it is checked, logged or returned in an error. A copy escaped four times over is not found, and one is not guaranteed to be found when a round of escaping left part of an earlier round's escape readable on its own, such as the `%BA` of `%&#68;0%BA`. The whole response is searched and passed on, never cut. When the 25 second budget runs out while a long one is searched, the action fails with `REQUEST_FAILED` and `detail: { reason: "timeout" }`. A key that no longer exists fails the action with `REQUEST_FAILED` and `detail: { reason: "secret_missing", secretName }`. See [Sending a Secret Key](#doc-tickets-actions-sending-a-secret-key).

See [Actions](#doc-tickets-actions)

## TicketError <a id="doc-api-reference-tickets-ticketerror"></a>

```ts
type TicketError = {
    code:
        'INVALID_SERVICE' |
        'SERVICE_DISABLED' |
        'TICKET_NOT_FOUND' |
        'TICKET_EXPIRED' |
        'TICKET_EXHAUSTED' |
        'USER_LIMIT_REACHED' |
        'ISSUER_CANNOT_CONSUME' |
        'AUTH_REQUIRED' |
        'METHOD_NOT_ALLOWED' |
        'CONDITION_FAILED' |
        'PATH_NOT_FOUND' |
        'PLACEHOLDER_MISSING' |
        'REQUEST_FAILED' |
        'TIMEOUT' |
        'ACTION_FAILED' |
        'ACTION_FORBIDDEN' |
        'QUOTA_EXCEEDED' |
        'ALREADY_RESPONDED' |
        'INTERNAL_ERROR';
    message: string; // Human readable, one sentence.
    stage: 'ticket' | 'condition' | 'action';
    action?: { // Only when stage is 'action'.
        act: 'acsg' | 'acsr' | 'pstr' | 'req' | 'resp' | 'cond';
        path: string; // The failed action's place in the ticket, such as "actions[1].err[0]".
    };
    detail?: { [key: string]: any }; // Code specific, such as { field, keys } on CONDITION_FAILED and { status, body } or { reason } on REQUEST_FAILED. See the error table.
    ticket_id: string;
}
```

The flat error body every consume endpoint answers with, and the `cause` of the `SkapiError` that [consumeTicket](#doc-api-reference-tickets-consumeticket) rejects with. A success body never has `stage`; an error body always does.

See [Error Codes](#doc-tickets-errors-error-codes)

# API Reference: Data Types <a id="doc-api-reference-data-types"></a>

Below are the data type references in TypeScript format.

You can import types in a TypeScript project as below:

```ts
import type {
    BinaryFile,
    Condition,
    Connection,
    ConnectionInfo,
    DatabaseResponse,
    DelRecordQuery,
    EncryptionOptions,
    FetchOptions,
    FileInfo,
    Form,
    GetRecordQuery,
    Index,
    Newsletter,
    NewsletterGroup,
    ProgressCallback,
    RTCConnector,
    RTCConnectorParams,
    RTCEvent,
    RTCReceiverParams,
    RTCResolved,
    RealtimeCallback,
    RecordData,
    RecordEncryptionInfo,
    RequestHistory,
    Subscription,
    Table,
    Tag,
    Ticket,
    TicketAction,
    TicketCondition,
    TicketConditionOperator,
    TicketConditionRow,
    TicketError,
    TicketErrorCode,
    TicketResponseCondition,
    TicketSignatureCondition,
    UniqueId,
    UserAttributes,
    UserProfile,
    UserPublic,
    WebSocketMessage,
} from "skapi-js";
```

## BinaryFile <a id="doc-api-reference-data-types-binaryfile"></a>

```ts
type BinaryFile = {
    access_group: number | 'private' | 'public' | 'authorized' | 'admin';
    filename: string;
    /** For an ENCRYPTED file this url serves CIPHERTEXT. Use getFile() below, which decrypts;
     *  an <img src> or a plain <a href> on it will not work. */
    url: string;
    path: string;
    /** The PLAINTEXT byte length. For an encrypted file the object on storage is larger:
     *  see stored_size. */
    size: number;
    uploaded: number;
    /** Present and true only on an encrypted attachment. Set on the plain data, not just
     *  on the getFile closure, so it survives being structured-cloned or JSON round-tripped. */
    encrypted?: boolean;
    /** Byte length of the encrypted object as stored. Present only when encrypted is true. */
    stored_size?: number;
    getFile: (dataType?: 'base64' | 'download' | 'endpoint' | 'blob' | 'text' | 'info', progress?: ProgressCallback) => Promise<Blob | string | void | FileInfo>;
}
```

## Condition <a id="doc-api-reference-data-types-condition"></a>

```ts
type Condition = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | '>' | '>=' | '<' | '<=' | '=';
```

On a `string`, `>=` and `<=` are searches: `>=` matches what **starts with** the given value, and `<=` matches what **ends with** it.
`>` and `<` remain lexicographic, and `number` / `boolean` values compare normally, so `<=` on a number is a plain 'lesser or equal' comparison.

That holds wherever a `Condition` is compared with text:

| Method | `>=` starts with, `<=` ends with |
| --- | --- |
| [`getRecords()`](#doc-api-reference-database-getrecords), [`deleteRecords()`](#doc-api-reference-database-deleterecords) | A `string` index value. With a compound index name ending in a period, the child name segment. See [Indexing](#doc-database-indexing-ends-with-string-values). |
| [`getTables()`](#doc-api-reference-database-gettables) | The table name. See [Table Information](#doc-database-table-info). |
| [`getTags()`](#doc-api-reference-database-gettags) | The tag, in `table`, or in every table when `table` is omitted. See [Tags](#doc-database-tags-querying-tags). |
| [`getUniqueId()`](#doc-api-reference-database-getuniqueid) | The unique ID. See [Unique ID](#doc-database-unique-id-fetching-unique-id-list). |
| [`getIndexes()`](#doc-api-reference-database-getindex) | The index name, when `order.by` is `index_name`. See [Searching index names](#doc-database-indexing-searching-index-names). Ordered by anything else, `order.value` is a number and compares normally. |

An 'ends with' search returns its matches in the order of the text read from its last character backwards, not in the order of the text itself.

`condition` is optional, and the SDK never fills in a value for it: an omitted `condition` is simply absent from the request and the backend decides what it means.
The default is not the same on every method:

| Method | `condition` omitted |
| --- | --- |
| [`getTables()`](#doc-api-reference-database-gettables) | Exact match on `table`. With `table` omitted as well, every table. |
| [`getTags()`](#doc-api-reference-database-gettags) | Exact match on `tag` when **both** `table` and `tag` are given. With only `table` given, `>=` (prefix), so every tag in the table. With neither, every tag in the project, ordered by record count, descending. |
| [`getIndexes()`](#doc-api-reference-database-getindex) | No top level `condition` exists. `order.condition` omitted is an exact match against `order.value`, and `order.condition` requires `order.value`. With `order.value` omitted as well, every index, in the order of `order.by`. |

Omit the key, do not blank it out. Passing `condition` explicitly as `undefined` or `null` is rejected with `INVALID_PARAMETER`, while leaving the key out works.
When you assemble a query object programmatically, `delete` the key rather than setting it to `undefined`.

A `condition` also needs something to compare against, and it is the value key that supplies it.
Sending a `condition` with that key left out is an error, not a listing: [`getTables()`](#doc-api-reference-database-gettables) with no `table` is rejected with `"table" is required for condition.`, [`getTags()`](#doc-api-reference-database-gettags) with neither `table` nor `tag` with `"table" or "tag" is required for condition.`, and [`getIndexes()`](#doc-api-reference-database-getindex) rejects `order.condition` without `order.value`.
To list everything instead, leave the `condition` out as well.

While you are exploring and do not know the exact spelling, pass `gte`: it is a prefix search, so it also surfaces related entries.
An entity whose name is recorded two ways, such as `Asian Spice House` and `Asian Spice House (alias)`, is only found by the prefix.
When you already know the exact name, omitting `condition` is the exact match you want.

## Connection <a id="doc-api-reference-data-types-connection"></a>

```ts
type Connection = {
    /** User's locale */
    locale: string;
    user_agent: string;
    /** Connected user's IP address */
    ip: string;
    /** Project group */
    group: number;
    /** Project name */
    service_name: string;
    /** Project description */
    service_description: string;
    /** Project options */
    opt: {
        freeze_database: boolean;
        prevent_inquiry: boolean;
        prevent_signup: boolean;
        prevent_anonymous: boolean;
    }
    /* AI agent info */
    ai_agent?: string;
}
```

## ConnectionInfo <a id="doc-api-reference-data-types-connectioninfo"></a>

```ts
type ConnectionInfo = {
    project_id: string; // Public project ID of the connected project (single token composed from the project and its owner). Also available directly as skapi.project_id.
    user_ip: string;
    user_agent: string;
    user_location: string;
    service_name: string;
    service_description: string;
    version: string;
    ai_agent: string;
    conf: {
        freeze_database: boolean;
        prevent_signup: boolean;
        prevent_inquiry: boolean;
        prevent_anonymous: boolean;
        // When true, the SDK refuses database READS from a signed-out visitor
        // (getRecords, getTables, getTags, getIndexes, getUniqueId) with
        // code 'REQUIRE_LOGIN'. Defaults to true.
        require_login?: boolean;
    }
};
```

## DatabaseResponse <a id="doc-api-reference-data-types-databaseresponse"></a>

```ts
type DatabaseResponse<T> = {
    list: T[];
    startKey: { [key: string]: any; } | 'end';
    endOfList: boolean;
    startKeyHistory: string[];
}
```

## DelRecordQuery <a id="doc-api-reference-data-types-delrecordquery"></a>

```ts
type DelRecordQuery = GetRecordQuery & {
    unique_id?: string;
    record_id?: string;
};
```

## FetchOptions <a id="doc-api-reference-data-types-fetchoptions"></a>

```ts
type FetchOptions = {
    limit?: number;
    fetchMore?: boolean;
    ascending?: boolean;
    startKey?: { [key: string]: any; };
    progress?: ProgressCallback;
}
```

## FileInfo <a id="doc-api-reference-data-types-fileinfo"></a>

```ts
type FileInfo = {
    url: string;
    filename: string;
    access_group: number | 'private' | 'public' | 'authorized';
    filesize: number;
    record_id: string;
    uploader: string;
    uploaded: number;
    fileKey: string;
}
```

## Form <a id="doc-api-reference-data-types-form"></a>

```ts
type Form<T> = HTMLFormElement | FormData | SubmitEvent | T;
```

## ForwardRequestStreamOptions <a id="doc-api-reference-data-types-forwardrequeststreamoptions"></a>

```ts
type ForwardRequestStreamOptions = {
    /** The URL the request was sent to. Required unless the request ID is an already-composed full ID. */
    url?: string;
    /** The method it was sent with. Required unless the request ID is an already-composed full ID. */
    method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
    /** Called with each relayed piece, in order, with the sequence number it was stored under
     *  and which transport carried it ('socket' when skapi's websocket got there first,
     *  'poll' when the poll did). Raw text: skapi relays bytes and parses none of them. */
    onStream?: (chunk: string, seq: number, via?: 'socket' | 'poll') => void;
    /** The `realtime_group` the dispatching forwardRequest() handed back. With it, this
     *  read ALSO listens on skapi's websocket while the request is still running, so text
     *  arrives as it is relayed instead of on each poll tick. The group cannot be rebuilt
     *  from a request ID, so keep it beside the ID. Without it the read works exactly as it
     *  always has, at poll speed. */
    realtimeGroup?: string;
    /** Start after this sequence number instead of from the beginning, so a reader that already
     *  holds part of a turn does not receive it twice. Default 0. */
    since?: number;
    /** Polling interval in milliseconds while the request is still running. Default 1000.
     *  Must be a finite, non-negative number. */
    poll?: number;
    /** Called once with whatever the read resolves with. Not called when the read is stopped.
     *  `meta` carries facts about the REQUEST rather than the response: a settled poll resolves
     *  with the destination's own answer, and that answer is the destination's, not a place to
     *  attach skapi's fields. `meta.executed` is when the worker BEGAN running the request, in
     *  milliseconds (the same value forwardRequestHistory() reports as `executed`), so
     *  `updated - executed` times the call while `updated - created` also counts the queue wait.
     *  Present only when this request was QUEUED and therefore polled, and absent even then for a
     *  request that began and ended between two ticks, so treat it as optional and show nothing
     *  rather than substituting `created`. A one-argument callback is unaffected. */
    onResponse?: (res: any, meta?: { executed?: number }) => void;
    /** Called if the read itself fails. */
    onError?: (err: any) => void;
    service?: string;
    owner?: string;
}
```

The second argument of [`forwardRequestStream()`](#doc-api-reference-api-bridge-forwardrequeststream).
It is written inline in that method's signature, so it is not importable from `skapi-js` under this name.

See [Streaming Request](#doc-api-bridge-streaming-request)

## GetRecordQuery <a id="doc-api-reference-data-types-getrecordquery"></a>

```ts
type GetRecordQuery = {
    unique_id?: string; // When unique_id is given, it will fetch the record with the given unique_id.
    record_id?: string; // When record_id is given, it will fetch the record with the given record_id. This overrides all other parameters.

    /** Table name not required when "record_id" is given. A bare string is shorthand for { name: <string> }. */
    table?: string | {
        /** Max 256 characters, where / ! * # % each count as 3. Blocks control chars and sentinel 􏿿. */
        name: string;
        /** Number range: 0 ~ 99. 'public' = 0, 'authorized' = 1, 'admin' = 99. '*' is shorthand for 'private'. Default: 'public' */
        access_group?: number | 'private' | '*' | 'public' | 'authorized' | 'admin';
        /** User ID of subscription */
        subscription?: string;
    };

    reference?: string | { record_id?: string; unique_id?: string; user_id?: string }; // Referenced record ID or unique ID. If user_id is given (object form), it will fetch records that are uploaded by the user.

    /** Index condition and range cannot be used simultaneously.*/
    index?: {
        /** Custom names: max 256 characters, where / ! * # % each count as 3. Cannot start with "$". Blocks control chars and sentinel 􏿿. Reserved names: $uploaded, $updated, $referenced_count, $user_id. */
        name: string | '$updated' | '$uploaded' | '$referenced_count' | '$user_id';
        /** String value max 256 characters. Any punctuation is allowed and counts as one character, and values compare exactly as written. Blocks control chars and sentinel 􏿿. */
        value: string | number | boolean;
        /** For a string value: '>=' = 'starts with', '<=' = 'ends with'. When the name is a compound name ending in '.', '>=' / '<=' match the child name segment (starts / ends with). '>' / '<' are lexicographic; numbers/booleans compare normally. */
        condition?: Condition;
        range?: string | number | boolean;
    };
    tag?: string;
}
```

## Index <a id="doc-api-reference-data-types-index"></a>

```ts
type Index = {
    table: string;
    index: string;
    number_of_records: number;
    string_count: number;
    number_count: number;
    boolean_count: number;
    total_number: number;
    total_bool: number;
    average_number: number;
    average_bool: number;
}
```

## Newsletter <a id="doc-api-reference-data-types-newsletter"></a>

```ts
type Newsletter = {
    /** Newsletter id */
    message_id: string;
    /** Time sent out */
    timestamp: number;
    /** Number of complaints */
    complaint: number;
    /** Number of read */
    read: number;
    /** Subject */
    subject: string;
    /**
     * Number of bounced.
     * When e-mail address is bounced, skapi no longer sends e-mail to the bounced address.
     */
    bounced: string;
    /**
     * Url of the message html.
     */
    url: string;
    /** Number users delivered */
    delivered: number;
    /** true on a newsletter saved without sending (the list's "nd" address); absent once it went out. */
    dry?: boolean;
    /** When a stored newsletter was last sent from the Newsletters page or by a ticket's nlsd action, in milliseconds. */
    sent?: number;
    /**
     * Newsletter group the message was sent to.
     * A number for the 0 ~ 99 groups, the group name for a named newsletter group.
     */
    group: number | string;
}
```

## ProgressCallback <a id="doc-api-reference-data-types-progresscallback"></a>

```ts
type ProgressCallback = (e: {
    status: 'upload' | 'download';
    progress: number; // 0 ~ 100, number of percent completed.
    loaded: number; // Number of bytes loaded.
    total: number; // Total number of bytes to be loaded.
    currentFile?: File, // Only for uploadFiles()
    completed?: File[]; // Only for uploadFiles()
    failed?: File[]; // Only for uploadFiles()
    abort: () => void;
}) => void;
```

## PostRecordsItem <a id="doc-api-reference-data-types-postrecordsitem"></a>

One item of [`postRecords()`](#doc-api-reference-database-postrecords): the two arguments of `postRecord()` as one object.

```ts
type PostRecordsItem = {
    data?: { [key: string]: any } | null; // The record's data. Leave it out to keep a record's stored data as it is on an update.
    config: PostRecordConfig; // The record's configuration, as postRecord() takes it.
}
```

## PostRecordsResult <a id="doc-api-reference-data-types-postrecordsresult"></a>

What [`postRecords()`](#doc-api-reference-database-postrecords) resolves with. `list.length + failed.length` is the number of items given.

```ts
type PostRecordsResult = {
    list: RecordData[]; // The records that were saved, in the order their items were given.
    failed: {
        index: number; // The item's position in the `items` given.
        code: string; // The code postRecord() would have rejected the item with.
        message: string;
    }[];
}
```

## UploadFilesResult <a id="doc-api-reference-data-types-uploadfilesresult"></a>

What [`uploadFiles()`](#doc-api-reference-database-uploadfiles) resolves with.

```ts
type UploadFilesResult = {
    completed: File[]; // Files that are stored.
    failed: File[]; // Files storage did not accept.
    errors: FileUploadError[]; // Why each file in `failed` was not stored, one entry per file in the same order. Empty when every file was stored.
    bin_endpoints: string[]; // The endpoint of every file, in the order they were given, whether or not it was stored.
}
```

## FileUploadError <a id="doc-api-reference-data-types-fileuploaderror"></a>

One file of an [`uploadFiles()`](#doc-api-reference-database-uploadfiles) call that storage did not accept.

```ts
type FileUploadError = {
    file: File; // The File as it was given: the same object that is listed in `failed`.
    name: string; // The file's name.
    error: SkapiError; // Why it was not stored.
}
```

`error.code` is the storage code (`AccessDenied`, `EntityTooLarge` ...), or `NETWORK_ERROR`, `TIMEOUT` (the upload took longer than the `timeout` given), `ABORTED` (the progress callback's `abort()` was called), `TOO_MANY_REQUESTS`, or `UPLOAD_FAILED` when storage gave no code. `error.cause` holds the raw response or rejection, when there was one.

## RealtimeCallback <a id="doc-api-reference-data-types-realtimecallback"></a>

```ts
type RealtimeCallback = (rt: WebSocketMessage) => void;
```

## RecordData <a id="doc-api-reference-data-types-recorddata"></a>

```ts
type RecordData = {
    record_id: string;
    unique_id?: string;
    user_id: string;
    updated: number;
    uploaded: number;
    referenced_count: number;

    table: {
        name: string;
        /** Number range: 0 ~ 99 */
        access_group: number | 'private' | 'public' | 'authorized' | 'admin';
        /** Subscription settings of the record */
        subscription?: {
            is_subscription_record: boolean; // When true, this record is a subscription record.
            upload_to_feed: boolean; // When true, record will be uploaded to the feed of the subscribers.
            notify_subscribers: boolean; // When true, creating the record sent a push notification to the uploader's subscribers who subscribed with get_notified.
            feed_referencing_records: boolean; // When true, records referencing this record are added to the feed of this record's uploader.
            notify_referencing_records: boolean; // When true, every new record referencing this one sends a push notification to the uploader's subscribers who subscribed with get_notified.
        };
    };
    source: {
        referencing_limit: number; // Default: null (Infinite)
        prevent_multiple_referencing: boolean; // If true, a single user can reference this record only once.
        can_remove_referencing_records: boolean; // When true, owner of the record can remove any record that are referencing this record. Also when this record is deleted, all the record referencing this record will be deleted.
        only_granted_can_reference: boolean; // When true, only the user who has granted private access to the record can reference this record.
        referencing_index_restrictions?: {
            name: string; // Allowed index name
            value?: string | number | boolean; // Allowed index value
            range?: string | number | boolean; // Allowed index range
            condition?: 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne' | '>' | '>=' | '<' | '<=' | '=' | '!='; // Allowed index value condition. Checked when a referencing record is posted: on a string value '>=' is a 'starts with' check, while '<=' is a plain 'lesser or equal' comparison and is not 'ends with'.
        }[];
    };
    reference?: string; // record id of the referenced record.
    index?: {
        name: string;
        value: string | number | boolean;
    };
    /** null (or the withheld placeholder) when this session could not decrypt it: see `encrypted`.
     *  { __is_private__: null } when it is another user's private record that the project owner or an
     *  admin in access group 99 has no access to: the database withheld the payload itself, so no
     *  `encrypted` field comes with it and isWithheld() does not report it.
     *  See /database/access-restrictions.md#private-records */
    data?: Record<string, any>;
    /** Present ONLY when the record's data passed through the client-side encryption layer.
     *  Its ABSENCE means the record was stored in the clear, which is how existing plaintext
     *  records keep working after the feature is enabled.
     *
     *  status 'encrypted' means `data` above is the decrypted value.
     *  status 'failed' means `data` is null (or the sentinel) and `reason` says why:
     *    NOT_A_RECIPIENT     this user has no key wrap on the record (a user who was granted
     *                        private access to it, but no key, lands here; unlocking cannot help)
     *    NO_SESSION_KEY      this user IS a recipient but encryption is locked; unlock and retry
     *    BAD_KEY             the key wrap did not open (usually a rolled key)
     *    BINDING_MISMATCH    the stored envelope does not belong to this record
     *    CORRUPT             the payload failed its authentication tag
     *    DATA_UNAVAILABLE    the payload is offloaded to storage and could not be fetched
     *    UNSUPPORTED_VERSION written by a newer SDK than this one
     *    ENCRYPTION_DISABLED the record is encrypted but this instance has the flag off */
    encrypted?: {
        status: 'encrypted' | 'failed';
        reason?: string;
        /** user_ids that hold a key wrap on this record. Metadata, not content. */
        recipients?: string[];
    };
    tags?: string[];
    bin: { [key: string]: BinaryFile[] };
    ip: string;
    readonly: boolean;
    /** Present ONLY on an element of a bulkPostRecords() result that the API refused.
     *  Such an element is an empty record (record_id is ""), and this carries the reason,
     *  e.g. { code: 'NOT_EXISTS', message: 'Record of "reference.unique_id": "src::report.xlsx" does not exists.' }.
     *  A saved record never has it, so record_id stays the test for "did this save". */
    error?: { code?: string; message?: string; [key: string]: any };
}
```
## RequestHistory <a id="doc-api-reference-data-types-requesthistory"></a>

```ts
type RequestHistory = {
    id: string; // request id. Format: {stamp}:{entropy}
    status_code: number; // http status code of the request
    response_body: any; // null on a STREAMED request until it is finalized: its text lives in the relayed chunks, not on the request. forwardRequestFinalize() is what stores a body here.
    error?: any;
    created: number; // timestamp of when the request was created, in milliseconds. Set once and never changes.
    updated: number; // timestamp of the last update of the request status (e.g. when the response arrived), in milliseconds.
    executed?: number; // timestamp of when the worker actually BEGAN executing the request, in milliseconds. Distinct from created, which is when it was enqueued: a request can wait in the queue first, so updated - executed is the execution time while updated - created also includes the wait. Absent on a request that has not started yet, and on rows written before the worker recorded it.
    responded?: number; // timestamp of when the destination's response finished arriving, in milliseconds. Recorded once and never moved, unlike updated: finalizing a streamed request stores its response later and moves updated to that moment, so on a streamed request this is the only field that still says when the response came back. responded - executed is how long the call took. Absent on a request that has not settled, on one that failed before any response arrived, and on rows written before the worker recorded it.
    request_body: any;
    expires?: number; // timestamp of when the request history will be deleted in epoch time (seconds).
    status: 'pending' | 'running' | 'resolved' | 'failed';
    queue_name?: string; // queue name if the request is in queue, empty string if the request is not in queue.
    // Compact-listing stubs. Present ONLY when the history was fetched with compact: true,
    // in which case request_body/response_body are omitted (the full bodies never leave the
    // server). Re-fetch without compact, or poll the item, when a full body is needed.
    request_text?: string; // stub: first text of the request's LAST user message, truncated. Missing when the request body's shape was unrecognisable.
    response_text?: string; // stub: the head of the response text, truncated.
    response_complete_marker?: boolean; // stub: whether the response carried the indexing completion marker.
    compact?: boolean; // true on items returned by a compact: true listing, so consumers can tell "bodies omitted" from "bodies empty".
    poll?: (arg?: {
        latency?: number;
        onResponse?: (res:any, meta?: { executed?: number })=>void; // called when the request settles. `meta` carries request-level facts, which cannot ride on `res`: a settled poll resolves with the DESTINATION's own answer. `meta.executed` is when the worker BEGAN running this request, in milliseconds, matching the `executed` on a RequestHistory item -- `updated - executed` is the call, `updated - created` is the call plus the queue wait. A poll built from a forwardRequestHistory() item starts out already knowing it, taken from that listing, so it is delivered even when the request settles on the poll's first read; otherwise it is learned from a running tick, and a request that began and ended between two ticks has none. Optional either way: show nothing rather than falling back to `created`.
        onError?: (err:any)=>void;
        onStream?: (chunk: string, seq: number, via?: 'socket' | 'poll')=>void; // reads a STREAMED item's text as it arrives, same as on the dispatch path. "via" names the transport that carried the piece: 'socket' when skapi's websocket got there first, 'poll' when the poll did. Supplying it is what makes the poll fetch chunks. An item that already settled has nothing left to poll: read that one back with forwardRequestStream().
    }) => Promise<any>; // function to poll the request status until it settles. The promise resolves with the final result of the request: the third-party API response body when it resolves, or the error payload when it fails. It does not resolve with a RequestHistory item, so "created" and "updated" are not on the polled value. A poll stopped by stopForwardRequestPolling() resolves with { id, status: 'stopped' }. Optional argument "latency" can be used to set the latency of the polling in milliseconds. Default latency is 1000ms. A STREAMED request has no body to resolve with until it is finalized, so it resolves with a StreamPollResult instead: the text arrived through onStream.
}
```

## RTCConnector <a id="doc-api-reference-data-types-rtcconnector"></a>

```ts
type RTCConnector = {
    hangup: () => void;
    connection: Promise<RTCResolved>;
}
```

## RTCConnectorParams <a id="doc-api-reference-data-types-rtcconnectorparams"></a>

```ts
type RTCConnectorParams = {
    cid: string;
    ice?: string;
    media?: {
        video: boolean;
        audio: boolean;
    } | MediaStream | MediaStreamConstraints;
    channels?: Array<RTCDataChannelInit | 'text-chat' | 'file-transfer' | 'video-chat' | 'voice-chat' | 'gaming'>;
}
```

## RTCEvent <a id="doc-api-reference-data-types-rtcevent"></a>

```ts
type RTCEvent = {
    type: 'track' | 'connectionstatechange' | 'close' | 'message' | 'open' | 'bufferedamountlow' | 'error' | 'icecandidate' | 'icecandidateend' | 'icegatheringstatechange' | 'negotiationneeded' | 'signalingstatechange';
    [key: string]: any;
}
```

## RTCReceiverParams <a id="doc-api-reference-data-types-rtcreceiverparams"></a>

```ts
type RTCReceiverParams = {
    ice?: string;
    media?: {
        video: boolean;
        audio: boolean;
    } | MediaStream | MediaStreamConstraints;
}
```

## RTCResolved <a id="doc-api-reference-data-types-rtcresolved"></a>

```ts
type RTCResolved = {
    target: RTCPeerConnection;
    channels: {
        [protocol: string]: RTCDataChannel
    };
    hangup: () => void;
    media: MediaStream;
}
```

## StreamChunk <a id="doc-api-reference-data-types-streamchunk"></a>

```ts
type StreamChunk = {
    /** Sequence number this piece was stored under. Ascending within one request, and the
     *  value a reader sends back as its cursor. */
    seq: number;
    /** Raw relayed text, exactly as the destination wrote it and in whatever format the
     *  destination chose. Skapi parses none of it. Empty string when the chunk was written
     *  without text, which still advances seq. */
    txt: string;
}
```

One piece of a streamed request's relayed response, as a poll hands it back. `onStream(chunk, seq, via)`
receives the `txt` and `seq` of each of these, in order, and never fires for an empty `txt`.

This shape is written inline in the SDK, so it is not importable from `skapi-js` under this name.

See [Streaming Request](#doc-api-bridge-streaming-request)

## StreamPollResult <a id="doc-api-reference-data-types-streampollresult"></a>

```ts
type StreamPollResult = {
    id: string;         // Request ID in "stamp:entropy" format.
    status: 'pending' | 'running' | 'resolved' | 'failed' | 'cancelled'; // Everything but 'pending' and 'running' is terminal: nothing further is written to the request or to its chunks.
    queue_name: string; // The plain queue name, or an empty string when the request is not queued.
    in_queue: number;   // Unresolved requests in this queue.
    // The fields below are present ONLY when the poll asked for chunks by sending a cursor:
    // forwardRequest()'s poll() does that when an onStream callback was supplied, and
    // forwardRequestStream() always does. Without a cursor you get exactly the four fields
    // above, the same response polling returned before streaming existed.
    stream: boolean;      // Whether the request was made with stream: true. false means there are no chunks to read, ever.
    chunks: StreamChunk[]; // The pieces with seq greater than the cursor that was sent, oldest first.
    last_seq: number;      // The sequence number to send as the next cursor. Stays at the requested value when nothing new arrived.
    more: boolean;         // This read was CAPPED, not the end of the data. See below.
    error?: any;           // Present only on a failed streamed request: what the server recorded about the failure, alongside the chunks that did arrive before the stream died.
}
```

What a poll of a **streamed** request resolves with while it has not been finalized. A buffered
request, and a streamed one that was finalized, resolve with the stored body itself instead: a
response with no `status` field is a body, not this envelope. Like `StreamChunk`, this is a response
shape rather than an exported type name, so there is nothing to import under it.

`more: true` means one read was capped by a size budget, not that the request is unfinished. Ask
again immediately with `last_seq` rather than waiting out the polling interval. The one exception is
`more: true` with no new chunks and an unchanged `last_seq`: that is the server saying the chunk read
itself failed, and re-asking immediately hammers a store that is already in trouble. The SDK's own
readers tell the two apart by whether the cursor moved, and back off on the second.

See [forwardRequest](#doc-api-reference-api-bridge-forwardrequest) and
[forwardRequestStream](#doc-api-reference-api-bridge-forwardrequeststream).

See [Streaming Request](#doc-api-bridge-streaming-request)

## Subscription <a id="doc-api-reference-data-types-subscription"></a>

```ts
type Subscription = {
    subscriber: string; // User ID of the subscriber.
    subscription: string; // User ID of the user subscribed to.
    timestamp: number;
    blocked: boolean;
    get_feed: boolean; // Records the user posts with upload_to_feed appear in the subscriber's getFeed().
    get_notified: boolean; // The subscriber gets push notifications for records the user posts with notify_subscribers, and for new references to the user's records with notify_referencing_records.
    get_email: boolean;
}
```

## Table <a id="doc-api-reference-data-types-table"></a>

```ts
type Table = {
    table: string;
    number_of_records: string;
    size: number;
    number_of_records_in_access_group_public?: number;
    number_of_records_in_access_group_private?: number;
    number_of_records_in_access_group_authorized?: number;
    number_of_records_in_access_group_admin?: number;
    [number_of_records_in_access_group_xx: string]: number | string | undefined; // for other access groups
}
```

## Tag <a id="doc-api-reference-data-types-tag"></a>

```ts
type Tag = {
    table: string;
    tag: string;
    number_of_records: number;
}
```

## UniqueId <a id="doc-api-reference-data-types-uniqueid"></a>

```ts
type UniqueId = {
    unique_id: string;
    record_id: string;
}
```

## UserAttributes <a id="doc-api-reference-data-types-userattributes"></a>

```ts
type UserAttributes = {
    /** User's name */
    name?: string;
    /**
     * User's E-Mail for signin.
     * 64 character max.
     * When E-Mail is changed, E-Mail verified state will be changed to false.
     * E-Mail is only visible to others when set to public.
     * E-Mail should be verified to set to public.
     * */
    email?: string;
    /**
     * User's phone number. Format: "+0012341234"
     * When phone number is changed, phone number verified state will be changed to false.
     * Phone number is only visible to others when set to public.
     * Phone number should be verified to set to public.
     */
    phone_number?: string;
    /** User's address, only visible to others when set to public. */
    address?: string | {
        /**
         * Full mailing address, formatted for display or use on a mailing label. This field MAY contain multiple lines, separated by newlines. Newlines can be represented either as a carriage return/line feed pair ("\r\n") or as a single line feed character ("\n").
         * street_address
         * Full street address component, which MAY include house number, street name, Post Office Box, and multi-line extended street address information. This field MAY contain multiple lines, separated by newlines. Newlines can be represented either as a carriage return/line feed pair ("\r\n") or as a single line feed character ("\n").
        */
        formatted: string;
        // City or locality component.
        locality: string;
        // State, province, prefecture, or region component.
        region: string;
        // Zip code or postal code component.
        postal_code: string;
        // Country name component.
        country: string;
    };
    /**
     * User's gender. Can be "female" and "male".
     * Other values may be used when neither of the defined values are applicable.
     * Only visible to others when set to public.
     */
    gender?: string;
    /** User's birthdate. String format: "1969-07-16", only visible to others when set to public.*/
    birthdate?: string;

    /** Additional string value that can be used freely. This is only accessible to the owner of the account and the admins. */
    misc?: string;
    picture?: string;
    profile?: string;
    website?: string;
    nickname?: string;
    /** User's E-Mail is public when true. E-Mail should be verified. */
    email_public?: boolean;
    /** User's phone number is public when true. Phone number should be verified. */
    phone_number_public?: boolean;
    /** User's address is public when true. */
    address_public?: boolean;
    /** User's gender is public when true. */
    gender_public?: boolean;
    /** User's birthdate is public when true. */
    birthdate_public?: boolean;
}
```

## UserProfile <a id="doc-api-reference-data-types-userprofile"></a>

```ts
type UserProfile = {
    /** Project id of the user account. */
    service: string;
    /** User ID of the project owner. */
    owner: string;
    /** Access level of the user's account. */
    access_group: number;
    /** User's ID. */
    user_id: string;
    /** Country code of where user first signed up from. */
    locale: string;
    /**
    Account approval info and timestamp.
    Comes with string with the following format: "{approver}:{approved | suspended}:{approved_timestamp}"

    {approver} is who approved the account:
        [by_master] is when account approval is done manually from skapi admin panel,
        [by_admin] is when approval is done by the admin account with api call within your project.
        [by_skapi] is when account approval is automatically done.
        Open ID logger ID will be the value if the user is logged with openIdLogin()
        This timestamp is generated when the user confirms their signup, or recovers their disabled account.

    {approved | suspended}
        [approved] is when the account is approved.
        [suspended] is when the account is blocked by the admin or the master.

    {approved_timestamp} is the timestamp when the account is approved or suspended.

     */
    approved: string;
    /** Last login timestamp(Seconds). */
    log: number;
    /** Shows true when user has verified their E-Mail. */
    email_verified?: boolean;
    /** Shows true when user has verified their phone number. */
    phone_number_verified?: boolean;
    /** User's E-Mail is public when true. E-Mail should be verified. */
    email_public?: boolean;
    /** User's phone number is public when true. Phone number should be verified. */
    phone_number_public?: boolean;
    /** User's address is public when true. */
    address_public?: boolean;
    /** User's gender is public when true. */
    gender_public?: boolean;
    /** User's birthdate is public when true. */
    birthdate_public?: boolean;

    /** User's name */
    name?: string;
    /**
     * User's E-Mail for signin.
     * 64 character max.
     * When E-Mail is changed, E-Mail verified state will be changed to false.
     * E-Mail is only visible to others when set to public.
     * E-Mail should be verified to set to public.
     * */
    email?: string;
    /**
     * User's phone number. Format: "+0012341234"
     * When phone number is changed, phone number verified state will be changed to false.
     * Phone number is only visible to others when set to public.
     * Phone number should be verified to set to public.
     */
    phone_number?: string;
    /** User's address, only visible to others when set to public. */
    address?: string | {
        /**
         * Full mailing address, formatted for display or use on a mailing label. This field MAY contain multiple lines, separated by newlines. Newlines can be represented either as a carriage return/line feed pair ("\r\n") or as a single line feed character ("\n").
         * street_address
         * Full street address component, which MAY include house number, street name, Post Office Box, and multi-line extended street address information. This field MAY contain multiple lines, separated by newlines. Newlines can be represented either as a carriage return/line feed pair ("\r\n") or as a single line feed character ("\n").
        */
        formatted: string;
        // City or locality component.
        locality: string;
        // State, province, prefecture, or region component.
        region: string;
        // Zip code or postal code component.
        postal_code: string;
        // Country name component.
        country: string;
    };
    /**
     * User's gender. Can be "female" and "male".
     * Other values may be used when neither of the defined values are applicable.
     * Only visible to others when set to public.
     */
    gender?: string;
    /** User's birthdate. String format: "1969-07-16", only visible to others when set to public.*/
    birthdate?: string;

    /** Additional string value that can be used freely. This is only accessible to the owner of the account and the admins. */
    misc?: string;
    picture?: string;
    profile?: string;
    website?: string;
    nickname?: string;
};
```

## UserPublic <a id="doc-api-reference-data-types-userpublic"></a>

```ts
type UserPublic = {
    /** Access level of the user's account. */
    access_group: number;
    /** User's ID. */
    user_id: string;
    /** Country code of where user first signed up from. */
    locale: string;
    /**
    Account approval info and timestamp.
    Comes with string with the following format: "{approver}:{approved | suspended}:{approved_timestamp}"

    {approver} is who approved the account:
        [by_master] is when account approval is done manually from skapi admin panel,
        [by_admin] is when approval is done by the admin account with api call within your project.
        [by_skapi] is when account approval is automatically done.
        Open ID logger ID will be the value if the user is logged with openIdLogin()
        This timestamp is generated when the user confirms their signup, or recovers their disabled account.

    {approved | suspended}
        [approved] is when the account is approved.
        [suspended] is when the account is blocked by the admin or the master.

    {approved_timestamp} is the timestamp when the account is approved or suspended.

     */
    approved: string;
    /** Account created timestamp(13 digit milliseconds). */
    timestamp: number;
    /** Last login timestamp(Seconds). */
    log: number;
    /** Number of the user's subscribers. */
    subscribers: number;
    /** Number of subscription the user has made */
    subscribed: number;
    /** Number of the records the user have created. */
    records: number;

    /** User's name */
    name?: string;
    /**
     * User's E-Mail for signin.
     * 64 character max.
     * When E-Mail is changed, E-Mail verified state will be changed to false.
     * E-Mail is only visible to others when set to public.
     * E-Mail should be verified to set to public.
     * */
    email?: string;
    /**
     * User's phone number. Format: "+0012341234"
     * When phone number is changed, phone number verified state will be changed to false.
     * Phone number is only visible to others when set to public.
     * Phone number should be verified to set to public.
     */
    phone_number?: string;
    /** User's address, only visible to others when set to public. */
    address?: string | {
        /**
         * Full mailing address, formatted for display or use on a mailing label. This field MAY contain multiple lines, separated by newlines. Newlines can be represented either as a carriage return/line feed pair ("\r\n") or as a single line feed character ("\n").
         * street_address
         * Full street address component, which MAY include house number, street name, Post Office Box, and multi-line extended street address information. This field MAY contain multiple lines, separated by newlines. Newlines can be represented either as a carriage return/line feed pair ("\r\n") or as a single line feed character ("\n").
        */
        formatted: string;
        // City or locality component.
        locality: string;
        // State, province, prefecture, or region component.
        region: string;
        // Zip code or postal code component.
        postal_code: string;
        // Country name component.
        country: string;
    };
    /**
     * User's gender. Can be "female" and "male".
     * Other values may be used when neither of the defined values are applicable.
     * Only visible to others when set to public.
     */
    gender?: string;
    /** User's birthdate. String format: "1969-07-16", only visible to others when set to public.*/
    birthdate?: string;

    picture?: string;
    profile?: string;
    website?: string;
    nickname?: string;
};
```

## WebSocketMessage <a id="doc-api-reference-data-types-websocketmessage"></a>

```ts
type WebSocketMessage = {
    type: 'message' | 'error' | 'success' | 'close' | 'notice' | 'private' | 'reconnect' | 'rtc:incoming' | 'rtc:closed';
    message?: any;
    connectRTC?: (params: RTCReceiverParams, callback: (e: RTCEvent) => void) => Promise<RTCResolved>;
    hangup?: () => void; // Reject incoming RTC connection.
    sender?: string; // user_id of the sender
    sender_cid?: string; // scid of the sender
    sender_rid?: string; // group of the sender
    code?: 'USER_LEFT' | 'USER_DISCONNECTED' | 'USER_JOINED' | null; // code for notice messeges
}
```


