# KV storage reference

Use the key-value storage with API functions in your project to have persistent data for your requests.

## Key structure

Keys are arrays of JavaScript primitive values (`string`, `number`, or `boolean`).
This hierarchical structure enables efficient querying by prefix.

Shared keyspace and data isolation
KV storage keyspace is shared across your project.
Prefix keys with a tenant or authenticated user identifier (for example, `['notes', context.user.email, noteId]`) to prevent cross-user data access.

Authentication in curl tests
Examples that access `context.user` require an authenticated request.
When testing with curl, include the authentication method configured for your project (for example, a session cookie or bearer token), or the endpoint returns `401`.


```ts
// Single-level key
['settings']

// Multi-level keys for hierarchical data
['notes', 'meeting-notes-001']        // Note by ID
['users', 'john', 'preferences']      // User preferences
['cache', 'api', 'weather', 'london'] // Cached API response
```

### Lexicographical ordering

Keys are sorted lexicographically (like words in a dictionary) component by component.
This ordering determines how `list()` returns results:


```ts
// These keys are sorted as:
['notes', 'a']       // First (a comes before b)
['notes', 'apple']   // Second (ap... comes before b)
['notes', 'b']       // Third
['notes', 'banana']  // Fourth

// Numbers are compared as strings, not numerically:
['items', '1']       // First
['items', '10']      // Second (1 < 2 in string comparison)
['items', '2']       // Third
['items', '9']       // Fourth
```

When using numeric IDs, pad them with leading zeros for correct ordering: `['items', '001']`, `['items', '002']`, `['items', '010']`.

## KV methods

### kv.set

Creates or updates an entry.
Returns the stored key-value entry on success, or `null` on failure.


```ts
kv.set<T>(key: KvKey, value: T, options?: KvSetOptions): Promise<KvListEntry<T> | null>
```

See [KvSetOptions](#kvsetoptions) for available options.


```ts @api/notes.post.ts
import type { ApiFunctionsContext } from '@redocly/config';

type Note = {
  title: string;
  content: string;
  ownerId: string;
  createdAt: string;
};

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const body = await request.json() as { title: string; content: string };
  const ownerId = context.user?.email;

  if (!ownerId) {
    return context.status(401).json({ error: 'Authentication required' });
  }

  const id = crypto.randomUUID();
  const note: Note = {
    title: body.title,
    content: body.content,
    ownerId,
    createdAt: new Date().toISOString()
  };

  // Store permanently
  const entry = await kv.set(['notes', ownerId, id], note);

  if (!entry) {
    return context.status(500).json({ error: 'Failed to save note' });
  }

  return context.status(201).json({ id, note });
}
```

**Test with curl:**


```bash
curl -X POST https://your-project.redocly.app/api/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "My note", "content": "Note content here"}'
```

#### Use TTL (time-to-live)

Set an expiration time for cache entries or temporary data:


```ts @api/cache/weather.get.ts
import type { ApiFunctionsContext } from '@redocly/config';

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const { city } = context.query;

  if (!city) {
    return context.status(400).json({ error: 'City parameter required' });
  }

  // Check cache first
  const cached = await kv.get(['cache', 'weather', city]);
  if (cached) {
    return context.json({ source: 'cache', data: cached });
  }

  // Fetch fresh data
  const response = await fetch(`https://api.weather.example/v1/${city}`);
  const weatherData = await response.json();

  // Cache for 30 minutes (1800 seconds)
  await kv.set(['cache', 'weather', city], weatherData, {
    ttlInSeconds: 1800
  });

  return context.json({ source: 'api', data: weatherData });
}
```

**Test with curl:**


```bash
curl "https://your-project.redocly.app/api/cache/weather?city=london"
```

### kv.get

Retrieves a single entry by key.
Returns `null` if the key does not exist.


```ts
kv.get<T>(key: KvKey): Promise<T | null>
```

Returns the stored value directly.


```ts @api/notes/[id].get.ts
import type { ApiFunctionsContext } from '@redocly/config';

type Note = {
  ownerId: string;
  title: string;
  content: string;
  createdAt: string;
};

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const { id } = context.params;
  const ownerId = context.user?.email;

  if (!ownerId) {
    return context.status(401).json({ error: 'Authentication required' });
  }

  const note = await kv.get<Note>(['notes', ownerId, id]);

  if (!note) {
    return context.status(404).json({ error: 'Note not found' });
  }

  return context.json({ note });
}
```

**Test with curl:**


```bash
curl https://your-project.redocly.app/api/notes/abc-123
```

### kv.list

Lists entries matching a selector with optional pagination.
Returns items sorted lexicographically by key.


```ts
kv.list<T>(selector: KvListSelector, options?: KvListOptions): Promise<KvListResponse<T>>
```

See [KvListSelector](#kvlistselector), [KvListOptions](#kvlistoptions), and [KvListResponse](#kvlistresponse) for type details.

#### Basic usage: List by prefix

The most common use case is listing all entries that share a prefix:


```ts @api/notes.get.ts
import type { ApiFunctionsContext } from '@redocly/config';

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const ownerId = context.user?.email;

  if (!ownerId) {
    return context.status(401).json({ error: 'Authentication required' });
  }

  // Get all notes for the authenticated user.
  const result = await kv.list({ prefix: ['notes', ownerId] });

  return context.json({
    notes: result.items.map(item => ({
      id: item.key[2],  // Extract ID from ['notes', ownerId, id]
      ...item.value
    })),
    total: result.total
  });
}
```

**Test with curl:**


```bash
curl https://your-project.redocly.app/api/notes
```

#### Pagination

Use `limit` and `cursor` for paginated results:


```ts @api/notes.get.ts
import type { ApiFunctionsContext } from '@redocly/config';

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const { limit = '10', cursor } = context.query;
  const ownerId = context.user?.email;

  if (!ownerId) {
    return context.status(401).json({ error: 'Authentication required' });
  }

  const result = await kv.list(
    { prefix: ['notes', ownerId] },
    {
      limit: Math.min(parseInt(limit, 10), 100), // Max 100 per page
      cursor: typeof cursor === 'string' ? cursor : undefined
    }
  );

  return context.json({
    notes: result.items.map(item => item.value),
    total: result.total,
    nextCursor: result.cursor,
    hasMore: result.cursor !== null
  });
}
```

**Test with curl:**


```bash
# First page
curl "https://your-project.redocly.app/api/notes?limit=10"

# Next page (use cursor from previous response)
curl "https://your-project.redocly.app/api/notes?limit=10&cursor=eyJrIjoiWydu..."
```

#### Reverse order

Get entries in reverse lexicographical order (newest first, if using sortable IDs):


```ts @api/notes.get.ts
import type { ApiFunctionsContext } from '@redocly/config';

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const ownerId = context.user?.email;

  if (!ownerId) {
    return context.status(401).json({ error: 'Authentication required' });
  }

  // Get notes in reverse order
  const result = await kv.list(
    { prefix: ['notes', ownerId] },
    { limit: 10, reverse: true }
  );

  return context.json({ notes: result.items });
}
```

**Test with curl:**


```bash
curl "https://your-project.redocly.app/api/notes?order=desc"
```

#### Range queries with start and end

Use `start` and `end` to query a specific range of keys.

The `start` key is **inclusive** (included in results) and the `end` key is **exclusive** (not included in results).

**Example 1: Query notes starting from a specific ID**


```ts
// Data in storage:
// ['notes', 'alice@example.com', 'note-001'] -> { title: 'First' }
// ['notes', 'alice@example.com', 'note-002'] -> { title: 'Second' }
// ['notes', 'alice@example.com', 'note-003'] -> { title: 'Third' }
// ['notes', 'alice@example.com', 'note-004'] -> { title: 'Fourth' }

// Get notes starting from 'note-002' (inclusive)
const result = await kv.list({
  prefix: ['notes', 'alice@example.com'],
  start: ['notes', 'alice@example.com', 'note-002']
});
// Returns: note-002, note-003, note-004
```

**Example 2: Query notes up to a specific ID**


```ts
// Get notes up to (but not including) 'note-003'
const result = await kv.list({
  prefix: ['notes', 'alice@example.com'],
  end: ['notes', 'alice@example.com', 'note-003']
});
// Returns: note-001, note-002
```

**Example 3: Range query without prefix**


```ts
// Query one user's notes using explicit bounds and no prefix
const result = await kv.list({
  start: ['notes', 'alice@example.com', 'note-002'],
  end: ['notes', 'alice@example.com', 'note-004']
});
// Returns: note-002, note-003
```

#### Hierarchical data queries

Query data at different levels of your key hierarchy:


```ts @api/organizations/[orgId]/members.get.ts
import type { ApiFunctionsContext } from '@redocly/config';

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const { orgId } = context.params;
  const { department } = context.query;

  // Data structure:
  // ['orgs', 'acme', 'engineering', 'alice'] -> { role: 'Engineer' }
  // ['orgs', 'acme', 'engineering', 'bob'] -> { role: 'Senior Engineer' }
  // ['orgs', 'acme', 'sales', 'charlie'] -> { role: 'Sales Rep' }

  let prefix: (string | number | boolean)[];

  if (department) {
    // Query specific department
    prefix = ['orgs', orgId, department];
  } else {
    // Query all departments in org
    prefix = ['orgs', orgId];
  }

  const result = await kv.list({ prefix });

  return context.json({
    members: result.items.map(item => ({
      name: item.key[item.key.length - 1], // Last key component is the name
      department: item.key[2],
      ...item.value
    })),
    total: result.total
  });
}
```

**Test with curl:**


```bash
# All members in organization
curl https://your-project.redocly.app/api/organizations/acme/members

# Only engineering department
curl "https://your-project.redocly.app/api/organizations/acme/members?department=engineering"
```

#### Filter by date range

Use sortable date strings in keys for date-based queries:


```ts @api/logs.get.ts
import type { ApiFunctionsContext } from '@redocly/config';

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const { from, to } = context.query;

  // Keys use ISO date format for proper sorting:
  // ['logs', '2024-01-15T10:30:00Z', 'abc123']
  // ['logs', '2024-01-15T14:45:00Z', 'def456']

  const selector: { prefix: string[]; start?: string[]; end?: string[] } = {
    prefix: ['logs']
  };

  if (from) {
    selector.start = ['logs', from];
  }
  if (to) {
    selector.end = ['logs', to];
  }

  const result = await kv.list(selector);

  return context.json({
    logs: result.items.map(item => ({
      timestamp: item.key[1],
      id: item.key[2],
      ...item.value
    }))
  });
}
```

**Test with curl:**


```bash
# Get logs from January 15, 2024
curl "https://your-project.redocly.app/api/logs?from=2024-01-15T00:00:00Z&to=2024-01-16T00:00:00Z"
```

### kv.getMany

Retrieves multiple entries by keys in a single operation.
Returns an array of results in the same order as the input keys.
Missing entries are returned as `null`.


```ts
kv.getMany<T>(keys: KvKey[]): Promise<(KvListEntry<T> | null)[]>
```


```ts @api/notes/batch.post.ts
import type { ApiFunctionsContext } from '@redocly/config';

type Note = {
  title: string;
  content: string;
};

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const { ids } = await request.json() as { ids: string[] };

  if (!ids || !Array.isArray(ids)) {
    return context.status(400).json({ error: 'ids array is required' });
  }

  const keys = ids.map(id => ['notes', id] as (string | number | boolean)[]);
  const notes = await kv.getMany<Note>(keys);

  const result = ids.map((id, index) => ({
    id,
    found: notes[index] !== null,
    note: notes[index]?.value || null
  }));

  return context.json({ notes: result });
}
```

**Test with curl:**


```bash
curl -X POST https://your-project.redocly.app/api/notes/batch \
  -H "Content-Type: application/json" \
  -d '{"ids": ["note-001", "note-002", "note-999"]}'
```

**Response:**


```json
{
  "notes": [
    { "id": "note-001", "found": true, "note": { "title": "First note", "content": "..." } },
    { "id": "note-002", "found": true, "note": { "title": "Second note", "content": "..." } },
    { "id": "note-999", "found": false, "note": null }
  ]
}
```

### kv.delete

Deletes an entry by key.
This method always returns `void`, regardless of whether the entry existed.


```ts
kv.delete(key: KvKey): Promise<void>
```


```ts @api/notes/[id].delete.ts
import type { ApiFunctionsContext } from '@redocly/config';

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const { id } = context.params;
  const ownerId = context.user?.email;

  if (!ownerId) {
    return context.status(401).json({ error: 'Authentication required' });
  }

  if (!id) {
    return context.status(400).json({ error: 'Note ID required' });
  }

  // Check if note exists before deleting (optional)
  const note = await kv.get(['notes', ownerId, id]);

  if (!note) {
    return context.status(404).json({ error: 'Note not found' });
  }

  await kv.delete(['notes', ownerId, id]);

  return context.json({ message: `Note '${id}' deleted` });
}
```

**Test with curl:**


```bash
curl -X DELETE https://your-project.redocly.app/api/notes/abc-123
```

### kv.transaction

Executes multiple operations atomically.
All operations within the transaction succeed together or fail together (rollback).


```ts
kv.transaction<T>(operation: (tx: KvTransaction) => Promise<T>): Promise<T>
```

The transaction object provides `get`, `getMany`, `set`, and `delete` methods with the same signatures as the main storage object.


```ts @api/transfer.post.ts
import type { ApiFunctionsContext, KvTransaction } from '@redocly/config';

type Account = {
  ownerId: string;
  balance: number;
  name: string;
};

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const callerId = context.user?.email;
  const { from, to, amount } = await request.json() as {
    from: string;
    to: string;
    amount: number;
  };

  if (!callerId) {
    return context.status(401).json({ error: 'Authentication required' });
  }

  if (!from || !to || typeof amount !== 'number' || amount <= 0) {
    return context.status(400).json({ error: 'Invalid transfer parameters' });
  }

  try {
    const result = await kv.transaction(async (tx: KvTransaction) => {
      // Read both accounts
      const fromAccount = await tx.get<Account>(['accounts', from]);
      const toAccount = await tx.get<Account>(['accounts', to]);

      if (!fromAccount || !toAccount) {
        throw new Error('One or both accounts not found');
      }

      // Prevent BOLA: users can only transfer from accounts they own.
      if (fromAccount.ownerId !== callerId) {
        throw new Error('Not authorized to transfer from this account');
      }

      if (fromAccount.balance < amount) {
        throw new Error('Insufficient balance');
      }

      // Update both accounts atomically
      await tx.set(['accounts', from], {
        ...fromAccount,
        balance: fromAccount.balance - amount
      });
      await tx.set(['accounts', to], {
        ...toAccount,
        balance: toAccount.balance + amount
      });

      return {
        success: true,
        from: { name: fromAccount.name, newBalance: fromAccount.balance - amount },
        to: { name: toAccount.name, newBalance: toAccount.balance + amount },
        amount
      };
    });

    return context.json(result);
  } catch (error) {
    if (error instanceof Error && error.message === 'Not authorized to transfer from this account') {
      return context.status(403).json({ error: error.message });
    }

    return context.status(400).json({
      error: error instanceof Error ? error.message : 'Transaction failed'
    });
  }
}
```

**Test with curl:**


```bash
curl -X POST https://your-project.redocly.app/api/transfer \
  -H "Content-Type: application/json" \
  -d '{"from": "alice", "to": "bob", "amount": 50}'
```

## Data modeling patterns

### Secondary indexes

Store the same data under multiple keys for different query patterns:


```ts @api/users.post.ts
import type { ApiFunctionsContext, KvTransaction } from '@redocly/config';

type User = {
  id: string;
  email: string;
  name: string;
};

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const body = await request.json() as { email: string; name: string };

  const id = crypto.randomUUID();
  const user: User = { id, email: body.email, name: body.name };

  // Use transaction to ensure both keys are created atomically
  await kv.transaction(async (tx: KvTransaction) => {
    // Primary key: by ID
    await tx.set(['users', id], user);
    // Secondary index: by email (stores reference to primary key)
    await tx.set(['usersByEmail', body.email], { id });
  });

  return context.status(201).json({ user });
}
```

**Test with curl:**


```bash
curl -X POST https://your-project.redocly.app/api/users \
  -H "Content-Type: application/json" \
  -d '{"email": "john@example.com", "name": "John Doe"}'
```


```ts @api/users/by-email/[email].get.ts
import type { ApiFunctionsContext } from '@redocly/config';

type User = {
  id: string;
  email: string;
  name: string;
};

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const { email } = context.params;

  // Look up the user ID using the email index
  const ref = await kv.get<{ id: string }>(['usersByEmail', email]);

  if (!ref) {
    return context.status(404).json({ error: 'User not found' });
  }

  // Fetch the actual user data using the ID
  const user = await kv.get<User>(['users', ref.id]);

  return context.json({ user });
}
```

**Test with curl:**


```bash
curl https://your-project.redocly.app/api/users/by-email/john@example.com
```

### Counters

Track counts or metrics:


```ts @api/articles/[id]/views.post.ts
import type { ApiFunctionsContext, KvTransaction } from '@redocly/config';

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const { id } = context.params;

  const newCount = await kv.transaction(async (tx: KvTransaction) => {
    const current = await tx.get<{ count: number }>(['views', id]);
    const count = (current?.count || 0) + 1;

    await tx.set(['views', id], { count });

    return count;
  });

  return context.json({ views: newCount });
}
```

**Test with curl:**


```bash
curl -X POST https://your-project.redocly.app/api/articles/my-article/views
```

## Types

This section documents the TypeScript types used in KV storage methods.
All type tables use the same format: **Name**, **Type**, **Description**.

### KvKey

| Name | Type | Description |
|  --- | --- | --- |
| KvKeyPart | string | number | boolean | A single component of a key.
Must be a JavaScript primitive value. |
| KvKey | KvKeyPart[] | An array of key parts forming a complete key.
Example: `['users', 'john']` or `['items', 42, true]` |


### KvValue

| Name | Type | Description |
|  --- | --- | --- |
| KvValue | Record<string, unknown> | unknown[] | unknown | Any JSON-serializable value.
Can be an object, array, or primitive. |


### KvSetOptions

| Name | Type | Description |
|  --- | --- | --- |
| ttlInSeconds | number | Time-to-live in seconds.
The entry automatically expires after this duration.
If not specified, the entry persists indefinitely. |


### KvListEntry

The object returned by `set`, `getMany`, and in `list` results.

| Name | Type | Description |
|  --- | --- | --- |
| key | KvKey | The array key used to store the value. |
| value | T | null | The stored value, or `null` if not found. |


### KvListSelector

Defines which entries to return from `list()`.
Use one of the following selector patterns:

| Name | Type | Description |
|  --- | --- | --- |
| prefix only | `{ prefix: KvKey }` | Returns all entries whose keys start with the specified prefix. |
| prefix + start | `{ prefix: KvKey, start: KvKey }` | Returns entries with the prefix, starting from the start key.
The **start key is inclusive** (included in results). |
| prefix + end | `{ prefix: KvKey, end: KvKey }` | Returns entries with the prefix, up to the end key.
The **end key is exclusive** (not included in results). |
| start + end | `{ start: KvKey, end: KvKey }` | Returns entries in the range from start to end.
The **start is inclusive, end is exclusive**. |


Range boundaries
In all selectors, `start` is always **inclusive** (included in results) and `end` is always **exclusive** (not included in results).

### KvListOptions

| Name | Type | Description |
|  --- | --- | --- |
| limit | number | Maximum number of entries to return. Use for pagination.
Allowed range: `1` to `1000`.
Default: `100` |
| reverse | boolean | Reverse the order of results.
Default: `false` |
| cursor | string | Cursor from a previous `list()` call to continue pagination. |


### KvListResponse

The object returned by `list()`.

| Name | Type | Description |
|  --- | --- | --- |
| items | `KvListEntry<T>[]` | Array of key-value entries. |
| total | number | Total count of entries matching the selector after applying the current cursor. |
| cursor | string | null | Cursor for the next page.
`null` if no more results. |


### KvTransaction

The transaction object passed to the callback in `kv.transaction()`.

| Method | Signature | Description |
|  --- | --- | --- |
| get | `<T>(key: KvKey) => Promise<T | null>` | Retrieves a single entry by key. |
| getMany | `<T>(keys: KvKey[]) => Promise<(KvListEntry<T> | null)[]>` | Retrieves multiple entries by keys. |
| set | `<T>(key: KvKey, value: T, options?: KvSetOptions) => Promise<KvListEntry<T> | null>` | Creates or updates an entry. |
| delete | `(key: KvKey) => Promise<void>` | Deletes an entry by key. |


## Limitations and errors

### Storage size

The available database storage depends on your plan.

| Plan | Available storage |
|  --- | --- |
| Pro | 500 MB |
| Enterprise | 5 GB |
| Enterprise+ | 50 GB |


### Size limits

| Limitation | Maximum value | Description |
|  --- | --- | --- |
| Value size | 1 MB | Maximum size of a single value after JSON serialization. |
| Key components | string | number | boolean | Keys must be arrays containing only primitive types. |
| Value types | JSON-serializable | Values must be JSON serializable.
Functions, circular references, and special objects are not supported. |


### Error handling

**Value size exceeded:**

When you try to store a value larger than 1 MB, the operation throws an error:


```text
Error: Value size (1234.56 KB) exceeds the maximum allowed size of 1 MB (1024 KB)
```

Handle this error in your API function:


```ts @api/data.post.ts
import type { ApiFunctionsContext } from '@redocly/config';

export default async function (request: Request, context: ApiFunctionsContext) {
  const kv = await context.getKv();
  const body = await request.json();

  try {
    await kv.set(['data', 'large-item'], body);
    return context.json({ success: true });
  } catch (error) {
    if (error instanceof Error && error.message.includes('exceeds the maximum allowed size')) {
      return context.status(413).json({
        error: 'Data too large',
        message: 'The data exceeds the 1 MB storage limit. Consider splitting it into smaller chunks.'
      });
    }
    throw error;
  }
}
```

**Test with curl:**


```bash
# This will fail if the JSON body exceeds 1 MB
curl -X POST https://your-project.redocly.app/api/data \
  -H "Content-Type: application/json" \
  -d '{"key": "value"}'
```

**Invalid JSON:**

When a value cannot be serialized to JSON:


```text
Error: Value is not JSON serializable: <error message>
```

## Resources

- **[KV storage](/docs/realm/customization/api-functions/kv-storage)** - Add KV storage to your API function
- **[API functions reference](/docs/realm/customization/api-functions/api-functions-reference)** - Complete API functions helper methods and properties reference
- **[Create API functions](/docs/realm/customization/api-functions/create-api-functions)** - Step-by-step guide to building API functions