> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.ekanos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Storage

> Schema-validated per-account and per-user state with client-readable opt-in and expiry support.

Storage is where your integration keeps state — settings, cached data, sync cursors, user preferences, anything scoped to an account or user. Every key declares a zod schema, and every read and write is validated against it. Credentials do not belong here; use [Secrets](/sdk/surfaces/secrets).

## Declaring storage

Declare two scope maps: `account` (shared by all users in an account) and `user` (personal to each user):

```ts theme={null}
import type { StorageSchemas } from '@ekanos/sdk';
import { z } from 'zod';

const LocationSchema = z.object({
  label: z.string().min(1),
  latitude: z.number(),
  longitude: z.number(),
});

export const myStorage = {
  account: {
    // Bare schema — server-only (most common)
    'cache/forecast': z.object({
      fetchedAt: z.string(),
      body: z.unknown(),
    }),

    // Descriptor form — only way to make it client-readable
    'settings/location': {
      schema: LocationSchema,
      clientReadable: true,
    },
  },
  user: {
    // Reachable from server handlers only
    'preferences/units': z.enum(['celsius', 'fahrenheit']),
  },
} satisfies StorageSchemas;

export type MyStorage = typeof myStorage;
```

Pass the type as a generic to `defineIntegration<MyStorage>`:

```ts theme={null}
export const integration = defineIntegration<MyStorage>({
  storage: myStorage,
  // Now ctx.storage.account.get('settings/location') is typed correctly
});
```

## Reading and writing

```ts theme={null}
// Read: null if missing or expired; throws StorageValidationError if the
// stored data no longer matches the schema
const saved = await ctx.storage.account.get('settings/location');
if (saved) {
  console.log(saved.data, saved.externalId, saved.expiresAt, saved.updatedAt);
}

// Write (validated against the schema before it is stored)
await ctx.storage.account.set('cache/forecast', body, {
  expiresAt: new Date(Date.now() + 600_000).toISOString(), // optional
});

// Delete
await ctx.storage.account.delete('cache/forecast');
```

All three operations are async. `get` returns `null` for a key that has never been written, and also for an entry past its `expiresAt`.

## Client access via opt-in

Widgets cannot directly query the database, but they can read storage back over HTTP if a key opts in:

```ts theme={null}
// In the definition:
'settings/location': {
  schema: LocationSchema,
  clientReadable: true,  // Now browsers can read it
}

// In a widget:
import { fetchIntegrationStorage } from '@ekanos/sdk/hooks';

const values = await fetchIntegrationStorage('acme-payments', {
  accountId,
  keys: ['settings/location'],
});

// Values come back raw and unvalidated (`unknown`) — parse them yourself
const parsed = LocationSchema.safeParse(values['settings/location']);
const location = parsed.success ? parsed.data : null;
```

**Keys that don't opt in are simply absent** — as are undeclared keys and keys never written. The response does not distinguish them. The request is `GET /api/integrations/<slug>/storage?accountId=…&keys=a,b`, and the result is a `{ key: value }` object.

<Warning>
  `clientReadable` defaults to `false`. A bare-schema key is server-only. The
  descriptor is `.strict()`, so a typo such as `clientReadible: true` is a
  validation error rather than a key you believe is exposed.
</Warning>

### Limits and error handling

* **1 to 20 keys per request** — asking for none, or more than 20, throws before fetching. Split larger reads.
* **403 covers everything bad** — not signed in, not a member, not activated, integration missing. Do not branch on a 403.
* **Throws for a failed request**, never for an absent key.
* **Account scope only** — user scope is deliberately not exposed; the person the data belongs to made no opt-in decision

## Schema evolution

A stored value that no longer matches the current schema throws `StorageValidationError` on read. **Never loosen a schema in place.** Version the key or widen with `z.union`:

```ts theme={null}
// ❌ Wrong: existing rows lack the new required field
'settings/location': z.object({ label: z.string(), newField: z.string() }),

// ✅ Right: a versioned key
'settings/location-v2': z.object({ /* new schema */ }),

// ✅ Also right: accept both shapes
'settings/location': z.union([
  z.object({ label: z.string(), newField: z.string() }),
  z.object({ label: z.string() }),
]),
```

## Key naming

A key is `"<dataType>"` or `"<dataType>/<subtype>"` in lowercase `[a-z0-9_-]`, with at most one `/`. The data type must be one the scope allows:

| Scope | Allowed data types |
| - | - |
| `account` | `activation`, `settings`, `metrics_summary`, `sync_state`, `cache` |
| `user` | `config`, `preferences`, `cache` |

* ✅ Valid account keys: `settings`, `cache/forecast`, `settings/location`, `sync_state/cursor`
* ✅ Valid user keys: `preferences/units`, `config`
* ❌ Invalid: `Settings` (capital), `cache/forecast/daily` (two slashes), `config` in the account scope, `secret/token` (reserved — use `ctx.secrets`)

`activation` keys can be read but never written or deleted — the host owns activation rows.

The subtype is optional — `cache/forecast` and `cache` are two different keys.

## Common patterns

### Caching with fingerprints

When a cache depends on activation data, store a fingerprint alongside the cached value. Treat a mismatch as a cache miss:

```ts theme={null}
const ForecastCacheSchema = z.object({
  forecast: z.unknown(),
  fingerprint: z.string().optional(), // New field, optional for existing rows
});

export const myStorage = {
  account: {
    'settings/location': LocationSchema,
    'cache/forecast': ForecastCacheSchema,
  },
};

// On activate or update (`hash` is your own helper)
const savedLocation = await ctx.storage.account.get('settings/location');
const fingerprint = hash(JSON.stringify(savedLocation?.data ?? null));

await ctx.storage.account.set('cache/forecast', {
  forecast: await fetchForecast(),
  fingerprint,
});

// On read in a schedule or tool
const cached = await ctx.storage.account.get('cache/forecast');
const currentFingerprint = hash(JSON.stringify(await getCurrentLocation()));

if (!cached || cached.data.fingerprint !== currentFingerprint) {
  // Cache miss or stale — refetch
}
```

### Temporary state with expiry

Use `expiresAt` for state that should auto-clean:

```ts theme={null}
await ctx.storage.account.set('cache/pending-events', events, {
  expiresAt: new Date(Date.now() + 86_400_000).toISOString(), // 24 hours
});
```

### User preferences

User scope is server-readable only, so a schedule or tool can read it, but a widget cannot:

```ts theme={null}
export const myStorage = {
  user: {
    'preferences/temperature-units': z.enum(['celsius', 'fahrenheit']),
  },
};

// In a schedule handler
const units = await ctx.storage.user.get('preferences/temperature-units');
if (units?.data === 'fahrenheit') {
  // format accordingly
}
```

A widget that needs a user preference in the browser needs its own route. User scope also throws for machine actors, so a workflow-driven run cannot read it.

## Troubleshooting

### StorageValidationError on read

The message begins `The data stored at account storage key "<key>" no longer matches its declared schema.` Your stored data no longer matches the schema. Version the key or widen the schema with `z.union`, as in [Schema evolution](#schema-evolution).

### StorageValidationError: No schema is declared

The message begins `No schema is declared for account storage key "<key>".` Declare the key in `defineIntegration({ storage })` — or in `createMockContext({ storageSchemas })` in tests — before reading, writing, or deleting it.

### Missing key in fetchIntegrationStorage response

The key is not declared with `clientReadable: true`, is not declared at all, or has never been written. Check your descriptor.

### 403 from fetchIntegrationStorage

The request failed because of auth, membership, or activation. Do not try to distinguish — treat it as "cannot read".

## Next steps

* [Secrets](/sdk/surfaces/secrets) — credential management
* [Egress](/sdk/surfaces/egress) — HTTP allowlist
* [Testing](/sdk/testing) — test storage read/write


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.