Skip to content

Crowdin Storage

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 (client.uploadStorageApi), which holds temporary files for REST API uploads.

Storage access requires the application.storage scope in the manifest:

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

Without it every storage call is rejected.

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 table has the full constraints). Two key prefixes are reserved and carry special semantics - user: and module:; 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:

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
}
}

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:

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.

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.

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 before offering per-user features.

A key under module:{moduleKey}: ties the record to the app module 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). 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.

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

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

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 always require a signed-in viewer, and module records exist only on their own surface. The app itself, calling with its own token over 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.

secret: true encrypts the value at rest:

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 - 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 of a module only that team can use:

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

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

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

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.

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.

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.

LimitValue
Key1-500 characters matching ^[a-zA-Z0-9:._-]+$; case-sensitive; unique per installation; immutable
Valueany JSON value except null - up to 240 KiB serialized
ttloptional, 60 seconds to 1 year (31536000 seconds)
Keys per installationno quota - only the general API rate limits apply