---
title: Key-Value Store
description: Persist intermediate results, cache data, and share state across logic function runs with the built-in application key-value store.
icon: "database"
---

Logic functions run sandboxed in short-lived Node.js processes — once a run finishes, nothing it kept in memory survives. When you need to **remember something between runs** (cache an expensive API response, store a cursor for incremental syncs, debounce work, or hand state from one function to another), persist it in the built-in key-value store.

Every application gets its own isolated namespace: entries are keyed by the authenticated app, so your keys can never collide with — or be read by — another application.

```text
  ┌─────────────────┐   kv.set(key, value)   ┌──────────────────────────┐
  │ Logic function  │ ─────────────────────▶ │ Application KV store     │
  │ (your handler)  │ ◀───────────────────── │  key (unique)  │  value  │
  └─────────────────┘   kv.get(key)          └──────────────────────────┘
```

## Get, set, delete

Import `kv` from `twenty-sdk/logic-function`. Values can be any JSON-serializable payload.

```ts src/logic-functions/sync-linear-issues.ts
import { kv } from 'twenty-sdk/logic-function';

// Read a value. Returns null when the key is missing.
const cursor = await kv.get<string>('sync-cursor:linear');

// Write a value. Creates the entry on first write, updates it afterwards.
await kv.set('sync-cursor:linear', newCursor);

// Delete an entry. Returns true when an entry was removed.
await kv.delete('sync-cursor:linear');
```

## Scopes

Each entry has a scope, passed as an option on every call. The default is `WORKSPACE`.

- **`WORKSPACE`** (default) — the entry is private to the current workspace install of your app. Each workspace that installs the app gets its own independent set of keys. This is what you want for caches, cursors, and per-workspace state.
- **`SERVER`** — the entry is shared across **every install** of your app on the server. Server entries behave like **claims**: the stored value is always the workspaceId that claimed the key (omit `value` on `set` to claim the key for the current workspace), and only that workspace can overwrite or delete it. Any install can read the entry.

Server claims exist for cross-workspace routing. A [server-route resolver](/developers/extend/apps/logic/logic-functions#server-route-trigger) runs in the application registration owner workspace, but an inbound webhook usually only carries an external account id — not a Twenty workspaceId. Have each workspace claim its external id at connect time, then resolve it in the route:

```ts
// In the connected workspace, when the external account is linked:
await kv.set(`slack:team:${teamId}`, undefined, { scope: 'SERVER' });

// In the server-route resolver (owner workspace), on each webhook:
const workspaceId = await kv.get<string>(`slack:team:${teamId}`, {
  scope: 'SERVER',
});
```

Because a server key can only be claimed for the caller's own workspace and never overwritten by another one, a workspace can't hijack a mapping that belongs to someone else. `kv.set` throws when the key is already claimed by another workspace.

## Use it: cache an expensive call

A typical use is caching a slow or rate-limited third-party response so repeated runs reuse it instead of paying the cost every time.

```ts src/logic-functions/getExchangeRate.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { kv } from 'twenty-sdk/logic-function';

const ONE_HOUR_MS = 60 * 60 * 1000;

type CachedRate = { rate: number; fetchedAt: number };

const handler = async (params: { from: string; to: string }) => {
  const cacheKey = `cache:exchange-rate:${params.from}:${params.to}`;
  const cached = await kv.get<CachedRate>(cacheKey);

  if (cached && Date.now() - cached.fetchedAt < ONE_HOUR_MS) {
    return { rate: cached.rate, cached: true };
  }

  const response = await fetch(
    `https://api.example.com/rate?from=${params.from}&to=${params.to}`,
  );
  const { rate } = (await response.json()) as { rate: number };

  await kv.set(cacheKey, { rate, fetchedAt: Date.now() });

  return { rate, cached: false };
};

export default defineLogicFunction({
  universalIdentifier: 'd9b2f4e6-1c83-4a07-9e52-6b1d3c8a0f47',
  name: 'get-exchange-rate',
  timeoutSeconds: 10,
  handler,
});
```

## Patterns & tips

- **Namespacing.** Prefix keys to keep different concerns apart — `sync-cursor:linear`, `cache:exchange-rate:USD:EUR`, `lock:nightly-report`.
- **Expiry (TTL).** The store has no built-in expiration. Store a timestamp inside the value (as in the cache example) and check it on read, or clear stale keys from a [cron-triggered function](/developers/extend/apps/logic/logic-functions).
- **What to store.** Any JSON-serializable value — numbers, strings, arrays, objects. Keep entries small; this is for coordination and caching, not large blobs or files. For files, use a `FILES` field and [`uploadFile`](/developers/extend/apps/logic/logic-functions#uploading-files).
- **Visibility.** Entries live in the instance database, not as workspace records — they never show up in the workspace UI, aren't part of your app's data model, and need no role or object permissions.

## Alternative: a queryable store object

The built-in store is deliberately opaque: entries aren't records, so you can't browse them in the UI, relate them to other objects, or filter them with record queries. When you need any of that — say a visible sync log, or per-record state — define a small **technical object** with a unique `key` field and a `RAW_JSON` `value` field instead, and query it through the [typed API client](/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk). See [Objects](/developers/extend/apps/data/objects) for the `defineObject` reference and [Data → Unique indexes](/developers/extend/apps/data/overview#unique-indexes) for enforcing key uniqueness.

- **Scoping to a record.** Add a [relation](/developers/extend/apps/data/relations) from the store object to the target object rather than encoding the id into the key.
- **Visibility & permissions.** Rows live in the workspace database like any other record, so they're queryable through the API and respect your app's [role](/developers/extend/apps/config/roles). To keep the store out of the main UI, leave it off your [navigation menu](/developers/extend/apps/layout/navigation-menu-items).

<Note>
Unlike the built-in store, a custom object is always scoped to one workspace — it can't share entries across installs the way `SERVER` keys do.
</Note>
