# Crowdin Storage

```ts
import { createStorage } from "@crowdin/serverless-apps-sdk/storage";

const { kv } = createStorage();

await kv.set("board:columns", ["To do", "In progress", "Done"]);
const columns = await kv.get<string[]>("board:columns");
```

`createStorage()` returns the app's storage hosted by Crowdin. Its `kv` namespace is a key-value space - one per installation, isolated from other apps and organizations on the server. The host executes the calls on behalf of the current viewer - no tokens ever reach the app. Not to be confused with the [Upload Storage API](/serverless-apps/building-app/crowdin-api/#uploading-files) (`client.uploadStorageApi`), which holds temporary files for REST API uploads.

:::caution
Plain shared keys are readable by everyone who can use the app - in public projects that includes anonymous visitors - and writable by every signed-in user among them. Put anything user-specific under [`kv.user.*`](#per-user-records) and anything meant for a single surface under its [`module:` prefix](#module-scoped-records); the [access model](#access-model) has the full rules.
:::

## Scopes

Storage access requires the `application.storage` scope in the [manifest](/serverless-apps/reference/manifest/):

```json title="manifest.json"
{
  "scopes": ["application.storage"]
}
```

Without it every storage call is rejected.

## Reading and writing

```ts
await kv.set("greeting", { text: "hello" });
const value = await kv.get<{ text: string }>("greeting"); // undefined when the key does not exist
const entry = await kv.getWithMetadata<{ text: string }>("greeting"); // value + secret, createdAt, updatedAt, expiresAt
await kv.delete("greeting");
```

A value is any JSON value - string, number, boolean, array, or object. Keys are 1-500 characters of letters, digits, `:`, `.`, `_`, and `-`, case-sensitive; `:` is the conventional namespace separator (the [limits](#limits) table has the full constraints). Two key prefixes are reserved and carry special semantics - [`user:`](#per-user-records) and [`module:`](#module-scoped-records); any other key is a plain shared record. A key that starts with a reserved word but does not match the full shape - `user:{userId}:` with a numeric id, `module:{moduleKey}:` with a manifest module key - is rejected. A key is immutable - to rename a record, delete it and create a new one.

By default `set` overwrites an existing key. Pass `keyPolicy: "FAIL_IF_EXISTS"` to fail instead of overwriting when the key already exists:

```ts
import { createStorage, KeyExistsError } from "@crowdin/serverless-apps-sdk/storage";

try {
  await kv.set("import:lock", Date.now(), { keyPolicy: "FAIL_IF_EXISTS" });
} catch (error) {
  if (error instanceof KeyExistsError) {
    // another member already started the import
  }
}
```

## Listing

`list` returns the entries visible to the caller, 25 per page by default (500 max); page through with `offset`, or let `withFetchAll` collect every page. Without `orderBy` entries come in a stable insertion order (oldest first) - a stable base order for offset paging (records deleted or expiring mid-walk still shift later pages, as with any offset pagination). `orderBy` takes the REST sort format: `key`, `createdAt`, `updatedAt`, or `expiresAt`, each optionally followed by `desc`, comma-separated:

```ts
const page = await kv.list({ prefix: "board:", limit: 100 });
for (const { key, value } of page.data) {
  console.log(key, value);
}

const freshest = await kv.list({ orderBy: "updatedAt desc,key" });
const everything = await kv.withFetchAll().list({ prefix: "board:" });
```

`withFetchAll` accepts an optional cap on the total number of entries, mirroring `@crowdin/crowdin-api-client`.

## Per-user records

`kv.user.*` mirrors the whole surface but prefixes every key with `user:{userId}:` for the current user. Records under another user's prefix do not exist for the caller - a direct `get` returns nothing, and listings skip them.

```ts
await kv.user.set("filters", { onlyMine: true });
const filters = await kv.user.get<{ onlyMine: boolean }>("filters");
const mine = await kv.user.list(); // only the current user's records
```

Unauthenticated viewers have no user identity, so `kv.user.*` throws for them while shared keys keep working - check `user.id` in the [context](/serverless-apps/building-app/context/) before offering per-user features.

## Module-scoped records

A key under `module:{moduleKey}:` ties the record to the app [module](/serverless-apps/reference/manifest/#modules) with that manifest `key` and scopes it to that module's surface: the record exists only where the app runs as that module, for viewers allowed to use it - so records of a managers-only module never reach the app's other surfaces (the app's installer is the one exception, see the [access model](#access-model)). Everywhere else listings skip the record and a direct `get` resolves `undefined`; `set` under a module key other than the current surface's (or an unknown one) is rejected, while `delete` of a record outside the caller's view resolves silently, like any missing key. Data the app's modules share belongs in plain shared keys.

```ts
await kv.set("module:reviewer-panel:checklist", items);
```

There is no dedicated scope object for module records - write the prefix into the key itself.

:::tip[TypeScript key hints]
TypeScript suggests `module:` in every key position. Optionally enumerate the app's module keys in the type parameter and the full prefixes get suggested instead - any other key stays accepted:

```ts
const { kv } = createStorage<"main" | "reports">();
await kv.set("module:reports:layout", layout); // key positions suggest module:main: and module:reports:
```
:::

## Access model

Who can reach a record follows where the app runs: the platform authorizes each call against the surface the app is running on, and every surface sees the shared records plus its own module's records. On Crowdin Enterprise, surfaces are open to the organization's members plus, for apps visible to guests, visitors of the organization's crowdsourcing portals; on Crowdin (crowdin.com) they follow the app's visibility settings in the installer's projects. On both editions, when the app is visible to guests in a public project, that includes anonymous visitors: they read shared records without signing in, while any write requires a signed-in viewer. Treat shared records as effectively public - anyone the app is visible to can read them, and every signed-in user among them can change them. [Per-user records](#per-user-records) always require a signed-in viewer, and [module records](#module-scoped-records) exist only on their own surface. The app itself, calling with its own token over [direct REST access](#direct-rest-access), and the app's installer, signed in on any of its surfaces, both see shared records and every module's records - other users' `user:` records stay private even from them. Anything sensitive belongs in `kv.user.*` - or under the `module:` prefix of a module only its team can open, when a whole team may hold it - with `secret: true` for tokens and credentials.

## Secrets

`secret: true` encrypts the value at rest:

```ts
await kv.user.set("gh-token", token, { secret: true });
```

The flag is fixed at creation - to change it, delete the key and create it again. Reads are transparent: the API returns the decrypted value to anyone who can read the record, in listings too. Encryption protects the stored value at rest; it does not hide the record from anyone who can read it under the [access model](#access-model) - so scope a secret to exactly the people who may know it. A personal credential lives in the `user:` space; a credential a whole team shares - an integration token, for example - lives under the [`module:` prefix](#module-scoped-records) of a module only that team can use:

```ts
await kv.set("module:cms-settings:api-token", token, { secret: true });
```

A plain shared key is never the place for a secret.

## Expiring records

Pass `ttl` in seconds - from 60 up to 31536000 (one year) - to make a record expire:

```ts
await kv.set("cache:report", report, { ttl: 3600 });
```

`getWithMetadata` and `list` expose `expiresAt` (ISO 8601, `null` for permanent records). Passing `ttl` when overwriting restarts the countdown from that moment; omitting it keeps the existing expiry. An expired record behaves as deleted: `get` resolves `undefined` and listings skip it.

## Direct REST access

The same records are also reachable directly through the Crowdin REST API (`/api/v2/applications/{applicationIdentifier}/storage/kv/records`), authenticated with the app's own access token carrying the `application.storage` scope. Personal access tokens are not accepted.

## Uninstall and data retention

Uninstalling the app immediately deletes all of its records - shared and per-user alike, with no retention. A reinstall starts with an empty store. The same applies per module: removing a module from the manifest - or renaming its `key`, which replaces the module - immediately deletes that module's records.

## Limits

| Limit | Value |
|-------|-------|
| Key | 1-500 characters matching `^[a-zA-Z0-9:._-]+$`; case-sensitive; unique per installation; immutable |
| Value | any JSON value except `null` - up to 240 KiB serialized |
| `ttl` | optional, 60 seconds to 1 year (31536000 seconds) |
| Keys per installation | no quota - only the general API rate limits apply |